fix harden preview against self XSS

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing committed 2026-10-09 10:58:02 +02:00
1 parent e6f5e20a61
commit 67cc91d30d
19 files changed
+421 -118

No files matched your search

+35 -6
View File
@@ -69,6 +69,7 @@ const (
ROUTE_V1_USER_MFA_TOTP = "/api/v1/user/mfa/totp"
ROUTE_V1_QR_FROM_TOTP = "/api/v1/qr/totp"
ROUTE_V1_QR_URL_TO_HTML = "/api/v1/qr/html"
ROUTE_V1_PREVIEW = "/api/v1/preview"
// session
ROUTE_V1_SESSION_ID = "/api/v1/session/:id"
ROUTE_V1_SESSION_PING = "/api/v1/session/ping"
@@ -279,6 +280,12 @@ type administrationServer struct {
embedBackendFS *embed.FS
certMagicConfig *certmagic.Config
softSessionHandler gin.HandlerFunc
// the single page app index html with the SvelteKit csp meta tag removed,
// and the full policy promoted to a response header, so the whole csp is
// delivered server side. nil when the meta could not be read, in which case
// index.html is served unchanged and the policy stays in its meta tag.
spaIndexHTML []byte
spaCSP string
}
// NewAdministrationServer creates a new administration app
@@ -381,6 +388,8 @@ func setupRoutes(
// qr
POST(ROUTE_V1_QR_FROM_TOTP, middleware.SessionHandler, controllers.QR.ToTOTPURL).
POST(ROUTE_V1_QR_URL_TO_HTML, middleware.SessionHandler, controllers.QR.ToHTML).
// preview renders admin authored content isolated with a sandbox CSP
POST(ROUTE_V1_PREVIEW, middleware.SessionHandler, controllers.Preview.Render).
// company
POST(ROUTE_V1_COMPANY, middleware.SessionHandler, controllers.Company.Create).
POST(ROUTE_V1_COMPANY_ID, middleware.SessionHandler, controllers.Company.ChangeName).
@@ -843,6 +852,17 @@ func (a *administrationServer) loadEmbeddedFileSystem(
embedFS := frontend.GetEmbededFS()
// make embedded .html work
frontend.LoadHTMLFromEmbedFS(a.router, *embedFS, "build/*.html")
// promote the SvelteKit csp meta tag to a response header so the whole
// policy is delivered server side and nothing is left in the document. The
// meta holds script-src with the build time script hash plus style-src and
// the other resource directives; we add the directives a meta can not carry.
// If the meta can not be read we leave index.html and its meta untouched.
if policy, indexHTML, ok := frontend.ExtractCSPFromIndex(*embedFS); ok {
a.spaIndexHTML = indexHTML
a.spaCSP = policy + "; frame-ancestors 'none'; form-action 'self'"
} else {
a.logger.Warn("could not read csp meta from index.html, serving it unchanged")
}
// serve favicons only to authenticated users, unauthenticated requests get 404
// so the file is not indexable by scanners probing common paths
for _, faviconPath := range []string{"/favicon.ico", "/favicon.png"} {
@@ -862,9 +882,7 @@ func (a *administrationServer) loadEmbeddedFileSystem(
if !entry.IsDir() {
// special case for the frontpage
if path == "index.html" {
a.router.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "build/index.html", nil)
})
a.router.GET("/", a.serveIndex)
continue
}
// skip favicons, registered separately with session gating
@@ -890,13 +908,24 @@ func (a *administrationServer) loadEmbeddedFileSystem(
}
}
// fall back to the root index.html
a.router.NoRoute(func(c *gin.Context) {
c.HTML(http.StatusOK, "build/index.html", nil)
})
a.router.NoRoute(a.serveIndex)
return nil
}
// serveIndex serves the single page app shell. When the csp meta was promoted
// to a header at startup it serves the stripped html with the full policy header,
// which replaces the strict header the security middleware set. Otherwise it
// serves the original template and the meta carries the resource directives.
func (a *administrationServer) serveIndex(c *gin.Context) {
if a.spaIndexHTML != nil {
c.Header("Content-Security-Policy", a.spaCSP)
c.Data(http.StatusOK, "text/html; charset=utf-8", a.spaIndexHTML)
return
}
c.HTML(http.StatusOK, "build/index.html", nil)
}
func (a *administrationServer) StartServer(
conf *config.Config,
) (chan server.StartupMessage, net.Listener, error) {
+5
View File
@@ -28,6 +28,7 @@ type Controllers struct {
CampaignTemplate *controller.CampaignTemplate
Campaign *controller.Campaign
QR *controller.QRGenerator
Preview *controller.Preview
APISender *controller.APISender
AllowDeny *controller.AllowDeny
GeoIP *controller.GeoIP
@@ -163,6 +164,9 @@ func NewControllers(
qr := &controller.QRGenerator{
Common: common,
}
preview := &controller.Preview{
Common: common,
}
apiSender := &controller.APISender{
Common: common,
APISenderService: services.APISender,
@@ -273,6 +277,7 @@ func NewControllers(
CampaignTemplate: campaignTemplate,
Campaign: campaign,
QR: qr,
Preview: preview,
APISender: apiSender,
AllowDeny: allowDeny,
GeoIP: geoIP,
+39
View File
@@ -0,0 +1,39 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Preview renders admin authored preview content isolated from the admin origin.
type Preview struct {
Common
}
// previewMaxBodyBytes caps the posted content so an authenticated request can
// not spill an unbounded body to disk during form parsing. A preview is a page
// or email template plus inline data urls, well under this.
const previewMaxBodyBytes = 20 << 20
// Render returns the posted content as an html document carrying a sandbox
// content security policy. The sandbox flag gives the document an opaque origin,
// so scripts in the previewed content can not read the admin session, cookies or
// call the API, while the document still gets its own policy and loads its own
// external images, styles, fonts and scripts for an accurate preview. The
// security headers middleware sets the strict admin policy on every response;
// this handler then replaces that header with its own sandbox policy, so the
// preview does not inherit it. allow-same-origin is deliberately absent.
func (p *Preview) Render(g *gin.Context) {
_, _, ok := p.handleSession(g)
if !ok {
return
}
g.Request.Body = http.MaxBytesReader(g.Writer, g.Request.Body, previewMaxBodyBytes)
content := g.PostForm("content")
g.Header(
"Content-Security-Policy",
"sandbox allow-scripts allow-forms allow-popups allow-modals",
)
g.Data(http.StatusOK, "text/html; charset=utf-8", []byte(content))
}
+36
View File
@@ -2,6 +2,7 @@ package frontend
import (
"embed"
"html"
"html/template"
"io/fs"
"regexp"
@@ -10,6 +11,41 @@ import (
"github.com/gin-gonic/gin"
)
// cspMetaRe matches the content security policy meta tag SvelteKit bakes into
// index.html during a hash mode build.
var cspMetaRe = regexp.MustCompile(`(?is)<meta[^>]+http-equiv=["']content-security-policy["'][^>]*>`)
// cspContentRe pulls the policy out of the meta tag's content attribute. A csp
// policy contains single quotes but never double quotes, so the content
// attribute is double quoted and its value runs to the next double quote.
var cspContentRe = regexp.MustCompile(`(?is)content="([^"]*)"`)
// ExtractCSPFromIndex reads build/index.html from the embedded file system,
// pulls the policy out of the SvelteKit content security policy meta tag, and
// returns that policy, the html with the meta tag removed, and whether a tag was
// found. When nothing is found it returns the original bytes and false so the
// caller can fall back to serving the document unchanged.
func ExtractCSPFromIndex(embedFS embed.FS) (policy string, indexHTML []byte, ok bool) {
data, err := embedFS.ReadFile("build/index.html")
if err != nil {
return "", nil, false
}
tag := cspMetaRe.Find(data)
if tag == nil {
return "", data, false
}
m := cspContentRe.FindSubmatch(tag)
if m == nil {
return "", data, false
}
policy = strings.TrimSpace(html.UnescapeString(string(m[1])))
if policy == "" {
return "", data, false
}
stripped := cspMetaRe.ReplaceAll(data, []byte(""))
return policy, stripped, true
}
// The version of gin I used when writting this, did not support using embeded files as html
// so I found this solution on good old https://stackoverflow.com/questions/26537299/golang-gin-framework-status-code-without-message-body
+14 -2
View File
@@ -4,11 +4,23 @@ import "github.com/gin-gonic/gin"
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// too many issues with previewing resources
// c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-src data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
// The built single page app ships its own CSP as a meta tag inside
// index.html: SvelteKit hash mode hashes its inline bootstrap script at
// build time and bakes the script and its matching hash together, which a
// static file can only carry in the document, not as a response header.
// That meta holds the resource directives (script-src with the hash,
// style-src, img-src and so on). This header adds only what a meta tag can
// not express, frame-ancestors and form-action, plus object-src and
// base-uri. It sets no script-src or style-src, so it never conflicts with
// the baked hash. It is applied to every admin response, including the
// single page app fallback on unmatched paths. The preview handler
// replaces this header with its own sandbox policy on its authenticated
// response, so the preview stays frameable while every other path keeps
// frame-ancestors none.
c.Header("Content-Security-Policy", "frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'")
c.Next()
}
}
+8
View File
@@ -170,6 +170,14 @@ func (c *CampaignTemplate) Validate() error {
)
}
// the redirect url is shown as a link in the admin area, so reject any
// scheme other than http or https to keep a javascript url out of an href
if v, err := c.AfterLandingPageRedirectURL.Get(); err == nil && v.String() != "" {
if err := validate.ErrorIfInvalidURL(v.String()); err != nil {
return validate.WrapErrorWithField(err, "afterLandingPageRedirectURL")
}
}
return nil
}
+13
View File
@@ -66,6 +66,19 @@ func (o *OAuthProvider) Validate() error {
if err := validate.NullableFieldRequired("clientSecret", o.ClientSecret); err != nil {
return err
}
// the auth url is opened in the operator browser during authorization, so
// reject any scheme other than http or https to keep a javascript url out.
// the token url is only called server side but is held to the same rule.
if v, err := o.AuthURL.Get(); err == nil {
if err := validate.ErrorIfNotHTTPURL(v.String()); err != nil {
return validate.WrapErrorWithField(err, "authURL")
}
}
if v, err := o.TokenURL.Get(); err == nil {
if err := validate.ErrorIfNotHTTPURL(v.String()); err != nil {
return validate.WrapErrorWithField(err, "tokenURL")
}
}
return nil
}
+21
View File
@@ -428,6 +428,27 @@ func OneOfNullableFieldsRequired(fields map[string]any) error {
return fmt.Errorf("one of the fields (%s) must be supplied", strings.Join(keys, ", "))
}
// ErrorIfNotHTTPURL validates that a string parses as a url with the http or
// https scheme. Unlike ErrorIfInvalidURL it does not require a dotted public
// domain, so a localhost or internal host is allowed. Use it where the goal is
// only to keep a javascript or data url out, not to enforce a public host.
func ErrorIfNotHTTPURL(urlStr string) error {
if urlStr == "" {
return errs.NewValidationError(errors.New("URL cannot be empty"))
}
parsedURL, err := url.Parse(urlStr)
if err != nil {
return errs.NewValidationError(errors.New("must be a valid URL"))
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return errs.NewValidationError(errors.New("must use http or https protocol"))
}
if parsedURL.Host == "" {
return errs.NewValidationError(errors.New("must have a valid host"))
}
return nil
}
// ErrorIfInvalidURL validates that a string is a valid URL with http/https scheme
func ErrorIfInvalidURL(urlStr string) error {
if urlStr == "" {