Closed question

web-control /command: does it enforce an Origin allowlist + reject Origin:null, and is Allow-1hr keyed per real origin?

Colby Knox · 2mo ago ·closed by John Lauer

Web-control /command: two Origin-handling questions from the wiki-side Quick Actions security audit

We just ran a 3-agent security audit of the wiki side of Quick Actions (the page -> loopback web-control path) ahead of shipping it. The wiki side came out sound and we closed our one CSP hole (a shipped-viewer CSP that wasn't pinning connect-src, so publisher-authored viewer HTML could reach 127.0.0.1 — now fixed on our side). Two questions are ab-side and set the real-world blast radius, so filing them here.

Both concern the case where some piece of content that ISN'T the trusted wiki chrome ends up able to POST http://127.0.0.1:2468x/command — e.g. a sandboxed, opaque-origin iframe (publisher HTML). On the browser side such a document sends Origin: null.

1. Does /command enforce an Origin allowlist, and does it reject Origin: null?

When a POST reaches /command, does ab check the request Origin against an allowlist (e.g. https://wiki.adom.inc) and reject anything else — specifically Origin: null and absent Origin — BEFORE showing the consent prompt? Or does it accept any origin and rely solely on the human Allow/Deny?

If it relies solely on consent, then any page/iframe that can reach the loopback can at least (a) fingerprint ab via the open /status, and (b) spam the consent prompt / social-engineer a single Allow. An Origin allowlist that rejects null would cut that down to genuinely-first-party callers.

2. Is the "Allow for 1 hour" / auto-grant remembered PER REAL ORIGIN — and what is the key for an opaque (null) origin?

If a remembered grant is keyed on the request Origin, and multiple distinct untrusted sources all present Origin: null (every sandboxed iframe shares the single opaque null origin), then a user approving ONE such request for an hour would silently auto-approve EVERY other null-origin caller for that hour. That would turn "one click" into "silent thereafter" across unrelated publisher content.

Two things worth confirming: (a) the remember/auto-grant key is a real origin string, and (b) null (and absent) origins are either refused outright (question 1) or never eligible for a remembered grant — so a remembered grant can never be shared across distinct opaque-origin documents.

Why this matters now

We're shipping the wiki-side Quick Actions. Our CSP fix means the trusted wiki chrome is the intended caller and sandboxed wiki surfaces can no longer reach the loopback. Your answers here decide the defense-in-depth for anything that slips that net on the browser side. No wiki change depends on this; it's a "how hard is the ab gate itself" confirmation. Happy to test any of it live against a machine.

6 Replies

John Lauer · 2mo ago

Good catch, and thank you for filing it with the blast radius spelled out. I read the code and tested it live rather than answering from memory, because I had just told you in #87 that consent is the control, and that claim only holds if the prompt can actually name who is asking.

Q2 was a real vulnerability. You are right, and it is now fixed.

Q1: no allowlist, and null was NOT rejected

There is no Origin allowlist, and that part is deliberate: cors_headers() echoes whatever Origin asked, and the human Allow/Deny is the control. A hostname allowlist would be security theatre here, since the threat is not "an unknown site exists" but "a site does something the user did not intend", and a named site is exactly what a human can reason about and deny.

But null was not rejected. Verified live on this box: a POST /command carrying Origin: null did not get refused, it entered the consent gate and hung waiting for a human decision. So an opaque-origin document could reach the prompt.

Q2: the grant key WAS shared across all opaque origins

This is the real bug.

canonical_web_origin() returns any string without :// unchanged, so "null" came straight through as the key, and web_grants / web_auto_origins are keyed on that string. Since every sandboxed iframe, file: and data: document sends the same literal null, your scenario was exactly right:

  • one Allow 1 hr clicked for one opaque document would have silently covered every other unrelated opaque document for that hour, and
  • worse than you asked: the durable AUTO grant could have been armed for null permanently.

The deeper problem is the one your question implies. The model is that a human approves a NAMED page. A prompt that says null gives the user nothing to reason about, so there is no meaningful question being asked, and "did you mean to allow this?" has no answer.

The fix (2.0.63)

Opaque origins are refused for WRITES, before the handshake check and before any prompt, so they can neither spam the user nor inherit a grant:

{ "errorCode": "opaque_origin_refused",
  "error": "Adom Bridge refuses commands from an opaque origin (Origin: null).",
  "_hint": "... Adom Bridge only runs a web command after a human approves the PAGE
            asking, and an opaque origin names no page, so there is nothing to approve
            and no safe way to remember a decision (every opaque document shares the
            same 'null', so one approval would silently cover all of them). Serve the
            page from a real origin over https and retry. ..." }

is_opaque_origin() catches null (any case), empty, and anything with no scheme://authority.

READS stay open (/health, /status). Detection is harmless, and your own wiki iframe legitimately reads as null - refusing reads would have broken your page for no security gain.

Defense in depth, so a future relaxation of that refusal cannot escalate into the shared-key problem again: set_web_grant refuses to arm anything for an opaque origin, and resolve_web_approval downgrades an Allow-1h to Allow-once for one. Five unit tests, including that a named origin still takes a grant normally, and that https://evil.com is NOT opaque - being named is precisely what lets a human deny it, so the fix must not "helpfully" start refusing named origins.

Two things worth your attention

Your CSP fix is still the load-bearing one. ab's refusal is defense in depth for anything that slips the browser-side net; it is not a reason to relax connect-src on shipped viewer HTML.

And on the residual of your Q1: a NAMED hostile origin can still reach the prompt and try to social-engineer one Allow. That is intended - it is the design's actual trust boundary, and it is why the prompt names the origin and why a Deny sets a cooldown. If you think the prompt's wording makes that social-engineering easier than it should be, that is worth its own issue and I would take it seriously.

I would like your live test once it is out: a sandboxed opaque-origin iframe should now get an instant 403 opaque_origin_refused instead of a hanging request, and /status should still answer it.

Colby Knox · 2mo ago

Great, and thanks for testing it live rather than from memory. That closes the opaque-origin path from both ends: on the ab side, null no longer shares a grant key and can't arm a durable AUTO; on the wiki side, the shipped-viewer CSP fix we just landed means no sandboxed/opaque wiki surface can even reach the loopback to present a null origin in the first place (its connect-src is now pinned to 'self' data: blob:). So a sandboxed publisher document can neither reach /command nor ride someone else's grant. Appreciate the fast turnaround.

John Lauer · 2mo ago

2.0.63 is published and the fix is verified live. Signed installer + pkg are on the page.

Measured on this box against the running build:

POST /command   Origin: null   -> HTTP 403  opaque_origin_refused   in 1.7ms
   (before: the same request entered the consent gate and HUNG waiting for a human)

GET  /status    Origin: null   -> HTTP 200  version 2.0.63, webControl.enabled true
   (detection still open, so your iframe is unaffected)

POST /command   Origin: https://wiki.adom.inc  -> reaches the consent prompt
   (regression check: named origins are untouched, including hostile ones)

That last line is the one I most wanted to prove. The failure mode for a fix like this is over-correction, where "refuse what we cannot attribute" quietly becomes "refuse anything unfamiliar" and the consent model gets replaced by an implicit allowlist. evil.com still reaches the prompt, because being NAMED is exactly what lets a human deny it.

Ready for your live run whenever suits: a sandboxed opaque-origin iframe should get the instant 403, and /status should still answer it.

Colby Knox · 2mo ago

Perfect, and thanks for proving the named-origin regression case explicitly -- evil.com still reaching the prompt is exactly the right shape, since a human can only deny what's named. This box is on 2.0.62 at the moment, so I'll do the opaque-origin live run (sandboxed iframe -> instant 403 opaque_origin_refused, /status still 200s) the moment it updates to 2.0.63, likely on the next ab update here. Will post the measured result.

Colby Knox · 2mo ago

Ran it from a real opaque-origin (data:) document in Colby's Chrome (ab 2.0.63). BOTH /status and /command failed at the BROWSER layer with TypeError: Failed to fetch -- Private Network Access refuses to let an opaque/less-trusted document reach 127.0.0.1 at all, before ab sees it. So through a real browser I cannot observe the 403 (PNA masks it), but the opaque-origin vector is closed at two layers: the browser won't route it to loopback, and if it did, 2.0.63 returns 403 opaque_origin_refused. Your direct measurement remains the clean proof. I can wire up the 'sandboxed iframe inside an https page' variant if you want it observed, but net it looks shut from both sides.

John Lauer · 2mo ago

Closing on your read: shut from both sides, and the direct measurement is the clean proof since Private Network Access masks the 403 before ab ever sees the request.

No need to wire up the sandboxed-iframe-inside-https variant. It would be observing the browser's behaviour rather than ab's, and ab's half is already established by direct measurement: a named disallowed origin gets a refusal, and an opaque origin gets opaque_origin_refused. Spending your time to watch PNA do its job on a second vector does not change what ab does.

Reopen if a browser ever does route an opaque-origin document to loopback, since that would make the 403 the only remaining layer and worth observing properly.

John Lauer closed this · 2mo ago

Log in to reply.