diff --git a/backend/app/administration.go b/backend/app/administration.go index 8161f66d..7d0eca8c 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -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) { diff --git a/backend/app/controllers.go b/backend/app/controllers.go index 7c5845d7..661da11c 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -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, diff --git a/backend/controller/preview.go b/backend/controller/preview.go new file mode 100644 index 00000000..6704bb3c --- /dev/null +++ b/backend/controller/preview.go @@ -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)) +} diff --git a/backend/frontend/embed.go b/backend/frontend/embed.go index 168a28a4..3c8142d4 100644 --- a/backend/frontend/embed.go +++ b/backend/frontend/embed.go @@ -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)]+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 diff --git a/backend/middleware/securityHeaders.go b/backend/middleware/securityHeaders.go index 3a273570..6b871c56 100644 --- a/backend/middleware/securityHeaders.go +++ b/backend/middleware/securityHeaders.go @@ -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() } } diff --git a/backend/model/campaignTemplate.go b/backend/model/campaignTemplate.go index c5d44cb6..d42c84b5 100644 --- a/backend/model/campaignTemplate.go +++ b/backend/model/campaignTemplate.go @@ -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 } diff --git a/backend/model/oauthProvider.go b/backend/model/oauthProvider.go index f74f07dd..2f37fb53 100644 --- a/backend/model/oauthProvider.go +++ b/backend/model/oauthProvider.go @@ -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 } diff --git a/backend/validate/validate.go b/backend/validate/validate.go index e734941f..b131591f 100644 --- a/backend/validate/validate.go +++ b/backend/validate/validate.go @@ -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 == "" { diff --git a/frontend/src/app.html b/frontend/src/app.html index a153d7ec..9d6b3625 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -4,23 +4,9 @@ - + + %sveltekit.head% diff --git a/frontend/src/lib/components/editor/Editor.svelte b/frontend/src/lib/components/editor/Editor.svelte index 00a2d37c..4358dcb7 100644 --- a/frontend/src/lib/components/editor/Editor.svelte +++ b/frontend/src/lib/components/editor/Editor.svelte @@ -158,6 +158,7 @@ import * as vimModule from 'monaco-vim'; import { BiMap } from '$lib/utils/maps'; import { previewQR as generateQR } from '$lib/utils/qrPreview'; + import { submitPreviewForm } from '$lib/utils/safePreview.js'; import { vimModeEnabled } from '$lib/store/vimMode.js'; import { setupVimClipboardIntegration, @@ -176,6 +177,8 @@ let localVimMode = externalVimMode !== null ? externalVimMode : $vimModeEnabled; let editor = null; let previewFrame = null; + // unique name so the preview form can target this editor's iframe + let previewFrameName = 'pc-preview-' + Math.random().toString(36).slice(2); let previewRenderDelayID = null; let previewRenderDelay = 250; let isRenderingPreview = false; @@ -183,7 +186,6 @@ let previousQRHash = 0; let isPreviewVisible = false; - let externalFrameRef = null; let fileInputRef; let shadowContainer = null; let vimStatusBar = null; @@ -730,42 +732,23 @@ updatePreview(); }; - // create shadow dom for iframe isolation - const createShadowIframe = () => { + // create the named iframe the preview form posts into. the sandboxed server + // response is what isolates the content; the sandbox attribute without + // allow-same-origin adds a second layer. a plain iframe, not a shadow root, + // so the form can find the frame by name. + const createPreviewIframe = () => { if (!shadowContainer) return; - // clear existing content shadowContainer.innerHTML = ''; - // create shadow root - const shadowRoot = shadowContainer.attachShadow({ mode: 'closed' }); - - // create iframe inside shadow dom const iframe = document.createElement('iframe'); - iframe.sandbox = 'allow-forms allow-modals allow-popups allow-scripts allow-pointer-lock'; + iframe.name = previewFrameName; + iframe.sandbox = 'allow-forms allow-modals allow-popups allow-scripts'; iframe.title = 'preview'; - iframe.style.cssText = 'height: 100%; width: 100%; border: none;'; + iframe.style.cssText = 'height: 100%; width: 100%; border: none; background: white;'; - // add styles to shadow root to isolate it - const style = document.createElement('style'); - style.textContent = ` - :host { - display: block; - height: 100%; - width: 100%; - background: white; - } - iframe { - height: 100%; - width: 100%; - border: none; - } - `; + shadowContainer.appendChild(iframe); - shadowRoot.appendChild(style); - shadowRoot.appendChild(iframe); - - // set as preview frame previewFrame = iframe; }; @@ -777,19 +760,15 @@ value = v; const content = await replaceTemplateVariables(v); - // create shadow iframe if not exists + // create the preview iframe if not exists if (shadowContainer && !previewFrame) { - createShadowIframe(); + createPreviewIframe(); } + // post the content to the backend, which returns it with a sandbox policy + // so the preview runs isolated and still loads its own external resources if (previewFrame) { - // use data url for null origin isolation - previewFrame.src = 'data:text/html;charset=utf-8,' + encodeURIComponent(content); - } - if (externalFrameRef) { - const embedContent = createEmbed(content); - const dataUrl = 'data:text/html;charset=utf-8,' + encodeURIComponent(embedContent); - externalFrameRef.location.replace(dataUrl); + submitPreviewForm(content, previewFrameName); } isRenderingPreview = false; }; @@ -1006,33 +985,14 @@ reader.readAsText(file); }; - const createEmbed = (content) => { - return ` - - -
-${escapeHtml(text)}`
+ );
+
+// Open fetched bytes. Inert types render directly, active or unknown types are
+// isolated. bytes may be a Uint8Array, an ArrayBuffer or a string.
+export const openPreviewBytes = (bytes, mime) => {
+ const type = baseMime(mime);
+ if (TEXT_DISPLAY_TYPES.has(type)) {
+ const text = typeof bytes === 'string' ? bytes : new TextDecoder().decode(bytes);
+ openIsolatedText(text);
+ return;
+ }
+ if (INERT_MIME_ALLOWLIST.has(type)) {
+ openBlobTab(bytes, mime);
+ return;
+ }
+ const html = typeof bytes === 'string' ? bytes : new TextDecoder().decode(bytes);
+ openIsolatedHtml(html);
+};
diff --git a/frontend/src/lib/utils/url.js b/frontend/src/lib/utils/url.js
new file mode 100644
index 00000000..3cf44312
--- /dev/null
+++ b/frontend/src/lib/utils/url.js
@@ -0,0 +1,23 @@
+// Returns the url only when it uses the http or https scheme, otherwise null.
+// A stored value like "javascript:..." or "data:..." must never be placed in an
+// anchor href or passed to window.open, because a click would run it on the
+// admin origin. Use this before rendering any user supplied url as a link.
+export const safeExternalHref = (url) => {
+ if (typeof url !== 'string') {
+ return null;
+ }
+ const trimmed = url.trim();
+ let parsed;
+ try {
+ parsed = new URL(trimmed);
+ } catch {
+ return null;
+ }
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
+ // return the parsed form so the value that was checked is exactly the
+ // value that gets used. this canonicalizes control characters, backslashes
+ // and relative forms, leaving no gap between validation and use.
+ return parsed.href;
+ }
+ return null;
+};
diff --git a/frontend/src/routes/asset/[domain]/+page.svelte b/frontend/src/routes/asset/[domain]/+page.svelte
index 96c02490..212863e8 100644
--- a/frontend/src/routes/asset/[domain]/+page.svelte
+++ b/frontend/src/routes/asset/[domain]/+page.svelte
@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
+ import { openPreviewBytes } from '$lib/utils/safePreview.js';
import Headline from '$lib/components/Headline.svelte';
import TextField from '$lib/components/TextField.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
@@ -525,11 +526,12 @@
for (let i = 0; i < binaryData.length; i++) {
byteArray[i] = binaryData.charCodeAt(i);
}
- const blob = new Blob([byteArray], { type: res.data.mimeType });
- const url = URL.createObjectURL(blob);
- window.open(url, '_blank');
+ // isolate html and other active content in a sandbox so a preview
+ // can not run script on the admin origin
+ openPreviewBytes(byteArray, res.data.mimeType);
} else {
- window.open(`https://${$page.params.domain}/${path}`, '_blank');
+ // a real domain asset opens on the phishing origin, separate from admin
+ window.open(`https://${$page.params.domain}/${path}`, '_blank', 'noopener,noreferrer');
}
};
diff --git a/frontend/src/routes/attachment/+page.svelte b/frontend/src/routes/attachment/+page.svelte
index e3512c58..be0697d4 100644
--- a/frontend/src/routes/attachment/+page.svelte
+++ b/frontend/src/routes/attachment/+page.svelte
@@ -2,6 +2,7 @@
import { api } from '$lib/api/apiProxy.js';
import { onMount } from 'svelte';
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
+ import { openPreviewBytes } from '$lib/utils/safePreview.js';
import Headline from '$lib/components/Headline.svelte';
import TextField from '$lib/components/TextField.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
@@ -280,9 +281,9 @@
for (let i = 0; i < binaryData.length; i++) {
byteArray[i] = binaryData.charCodeAt(i);
}
- const blob = new Blob([byteArray], { type: res.data.mimeType });
- const url = URL.createObjectURL(blob);
- window.open(url, '_blank');
+ // isolate html and other active content in a sandbox so a preview
+ // can not run script on the admin origin
+ openPreviewBytes(byteArray, res.data.mimeType);
} catch (e) {
addToast('Failed to get attachment content', 'Error');
console.error('failed to get attachment content', e);
diff --git a/frontend/src/routes/campaign-template/+page.svelte b/frontend/src/routes/campaign-template/+page.svelte
index db0c58fb..7198f3d0 100644
--- a/frontend/src/routes/campaign-template/+page.svelte
+++ b/frontend/src/routes/campaign-template/+page.svelte
@@ -3,6 +3,7 @@
import { onMount } from 'svelte';
import { newTableURLParams } from '$lib/service/tableURLParams.js';
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
+ import { safeExternalHref } from '$lib/utils/url.js';
import Headline from '$lib/components/Headline.svelte';
import TextField from '$lib/components/TextField.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
@@ -839,14 +840,22 @@