From f4ed74c0c7bff06e82de76336ad299dd92d5c457 Mon Sep 17 00:00:00 2001 From: Teffen Ellis Date: Thu, 17 Apr 2025 19:57:43 +0200 Subject: [PATCH] web: Fix issue where references to Lit SSR break page styles. --- web/package-lock.json | 52 +++------ web/package.json | 2 +- web/src/common/purify.ts | 114 +++++++++++++++++--- web/src/elements/utils/iframe.ts | 55 ++++++++++ web/src/flow/components/ak-brand-footer.ts | 24 +++-- web/src/flow/stages/captcha/CaptchaStage.ts | 91 +++++++++------- 6 files changed, 235 insertions(+), 103 deletions(-) create mode 100644 web/src/elements/utils/iframe.ts diff --git a/web/package-lock.json b/web/package-lock.json index abae7bceb4..93a1b142bf 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -25,7 +25,6 @@ "@formatjs/intl-listformat": "^7.5.7", "@fortawesome/fontawesome-free": "^6.6.0", "@goauthentik/api": "^2025.2.4-1745325566", - "@lit-labs/ssr": "^3.2.2", "@lit/context": "^1.1.2", "@lit/localize": "^0.12.2", "@lit/reactive-element": "^2.0.4", @@ -66,6 +65,7 @@ "remark-gfm": "^4.0.1", "remark-mdx-frontmatter": "^5.0.0", "style-mod": "^4.1.2", + "trusted-types": "^2.0.0", "ts-pattern": "^5.4.0", "unist-util-visit": "^5.0.0", "webcomponent-qr-code": "^1.2.0", @@ -2281,47 +2281,11 @@ "@lezer/lr": "^1.0.0" } }, - "node_modules/@lit-labs/ssr": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@lit-labs/ssr/-/ssr-3.3.1.tgz", - "integrity": "sha512-JlF1PempxvzrGEpRFrF+Ki0MHzR3HA51SK8Zv0cFpW9p0bPW4k0FeCwrElCu371UEpXF7RcaE2wgYaE1az0XKg==", - "dependencies": { - "@lit-labs/ssr-client": "^1.1.7", - "@lit-labs/ssr-dom-shim": "^1.3.0", - "@lit/reactive-element": "^2.0.4", - "@parse5/tools": "^0.3.0", - "@types/node": "^16.0.0", - "enhanced-resolve": "^5.10.0", - "lit": "^3.1.2", - "lit-element": "^4.0.4", - "lit-html": "^3.1.2", - "node-fetch": "^3.2.8", - "parse5": "^7.1.1" - }, - "engines": { - "node": ">=13.9.0" - } - }, - "node_modules/@lit-labs/ssr-client": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@lit-labs/ssr-client/-/ssr-client-1.1.7.tgz", - "integrity": "sha512-VvqhY/iif3FHrlhkzEPsuX/7h/NqnfxLwVf0p8ghNIlKegRyRqgeaJevZ57s/u/LiFyKgqksRP5n+LmNvpxN+A==", - "dependencies": { - "@lit/reactive-element": "^2.0.4", - "lit": "^3.1.2", - "lit-html": "^3.1.2" - } - }, "node_modules/@lit-labs/ssr-dom-shim": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz", "integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==" }, - "node_modules/@lit-labs/ssr/node_modules/@types/node": { - "version": "16.18.126", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", - "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==" - }, "node_modules/@lit/context": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.5.tgz", @@ -3557,6 +3521,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@parse5/tools/-/tools-0.3.0.tgz", "integrity": "sha512-zxRyTHkqb7WQMV8kTNBKWb1BeOFUKXBXTBWuxg9H9hfvQB3IwP6Iw2U75Ia5eyRxPNltmY7E8YAlz6zWwUnjKg==", + "dev": true, "dependencies": { "parse5": "^7.0.0" } @@ -10723,6 +10688,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, "engines": { "node": ">= 12" } @@ -11343,6 +11309,7 @@ "version": "5.18.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "dev": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -13820,7 +13787,8 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true }, "node_modules/grapheme-splitter": { "version": "1.0.4", @@ -18256,6 +18224,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", @@ -22373,6 +22342,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, "engines": { "node": ">=6" } @@ -22724,6 +22694,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/trusted-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/trusted-types/-/trusted-types-2.0.0.tgz", + "integrity": "sha512-Eam+AUp6lg04YjmYkuLNhEJX+6ByocrKTpY/TtfRK/gV6OmxeN0OwkIasor28SUJ606snArpPLGtPMGbqdaaUA==", + "license": "W3C-20150513" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", diff --git a/web/package.json b/web/package.json index 7810b9985c..923fc86b63 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,6 @@ "@formatjs/intl-listformat": "^7.5.7", "@fortawesome/fontawesome-free": "^6.6.0", "@goauthentik/api": "^2025.2.4-1745325566", - "@lit-labs/ssr": "^3.2.2", "@lit/context": "^1.1.2", "@lit/localize": "^0.12.2", "@lit/reactive-element": "^2.0.4", @@ -54,6 +53,7 @@ "remark-gfm": "^4.0.1", "remark-mdx-frontmatter": "^5.0.0", "style-mod": "^4.1.2", + "trusted-types": "^2.0.0", "ts-pattern": "^5.4.0", "unist-util-visit": "^5.0.0", "webcomponent-qr-code": "^1.2.0", diff --git a/web/src/common/purify.ts b/web/src/common/purify.ts index 5da9810c8a..81ada057c3 100644 --- a/web/src/common/purify.ts +++ b/web/src/common/purify.ts @@ -1,26 +1,110 @@ import type { Config as DOMPurifyConfig } from "dompurify"; import DOMPurify from "dompurify"; +import { trustedTypes } from "trusted-types"; -import { render } from "@lit-labs/ssr"; -import { collectResult } from "@lit-labs/ssr/lib/render-result.js"; -import { TemplateResult, html } from "lit"; +import { render } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { until } from "lit/directives/until.js"; +/** + * Trusted types policy that escapes HTML content in place. + * + * @see {@linkcode SanitizedTrustPolicy} to strip HTML content. + * + * @returns {TrustedHTML} All HTML content, escaped. + */ +export const EscapeTrustPolicy = trustedTypes.createPolicy("authentik-escape", { + createHTML: (untrustedHTML: string) => { + return DOMPurify.sanitize(untrustedHTML, { + RETURN_TRUSTED_TYPE: false, + }); + }, +}); + +/** + * Trusted types policy, stripping all HTML content. + * + * @returns {TrustedHTML} Text content only, all HTML tags stripped. + */ +export const SanitizedTrustPolicy = trustedTypes.createPolicy("authentik-sanitize", { + createHTML: (untrustedHTML: string) => { + return DOMPurify.sanitize(untrustedHTML, { + RETURN_TRUSTED_TYPE: false, + ALLOWED_TAGS: ["#text"], + }); + }, +}); + +/** + * Trusted types policy, allowing a minimal set of _safe_ HTML tags supplied by + * a trusted source, such as the brand API. + */ +export const BrandedHTMLPolicy = trustedTypes.createPolicy("authentik-restrict", { + createHTML: (untrustedHTML: string) => { + return DOMPurify.sanitize(untrustedHTML, { + RETURN_TRUSTED_TYPE: false, + FORBID_TAGS: [ + "script", + "style", + "iframe", + "link", + "object", + "embed", + "applet", + "meta", + "base", + "form", + "input", + "textarea", + "select", + "button", + ], + FORBID_ATTR: [ + "onerror", + "onclick", + "onload", + "onmouseover", + "onmouseout", + "onmouseup", + "onmousedown", + "onfocus", + "onblur", + "onsubmit", + ], + }); + }, +}); + +export type AuthentikTrustPolicy = + | typeof EscapeTrustPolicy + | typeof SanitizedTrustPolicy + | typeof BrandedHTMLPolicy; + +/** + * Sanitize an untrusted HTML string using a trusted types policy. + */ +export function sanitizeHTML(trustPolicy: AuthentikTrustPolicy, untrustedHTML: string) { + return unsafeHTML(trustPolicy.createHTML(untrustedHTML).toString()); +} + +/** + * DOMPurify configuration for strict sanitization. + * + * This configuration only allows text nodes and disallows all HTML tags. + */ export const DOM_PURIFY_STRICT = { ALLOWED_TAGS: ["#text"], } as const satisfies DOMPurifyConfig; -export async function renderStatic(input: TemplateResult): Promise { - return await collectResult(render(input)); -} +/** + * Render untrusted HTML to a string without escaping it. + * + * @returns {string} The rendered HTML string. + */ +export function renderStaticHTMLUnsafe(untrustedHTML: unknown): string { + const container = document.createElement("html"); + render(untrustedHTML, container); -export function purify(input: TemplateResult): TemplateResult { - return html`${until( - (async () => { - const rendered = await renderStatic(input); - const purified = DOMPurify.sanitize(rendered); - return html`${unsafeHTML(purified)}`; - })(), - )}`; + const result = container.innerHTML; + + return result; } diff --git a/web/src/elements/utils/iframe.ts b/web/src/elements/utils/iframe.ts new file mode 100644 index 0000000000..6c931e42a8 --- /dev/null +++ b/web/src/elements/utils/iframe.ts @@ -0,0 +1,55 @@ +/** + * @file IFrame Utilities + */ + +interface IFrameLoadResult { + contentWindow: Window; + contentDocument: Document; +} + +export function pluckIFrameContent(iframe: HTMLIFrameElement) { + const contentWindow = iframe.contentWindow; + const contentDocument = iframe.contentDocument; + + if (!contentWindow) { + throw new Error("Iframe contentWindow is not accessible"); + } + + if (!contentDocument) { + throw new Error("Iframe contentDocument is not accessible"); + } + + return { + contentWindow, + contentDocument, + }; +} + +export function resolveIFrameContent(iframe: HTMLIFrameElement): Promise { + if (iframe.contentDocument?.readyState === "complete") { + return Promise.resolve(pluckIFrameContent(iframe)); + } + + return new Promise((resolve) => { + iframe.addEventListener("load", () => resolve(pluckIFrameContent(iframe)), { once: true }); + }); +} + +/** + * Creates a minimal HTML wrapper for an iframe. + * + * @deprecated Use the `contentDocument.body` directly instead. + */ +export function createIFrameHTMLWrapper(bodyContent: string): string { + const html = String.raw; + + return html` + + + + + + ${bodyContent} + + `; +} diff --git a/web/src/flow/components/ak-brand-footer.ts b/web/src/flow/components/ak-brand-footer.ts index a30cb4ddcc..6d64fb655e 100644 --- a/web/src/flow/components/ak-brand-footer.ts +++ b/web/src/flow/components/ak-brand-footer.ts @@ -1,4 +1,4 @@ -import { purify } from "@goauthentik/common/purify"; +import { BrandedHTMLPolicy, sanitizeHTML } from "@goauthentik/common/purify"; import { AKElement } from "@goauthentik/elements/Base.js"; import { msg } from "@lit/localize"; @@ -21,8 +21,6 @@ const styles = css` } `; -const poweredBy: FooterLink = { name: msg("Powered by authentik"), href: null }; - @customElement("ak-brand-links") export class BrandLinks extends AKElement { static get styles() { @@ -33,13 +31,21 @@ export class BrandLinks extends AKElement { links: FooterLink[] = []; render() { - const links = [...(this.links ?? []), poweredBy]; + const links = [...(this.links ?? [])]; + return html`
    - ${map(links, (link) => - link.href - ? purify(html`
  • ${link.name}
  • `) - : html`
  • ${link.name}
  • `, - )} + ${map(links, (link) => { + const children = sanitizeHTML(BrandedHTMLPolicy, link.name); + + if (link.href) { + return html`
  • ${children}
  • `; + } + + return html`
  • + ${children} +
  • `; + })} +
  • ${msg("Powered by authentik")}
`; } } diff --git a/web/src/flow/stages/captcha/CaptchaStage.ts b/web/src/flow/stages/captcha/CaptchaStage.ts index ec29889a95..df5ba30787 100644 --- a/web/src/flow/stages/captcha/CaptchaStage.ts +++ b/web/src/flow/stages/captcha/CaptchaStage.ts @@ -1,15 +1,16 @@ -/// -import { renderStatic } from "@goauthentik/common/purify"; +/// +/// +import { renderStaticHTMLUnsafe } from "@goauthentik/common/purify"; import "@goauthentik/elements/EmptyState"; import { akEmptyState } from "@goauthentik/elements/EmptyState"; import { bound } from "@goauthentik/elements/decorators/bound"; import "@goauthentik/elements/forms/FormElement"; +import { createIFrameHTMLWrapper } from "@goauthentik/elements/utils/iframe"; import { ListenerController } from "@goauthentik/elements/utils/listenerController.js"; import { randomId } from "@goauthentik/elements/utils/randomId"; import "@goauthentik/flow/FormStatic"; import { BaseStage } from "@goauthentik/flow/stages/base"; import { P, match } from "ts-pattern"; -import type * as _ from "turnstile-types"; import { msg } from "@lit/localize"; import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit"; @@ -56,40 +57,36 @@ type CaptchaHandler = { // a resize. Because the Captcha is itself in an iframe, the reported height is often off by some // margin, so adding 2rem of height to our container adds padding and prevents scroll bars or hidden // rendering. +function iframeTemplate(children: TemplateResult, challengeURL: string): TemplateResult { + return html` ${children} + - - - - - `; + window.parent.postMessage({ + message: "resize", + source: "goauthentik.io", + context: "flow-executor", + size: { height }, + }); + }).observe(document.querySelector(".ak-captcha-container")); + + + + + `; +} @customElement("ak-stage-captcha") export class CaptchaStage extends BaseStage { @@ -305,11 +302,25 @@ export class CaptchaStage extends BaseStage