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
55 changes: 55 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,61 @@ only that N objects exist. Tokens rest sealed beside the device seed;
the OAuth code is the one artifact that crosses the port, bound to a PKCE
verifier that never left the kernel.

## Windows and handles

A document holding a WindowProxy to the visor's window can assign its
`location` cross-origin — trusted pixels under someone else's control.
Two holders exist: a parent (the visor framed in another page) and an
opener (a page that opened us, or a provider page in a popup *we* open).
Phishing in general is answered by the visor's identity mechanisms (grey
until unseal, the hue painted only after); what is ruled here is
narrower: refuse to give a controllable window trusted pixels, and stop
handing out handles.

- **The visor refuses to boot in a window something else may control**:
`self !== top` or `window.opener !== null`. The opener test is
one-sided — a positive is reliable, a null is not, because a hostile
opener can null its popup's `opener` on the initial `about:blank`
before navigating it to us and keep its own handle working. This
catches the naive case only, and the record says so. Browsers have
defaulted `target=_blank` to `noopener` since ~2020, so a non-null
opener today means someone opened us deliberately.
- **On refusal**: a framework-voice notice in `#visor` — nothing
personal, no worker, no device anchor read — and a button that reopens
`location.href` with `window.open(href, "_blank", "noopener")`: a
fresh browsing context nobody holds a handle to, not even us. A user
gesture is what popup blockers want, hence a button. The refusing
window is left as it is; blanking it undoes nothing a handle-holder
could not redo.
- **Our own OAuth popup opens with `noopener`.** `window.open` then
returns null, so nothing on our side holds a handle either; the
returning page (provider → redirect → our URL) has no opener and
reports over a same-origin `BroadcastChannel` instead of
`opener.postMessage`. The waiting side matches the message's `state`
against the `state` of the URL it opened, so another tab's ceremony —
or a stale one — is ignored; the kernel checks state again at
`oauth-complete`. A leaked code is inert without the PKCE verifier the
kernel holds.
- **Closed-detection went with the handle; there is none.** The
returning page broadcasts the outcome both for a code and for a
declined consent (RFC 6749 §4.1.2.1 echoes `state` on the error
redirect too), then tries `window.close()` and, if it is still open,
says "You can close this window." — a `noopener` window is
script-closable only while its history has one entry, which a real
provider's multi-page consent breaks and the e2e fake's single 302
does not. The one silent case, a user closing the popup mid-flow, is
covered by a ten-minute bound on the waiting side; provider codes do
not outlive that.
- **No kernel change.** The pending ceremony in the kernel is replaced
by the next `oauth-start`; its state is random and its code one-shot,
so a stale one costs nothing.
- **COOP `same-origin` on the home origin is the general form** of all
of this: the browsing-context-group swap makes a null opener actually
mean "no handle", and it covers windows we never opened. It is an
OPTIONAL enhancement for hosts that can set response headers — GitHub
Pages cannot, and `<meta>` does not carry COOP — never a requirement.
Not `same-origin-allow-popups`, which keeps the very link this cuts.

## Devices

A device is one kernel identity and everything it holds; a browser may
Expand Down
100 changes: 95 additions & 5 deletions e2e/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,9 +524,10 @@ async function waitForConnectedPeer(page: Page, peer: string): Promise<void> {
//
// The ceremony runs headless. "Connect Google Drive" opens a popup at the
// URL the kernel minted; the fake's `/auth` 302s straight back to this
// page's URL with `code` and `state`, that returning load posts the pair to
// its opener and closes itself (web/boot.ts), and the opener's
// `shell.open-popup` resolves with it. No consent screen exists to click.
// page's URL with `code` and `state`, that returning load broadcasts the
// pair on the ceremony's BroadcastChannel and closes itself (web/boot.ts —
// the popup is opened `noopener`, so there is no opener to post to), and the
// waiting `shell.open-popup` resolves with it. No consent screen to click.
// ---------------------------------------------------------------------------

const storageSheet = (page: Page) => sheet(page, "Storage");
Expand Down Expand Up @@ -766,6 +767,94 @@ const scenarios: Scenario[] = [
},
},

{
// docs/design.md "Windows and handles": a window a document holds a
// handle to can be navigated cross-origin by that document, so it gets
// no trusted pixels at all. Both holders are exercised — an opener and
// a parent — and both from a page on the HOME origin, because what the
// visor refuses is the handle, not the holder's origin.
name: "handled-window-refuses-to-boot",
async run(ctx, origin) {
const indexUrl = origin + "/";
const refusal = "not this visor's own";

const host = await open(ctx, origin);
await visorReady(host);

// (i) OPENED. Chromium under Playwright blocks no popups, so a bare
// `window.open` is enough; the window it hands back is exactly the
// handle the ruling is about.
const [opened] = await Promise.all([
host.waitForEvent("popup"),
host.evaluate((u: string) => {
globalThis.open(u);
}, indexUrl),
]);
await opened.locator("#visor-reopen").waitFor({ timeout: 20_000 });
check(
(await opened.locator("#visor").textContent() ?? "").includes(refusal),
"the opened window did not paint the framework's refusal",
);
// Give the boot every chance to happen anyway before claiming it did
// not: the claim is that no strip and no worker ever appear, and a
// check made at once would pass against a visor still starting.
await opened.waitForTimeout(3_000);
eq(
await opened.locator("#visor-strip").count(),
0,
"the opened window painted trusted pixels",
);
// `__polyvisor` is not even installed in a refusing window — the
// tripwire parks above the line that installs it — so "no worker" is
// read as "nothing said a worker booted".
eq(
await opened.evaluate(() =>
((globalThis as Record<string, unknown>).__polyvisor as
| { workerBooted?: boolean }
| undefined)?.workerBooted ?? false
),
false,
"the refusing window booted a worker",
);

// The way out: a fresh browsing context nobody holds a handle to.
// Awaited on the CONTEXT rather than as the clicking page's `popup`,
// because `noopener` is precisely the case where the new window is
// not related to the one that asked for it.
const [reopened] = await Promise.all([
ctx.waitForEvent("page"),
opened.locator("#visor-reopen").click(),
]);
await visorReady(reopened);
check(
await reopened.locator("#visor-strip").count() === 1,
"the reopened window did not boot the visor",
);
eq(
await reopened.evaluate(() => globalThis.opener === null),
true,
"the reopened window still has an opener",
);

// (ii) FRAMED, on the same origin as the frame's own document.
await host.setContent(
`<iframe id="framed" src="${indexUrl}" width="800" height="600"></iframe>`,
);
const framed = host.frameLocator("#framed");
await framed.locator("#visor-reopen").waitFor({ timeout: 20_000 });
check(
(await framed.locator("#visor").textContent() ?? "").includes(refusal),
"the framed visor did not paint the framework's refusal",
);
await host.waitForTimeout(3_000);
eq(
await framed.locator("#visor-strip").count(),
0,
"the framed visor painted trusted pixels",
);
},
},

{
name: "open-app",
async run(ctx, origin) {
Expand Down Expand Up @@ -1176,8 +1265,9 @@ const scenarios: Scenario[] = [
{
// The ceremony, end to end and headless: the kernel mints the URL, the
// page opens the popup, the fake consents at once and redirects back,
// the returning load hands the pair to its opener, and the kernel
// exchanges and seals it (internal.wit `storage`, `shell.open-popup`).
// the returning load broadcasts the pair on the ceremony's channel, and
// the kernel exchanges and seals it (internal.wit `storage`,
// `shell.open-popup`).
name: "drive-connect",
async run(ctx, origin) {
const page = await open(ctx, origin);
Expand Down
6 changes: 4 additions & 2 deletions runtime/wit/internal.wit
Original file line number Diff line number Diff line change
Expand Up @@ -471,8 +471,10 @@ interface shell {
request-persistence: async func() -> bool;
/// Open `url` in a popup and resolve with the `code` and `state` query
/// parameters the popup returns to this page's URL with; `none` if the
/// user closed it. A window is a page capability, so the ceremony's
/// browser half lives here and the kernel never sees a window.
/// ceremony did not come back: consent was declined, or nothing
/// returned within the glue's bound. A window is a page capability, so
/// the ceremony's browser half lives here and the kernel never sees a
/// window.
open-popup: async func(url: string) -> option<tuple<string, string>>;
}

Expand Down
39 changes: 30 additions & 9 deletions visor/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -901,8 +901,8 @@ fn KeepSheet(on_kept: EventHandler<bool>) -> Element {
/// authority on what a binding is doing. `binding.state` is the kernel's
/// own framework voice and is rendered unparaphrased, exactly as a peer's
/// state is; the visor adds no sentence about a store beyond the ones it
/// composes about its own acts (a window the user closed, a field left
/// empty).
/// composes about its own acts (a ceremony that did not come back, a field
/// left empty).
///
/// The client pair is typed here and goes straight through to
/// `oauth-start`. Nothing about it is kept: the kernel seals what it needs
Expand All @@ -921,6 +921,12 @@ fn StorageSection(binding: Option<Binding>, on_refresh: EventHandler<()>) -> Ele
let mut client_id = use_signal(String::new);
let mut client_secret = use_signal(String::new);
let mut error = use_signal(|| None::<String>);
// The ceremony is a window the user is looking at somewhere else, and
// this glue holds no handle to it any more (docs/design.md "Windows and
// handles"), so all the visor knows is that it asked and has not been
// answered. Read in the render body below — a signal written and never
// read renders once forever (docs/design.md, the visor-dioxus costs).
let mut waiting = use_signal(|| false);

// Same shape as the Devices section's `acted`: show the kernel's
// refusal if it refused, then re-read, because what the binding is now
Expand All @@ -944,19 +950,25 @@ fn StorageSection(binding: Option<Binding>, on_refresh: EventHandler<()>) -> Ele
return;
}
};
// Set only once there is a URL to open: an `oauth-start` the kernel
// refused never opened a window, so there is nothing to wait for.
waiting.set(true);
match kernel::open_popup(url).await {
// `none` is a window the user closed, or one the browser
// refused to open (internal.wit `shell.open-popup`). Neither is
// a failure the kernel has to hear about: the ceremony it
// minted is simply not completed, and pressing Connect again
// mints another.
// `none` is a ceremony that did not come back: consent declined,
// the window closed, or nothing inside the glue's bound
// (internal.wit `shell.open-popup`). None of those is a failure
// the kernel has to hear about — the ceremony it minted is
// simply not completed, and pressing Connect again mints
// another.
None => {
waiting.set(false);
error.set(Some(
"the authorization window closed before it came back".into(),
"the sign-in window closed without authorizing this device".into(),
));
on_refresh.call(());
}
Some((code, state)) => {
waiting.set(false);
client_id.set(String::new());
client_secret.set(String::new());
acted(kernel::oauth_complete(code, state).await);
Expand Down Expand Up @@ -1037,7 +1049,16 @@ fn StorageSection(binding: Option<Binding>, on_refresh: EventHandler<()>) -> Ele
div { class: "{Voice::Framework.class()}",
"an installed-app client pair — the secret gates nothing without your consent, and nothing is built in"
}
button { onclick: connect, "Connect Google Drive" }
button {
onclick: connect,
disabled: "{waiting}",
"Connect Google Drive"
}
if waiting() {
div { class: "{Voice::Framework.class()}",
"waiting for the sign-in window…"
}
}
}
},
}
Expand Down
Loading