From b57ca13b9f4e7d3b48d9253bb1522d0653d882a6 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Fri, 29 May 2026 14:38:01 -0700 Subject: [PATCH] fix(fast-element): harden DOM policy defaults Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-c83200b6-e767-47cd-9726-03f03016b77b.json | 7 + packages/fast-element/DESIGN.md | 20 +++ .../fast-element/src/dom-policy.pw.spec.ts | 127 ++++++++++++++++++ packages/fast-element/src/dom-policy.ts | 44 +++++- 4 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 change/@microsoft-fast-element-c83200b6-e767-47cd-9726-03f03016b77b.json diff --git a/change/@microsoft-fast-element-c83200b6-e767-47cd-9726-03f03016b77b.json b/change/@microsoft-fast-element-c83200b6-e767-47cd-9726-03f03016b77b.json new file mode 100644 index 00000000000..4be836c8101 --- /dev/null +++ b/change/@microsoft-fast-element-c83200b6-e767-47cd-9726-03f03016b77b.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "Harden DOM policy guard merging and unsafe URL filtering.", + "packageName": "@microsoft/fast-element", + "email": "7559015+janechu@users.noreply.github.com", + "dependentChangeType": "none" +} \ No newline at end of file diff --git a/packages/fast-element/DESIGN.md b/packages/fast-element/DESIGN.md index 752c5f110e5..92ade5562b9 100644 --- a/packages/fast-element/DESIGN.md +++ b/packages/fast-element/DESIGN.md @@ -14,6 +14,7 @@ For deep dives into specific areas, see the linked detailed documents. - [FASTElement & ElementController](#fastelement--elementcontroller) - [Observables & Notifiers](#observables--notifiers) - [Bindings](#bindings) + - [DOM Policy](#dom-policy) - [html Tagged Template Literal](#html-tagged-template-literal) - [ViewTemplate & Compiler](#viewtemplate--compiler) - [Views & Behaviors](#views--behaviors) @@ -169,6 +170,25 @@ This gives FAST automatic, fine-grained dependency tracking without explicit dec --- +### DOM Policy + +**Files**: `src/dom.ts`, `src/dom-policy.ts` + +`DOM.policy` is the templating system's DOM write policy. `DOMPolicy.create()` builds a +policy from Trusted Types integration plus default guard maps for element-specific and +aspect-wide sinks. User guard configuration is merged with those defaults so partial +overrides preserve unspecified built-in guards. + +The default guards block known dangerous sinks such as inline event handlers, +`innerHTML`, script text/source writes, and executable object/link/embed sources. URL +attributes and properties that remain writable are routed through the built-in URL guard, +which trims surrounding whitespace/control characters, normalizes protocol detection +across case, embedded controls, and bounded percent-decoding, and rejects +`javascript:`, `vbscript:`, and `data:` protocols without treating safe or +protocol-relative URLs as unsafe. + +--- + ### html Tagged Template Literal **File**: `src/templating/template.ts` diff --git a/packages/fast-element/src/dom-policy.pw.spec.ts b/packages/fast-element/src/dom-policy.pw.spec.ts index 8aae48721f3..5e15e8a27a8 100644 --- a/packages/fast-element/src/dom-policy.pw.spec.ts +++ b/packages/fast-element/src/dom-policy.pw.spec.ts @@ -156,6 +156,133 @@ test.describe("the dom policy helper", () => { expect(await (await states.getProperty("invoked")).jsonValue()).toBe(1); }); + test("preserves default element guards when applying partial element guard configs", async ({ + page, + }) => { + await page.goto("/"); + + const result = await page.evaluate(async () => { + // @ts-expect-error: Client modules. + const { DOM, DOMAspect, DOMPolicy } = await import("./main.js"); + + const policy = DOMPolicy.create({ + guards: { + elements: { + a: { + [DOMAspect.attribute]: { + title(tagName, aspect, aspectName, sink) { + return sink; + }, + }, + }, + }, + }, + }); + + function setProperty(node, name, value) { + node[name] = value; + } + + let scriptSrcBlocked = false; + + try { + policy.protect("script", DOMAspect.property, "src", setProperty); + } catch { + scriptSrcBlocked = true; + } + + const area = document.createElement("area"); + const areaHrefSink = policy.protect( + "area", + DOMAspect.attribute, + "href", + DOM.setAttribute, + ); + + areaHrefSink(area, "href", " JaVaScRiPt:alert(1) "); + + return { + areaHref: area.getAttribute("href"), + scriptSrcBlocked, + }; + }); + + expect(result).toEqual({ + areaHref: "", + scriptSrcBlocked: true, + }); + }); + + test("filters unsafe URL protocols with case, whitespace, controls, and encoding", async ({ + page, + }) => { + await page.goto("/"); + + const results = await page.evaluate(async () => { + // @ts-expect-error: Client modules. + const { DOM, DOMAspect, DOMPolicy } = await import("./main.js"); + + const policy = DOMPolicy.create(); + const hrefSink = policy.protect( + "a", + DOMAspect.attribute, + "href", + DOM.setAttribute, + ); + + return [ + "javascript:alert(1)", + " JaVaScRiPt:alert(1) ", + "\u0000java\u000Ascript:alert(1)", + "java%0Ascript:alert(1)", + "%6Aava%73cript%3Aalert(1)", + "vbscript:msgbox(1)", + "data:text/html,", + ].map(value => { + const element = document.createElement("a"); + hrefSink(element, "href", value); + return element.getAttribute("href"); + }); + }); + + expect(results).toEqual(["", "", "", "", "", "", ""]); + }); + + test("preserves safe and protocol-relative URL values after trimming", async ({ + page, + }) => { + await page.goto("/"); + + const results = await page.evaluate(async () => { + // @ts-expect-error: Client modules. + const { DOM, DOMAspect, DOMPolicy } = await import("./main.js"); + + const policy = DOMPolicy.create(); + const hrefSink = policy.protect( + "a", + DOMAspect.attribute, + "href", + DOM.setAttribute, + ); + + return [ + " https://fast.design/docs ", + "//fast.design/assets/logo.svg", + "/docs/data:text/plain", + ].map(value => { + const element = document.createElement("a"); + hrefSink(element, "href", value); + return element.getAttribute("href"); + }); + }); + + expect(results).toEqual([ + "https://fast.design/docs", + "//fast.design/assets/logo.svg", + "/docs/data:text/plain", + ]); + }); + test("can create a policy with custom aspect guards", async ({ page }) => { await page.goto("/"); diff --git a/packages/fast-element/src/dom-policy.ts b/packages/fast-element/src/dom-policy.ts index 7bc35d597a5..67fc7f2a0a5 100644 --- a/packages/fast-element/src/dom-policy.ts +++ b/packages/fast-element/src/dom-policy.ts @@ -69,6 +69,46 @@ export type DOMGuards = { aspects: DOMAspectGuards; }; +const surroundingWhitespaceAndControlChars = + /^[\u0000-\u0020\u007F]+|[\u0000-\u0020\u007F]+$/g; +const whitespaceAndControlChars = /[\u0000-\u0020\u007F]+/g; +const unsafeURLProtocol = /^(?:javascript|vbscript|data):/; + +function trimURL(value: string): string { + return value.replace(surroundingWhitespaceAndControlChars, ""); +} + +function decodeURL(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function hasUnsafeURLProtocol(value: string): boolean { + let normalized = trimURL(value); + + for (let i = 0; i < 3; ++i) { + const decoded = decodeURL(normalized); + + if (decoded === normalized) { + break; + } + + normalized = trimURL(decoded); + } + + normalized = normalized.replace(whitespaceAndControlChars, "").toLowerCase(); + + return unsafeURLProtocol.test(normalized); +} + +function sanitizeURL(value: string): string { + const trimmed = trimURL(value); + return hasUnsafeURLProtocol(trimmed) ? "" : trimmed; +} + function safeURL( tagName: string | null, aspect: DOMAspect, @@ -77,7 +117,7 @@ function safeURL( ): DOMSink { return (target: Node, name: string, value: string, ...rest: any[]) => { if (isString(value)) { - value = value.replace(/(javascript:|vbscript:|data:)/, ""); + value = sanitizeURL(value); } sink(target, name, value, ...rest); @@ -378,7 +418,7 @@ function createElementGuards( break; case undefined: // keep the default - result[tag] = createDOMAspectGuards(overrideValue, {}); + result[tag] = createDOMAspectGuards(defaultValue, {}); break; default: // override the default aspects