Skip to content
Merged
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
18 changes: 16 additions & 2 deletions .github/workflows/deploy-sandboxed-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ on:
branches: [main]
paths:
- 'hub-client/quarto-hub-sandboxed-preview/**'
# The bundle embeds the renderer from preview-renderer source and
# vendored resources; rebuild when they change.
- 'ts-packages/preview-renderer/**'
- 'resources/js/**'
- 'resources/revealjs/**'
- 'resources/attribution/**'
- '.github/workflows/deploy-sandboxed-preview.yml'
workflow_dispatch:

Expand All @@ -30,10 +36,18 @@ jobs:
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: hub-client/quarto-hub-sandboxed-preview/package-lock.json
cache-dependency-path: |
hub-client/quarto-hub-sandboxed-preview/package-lock.json
package-lock.json

# The bundle imports @quarto/preview-renderer *source*, whose bare
# deps (tiptap, reveal.js, katex, …) resolve from the repo-root
# node_modules — install the workspaces first.
- name: Install workspace dependencies
run: npm ci

# This package is deliberately a standalone npm project (own lockfile),
# not part of the root npm workspaces — install inside it.
# not part of the root npm workspaces — install inside it too.
- name: Install dependencies
run: npm ci
working-directory: hub-client/quarto-hub-sandboxed-preview
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/github-pages.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# GitHub Pages deployment

