Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
20 changes: 20 additions & 0 deletions packages/fast-element/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`
Expand Down
127 changes: 127 additions & 0 deletions packages/fast-element/src/dom-policy.pw.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,<svg onload=alert(1)>",
].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("/");

Expand Down
44 changes: 42 additions & 2 deletions packages/fast-element/src/dom-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down