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
13 changes: 13 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,19 @@ delivering nothing, so its return value is not evidence. A form with
'none'`. `window.open` was blocked in the probe, but no user gesture was
present, so the policy is not what stopped it; popups are removed by
dropping the `allow-popups` sandbox flag instead.
### 2. Reader-activated external links

**2026-09-24: permitted through the parent, not through the render frame.** A
reader may activate an absolute `http`, `https`, or `mailto` link in an
HTML or JSX relic. The frame prevents its own navigation, validates the scheme,
and posts the canonical destination to the parent. The parent validates it
again and opens a new top-level tab with `noopener` and `noreferrer`.

This does not restore rendered-content egress or arbitrary popups. Author code
cannot invoke the path because the frame accepts only a trusted click event,
and the iframe still carries exactly `sandbox="allow-scripts"`. Relative,
fragment-only, `javascript`, `data`, `blob`, and `file` destinations stay
inside the boundary and do not navigate.

## What is not decided here

Expand Down
6 changes: 6 additions & 0 deletions docs/frame.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ The wedge is opinionated, mimetype-aware rendering of agent output, and it holds
**Honesty constraint, non-negotiable.** The JavaScript that performs the decryption is served by the same server the zero-knowledge claim is made against, and that server could ship different JavaScript tomorrow. It's a claim about operator intent, not a property a recipient can verify. PrivateBin's threat model says this out loud ([threat model](https://github.com/PrivateBin/PrivateBin/wiki/Threat-Model)) and Relic says it too. Overclaiming here is a reputational liability with no upside.

**A second honesty constraint, which JSX rendering created and closing egress resolved.** The usercontent frame renders untrusted HTML, and now JSX as well, and the first recorded answer to that capability was parity: rendered content kept the network reach HTML has always had, and the disclosure said so, which meant whoever authored a relic could learn the recipient's IP, user agent, and open time by pointing that reach at a host they controlled. That decision is reversed on owner instruction, and the reversal is recorded in `docs/decisions.md`. The frame's response now carries a CSP that permits no remote source of any kind: rendered content cannot fetch, cannot load external images or fonts, cannot open a WebSocket or EventSource, and cannot beacon. React is bundled into the frame's own inlined bundle, and nothing is fetched from a CDN. Inlining is the only option, for a structural reason: the frame is sandboxed without `allow-same-origin`, so it runs in an opaque origin, and in an opaque origin `'self'` matches nothing, meaning the frame cannot fetch even its own assets. What an author's relic can do now is execute locally and reach nothing, and the origin boundary still prevents the key: rendered content executes on an opaque origin that can never read the fragment on the viewing origin. The cost is named rather than absorbed: a published page that references a CDN stylesheet, a CDN script, an external font, or a remote image renders without it, and publishers must inline what their page needs. The published `/policy` disclosure states the block plainly, while the viewer carries a compact marker in the header chrome stating the page runs the author's code with a link to that policy.
Reader-activated external links are the narrow exception, and they do not give
the rendered document network access. A trusted click on an absolute `http`,
`https`, or `mailto` destination is contained in the frame and passed to the
parent through a validated message. The parent opens it in a separate tab with
no opener or referrer. Scripted clicks and every other scheme remain blocked,
and the iframe still carries `allow-scripts` and nothing else.

## The success metric

Expand Down
12 changes: 12 additions & 0 deletions docs/spec/viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,12 @@ The object fetch runs client-to-GCS on a signed URL, so the app server can't set
**React is bundled into the frame's inlined bundle, and nothing is fetched from a CDN.** An earlier revision of this section had the frame importing React from a permissive CDN, because the render frame is an opaque origin, its module fetches are CORS requests carrying `Origin: null`, and Relic's own origins do not answer those while a permissive CDN does. Closing egress removed that middle ground: a policy that permits no remote source permits no CDN either, and in an opaque origin `'self'` matches nothing, so the frame cannot fetch even its own assets. Same-origin assets are the one thing this origin structurally cannot reach, which forces the only remaining answer: React is inlined into the `sandbox.html` response at build time and reaches transpiled component code as the global `globalThis.React`. The transpiler emits classic-runtime `React.createElement(...)` calls, which resolve against that global, so the mounted module needs no import statement at all.

**The frame has no network reach, and the property is measured rather than argued.** The response that builds the frame carries a policy permitting no remote source of any kind, and under it `fetch`, `<img>`, `sendBeacon`, `WebSocket`, and `EventSource` all produced zero arrivals at a collector server, so whoever authored a JSX or HTML relic has no channel to the recipient through the rendered content. Two probe results are stated with their caveats, because each can be read as proving more than it does. `sendBeacon` returned `true` while delivering nothing, so its return value is not evidence. `window.open` was blocked in the probe, but no user gesture was present, so the policy is not what stopped it; popups are removed by dropping the `allow-popups` flag from the sandbox attribute, which leaves `allow-scripts` and nothing else. A form with `target=_blank` submitted and never arrived, blocked by `form-action 'none'`. What remains true of the boundary itself: the frame is opaque, holds no credentials, cannot read the fragment on the viewing origin, and cannot reach anything of Relic's. The cost is named rather than absorbed: a page or component that references a CDN stylesheet, a CDN script, an external font, or a remote image renders without it, and publishers must inline what their page needs (§6.3).
A reader clicking an external link is not rendered-content network reach. The
frame prevents its own navigation and sends only a canonical `http`, `https`,
or `mailto` destination across the existing message boundary after a trusted
click. The parent validates it again and opens a separate tab with `noopener`
and `noreferrer`. Author code still cannot fetch, beacon, navigate, or open a
popup, and a scripted click is rejected.

## 4. The sandbox boundary

Expand Down Expand Up @@ -207,6 +213,12 @@ Shim to parent: a `ready` handshake, a rendered-or-failed ack that names blocked
**Letterboxing is a product consequence, and it's stated here before somebody finds it in review.** The taskbar and the content sit on different origins by construction, so the content iframe is never full-viewport, and a relic authored to fill the screen renders letterboxed.

**Present the sandbox as deliberate.** An HTML relic can't navigate the top-level window, can't open popups, can't reach the decryption key, and can't reach the network: every remote source is blocked, so a page that references a CDN stylesheet, a CDN script, an external font, or a remote image renders without it. Left unexplained, the recipient concludes the relic is corrupt and the publisher concludes the product is broken, and a page stripped of its external assets makes both conclusions more likely rather than less. The taskbar says the content is sandboxed and says what that blocks, and the list it gives is the true one: top-level navigation, popups, everything same-origin, and the network itself. Publishers who need external assets must inline them.
"Can't open popups" means author code has no popup capability. A reader may
activate an absolute `http`, `https`, or `mailto` link. The frame contains
that click, validates the destination, and asks the parent to open it in a new
tab with no opener or referrer. The parent validates the destination again.
Relative, fragment-only, executable, data, blob, and local-file URLs do not
cross the boundary, and the sandbox attribute remains `allow-scripts` only.

## 5. Platform ceilings and degradation

Expand Down
48 changes: 43 additions & 5 deletions packages/relic-viewer/src/annotate-frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* - `relic:frame-point`: reader clicked a point while pointing was armed.
* - `relic:frame-region`: reader dragged a box while pointing was armed.
* - `relic:frame-mark-click`: reader clicked an existing mark inside the frame.
* - `relic:open-link`: reader activated a permitted external link.
*
* 2. Inward (Parent to Frame):
* - `relic:arm-pointing`: arm or disarm click/drag capture inside the frame.
Expand All @@ -37,11 +38,7 @@ import {
COMMENT_ANCHOR_QUOTE_LIMIT_BYTES,
type CommentAnchor,
} from '@relic/format';
import {
type AnchorAdapter,
type AnchorSurface,
registerAnchorAdapter,
} from './anchoring.ts';
import type { AnchorAdapter, AnchorSurface } from './anchoring.ts';

/**
* Take code points from the start of text up to maxBytes in UTF-8.
Expand Down Expand Up @@ -124,6 +121,47 @@ export function isValidAnchorRect(data: unknown): data is AnchorRect {
// ---------------------------------------------------------------------------
// Outward messages (Frame -> Parent)
// ---------------------------------------------------------------------------
/** Long enough for a real URL, bounded before it crosses the frame boundary. */
export const FRAME_EXTERNAL_LINK_LIMIT_BYTES = 8 * 1024;

/**
* A reader-activated destination the parent may open outside the sandbox.
*
* Relative URLs stay inside the opaque frame, while executable and local
* schemes would turn this narrow escape hatch into a second rendering path.
* Canonicalising here and validating again in the parent keeps the message
* contract identical on both sides of the boundary.
*/
export function normaliseFrameExternalLink(href: unknown): string | undefined {
if (typeof href !== 'string' || href.length === 0) return undefined;
if (new TextEncoder().encode(href).length > FRAME_EXTERNAL_LINK_LIMIT_BYTES) {
return undefined;
}

let url: URL;
try {
url = new URL(href);
} catch {
return undefined;
}

if (!['http:', 'https:', 'mailto:'].includes(url.protocol)) return undefined;
return url.href;
}

export interface FrameOpenLinkMessage {
readonly type: 'relic:open-link';
readonly href: string;
}

export function isFrameOpenLinkMessage(
data: unknown
): data is FrameOpenLinkMessage {
if (typeof data !== 'object' || data === null) return false;
const msg = data as Record<string, unknown>;
if (msg.type !== 'relic:open-link') return false;
return normaliseFrameExternalLink(msg.href) === msg.href;
}

/**
* A box in the frame's own client coordinates.
Expand Down
33 changes: 25 additions & 8 deletions packages/relic-viewer/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@ registerBuiltInAnchorAdapters();
import {
type FrameMarkClickMessage,
type FrameMarkHoverMessage,
type FrameOpenLinkMessage,
type FramePointMessage,
type FrameRect,
type FrameRegionMessage,
type FrameSelectionMessage,
isFrameMarkClickMessage,
isFrameMarkHoverMessage,
isFrameOpenLinkMessage,
isFramePointMessage,
isFrameRegionMessage,
isFrameSelectionClearedMessage,
Expand Down Expand Up @@ -86,7 +88,6 @@ import {
plainLabel,
postComment,
quotedTargetLabel,
RESOLUTION_DISCLOSURE,
type Refusal,
readSession,
requestMagicLink,
Expand Down Expand Up @@ -821,6 +822,22 @@ function renderImageView(view: ReadyView): HTMLElement {
type RenderPayload =
| { readonly type: 'relic:render'; readonly html: string }
| { readonly type: 'relic:render-jsx'; readonly code: string };
type OpenWindow = (
url: string,
target: string,
features: string
) => WindowProxy | null;

/** Open one frame-requested destination without an opener or referrer. */
export function openFrameExternalLink(
message: FrameOpenLinkMessage,
open: OpenWindow = (url, target, features) =>
window.open(url, target, features)
): boolean {
if (!isFrameOpenLinkMessage(message)) return false;
open(message.href, '_blank', 'noopener,noreferrer');
return true;
}

interface FrameHandle {
readonly frame: HTMLIFrameElement;
Expand All @@ -847,6 +864,9 @@ interface FrameHandle {
* Scripts and nothing else. Popups are removed by dropping the flag, not by
* CSP: a popup opens a new top-level context this frame's policy does not
* govern.
* Reader-activated external links are the deliberate exception: the frame
* sends a validated destination to the parent, which opens it without an
* opener or referrer while leaving the sandbox unchanged.
*
* A comparison needs two renders of untrusted content, and it gets them from
* two of these rather than by relaxing the frame's one-render guard. The
Expand Down Expand Up @@ -951,6 +971,10 @@ function sandboxFrame(
);
return;
}
if (isFrameOpenLinkMessage(event.data)) {
openFrameExternalLink(event.data);
return;
}
if (isFrameScrollMessage(event.data)) {
frame.dispatchEvent(
new CustomEvent('relic:frame-scroll', {
Expand Down Expand Up @@ -3350,13 +3374,6 @@ export const MARK_PIN_HINT = 'Click the document to place a point';
export const MARK_REGION_HINT =
'Click to place a point, or drag to select a region (use arrows and Enter for keyboard)';

/**
* The clearance between a selection and the button offered above it, in CSS
* pixels. Small enough to read as attached to the selection, wide enough that
* it does not sit on the words it is about.
*/
const MARK_BUBBLE_GAP = 6;

/**
* How long a pointer rests on a mark before its comment opens.
*
Expand Down
31 changes: 26 additions & 5 deletions packages/relic-viewer/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
isPaintMarksMessage,
isQuietMarksMessage,
isRevealMarkMessage,
normaliseFrameExternalLink,
type RevealMarkMessage,
takeHeadUtf8,
takeTailUtf8,
Expand Down Expand Up @@ -687,6 +688,24 @@ export function setupFrameInteraction(
if (typeof win.addEventListener === 'function') {
win.addEventListener('scroll', handleScroll, { passive: true });
}
doc.addEventListener('click', (event: MouseEvent) => {
let node = event.target as Element | null;
if (node?.nodeType !== 1) node = node?.parentElement ?? null;
while (node !== null && node.tagName.toLowerCase() !== 'a') {
node = node.parentElement;
}
if (node === null) return;

// Every authored anchor is contained here. The frame never navigates and
// never opens its own popup, even when the URL is rejected or pointing is
// armed. Only a trusted reader action may ask the parent to navigate.
event.preventDefault();
if (armed || !event.isTrusted) return;

const href = normaliseFrameExternalLink(node.getAttribute('href'));
if (href === undefined) return;
postOutward({ type: 'relic:open-link', href });
});

// A selection collapses by more paths than mouseup: clicking elsewhere,
// a new armed drag, or the parent replacing the document. selectionchange
Expand Down Expand Up @@ -1173,17 +1192,19 @@ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
const handle = createSandboxHandler(
(html) => {
document.open();
// Install the capture listener before authored inline scripts execute
// during document.write. A script-triggered anchor click must never win
// the race and replace the rendered relic before interaction is wired.
initInteraction();
document.write(html);
document.close();
initInteraction();
listen();
scheduleReport();
},
(code) => {
void mountComponent(code).then(() => {
initInteraction();
scheduleReport();
}, scheduleReport);
// React render and effects may run before the mount promise settles.
initInteraction();
void mountComponent(code).then(scheduleReport, scheduleReport);
},
(marks, anchors) => {
const style = document.createElement('style');
Expand Down
92 changes: 92 additions & 0 deletions packages/relic-viewer/test/annotate-frame.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import {
isClearMarksMessage,
isFrameMarkClickMessage,
isFrameMarkHoverMessage,
isFrameOpenLinkMessage,
isFramePointMessage,
isFrameRegionMessage,
isFrameSelectionMessage,
isPaintMarkMessage,
isPaintMarksMessage,
isRevealMarkMessage,
normaliseFrameExternalLink,
takeHeadUtf8,
takeTailUtf8,
} from '../src/annotate-frame.ts';
Expand Down Expand Up @@ -859,6 +861,96 @@ describe('frame interaction and click capture', () => {
);
expect(pointMsgs.length).toBeGreaterThan(0);
});
test('posts a canonical external link only for a trusted reader click', () => {
const label = makeElement('span', {}, 'Open site');
const link = makeElement(
'a',
{ href: 'https://example.com/report?q=1' },
label
);
mockDoc.body.appendChild(link);

const messages: object[] = [];
setupFrameInteraction(
mockDoc as unknown as Document,
mockWin as unknown as Window,
(msg) => messages.push(msg)
);

let prevented = false;
label.dispatchEvent({
type: 'click',
target: label,
isTrusted: true,
preventDefault: () => {
prevented = true;
},
});

expect(prevented).toBe(true);
expect(messages).toContainEqual({
type: 'relic:open-link',
href: 'https://example.com/report?q=1',
});
});

test('contains rejected and scripted anchor clicks inside the frame', () => {
const messages: object[] = [];
setupFrameInteraction(
mockDoc as unknown as Document,
mockWin as unknown as Window,
(msg) => messages.push(msg)
);

for (const [href, isTrusted] of [
['/relative', true],
['#section', true],
['javascript:alert(1)', true],
['data:text/html,hello', true],
['blob:https://example.com/id', true],
['file:///etc/passwd', true],
['https://example.com/scripted', false],
] as const) {
const link = makeElement('a', { href }, href);
mockDoc.body.appendChild(link);
let prevented = false;
link.dispatchEvent({
type: 'click',
target: link,
isTrusted,
preventDefault: () => {
prevented = true;
},
});
expect(prevented).toBe(true);
}

expect(messages).toHaveLength(0);
});

test('validates and bounds the frame link message', () => {
expect(normaliseFrameExternalLink('HTTP://EXAMPLE.COM')).toBe(
'http://example.com/'
);
expect(normaliseFrameExternalLink('mailto:reader@example.com')).toBe(
'mailto:reader@example.com'
);
expect(
isFrameOpenLinkMessage({
type: 'relic:open-link',
href: 'https://example.com/',
})
).toBe(true);
expect(
isFrameOpenLinkMessage({
type: 'relic:open-link',
href: 'https://example.com',
})
).toBe(false);
expect(
normaliseFrameExternalLink(`https://example.com/${'x'.repeat(9 * 1024)}`)
).toBeUndefined();
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading