Skip to content

Commit

Permalink
chore: add tests for iframe CSP and API contracts and start on CI (co…
Browse files Browse the repository at this point in the history
  • Loading branch information
jsantell authored Jan 28, 2025
1 parent 28210a5 commit 14d5f0f
Show file tree
Hide file tree
Showing 6 changed files with 270 additions and 102 deletions.
34 changes: 34 additions & 0 deletions .github/workflows/ui-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: UI Tests
on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache-dependency-path: typescript/packages/pnpm-lock.yaml
- name: Install dependencies
run: |
cd typescript/packages
pnpm install
- name: UI Tests
run: |
cd typescript/packages/common-ui
pnpm test
7 changes: 5 additions & 2 deletions typescript/packages/common-ui/src/components/common-iframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import { IframeIPC } from "../index.js";
// many traditional uses of CSP.
const CSP = "" +
// Disable all fetch directives by default
"default-src 'none';" +
"default-src 'self';" +
// Allow CDN scripts, unsafe inline.
"script-src 'unsafe-inline' unpkg.com cdn.tailwindcss.com;" +
// unsafe inline.
"style-src 'unsafe-inline';";
"style-src 'unsafe-inline';" +
// Disabling until we have a concrete case.
"form-action 'none';" +
"";

// @summary A sandboxed iframe to execute arbitrary scripts.
// @tag common-iframe
Expand Down
98 changes: 98 additions & 0 deletions typescript/packages/common-ui/test/iframe-csp.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { waitForEvent, invertPromise, setIframeTestHandler, cleanup, render, assertEquals } from "./utils.js";

setIframeTestHandler();

// 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.
const CSP_REPORTER = `
<script>
document.addEventListener('securitypolicyviolation', e => {
window.parent.postMessage({
type: 'error',
data: {
description: e.violatedDirective,
source: e.sourceFile,
lineno: 0,
colno: 0,
stacktrace: "",
}
}, '*');
});
</script>
`;

const HTML_URL = "https://common.tools";
const SCRIPT_URL = "https://common.tools/static/sketch.js";
const STYLE_URL = "https://common.tools/static/main.css";
const IMG_URL = "https://common.tools/static/text.png";
const ORIGIN_URL = new URL(window.location.href).origin;

describe("common-iframe CSP", () => {
afterEach(cleanup);

const cases = [[
"allows inline script",
`<script>console.log("foo")</script><style>* { background-color: red; }</style><div>foo</div>`,
null
], [
"allows self resources",
`<script>fetch("${ORIGIN_URL}/foo.js")</script>`,
null,
], [
"disallows 3P JS elements",
`<script src="${SCRIPT_URL}"></script>`,
"script-src-elem",
], [
"disallows 3P CSS elements",
`<link rel="stylesheet" href="${STYLE_URL}">`,
"style-src-elem",
], [
"disallows 3P CSS imports",
`<style>@import url("${STYLE_URL}") print;</style>`,
"style-src-elem",
], [
"disallows 3P images in styles",
`<style>* { background-image: url("${IMG_URL}"); }</style>`,
"img-src",
], [
"disallows 3P images in elements",
`<img src="${IMG_URL}" />`,
"img-src",
], [
"disallows fetch",
`<script>fetch("${SCRIPT_URL}");</script>`,
"connect-src",
]];

// These tests do not report correctly.
const falseNegatives = [
[ // An error isn't fired in the test here, but does correctly
// prevents iframe rendering in practice
"disallows iframes",
`<iframe src="${HTML_URL}"></iframe>`,
"frame-src",
]];

for (let [name, html, expected] of cases) {
it(name, async () => {
const body = `
${CSP_REPORTER}
${html}
`;
const iframe = await render(body);
if (expected == null) {
await invertPromise(waitForEvent(iframe, "error"));
} else {
let event = await waitForEvent(iframe, "error");
assertEquals(event.detail.description, expected);
}
});
}
});
129 changes: 32 additions & 97 deletions typescript/packages/common-ui/test/iframe.test.js
Original file line number Diff line number Diff line change
@@ -1,103 +1,38 @@
import { IframeIPC } from "../lib/src/index.js";
import { ContextShim, setIframeTestHandler, cleanup, render } from "./utils.js";

IframeIPC.setIframeContextHandler({
read(_context, _key) { },
write(_context, _key, _value) {
},
subscribe(_context, _key, _callback) {
return {};
},
unsubscribe(_context, _receipt) {
}
});

// 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.
const CSP_REPORTER = `
<script>
document.addEventListener('securitypolicyviolation', e => {
window.parent.postMessage({
type: 'error',
data: {
description: e.violatedDirective,
source: e.sourceFile,
lineno: 0,
colno: 0,
stacktrace: "",
}
}, '*');
});
</script>
`;

const FIXTURE_ID = "common-iframe-csp-fixture-container";

function assertEquals(a, b) {
if (a !== b) {
throw new Error(`${a} does not equal ${b}.`);
}
}

function render(src) {
return new Promise(resolve => {
const parent = document.createElement('div');
parent.id = FIXTURE_ID;
const iframe = document.createElement('common-iframe');
iframe.context = {};
iframe.addEventListener('load', e => {
resolve(iframe);
})
parent.appendChild(iframe);
document.body.appendChild(parent);
iframe.src = src;
});
}
setIframeTestHandler();

function invertPromise(promise) {
return new Promise((resolve, reject) => promise.then(reject, resolve))
}
describe("common-iframe API", () => {
afterEach(cleanup);

function waitForEvent(element, eventName, timeout = 1000) {
return new Promise((resolve, reject) => {
let timer = setTimeout(() => {
reject(`Timeout reached waiting for ${eventName}`)
}, timeout);
element.addEventListener(eventName, e => {
clearTimeout(timer);
resolve(e);
}, { once: true });
});
}

describe("common-iframe", () => {
afterEach(() => {
const parent = document.querySelector(`#${FIXTURE_ID}`);
document.body.removeChild(parent);
});

it('works without 3P resources', async () => {
const body = `
${CSP_REPORTER}
<div>foo</div>
`;
const iframe = await render(body);
await invertPromise(waitForEvent(iframe, "error"));
});
it("read and writes", async () => {
let context = new ContextShim({ a: 1 });

it('disallows 3P JS', async () => {
const body = `
${CSP_REPORTER}
<script src="https://ajax.googleapis.com/ajax/libs/threejs/r84/three.min.js"></script>
`;
const iframe = await render(body);
let event = await waitForEvent(iframe, "error");
assertEquals(event.detail.description, "script-src-elem");
<script>
window.addEventListener('message', e => {
let { type, data: [key, value] } = e.data;
if (type === "update" && key === "a") {
window.parent.postMessage({
type: 'write',
data: [key, value + 1,]
})
}
});
window.parent.postMessage({
type: 'read',
data: 'a',
}, '*');
</script>
`;
const _iframe = await render(body, context);

let tries = 10;
while (tries-- > 0) {
if (context.get("a") === 2) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
});
})
});
98 changes: 98 additions & 0 deletions typescript/packages/common-ui/test/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { IframeIPC } from "../lib/src/index.js";

export class ContextShim {
constructor(object = {}) {
this.data = object;
this.callbacks = [];
this.receiptIds = 0;
}
set(key, value) {
this.data[key] = value;
for (let i = 0; i < this.callbacks.length; i++) {
let [_, callback_key, callback] = this.callbacks[i];
if (key === callback_key) {
callback(key, value);
}
}
}
get(key) {
return this.data[key];
}
subscribe(key, callback) {
let id = this.receiptIds++;
this.callbacks.push([id, key, callback]);
return id;
}
unsubscribe(receipt) {
for (let i = 0; i < this.callbacks.length; i++) {
let [id, ...rest] = this.callbacks[i];
if (id === receipt) {
this.callbacks.splice(i, 1);
return;
}
}
}
}

export function setIframeTestHandler() {
IframeIPC.setIframeContextHandler({
read(context, key) {
return context.get(key);
},
write(context, key, value) {
context.set(key, value);
},
subscribe(context, key, callback) {
return context.subscribe(key, callback);
},
unsubscribe(context, receipt) {
context.unsubscribe(receipt);
}
});
}


export function assertEquals(a, b) {
if (a !== b) {
throw new Error(`${a} does not equal ${b}.`);
}
}

const FIXTURE_ID = "common-iframe-csp-fixture-container";
export function render(src, context = {}) {
return new Promise(resolve => {
const parent = document.createElement('div');
parent.id = FIXTURE_ID;
const iframe = document.createElement('common-iframe');
iframe.context = context;
iframe.addEventListener('load', e => {
resolve(iframe);
})
parent.appendChild(iframe);
document.body.appendChild(parent);
iframe.src = src;
});
}

export function cleanup() {
const parent = document.querySelector(`#${FIXTURE_ID}`);
document.body.removeChild(parent);
}

export function invertPromise(promise) {
return new Promise((resolve, reject) => promise.then(reject, resolve))
}

export function waitForEvent(element, eventName, timeout = 1000) {
return new Promise((resolve, reject) => {
let timer = setTimeout(() => {
reject(`Timeout reached waiting for ${eventName}`)
}, timeout);
let handler = e => {
element.removeEventListener(eventName, handler);
clearTimeout(timer);
resolve(e);
};
element.addEventListener(eventName, handler);
});
}
Loading

0 comments on commit 14d5f0f

Please sign in to comment.