import { defer } from "@commonfabric/utils/defer"; import "../src/common-iframe-sandbox.ts"; import { assert, assertDeepEquals, assertEquals, cleanupFixtures, ContextShim, invertPromise, render, waitForEvent, } from "./utils.ts"; type TestCase = [string, string, string | null | RegExp]; // Cookies should not be set with SameSite=None, but // used to test that cookies are not accessible to iframe. document.cookie = "testcookie=1; SameSite=None;"; // When CSP is applied to an iframe, the `securitypolicyviolation` // event is emitted on the iframe's `document`. // As the host and iframe's do not share origin, and `securitypolicyviolation` // events occur during load, we have to inject a CSP listener into // the iframe content for these its. // Outside of its/in app, we *may* want to inject this, though // content may still work with some imports (e.g. styles/images) failing. // If so, we may want to add a new post message "event" in addition // to the not-very-CSP-compatible "error" event. // // Additionally, we want to propagate other errors to the parent frame // for inspection. const CSP_REPORTER = ` `; // Raises a recognizable alarm once the guest has passed the point where a // case's error would already have been posted. // // It is an alarm rather than a write, and that is what makes it a barrier. An // error leaves the guest as a postMessage to its parent, which forwards it to // the host on a second fixed pair of windows; this takes that same route, and // each hop preserves the order it received messages in, so an alarm posted // after an error arrives after it. A write would not do: it rides the guest's // port, which is a channel of its own with no ordering against this one, and a // barrier that can arrive before an error already sent bounds nothing. // // The load event is the point the cases below share. It fires once the document // has parsed and its subresources have settled, and it lands after the // violation for the two kinds of subresource those cases load: an `` // element and a ``. // // It is not a general barrier for CSP violations, and the cases below stay // inside what it covers. A blocked ` `; const HTML_URL = "https://commonfabric.com"; const SCRIPT_URL = "https://commonfabric.com/static/sketch.js"; const STYLE_URL = "https://commonfabric.com/static/main.css"; const IMG_URL = "https://commonfabric.com/static/text.png"; const ORIGIN_URL = new URL(globalThis.location.href).origin; const BASE64_IMG_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="; function openWindow(target: string) { return ``; } function clickAnchor(target: string) { return ` `; } // `openWindow` with its guard inverted: throws when the window did not open, // which is the outcome the real cases assert. Used by `barrierControls`. function invertedOpenWindow(target: string) { return ``; } // `clickAnchor` with a throw at the point the click happens, which the real // cases reach without raising anything. Used by `barrierControls`. function throwingClickAnchor(target: string) { return ` `; } const cases: TestCase[] = [[ "allows inline script", `
foo
`, null, ], [ "allows 1P img", ``, null, ], [ "allows data: img", ``, null, ], [ "allows 1P CSS", ``, null, ], [ "disallows opening windows (_blank)", openWindow("_blank"), null, ], [ "disallows opening windows (_parent)", openWindow("_parent"), null, ], [ "disallows opening windows (_top)", openWindow("_top"), null, ], [ "disallows anchor link target (_parent)", clickAnchor("_parent"), null, ], [ "disallows anchor link target (_top)", clickAnchor("_top"), null, ], [ "disallows fetch", ``, "CSP:connect-src", ], [ "disallows 3P JS elements", ``, "CSP:script-src-elem", ], [ "disallows 3P CSS elements", ``, "CSP:style-src-elem", ], [ "disallows 3P CSS imports", ``, "CSP:style-src-elem", ], [ "disallows 3P images in styles", ``, "CSP:img-src", ], [ "disallows 3P images in elements", ``, "CSP:img-src", ], [ "disallows base element", ``, "CSP:base-uri", ], [ "disallows prefetch", ``, "CSP:default-src", ], [ "disallows Worker", ``, /Uncaught SecurityError/, ], [ "disallows SharedWorker", ``, /Uncaught SecurityError/, ], [ "disallows ServiceWorker", ``, /Uncaught SecurityError/, ], [ "disallows cookie access", ``, /Uncaught SecurityError/, ]]; // These negative cases keep the fixed-duration wait. For each of them nothing // the guest can post is ordered after the error the case rules out, so there is // no marker to barrier on, and each stays only as good as its timeout makes it: // an error arriving later than the wait is missed. // // "allows 1P fetch" is bounded by the browser rather than by the guest. A // `fetch()` does not hold the document's load event, and a blocked request // reports its violation a macrotask turn after the request has already // rejected — later than the load event as well. The outcome of the request // cannot stand in for the violation either: the guest runs on an opaque // origin, so the host's own origin is cross-origin to it, and both a 1P and a // 3P request reject with "TypeError: Failed to fetch" once CORS fails the // response. The violation is the only thing that separates allowed from // blocked, and the browser reports it after every guest continuation for the // request has run. // // "disallows anchor link target (_self)" navigates the guest's own frame, and // the navigation tears the guest document down, so no guest code is left to // report a marker. It raises no error either, and so asserts only that the // guest reported nothing before it was torn down. const unbarrierable: TestCase[] = [[ "allows 1P fetch", ``, null, ], [ "disallows anchor link target (_self)", clickAnchor("_self"), null, ]]; // /!\ This test cannot assert what its name says, so it does not run. // // It is here rather than in `cases` because its guard is unsound. // `window.open(url, "_self")` targets the current browsing context, so it hands // that window back whether or not the navigation is allowed, and the fixture's // `if (win) throw` therefore always throws. The host does dispatch the // resulting "Uncaught Error: Window Opened": a listener installed before the // load sees it every time. The case only ever passed because it began listening // after `render` had resolved, by which point the error had already been // dispatched, and reading the resulting silence as "no window was opened". // // What it set out to check is not observable from the host. The `_self` // navigation tears the guest down before any guest code could report on it, and // whether the navigation is ultimately blocked cannot be seen from out here. // That is the same wall "disallows navigation" in `falseNegatives` below runs // into — it drives the same self-navigation through `location.href`, is blocked // in practice, and reports nothing — which is why it does not run either. // // Deciding what should be asserted about `_self` navigation is a question about // the sandbox's intended policy, not about how this test waits. const unsoundAssertions: TestCase[] = [[ "disallows opening windows (_self)", openWindow("_self"), null, ]]; // Every negative case above proves the same thing: no error had fired by the // time the barrier arrived. Each control here runs one of those fixtures' // shapes through the same body and the same wait, against html that does raise // an error, and requires the error to be in hand by barrier time. The third // element is the description it must carry, so a control cannot pass on an // unrelated error. // // The controls are not all doing the same job, and the difference decides what // each is evidence of. // // Ordering only needs proving for a case whose error can arrive after the // guest's own scripts have run, and the two subresource controls are the ones // that prove it. Post the barrier during parse instead of on load and they go // red, because the violation for a blocked `` or `` // is reported after the parser has passed the barrier script. // // The other six raise their error from a synchronous throw while the document // parses, before the barrier script has been reached at all. No barrier the // guest could post can come first, so they cannot fail on ordering and say // nothing about it. Their converted cases do not need them to: the error those // cases rule out is raised the same way, during parse, so any barrier is // already after it. What these six check instead is that the error channel is // live for their shape — that `CSP_REPORTER` is installed, and that the guest // is still there to report. That is the property separating the `_parent` and // `_top` cases from the `_self` ones, which tear the guest down before it can // report anything. const barrierControls: [string, string, string][] = [[ // "allows inline script": an error raised while the document parses. "inline script throws", ``, "Uncaught Error: boom", ], [ // "allows 1P img" and "allows data: img". "3P img element", ``, "CSP:img-src", ], [ // "allows 1P CSS". "3P CSS link", ``, "CSP:style-src-elem", ], [ // The three `openWindow` cases, with the guard inverted so the fixture // throws on the path the real cases leave silent. "openWindow (_blank) throws", invertedOpenWindow("_blank"), "Uncaught Error: Window Opened", ], [ "openWindow (_parent) throws", invertedOpenWindow("_parent"), "Uncaught Error: Window Opened", ], [ "openWindow (_top) throws", invertedOpenWindow("_top"), "Uncaught Error: Window Opened", ], [ // The two `clickAnchor` cases, throwing where the click happens. "clickAnchor (_parent) throws", throwingClickAnchor("_parent"), "Uncaught Error: clicked", ], [ "clickAnchor (_top) throws", throwingClickAnchor("_top"), "Uncaught Error: clicked", ]]; // /!\ These tests do not report correctly. // /!\ Not sure why! But they correctly are blocked // /!\ in practice. How can we ensure this is properly tested? const falseNegatives: TestCase[] = [[ "disallows iframes", ``, "CSP:frame-src", ], [ "disallows navigation", ``, "CSP:default-src", ]]; // /!\ These tests do not report correctly. // /!\ Not sure why! But they appear to be allowed // /!\ but are not in practice. const falsePositives: TestCase[] = [[ "Allows anchor link target (_blank)", clickAnchor("_blank"), null, ]]; const unknownStatuses: TestCase[] = [ [ // `prerender` is a Chrome-only feature-flagged // source of exfiltration. This *should* be // covered by `default-src`, but TBD on testing that. "disallows prerender", ``, "CSP:default-src", ], ]; for (const [name, html, expected] of cases) { defineTest(name, html, expected); } for (const [name, html, expected] of unbarrierable) { defineTimedNegativeTest(name, html, expected); } for (const [name, html, expected] of unsoundAssertions) { definePending(name, html, expected); } for (const [name, html, expected] of barrierControls) { defineBarrierControl(name, html, expected); } for (const [name, html, expected] of falseNegatives) { definePending(name, html, expected); } for (const [name, html, expected] of falsePositives) { definePending(name, html, expected); } for (const [name, html, expected] of unknownStatuses) { definePending(name, html, expected); } function defineTest( name: string, html: string, expected: string | null | RegExp, ) { Deno.test(name, async () => { cleanupFixtures(); // The event bubbles and is composed, so the document sees it. Listening // there rather than on the element catches an error the host dispatches // before `render` hands the element back. // The barrier arrives as an alarm like any other, and is separated from // the errors a case is asserting about rather than counted among them. const errors: string[] = []; let barrierArrived = false; let noticeError: (() => void) | undefined; let noticeBarrier: (() => void) | undefined; const onError = (event: Event) => { const description = (event as CustomEvent).detail.description as string; if (description === BARRIER_DESCRIPTION) { barrierArrived = true; noticeBarrier?.(); return; } errors.push(description); noticeError?.(); }; document.addEventListener("common-iframe-error", onError); // Resolves with the first error's description, from the collection this // listener has been filling since before the frame existed. A case whose // error is dispatched during the load `render` waits on has already been // recorded by the time this is called. const firstErrorDescription = (): Promise => { if (errors.length > 0) { return Promise.resolve(errors[0]); } const deferred = defer(); noticeError = () => deferred.resolve(errors[0]); return deferred.promise; }; // Resolves once the barrier has arrived, which the same listener records. const barrierSeen = (): Promise => { if (barrierArrived) { return Promise.resolve(); } const deferred = defer(); noticeBarrier = () => deferred.resolve(); return deferred.promise; }; try { const context = new ContextShim(); const body = ` ${CSP_REPORTER} ${html} ${BARRIER} `; await render(body, context); if (expected == null) { await barrierSeen(); assertDeepEquals(errors, []); } else { const description = await firstErrorDescription(); if (typeof expected === "string") { assertEquals(description, expected); } else { assert(expected.test(description)); } } } finally { document.removeEventListener("common-iframe-error", onError); cleanupFixtures(); } }); } // Runs a `barrierControls` case: same body and same wait as a negative case in // `defineTest`, against html that does break the policy. The error must be in // hand at the moment the barrier arrives. function defineBarrierControl( name: string, html: string, expected: string, ) { Deno.test(`barrier control: ${name}`, async () => { cleanupFixtures(); const errors: string[] = []; let barrierArrived = false; let noticeBarrier: (() => void) | undefined; const onError = (event: Event) => { const description = (event as CustomEvent).detail.description as string; if (description === BARRIER_DESCRIPTION) { barrierArrived = true; noticeBarrier?.(); return; } errors.push(description); }; document.addEventListener("common-iframe-error", onError); const barrierSeen = (): Promise => { if (barrierArrived) { return Promise.resolve(); } const deferred = defer(); noticeBarrier = () => deferred.resolve(); return deferred.promise; }; try { const context = new ContextShim(); const body = ` ${CSP_REPORTER} ${html} ${BARRIER} `; await render(body, context); await barrierSeen(); assertDeepEquals(errors, [expected]); } finally { document.removeEventListener("common-iframe-error", onError); cleanupFixtures(); } }); } // The pre-barrier shape, kept for the `unbarrierable` cases: wait out // `waitForEvent`'s timeout and read the rejection as "no error fired". It // bounds how late an error can arrive and still be caught, and it starts // listening only once `render` has resolved. function defineTimedNegativeTest( name: string, html: string, expected: string | null | RegExp, ) { if (expected != null) { throw new Error(`${name}: expected a negative case.`); } Deno.test(name, async () => { cleanupFixtures(); try { const body = ` ${CSP_REPORTER} ${html} `; const iframe = await render(body); await invertPromise(waitForEvent(iframe, "common-iframe-error")); } finally { cleanupFixtures(); } }); } function definePending( name: string, _html: string, _expected: string | null | RegExp, ) { Deno.test(name, () => {}); }