Closed bug report

Web-control handshake unusable cross-origin: CORS preflight omits X-Adom-WebControl-Secret

Colby Knox · 2mo ago ·closed by John Lauer

Summary

The web-control handshake is unusable from a real (cross-origin) web page, because ab's CORS preflight for POST /command does not allow the X-Adom-WebControl-Secret header. When "Require wiki handshake" is enabled, every web command is blocked by the browser before it ever reaches ab's consent gate.

Root cause

When requireHandshake is on, a web page must send X-Adom-WebControl-Secret on POST /command. A custom request header makes the POST a non-simple CORS request, so the browser first sends an OPTIONS preflight and requires the server to echo that header in Access-Control-Allow-Headers.

web_gate's OPTIONS handler in src-tauri/src/direct_api.rs sets:

h.insert(
    HeaderName::from_static("access-control-allow-headers"),
    "content-type".parse().unwrap(),
);

It lists only content-type, not x-adom-webcontrol-secret. So the preflight fails and the browser never sends the actual command.

Reproduction (live, ab v2.0.59, real Chrome)

From a public https page (kcknox-...adom.cloud) reaching loopback, fetching http://127.0.0.1:24680/command:

  • With Content-Type: application/json only: HTTP 200 (runs).
  • Same POST plus X-Adom-WebControl-Secret: <secret>: TypeError: Failed to fetch (preflight blocked).

Driven via the puppeteer bridge against the git-wiki Quick Actions demo; the two calls differ only by the presence of the secret header.

Impact

The handshake is self-defeating for exactly the case it exists for (wiki.adom.inc, or any first-party wiki surface, reaching the user's loopback ab). With requireHandshake OFF the loop works (no header is sent); turn it ON to actually require the secret and every cross-origin web command is blocked by the browser before the consent prompt. This blocks the wiki-side Quick Actions handshake integration end to end.

Note: the read paths (/health, /status) and a header-less POST /command are unaffected, since their preflights only need content-type. So the bug only surfaces once a page presents the secret.

Suggested fix

Add the handshake header to the preflight allow-list in web_gate:

"access-control-allow-headers",
"content-type, x-adom-webcontrol-secret",

Browsers send the requested header lowercased in Access-Control-Request-Headers, so the lowercase spelling is what matters. A small follow-up worth considering: reflect back whatever the request's Access-Control-Request-Headers asked for (still gated to the loopback host + web paths), so a future client header does not hit the same wall.

Environment

  • Adom Bridge v2.0.59 (6e078da), HOST, Direct API :24680, signed in.
  • Verified in real Chrome on the same machine; source confirmed in the 2.0.59 tree.

7 Replies

Colby Knox · 2mo ago

Wiki side is ready and does not need changes for this. The Quick Actions client (git-wiki static/webcontrol.js) fetches the handshake from the wiki backend and presents X-Adom-WebControl-Secret on POST /command exactly as designed, so this preflight fix is the only thing between here and a working end-to-end handshake.

Until it ships, the client now degrades gracefully rather than showing a bare network error: if it holds a secret and a command dies at the network layer, it probes /status; if ab answers, it reports a coded handshake_header_blocked with an actionable message ("update Adom Bridge, or turn off Require wiki handshake"). So a user on a pre-fix ab with the handshake required gets a clear reason instead of a silent failure.

Happy to verify the fix live (real Chrome via pup, public origin) the moment a build with the header allow-listed is out.

Colby Knox · 2mo ago

Fair to sanity-check, but the demo working is consistent with this bug, not evidence against it, because the native demo never sends the handshake header.

Grep web-control-demo.html for X-Adom-WebControl-Secret: the only hit is the descriptive <li> ("supports an optional cloud-K-V handshake"), no code that sets it. So the demo is consent-only, and its POST /command carries just Content-Type: application/json. That preflight asks for content-type, which the OPTIONS handler allows, so it passes. The demo never exercises the handshake path, so it can't hit this.

The bug is specifically the header. Controlled repro, same real Chrome, same public origin, same POST /command, the ONLY difference is the header:

  • Content-Type: application/json only -> HTTP 200
  • same + X-Adom-WebControl-Secret: <secret> -> TypeError: Failed to fetch

Mechanism: Content-Type: application/json already makes this a non-simple request, so the browser preflights either way. Adding the custom header puts x-adom-webcontrol-secret into the preflight's Access-Control-Request-Headers. web_gate's OPTIONS returns a STATIC Access-Control-Allow-Headers: content-type (it does not reflect the requested headers), so the browser fails the preflight and the POST never leaves the browser.

It only bites when requireHandshake is ON (which forces the header) AND the caller is cross-origin, i.e. the wiki.adom.inc -> loopback case the handshake exists for. Default is off and the demo does not use it, which is why nothing has tripped it yet. The one-line fix is allow-listing the header (or reflecting Access-Control-Request-Headers). Happy to verify live via pup the moment a build has it.

Colby Knox · 2mo ago

Ran it live just now to nail it down. Real Chrome on the same machine as ab (via pup), a public HTTPS page reaching loopback, two POST http://127.0.0.1:24680/command calls that are byte-for-byte identical except the header:

pageOrigin: https://kcknox-...adom.cloud   (public)
target:     http://127.0.0.1:24680/command
A) headers: { Content-Type: application/json }                          -> HTTP 200 (7ms)   [runs]
B) headers: { Content-Type: application/json, X-Adom-WebControl-Secret } -> Failed to fetch  [blocked]

Same command (desktop_list_windows), same body. A returns 200 and runs; B never leaves the browser. The only variable is the presence of the handshake header, and the server-side reason is the OPTIONS handler in web_gate returning a static Access-Control-Allow-Headers: content-type (2.0.59 source), so the browser fails the preflight when the request asks for x-adom-webcontrol-secret.

That is the whole bug. It is invisible until a page sends the secret, which only happens under requireHandshake (off by default) and only cross-origin (the wiki case). Adding x-adom-webcontrol-secret to the allow-list closes it.

Colby Knox · 2mo ago

On "wiki is on the allowlist": the current code does not allowlist the origin at all. cors_headers() echoes back whatever Origin asked (direct_api.rs:1085), and the web_gate comment says it directly (:1119): "the origin is NOT allowlisted here anymore -- reads are open... writes are gated by human consent." The "ONLY https://wiki.adom.inc" line is in the /verbs help page (:1502), which still describes the pre-2.0.31 exact-origin model and is now stale (worth updating -- it is what is causing the "but wiki is allowlisted" confusion).

Proof the origin is not the factor here: in the A/B above, the header-less call returned HTTP 200 and ran, and that page's origin was a plain ...adom.cloud container host, NOT wiki.adom.inc. If ab still gated on an exact origin, that call would have been CORS-blocked too. It was not. Same origin, only the header flips it. So the origin is not the variable; the static Access-Control-Allow-Headers: content-type is.

Why a naive "test it from a real wiki.adom.inc page" currently blocks EVERYTHING (both with and without the header): that is a second, independent gate on the WIKI side, not ab. A wiki page's own CSP has to allow connect-src http://127.0.0.1:24680, and prod wiki today sends connect-src 'self' https://<r2-bucket> with no loopback, so the fetch never leaves the browser. (The first-party Quick Actions bar adds that grant; a sandboxed readme cannot, since its CSP is connect-src 'self' data: and the applet_connect widener rejects all IP literals + localhost by SSRF design.) So to reproduce this ab bug from a real wiki.adom.inc page you need both the wiki-side loopback CSP grant AND this fix live; the two gates are separate.

Net: this issue is purely gate 2 (ab's preflight allow-headers). It is origin-independent, so wiki.adom.inc will behave exactly like the container demo once its request can reach ab. One-line fix stands: add x-adom-webcontrol-secret to the allow-list (or reflect Access-Control-Request-Headers).

Colby Knox · 2mo ago

Opened a PR with the one-line fix (add the header to the preflight allow-list): https://github.com/adom-inc/adom-bridge/pull/13

It is a static header-value change so it should be low risk, but a local cargo build before merge is worth it. Glad to verify it live via pup the moment a build with it lands.

John Lauer · 2mo ago

Fixed and shipped in 2.0.61 (signed installer + pkg are live on the page now).

I took your suggested follow-up rather than the minimal patch: the preflight now reflects Access-Control-Request-Headers back instead of returning a longer static list, with content-type, x-adom-webcontrol-secret as the fallback when the browser sends no request-headers line. That way the next client header nobody has thought of yet cannot hit this same wall, and there is no allow-list to keep in sync with your client.

Your diagnosis was right, including both pre-refutations. Worth stating the root cause plainly for the record, because it made the feature self-defeating: presenting the handshake secret is what turns the POST into a non-simple request, so the very act of using requireHandshake created a preflight that we then failed. The command never reached the consent gate at all, which is why you only ever saw TypeError: Failed to fetch and never a prompt.

Scope is unchanged: still loopback-Host only, still only the web-reachable paths, and reflecting a header NAME is not authorization. Every write still waits on the per-origin human Allow/Deny.

I also fixed the stale copy you flagged. The /verbs page claimed ab returns CORS headers only when the Origin is exactly https://wiki.adom.inc, which has not been true since the per-origin consent model landed and actively misdescribed the security model. It now says what actually stops a hostile site: any page may reach ab, and the human consent prompt is the control. Same correction applied to the module header comment.

On PR #13: I did not merge it, because the reflect-based version rewrites the same lines and goes further than the one-line allow-list. Please close it, or push back here if you disagree with the reflect approach. Credit is in the commit message either way.

Verified live before shipping, from a public origin against this box:

OPTIONS /command   Origin: https://example.com
                   Access-Control-Request-Headers: content-type, x-adom-webcontrol-secret
-> HTTP/1.1 204 No Content
   access-control-allow-origin:  https://example.com
   access-control-allow-headers: content-type, x-adom-webcontrol-secret

That is the header echoed back, so the real POST is no longer blocked before it can reach the gate. Ready for your end-to-end run whenever you are: real Chrome via pup, public origin, handshake on. If the POST now surfaces an Allow/Deny prompt on the machine, that is the whole path working.

John Lauer · 2mo ago

Closing: fixed and shipped in 2.0.61, and your PR was the correct one-line diagnosis.

The correction on the record: I said the wiki origin was allowlisted and it was not. The code did not allowlist the origin at all, and you were right to push back on that rather than accept my framing of it.

John Lauer closed this · 2mo ago

Log in to reply.