Skip to content

Commit

Permalink
chore: initial common-iframe-sandbox, isolating CSP/security features…
Browse files Browse the repository at this point in the history
… to a single package (commontoolsinc#300)

chore: initial common-iframe-sandbox, isolating CSP/security features to a single package.
  • Loading branch information
jsantell authored Jan 30, 2025
1 parent 6c125dc commit 1ce1f34
Show file tree
Hide file tree
Showing 20 changed files with 395 additions and 204 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ui-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ jobs:
pnpm install
- name: UI Tests
run: |
cd typescript/packages/common-ui
cd typescript/packages/common-iframe-sandbox
pnpm test
43 changes: 43 additions & 0 deletions typescript/packages/common-iframe-sandbox/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# common-iframe-sandbox

This package contains a custom element `<common-iframe-sandbox>`, which enables a sandboxed iframe to execute arbitrary code.

> [!CAUTION]
> This is experimental software and no guarantees of security are provided.
> Continue reading for full details of current status and limitations.
## Usage

`<common-iframe-sandbox>` takes a `src` string of HTML content and a `context` object. The contents in `src` are loaded
inside a sandboxed iframe (via `srcdoc`).

```js
const element = document.createElement('common-iframe-sandbox');
element.src = "<h1>Hello</h1>";
element.context = {};
```

The element has a `context` property that can be set to read, write, or subscribe to a key/value store from the iframe contents.
See the inter-frame communications in [ipc.ts](/typescript/packages/common-iframe-sandbox/src/ipc.ts).

To handle these messages, a global singleton handler is used that must be set via `setIframeContextHandler(handler)`. The handler is called with the `context` provided to the iframe, and the handler determines how values are stored and retrieved.
See [context.ts](/typescript/packages/common-iframe-sandbox/src/context.ts).

## Missing Functionality

* Support updating the `src` property.
* Flushing subscriptions inbetween frame loads.
* Uniquely identify context handler calls so that they can be mapped to the correct iframe instance when there are multiple active sandboxed iframes.
* Support browsers that do not support `HTMLIFrameElement.prototype.csp` (non-chromium).
* Abort on unsupported browsers.
* Further testing.

## Incomplete Security Considerations

* `document.baseURI` is accessible in an iframe, leaking the parent URL
* Currently without CFC, data can be written in the iframe containing other sensitive data,
or newly synthesized fingerprinting via capabilities (accelerometer, webrtc, canvas),
and saved back into the database, where some other vector of exfiltration could occur.
* Exposing iframe status to outer content could be considered leaky,
though all content is inlined, not HTTP URLs.
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#error_and_load_event_behavior
40 changes: 40 additions & 0 deletions typescript/packages/common-iframe-sandbox/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@commontools/iframe-sandbox",
"version": "0.1.0",
"description": "ui",
"main": "src/index.js",
"type": "module",
"packageManager": "pnpm@10.0.0",
"engines": {
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 10.0.0",
"node": "20.11.0"
},
"scripts": {
"test": "npm run build && web-test-runner test/**/*.test.js --node-resolve",
"format": "prettier --write . --ignore-path ../../../.prettierignore",
"lint": "eslint .",
"build": "tsc && vite build",
"watch": "web-test-runner test/**/*.test.js --node-resolve --watch"
},
"author": "",
"license": "UNLICENSED",
"dependencies": {
"lit": "^3.2.1",
"tslib": "^2.8.1"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/node": "^22.10.10",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.3",
"globals": "^15.14.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vite-plugin-dts": "^4.5.0",
"@web/test-runner": "^0.19.0"
}
}
140 changes: 140 additions & 0 deletions typescript/packages/common-iframe-sandbox/src/common-iframe-sandbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from "lit/directives/ref.js";
import * as IPC from "./ipc.js";
import { getIframeContextHandler } from "./context.js";
import { CSP } from "./csp.js";

// @summary A sandboxed iframe to execute arbitrary scripts.
// @tag common-iframe-sandbox
// @prop {string} src - String representation of HTML content to load within an iframe.
// @prop context - Cell context.
// @event {CustomEvent} error - An error from the iframe.
// @event {CustomEvent} load - The iframe was successfully loaded.
@customElement("common-iframe-sandbox")
export class CommonIframeSandboxElement extends LitElement {
@property({ type: String }) src = "";
@property({ type: Object }) context?: object;

private iframeRef: Ref<HTMLIFrameElement> = createRef();

private subscriptions: Map<string, any> = new Map();

private handleMessage = (event: MessageEvent) => {
if (event.data?.source == "react-devtools-content-script") {
return;
}

if (event.source !== this.iframeRef.value?.contentWindow) {
return;
}

const IframeHandler = getIframeContextHandler();
if (IframeHandler == null) {
console.error("common-iframe-sandbox: No iframe handler defined.");
return;
}

if (!this.context) {
console.error("common-iframe-sandbox: missing `context`.");
return;
}

if (!IPC.isGuestMessage(event.data)) {
console.error("common-iframe-sandbox: Malformed message from guest.");
return;
}

const message: IPC.GuestMessage = event.data;

switch (message.type) {
case IPC.GuestMessageType.Error: {
const { description, source, lineno, colno, stacktrace } = message.data;
const error = { description, source, lineno, colno, stacktrace };
this.dispatchEvent(new CustomEvent("error", {
detail: error,
}));
return;
}

case IPC.GuestMessageType.Read: {
const key = message.data;
const value = IframeHandler.read(this.context, key);
const response: IPC.HostMessage = {
type: IPC.HostMessageType.Update,
data: [key, value],
}
this.iframeRef.value?.contentWindow?.postMessage(response, "*");
return;
}

case IPC.GuestMessageType.Write: {
const [key, value] = message.data;
IframeHandler.write(this.context, key, value);
return;
}

case IPC.GuestMessageType.Subscribe: {
const key = message.data;

if (this.subscriptions.has(key)) {
console.warn("common-iframe-sandbox: Already subscribed to `${key}`");
return;
}
let receipt = IframeHandler.subscribe(this.context, key, (key, value) => this.notifySubscribers(key, value));
this.subscriptions.set(key, receipt);
return;
}

case IPC.GuestMessageType.Unsubscribe: {
const key = message.data;
let receipt = this.subscriptions.get(key);
if (!receipt) {
return;
}
IframeHandler.unsubscribe(this.context, receipt);
this.subscriptions.delete(key);
return;
}
};
}

private notifySubscribers(key: string, value: any) {
const response: IPC.HostMessage = {
type: IPC.HostMessageType.Update,
data: [key, value],
}
this.iframeRef.value?.contentWindow?.postMessage(response, "*");
}

private boundHandleMessage = this.handleMessage.bind(this);

override connectedCallback() {
super.connectedCallback();
window.addEventListener("message", this.boundHandleMessage);
}

override disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("message", this.boundHandleMessage);
}

private handleLoad() {
this.dispatchEvent(new CustomEvent("load"));
}

override render() {
return html`
<iframe
${ref(this.iframeRef)}
sandbox="allow-scripts allow-forms allow-pointer-lock"
csp="${CSP}"
.srcdoc=${this.src}
height="100%"
width="100%"
style="border: none;"
@load=${this.handleLoad}
></iframe>
`;
}
}
22 changes: 22 additions & 0 deletions typescript/packages/common-iframe-sandbox/src/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// An `IframeContextHandler` is used by consumers to
// register how read/writing values from frames are handled.
export interface IframeContextHandler {
read(context: any, key: string): any,
write(context: any, key: string, value: any): void,
subscribe(context: any, key: string, callback: (key: string, value: any) => void): any,
unsubscribe(context: any, receipt: any): void,
}

let IframeHandler: IframeContextHandler | null = null;

// Set the `IframeContextHandler` singleton. Allows indirect cell synchronizing
// so that this sandboxing doesn't need to concern itself with application-level
// synchronizing mechanisms.
export function setIframeContextHandler(handler: IframeContextHandler) {
IframeHandler = handler;
}

// Get the `IframeContextHandler` singleton.
export function getIframeContextHandler(): IframeContextHandler | null {
return IframeHandler;
}
43 changes: 43 additions & 0 deletions typescript/packages/common-iframe-sandbox/src/csp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const SCRIPT_CDNS = [
'https://unpkg.com',
'https://cdn.tailwindcss.com'
];

