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 ` - - - - - - - - - - - `; - }; - const openFullPagePreview = async (e) => { e.preventDefault(); const v = editor.getValue(); value = v; const content = await replaceTemplateVariables(v); - const blob = new Blob([createEmbed(content)], { type: 'text/html' }); - let url = URL.createObjectURL(blob); - externalFrameRef = window.open(url, '_blank'); + // open the current content isolated in a new tab through the backend so it + // gets its own sandbox policy and loads its own external resources + submitPreviewForm(content, '_blank'); }; const triggerFileInput = () => { diff --git a/frontend/src/lib/utils/safePreview.js b/frontend/src/lib/utils/safePreview.js new file mode 100644 index 00000000..af476ea4 --- /dev/null +++ b/frontend/src/lib/utils/safePreview.js @@ -0,0 +1,123 @@ +// Opens untrusted preview content without ever giving it the admin origin. +// Untrusted markup is posted to the backend preview endpoint, which returns it +// with a sandbox content security policy. The sandbox gives the document an +// opaque origin, so its scripts can not read admin cookies, call the API with +// the admin session, or reach window.opener. Because it is a real server +// response it carries its own policy and does not inherit the strict admin CSP, +// so the preview still loads its own images, styles, fonts and scripts. A data: +// or blob: document would instead inherit the admin CSP and break the preview. + +// Types the browser does not run page script for. These open directly so the +// native viewer or download is used. Everything else, including an unknown or +// empty type, is treated as active content and isolated in the sandbox, so a +// mislabeled file errs on the safe side. image/svg+xml is deliberately left out +// because an svg opened as a document can run script. +const INERT_MIME_ALLOWLIST = new Set([ + // documents and images that render in a native viewer + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'image/bmp', + 'image/x-icon', + 'image/vnd.microsoft.icon', + // archives and office files, which the browser downloads. office documents + // are zip containers, so they are sniffed as application/zip + 'application/zip', + 'application/octet-stream', + 'application/x-gzip', + 'application/gzip', + 'application/x-rar-compressed', + 'application/x-7z-compressed', + 'application/postscript', + 'application/wasm', + // audio and video the browser plays or downloads + 'application/ogg', + 'audio/aiff', + 'audio/midi', + 'audio/mpeg', + 'audio/ogg', + 'audio/wave', + 'audio/wav', + 'audio/webm', + 'video/avi', + 'video/mp4', + 'video/ogg', + 'video/webm', + 'video/quicktime', + // fonts + 'font/collection', + 'font/otf', + 'font/ttf', + 'font/woff', + 'font/woff2' +]); + +const baseMime = (mime) => (mime || '').split(';')[0].trim().toLowerCase(); + +export const PREVIEW_ENDPOINT = '/api/v1/preview'; + +// Post html to the preview endpoint and navigate the target to the sandboxed +// response. target is a window or frame name, or '_blank' for a new tab. A new +// tab opened this way gets no opener in current browsers, and the response +// sandbox keeps it isolated regardless. +export const submitPreviewForm = (html, target = '_blank') => { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = PREVIEW_ENDPOINT; + form.target = target; + // a new tab must not keep a handle to the admin window. current browsers + // imply this for a blank target, this makes it explicit where honored. + form.rel = 'noopener noreferrer'; + form.style.display = 'none'; + const field = document.createElement('textarea'); + field.name = 'content'; + field.value = String(html ?? ''); + form.appendChild(field); + document.body.appendChild(form); + form.submit(); + form.remove(); +}; + +const openBlobTab = (content, type) => { + const url = URL.createObjectURL(new Blob([content], { type })); + window.open(url, '_blank', 'noopener,noreferrer'); + // noopener gives no window handle to watch for load, so revoke on a timer. + // The new tab reads the blob immediately when it opens. + setTimeout(() => URL.revokeObjectURL(url), 60000); +}; + +// Open an html string isolated in a new tab through the sandbox endpoint. +export const openIsolatedHtml = (html) => submitPreviewForm(html, '_blank'); + +const escapeHtml = (s) => + String(s).replace(/&/g, '&').replace(//g, '>'); + +// Text types are shown as escaped text inside the sandbox rather than opened as +// a direct blob. A direct text blob is safe in Chrome and Firefox, but some +// browsers up sniff a text blob that contains markup into html. Rendering it as +// escaped text in the opaque sandbox keeps it readable and safe everywhere. +const TEXT_DISPLAY_TYPES = new Set(['text/plain', 'text/csv', 'application/json']); + +const openIsolatedText = (text) => + openIsolatedHtml( + `
${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 @@ {#if template.afterLandingPageRedirectURL} - - {template.afterLandingPageRedirectURL} - + {@const redirectHref = safeExternalHref(template.afterLandingPageRedirectURL)} + {#if redirectHref} + + {template.afterLandingPageRedirectURL} + + {:else} + + {template.afterLandingPageRedirectURL} + + {/if} {/if} diff --git a/frontend/src/routes/campaign/[id]/+page.svelte b/frontend/src/routes/campaign/[id]/+page.svelte index 0eda0b6d..6f240962 100644 --- a/frontend/src/routes/campaign/[id]/+page.svelte +++ b/frontend/src/routes/campaign/[id]/+page.svelte @@ -6,6 +6,7 @@ import SubHeadline from '$lib/components/SubHeadline.svelte'; import { addToast } from '$lib/store/toast'; import { BiMap } from '$lib/utils/maps'; + import { openIsolatedHtml } from '$lib/utils/safePreview.js'; import { fetchAllRows } from '$lib/utils/api-utils'; import { AppStateService } from '$lib/service/appState'; import ProxySvgIcon from '$lib/components/ProxySvgIcon.svelte'; @@ -699,10 +700,9 @@ throw res.error; } - // open email in new tab as a blob - const blob = new Blob([res.data], { type: 'text/html' }); - const url = URL.createObjectURL(blob); - window.open(url, '_blank'); + // open the rendered email isolated in a sandbox so its markup can + // not run script on the admin origin + openIsolatedHtml(res.data); // clear pending recipient pendingEmailPreviewRecipient = null; diff --git a/frontend/src/routes/oauth-provider/+page.svelte b/frontend/src/routes/oauth-provider/+page.svelte index 772a1d93..6fe691a5 100644 --- a/frontend/src/routes/oauth-provider/+page.svelte +++ b/frontend/src/routes/oauth-provider/+page.svelte @@ -3,6 +3,7 @@ import { api } from '$lib/api/apiProxy.js'; import { onMount } from 'svelte'; import { newTableURLParams } from '$lib/service/tableURLParams.js'; + import { safeExternalHref } from '$lib/utils/url.js'; import { globalButtonDisabledAttributes } from '$lib/utils/form.js'; import Headline from '$lib/components/Headline.svelte'; import TextField from '$lib/components/TextField.svelte'; @@ -491,13 +492,20 @@ showIsLoading(); const res = await api.oauthProvider.getAuthorizationURL(id); if (res.success && res.data.authorizationURL) { + // the url comes from the provider discovery document, so only open + // it when it is http or https, never a javascript or data scheme + const authURL = safeExternalHref(res.data.authorizationURL); + if (!authURL) { + addToast('Provider returned an invalid authorization URL', 'Error'); + return; + } // open authorization url in popup window const width = 600; const height = 700; const left = window.screenX + (window.outerWidth - width) / 2; const top = window.screenY + (window.outerHeight - height) / 2; const popup = window.open( - res.data.authorizationURL, + authURL, 'OAuth Authorization', `width=${width},height=${height},left=${left},top=${top},toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes` ); diff --git a/frontend/static/theme-init.js b/frontend/static/theme-init.js new file mode 100644 index 00000000..3120ae9a --- /dev/null +++ b/frontend/static/theme-init.js @@ -0,0 +1,18 @@ +// Runs before the app renders to prevent a white flash in dark mode. Kept as a +// file served from the app origin, not an inline script, so it is allowed by the +// Content Security Policy script-src 'self' without an inline hash. Loaded as a +// classic blocking script in the head so it runs before first paint. +(function () { + try { + const storedTheme = localStorage.getItem('theme-mode'); + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const isDark = storedTheme === 'dark' || (!storedTheme && prefersDark); + + if (isDark) { + document.documentElement.classList.add('dark'); + document.documentElement.style.backgroundColor = '#111827'; + } + } catch (e) { + // ignore errors + } +})(); diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js index a916beeb..f95f84a4 100644 --- a/frontend/svelte.config.js +++ b/frontend/svelte.config.js @@ -13,20 +13,30 @@ export default { alias: { $lib: './src/lib', '$lib/*': './src/lib/*' - } - // magic for dynamic routes to render at build time - // creates one page for each word in the dictionary - /* - prerender: { - crawl: true, - entries: ['/'] }, - */ - /* TODO + // Hash mode lets SvelteKit add the hash of its own inline start script, so + // the app boots under script-src 'self' with no unsafe-inline. style-src + // keeps unsafe-inline because Monaco and Svelte inject styles at runtime + // that can not be hashed. blob: and data: are allowed only where previews + // and Monaco workers need them, never for script. frame-ancestors and + // form-action can not be set from a meta tag, so they live in the Go + // security headers middleware instead. csp: { - mode: "hash", - directives: { "script-src": ["self"] }, - }, - */ + mode: 'hash', + directives: { + 'default-src': ['self'], + 'script-src': ['self'], + 'style-src': ['self', 'unsafe-inline'], + 'img-src': ['self', 'data:', 'blob:'], + 'font-src': ['self', 'data:'], + 'connect-src': ['self'], + 'worker-src': ['self', 'blob:'], + // previews load from the same origin preview endpoint, not data: or + // blob: documents, so frame-src only needs self + 'frame-src': ['self'], + 'object-src': ['none'], + 'base-uri': ['self'] + } + } } };