`deploy-sandboxed-preview.yml` builds `hub-client/quarto-hub-sandboxed-preview/`
(`npm ci` + `npm run build`) and publishes its `dist/` — a self-contained
`index.html` plus `serviceWorker.js` — to **https://quarto-dev.github.io/q2/**,
(root `npm ci` for the workspace deps the renderer source needs, then
`npm ci` + `npm run build` inside the package) and publishes its `dist/` —
`index.html`, hashed `assets/` (renderer bundle, KaTeX fonts), and
`serviceWorker.js` — to **https://quarto-dev.github.io/q2/**,
where hub-client's `Q2SandboxedPreviewIframe.tsx` loads it cross-origin as its
iframe `src` (override with `VITE_Q2_SANDBOXED_PREVIEW_URL`). It runs on any
push to `main` touching that package, or manually via
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/hub-client-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,20 @@ jobs:
- name: Build TypeScript packages (ts-packages + hub-client)
env:
VITE_E2E: '1'
# Point the sandboxed-preview iframe at the freshly built
# same-origin copy in public/ (built by build:sandboxed below)
# instead of the GitHub Pages deployment — PR CI must test this
# branch's renderer, not the last deployed one.
VITE_Q2_SANDBOXED_PREVIEW_URL: 'q2-sandboxed-preview/index.html'
run: |
for pkg in ts-packages/*/; do
if [ -f "$pkg/package.json" ]; then
npm run build --workspace "$pkg" --if-present
fi
done
# The sandboxed renderer dist must land in hub-client/public/
# before hub-client's vite build copies public/ into dist/.
npm run build:sandboxed --workspace hub-client
npm run build --workspace hub-client --if-present

# globalSetup launches the hub via `cargo run --bin hub` with a 120s
Expand Down
305 changes: 133 additions & 172 deletions claude-notes/designs/q2-sandboxed-preview-separate-domain.md
Original file line number Diff line number Diff line change
@@ -1,186 +1,147 @@
# q2-sandboxed-preview.html Separate Domain Design
# q2-sandboxed-preview Separate Origin Design

## Problem

The `q2-sandboxed-preview.html` iframe renders raw AST JSON in a sandboxed context. For security isolation, it should be served from a separate domain (cross-origin) rather than the same origin as the main hub-client application.

## Solution
> Updated 2026-09-10 as part of the q2-preview → sandboxed-preview port
> (`claude-notes/plans/2026-09-01-port-q2-preview-into-sandboxed-preview.md`,
> epic bd-r9yr0hbe). The original document described a prototype that
> rendered raw AST JSON; the sandboxed frame now runs the full
> `@quarto/preview-renderer` renderer.

### Production Architecture

```
Main app: https://your-hub-domain.com/
├─ Serves the main React app
├─ WebSocket connection to sync server
└─ Contains Q2SandboxedPreviewIframe component

q2-sandboxed-preview: https://raw.your-hub-domain.com/q2-sandboxed-preview.html
└─ Single static HTML file
├─ No external dependencies
├─ Inline JavaScript only
└─ Communicates via postMessage
```

### Security Benefits

1. **Origin isolation**: q2-sandboxed-preview.html runs in a completely separate origin
2. **No cookie access**: raw domain can't access hub cookies
3. **No localStorage access**: raw domain has separate storage
4. **Minimal attack surface**: Single static file, no build artifacts
5. **CSP enforcement**: Strict CSP on raw domain prevents XSS
## Problem

### Local Development
The sandboxed preview renders untrusted document content (including user
TSX components) in an iframe. For security isolation it is served from a
**separate origin** (cross-origin) rather than the same origin as the
main hub-client application.

For local development, we simulate the separate domain using ports:
## Architecture

```
Main app: http://127.0.0.1:8080/ (local-prod.sh)
q2-sandboxed-preview: http://127.0.0.1:8081/ (q2-sandboxed-preview-server.mjs)
Main app: https://your-hub-domain.com/
├─ Serves the main React app (owns WASM + VFS)
└─ Q2SandboxedPreviewIframe (parent side of the protocol)

Sandbox: https://quarto-dev.github.io/q2/ (GitHub Pages)
├─ index.html + q2-preview-assets/* (renderer bundle, KaTeX fonts)
├─ serviceWorker.js (asset proxy)
└─ Communicates ONLY via postMessage
```

This mimics the cross-origin setup and allows testing the postMessage communication.

## Implementation Details

### Files

- **`hub-client/q2-sandboxed-preview.html`**: The sandboxed HTML file
- **`scripts/q2-sandboxed-preview-server.mjs`**: Dedicated static server for local-prod
- **`hub-client/src/components/render/q2-sandboxed-preview/Q2SandboxedPreviewIframe.tsx`**: React component that loads the iframe

### Environment Variables

- **`VITE_Q2_SANDBOXED_PREVIEW_URL`**: URL to load q2-sandboxed-preview.html from
- Dev: `q2-sandboxed-preview.html` (served by Vite from same origin)
- Local-prod: `http://127.0.0.1:8081/q2-sandboxed-preview.html`
- Production: `https://raw.your-hub-domain.com/q2-sandboxed-preview.html` (configure as needed)

### Build Configuration

```bash
# Local-prod build
VITE_Q2_SANDBOXED_PREVIEW_URL=http://127.0.0.1:8081/q2-sandboxed-preview.html npm run build

# Production build (use your actual raw domain)
VITE_Q2_SANDBOXED_PREVIEW_URL=https://raw.your-hub-domain.com/q2-sandboxed-preview.html npm run build
```

### Nginx Configuration

#### Main domain

```nginx
server {
listen 443 ssl http2;
server_name your-hub-domain.com;

# Normal hub-client serving
location / {
root /var/www/hub-client/dist;
try_files $uri $uri/ /index.html;
}
}
```

#### Raw subdomain (separate for security isolation)

```nginx
server {
listen 443 ssl http2;
server_name raw.your-hub-domain.com;

# Only serve q2-sandboxed-preview.html
location = /q2-sandboxed-preview.html {
root /var/www/hub-client/dist;

# Strict security headers
add_header X-Frame-Options "ALLOWALL" always;
add_header Content-Security-Policy "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';" always;
add_header X-Content-Type-Options "nosniff" always;

# No caching
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}

# Deny all other requests
location / {
return 404;
}
}
```

## Communication Protocol

The iframe communicates with the parent via postMessage:
- Deployment: `.github/workflows/deploy-sandboxed-preview.yml` publishes
`hub-client/quarto-hub-sandboxed-preview/dist/` to GitHub Pages (see
`.github/workflows/github-pages.md`).
- Local dev fallback: the build also copies dist/ to
`hub-client/public/q2-sandboxed-preview/` (gitignored); point
`VITE_Q2_SANDBOXED_PREVIEW_URL=q2-sandboxed-preview/index.html` at it.
- local-prod: `scripts/q2-sandboxed-preview-server.mjs` serves the dist
dir on port 8081 to simulate the separate origin
(`VITE_Q2_SANDBOXED_PREVIEW_URL=http://127.0.0.1:8081/`).

### Security posture

1. **Origin isolation**: the frame runs on a separate origin — no
cookies, no localStorage, no DOM reach into the parent, no WASM/VFS
access.
2. **`sandbox="allow-scripts allow-same-origin"` + separate origin**:
`allow-same-origin` is load-bearing (an opaque-origin frame cannot
register the service worker); the isolation comes from the separate
origin, not the sandbox attribute.
3. **Source checks both ways**: the parent ignores messages whose
`event.source` is not the iframe's `contentWindow`; the frame ignores
messages whose `event.source` is not `window.parent` (including the
page bridge's `url_response` listener — a forged response would
inject attacker bytes as document assets).
4. **Pinned target origins**: the parent posts to the sandbox origin
derived from the iframe URL; the frame pins the parent origin from
the first accepted message. Only the pre-contact `IFRAME_READY` and
SW-bridge `url` posts use `'*'` (they go to `window.parent`, which
only the embedder receives, and carry no document content).
5. **`allow="clipboard-write"`** is delegated so the renderer's
code-copy button works cross-origin.
6. **No strict CSP yet**: user TSX components load as blob-URL module
imports inside the frame, which a `script-src` without `blob:` would
break. A CSP for the Pages origin is deferred (see the port plan's
"Deferred" section).

## Communication protocol

All parent-side handling lives in
`hub-client/src/components/render/q2-sandboxed-preview/Q2SandboxedPreviewIframe.tsx`;
all frame-side handling in `hub-client/quarto-hub-sandboxed-preview/src/`
(`entry.tsx`, `registerServiceWorker.ts`, `serviceWorker.ts`).

### Parent → iframe

```typescript
iframe.contentWindow.postMessage({
type: 'UPDATE_AST',
payload: { astJson: string }
}, '*')
```

### iframe → Parent

```typescript
// Ready signal
window.parent.postMessage({ type: 'IFRAME_READY' }, '*')

// VFS read request (currently unused by q2-sandboxed-preview, kept for compatibility)
window.parent.postMessage({
type: 'url',
path: string
}, '*')
```
| type | payload |
|---|---|
| `UPDATE_AST` | `{ payload: { astJson, currentFilePath, assetManifest, projectFilePaths?, pendingAnchor?, pendingAnchorEpoch?, renderedContent?, untransformedAstJson?, currentActor?, commentsMode?, unlockNestingCursor?, richText?, nestedEditBuffers? } }` — post-pipeline AST (the format maps to the preview `pipeline_kind`) |
| `UPDATE_THEME` | `{ cssText: string \| null, fingerprint }` — compiled theme as **text** (blob URLs are origin-scoped; the frame mints its own, and rewrites relative `url()` refs into the proxy namespace) |
| `LOAD_CUSTOM_COMPONENTS` | `{ componentsCode: Record<path, jsCode> }` |
| `SET_SLIDE` | `{ index }` |
| `SCROLL_TO_LINE` | `{ line }` — the frame does the `data-loc` lookup itself |
| `url_response` | `{ id, path, success, content?, error?, isBinary }` — answer to a VFS proxy request |

### Iframe → parent

| type | payload |
|---|---|
| `IFRAME_READY` | `{}` (posted after the service worker is registered) |
| `AST_RENDERED` | `{}` |
| `NAVIGATE_TO_DOCUMENT` | `{ path, anchor }` |
| `SET_AST` | `{ ast }` (block edits / richtext) |
| `SLIDE_CHANGED` | `{ index }` |
| `PREVIEW_SCROLLED` | `{ ratio }` (preview→editor scroll sync) |
| `CLICK_AT_LINE` | `{ line, iframeY }` (parent adds its iframe rect top for `hostY`) |
| `url` | `{ id, path }` — **the core of the asset path**: VFS proxy request relayed from the service worker |
| `hub-client-save` | `{}` (Cmd+S, from the shared link handlers) |

### Asset proxying (any relative path, bd-00bgt5cy)

**Any in-scope path outside the frame's own app files is served from the
VFS.** The service worker exempts only the page itself, `serviceWorker.js`,
and `q2-preview-assets/*` (the hashed renderer chunks + KaTeX fonts — the
dir is deliberately not `assets/` so a project's own `assets/` folder is
proxied normally); every other same-origin GET is relayed `SW → page → parent → WASM VFS → back`,
correlated by request id with timeouts at each hop. The URL path relative
to the SW scope IS the VFS path.

The parent still resolves AST image targets against `currentFilePath`
(mirroring q2-preview's `assetWalker`) and ships a manifest of
`origPath → <bare resolved path>` page-relative URLs — that's what keeps
`../` and subdirectory images correct, and same-named files in different
directories distinct. Paths the manifest never saw (an `<img>` in raw
HTML or navbar chrome) are proxied too; the parent retries them against
the current document's directory on a VFS miss. Theme CSS relative
`url()` refs are rewritten to absolute page URLs against
`.quarto/project-artifacts`, so theme fonts resolve (they don't in
q2-preview's blob-based `<link>`).

Known limitation, deliberate: project files literally named `index.html`,
`serviceWorker.js`, or under `q2-preview-assets/` at the VFS root are
shadowed by the app-file exemption (they fall through to the network).
The asset dir is named `q2-preview-assets` precisely so no real project
trips over this.

Policy (interception namespace, binary classification, MIME table,
CSS rewriting) is a single module shared by the SW, the page bridge,
and the parent responder:
`quarto-hub-sandboxed-preview/src/assetPolicy.ts`.

## Testing

### Local-prod mode

```bash
# Build with local-prod URL
cd hub-client
npm run build:local-prod

# Start all servers
cd ..
./scripts/local-prod.sh

# Open http://127.0.0.1:8080
# Navigate to a document with q2-sandboxed-preview format
```

### Verification

1. Open browser DevTools → Network tab
2. Find the q2-sandboxed-preview.html request
3. Verify it loads from `http://127.0.0.1:8081`
4. Check Console for postMessage events
5. Verify AST renders correctly

## Deployment Checklist

- [ ] Choose subdomain for raw rendering (e.g., `raw.your-hub-domain.com`)
- [ ] Set up DNS: subdomain → server IP
- [ ] TLS certificate for raw subdomain
- [ ] Nginx config for raw subdomain
- [ ] Build with production URL: `VITE_Q2_SANDBOXED_PREVIEW_URL=https://raw.your-hub-domain.com/q2-sandboxed-preview.html`
- [ ] Deploy q2-sandboxed-preview.html to raw subdomain
- [ ] Test cross-origin postMessage
- [ ] Verify CSP headers
- [ ] Check iframe sandbox attributes

## Future Enhancements

1. **Subdomain isolation for other renderers**: Apply same pattern to q2-debug, q2-preview
2. **CDN deployment**: Serve from CDN for global edge caching
3. **Version pinning**: URL with hash for cache-busting (`q2-sandboxed-preview-abc123.html`)
4. **Multiple raw formats**: Extend pattern to other simple renderers

## References

- MDN: [Window.postMessage()](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage)
- MDN: [iframe sandbox attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox)
- OWASP: [Clickjacking Defense](https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html)
- Unit/protocol: `hub-client/src/components/render/q2-sandboxed-preview/*.test.ts(x)`
(policy round-trips, manifest resolution, protocol forwarding, source
checks) and `scrollClickBridge.test.ts`.
- End-to-end: `hub-client/e2e/q2-sandboxed-preview.spec.ts` — real hub +
WASM pipeline; asserts themed render, KaTeX, and an image decoded
through the SW proxy inside the frame.
- local-prod: `npm run local-prod:fresh` (or `:nginx`), open a document
with `format: q2-sandboxed-preview`.

## Future enhancements

1. Strict CSP on the Pages origin (needs a story for blob-URL module
imports used by custom components).
2. SW caching/offline (disabled in commit `103af4445`).
3. Version pinning / cache-busting for the Pages deployment.
4. Routing `format: revealjs` through the sandboxed frame (RevealDeck is
bundled but has no production route today).
Loading
Loading