// This CSP directive uses 'unsafe-inline' to allow
// origin-less styles and scripts to be used, defeating
// many traditional uses of CSP.
export const CSP = `` +
// Disable all fetch directives. Re-enable
// each specific fetch directive as needed.
`default-src 'none';` +
// Scripts: Allow 1P, inline, and CDNs.
`script-src 'self' 'unsafe-inline' ${SCRIPT_CDNS.join(' ')};` +
// Styles: Allow 1P, inline.
`style-src 'self' 'unsafe-inline';` +
// Images: Allow 1P, inline.
`img-src 'self' 'unsafe-inline';` +
// Disabling until we have a concrete case.
`form-action 'none';` +
// Disable <base> element
`base-uri 'none';` +
// Iframes/Workers: Use default (disabled)
`child-src 'none';` +
// Ping/XHR/Fetch/Sockets: Allow 1P only
`connect-src 'self';` +
// This is a deprecated/Chrome-only CSP directive.
// This blocks `<link rel=`prefetch`>` and
// the Chrome-only `<link rel=`prerender`>`.
// `default-src` is used correctly as a fallback for
// prefetch
//`prefetch-src 'none';` +
// Fonts: Use default (disabled)
//`font-src 'none';` +
// Media: Use default (disabled)
//`media-src 'none';` +
// Manifest: Use default (disabled)
//`manifest-src 'none';` +
// Object/Embeds: Use default (disabled)
//`object-src 'none';` +
``;

export const META_TAG_CSP = `<meta http-equiv="Content-Security-Policy" content="${CSP}" />`;
4 changes: 4 additions & 0 deletions typescript/packages/common-iframe-sandbox/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./csp.js";
export * as IPC from "./ipc.js";
export * from "./context.js";
export * from "./common-iframe-sandbox.js";
Original file line number Diff line number Diff line change
@@ -1,23 +1,4 @@
// Types used by the `common-iframe` IPC.

export interface IframeContextHandler {
read(context: any, key: string): any,
write(context: any, key: string, value: any): void,
subscribe(context: any, key: string, callback: (key: string, value: any) => void): any,
unsubscribe(context: any, receipt: any): void,
}

let IframeHandler: IframeContextHandler | null = null;
// Set the `IframeContextHandler` singleton. Allows cell synchronizing
// so that `common-ui` doesn't directly depend on `common-runner`.
export function setIframeContextHandler(handler: IframeContextHandler) {
IframeHandler = handler;
}

// Get the `IframeContextHandler` singleton.
export function getIframeContextHandler(): IframeContextHandler | null {
return IframeHandler;
}
// Types used by the `common-iframe-sandbox` IPC.

export interface GuestError {
description: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IframeIPC } from "../lib/src/index.js";
import { setIframeContextHandler } from "../lib/src/index.js";

export class ContextShim {
constructor(object = {}) {
Expand Down Expand Up @@ -35,7 +35,7 @@ export class ContextShim {
}

export function setIframeTestHandler() {
IframeIPC.setIframeContextHandler({
setIframeContextHandler({
read(context, key) {
return context.get(key);
},
Expand Down Expand Up @@ -68,9 +68,9 @@ export function render(src, context = {}) {
return new Promise(resolve => {
const parent = document.createElement('div');
parent.id = FIXTURE_ID;
const iframe = document.createElement('common-iframe');
const iframe = document.createElement('common-iframe-sandbox');
iframe.context = context;
iframe.addEventListener('load', e => {
iframe.addEventListener('load', _ => {
resolve(iframe);
})
parent.appendChild(iframe);
Expand Down
16 changes: 16 additions & 0 deletions typescript/packages/common-iframe-sandbox/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"moduleResolution": "nodenext",
"compilerOptions": {
"lib": ["es2022", "esnext.array", "esnext", "dom"],
"outDir": "./lib",
"declaration": true,
"jsx": "preserve",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"experimentalDecorators": true,
"useDefineForClassFields": false
},
"include": ["src/**/*.ts", "test/**/*.ts", "src/**/*.tsx", "vite-env.d.ts"],
"exclude": []
}
14 changes: 14 additions & 0 deletions typescript/packages/common-iframe-sandbox/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import { resolve } from "path";
import dts from "vite-plugin-dts";

// https://vitejs.dev/config/
export default defineConfig({
build: { lib: {
entry: resolve(__dirname, "src/index.ts"),
// We need a name when building as umd/iife, which web-test-runner does
name: "common-ui"
}},
resolve: { alias: { src: resolve("src/") } },
plugins: [dts()],
});
Loading

0 comments on commit 1ce1f34

Please sign in to comment.