Closed bug report

web-control /status has no usable Access-Control-Allow-Origin: Quick Actions detection CORS-fails in every real browser (only puppeteer's CORS-disabled Chrome worked)

Colby Knox · 2mo ago ·closed by John Lauer

web-control /status: no usable Access-Control-Allow-Origin for a cross-origin browser fetch -> 'CORS Failed', so Quick Actions DETECTION fails in every real browser

The wiki-side Quick Actions bar detects the bridge by fetching http://127.0.0.1:2468x/status from a wiki.adom.inc page. In real browsers this fails at the CORS layer, so the bar reports 'no bridge connected to this machine' even though ab is running and healthy.

Live repro (ab 2.0.63, machine k-zephyrus, a second user's box; also seen earlier in Colby's Edge)

  • Direct navigation to http://127.0.0.1:24680/status -> full JSON, service: adom-bridge, webControl.enabled: true, webControl.reads: "open". ab is fine.
  • The wiki page's fetch to the same /status, in DevTools -> Network:
    • port 24680: "Response body is not available to scripts (Reason: CORS Failed)"
    • ports 24681-24689: NS_Binding_aborted (the client's parallel probe cancels the idle ports; expected)
  • Fails in Firefox AND Edge. The request reaches ab (ab answers) but the response's CORS headers don't let the page read it.

Why it was never caught

Every green detection we had was through puppeteer's Chrome, which is launched with web security / CORS relaxed, so a missing/!matching Access-Control-Allow-Origin on /status didn't matter. A normal browser enforces it, so detection has actually never worked in a real browser.

Ask

/status (and /health, since the client probes both, and /command's real response) must return an Access-Control-Allow-Origin a browser will accept for a first-party wiki fetch -- either * (fine for these non-credentialed reads) or reflect the request Origin. Right now a cross-origin GET of /status from https://wiki.adom.inc is CORS-blocked. This is the single gate on Quick Actions detection working in any real browser; the whole feature is dark until it lands.

If a public->loopback preflight is also in play in Chrome/Edge, the OPTIONS response additionally needs Access-Control-Allow-Private-Network: true -- but the Firefox "CORS Failed" points first at the plain ACAO on the GET response. I can grab the exact /status response headers from a box if that speeds it up.

8 Replies

Colby Knox · 2mo ago

Correction after reading the client (static/webcontrol.js): the Quick Actions client fetches only two ab endpoints in a browser --

  • GET /status for detection (probeStatus, sweeps 24680-24689) -- this is the gate that's CORS-failing today, and nothing else in the feature runs until it reads.
  • POST /command for the actual verbs (Content-Type: application/json, so it's a non-simple request and will preflight).

It does NOT fetch /health, so scratch that from the ask. The two endpoints that must return a browser-acceptable Access-Control-Allow-Origin (* or reflected origin) for a first-party wiki fetch are /status and /command. /status is the detection gate; /command needs it too (and the preflight allow-methods/allow-headers, which ties into the #87 header work) or the verbs will CORS-fail the same way right after detection succeeds -- note we only ever exercised /command through the CORS-disabled puppeteer Chrome, same blind spot as detection. /health may share the same handler and is worth fixing for consistency, but it's not on the QA path.

Colby Knox · 2mo ago

Amending the ask -- Access-Control-Allow-Origin: * is the wrong fix, and I don't want to steer this into a fingerprinting hole. Splitting by endpoint:

/status -- allowlist, do NOT use *. /status is un-consented and its body is fingerprintable (clientName/hostname, version, install path, webControl config, permissions). * (or reflect-any-origin) lets ANY site the user visits read all of that and silently fingerprint the machine. Instead, return Access-Control-Allow-Origin only for a first-party allowlist -- https://wiki.adom.inc plus whatever other first-party surfaces need detection (the 24681 demo, Hydrogen, etc.). The wiki page can then read detection; random sites get no ACAO and are CORS-blocked from the body. This is NOT the 'allowlist is theatre' case from #88 -- that argument was about /command EXECUTION, where consent already names the origin; here it's about DISCLOSURE of the status body, where an allowlist genuinely limits fingerprinting. (If you'd rather keep detection fully open, the alternative is to split /status into a minimal cross-origin subset -- just service + webControl.enabled + requireHandshake -- and keep the sensitive fields same-origin only. Allowlist is simpler.)

/command -- reflect the specific request Origin (what cors_headers() already does per #88), not *. Consent stays the gate; CORS just lets the first-party page read the verb result it consented to. Plus the preflight allow-methods/allow-headers so the application/json POST doesn't fail preflight (ties into #87).

Net: the browser fetch needs to succeed WITHOUT opening the status body to arbitrary origins. Sorry for the earlier '* or reflect' phrasing -- * on /status would trade one problem for a worse one.

John Lauer · 2mo ago

I cannot reproduce the missing ACAO, and I think that matters before either of us builds on it. But your amended security point IS a real hole, independent of the CORS symptom, and I am fixing that.

The CORS premise does not reproduce here

Measured just now against a live ab, from the shell, with the exact header a wiki page sends:

GET /status                Origin: https://wiki.adom.inc
-> HTTP/1.1 200 OK
   access-control-allow-origin: https://wiki.adom.inc
   vary: Origin

And the Private-Network-Access preflight that Chrome/Edge send for a public-page -> loopback fetch:

OPTIONS /status            Origin: https://wiki.adom.inc
                           Access-Control-Request-Method: GET
                           Access-Control-Request-Private-Network: true
-> HTTP/1.1 204 No Content
   access-control-allow-origin:          https://wiki.adom.inc
   access-control-allow-methods:         GET, POST, OPTIONS
   access-control-allow-headers:         content-type, x-adom-webcontrol-secret
   access-control-allow-private-network: true
   access-control-max-age:               600

cors_headers() reflects the request Origin and is applied to the real GET/POST responses, not just the preflight (direct_api.rs, three call sites). /status and /health are both in WEB_READ_PATHS, so both take the open-read path.

So on this box the browser has everything it needs. Two things would help more than me guessing:

  1. The ab version on k-zephyrus (GET /status -> version, or the footer). This behaviour is not new in 2.0.63; if that box is materially older it is the first thing I would suspect.
  2. The exact response headers you see for the failing GET /status — you offered, and yes please. Specifically whether access-control-allow-origin is absent, or present-but-mismatched.

One more candidate worth eliminating on your side: your client sweeps 24680-24689, and 24681 is ab's built-in demo server, which is a different server (static-only, deliberately a separate origin so the demo gets no special privilege). If a probe result from 24681 is being attributed to 24680, that would look exactly like this.

Your amended ask is right, and it is a real hole regardless

This part I am acting on immediately, because you are correct and it is worse than the CORS symptom:

cors_headers() reflects any origin, so today evil.com can read the full /status body. That body contains:

install.path   C:\Users\<username>\AppData\Local\Adom Bridge\adom-bridge.exe   <- the USERNAME
machine, version, permissions, wsl, bridgeUpdates, transport, directApi, endpoint

That is a genuine fingerprinting and PII disclosure to any site the user visits, and it exists whether or not your detection bug is real. Thank you for catching it and for correcting the * phrasing yourself — * would have made it worse.

I am taking your alternative rather than the allowlist, for two reasons:

A cross-origin GET /status will return a MINIMAL projection — service, version, and webControl:{enabled, requireHandshake} — enough for detection and for "your bridge is too old", and nothing else. Same-origin and non-browser callers (the CLI, Hydrogen, bridges on loopback) keep the full body.

Why not the allowlist: (1) it hardcodes first-party origins into ab, and your own list already has three entries (wiki, the 24681 demo, Hydrogen) — that is a maintenance trap that will silently break the next surface; and (2) ab deliberately does not allowlist origins, and #87 was partly about deleting a stale "only wiki.adom.inc" claim from the docs. The subset gets your detection working for ANY first-party surface with no registration step, while disclosing strictly less than an allowlist would to the origins on it.

Your framing of why this differs from #88 is exactly right and I want it on the record: #88 was execution, where consent already names the origin, so an allowlist adds nothing; this is disclosure, where the read happens with no consent at all and scoping genuinely limits what leaks. Same feature, opposite conclusion, and the distinction is the reason.

Shipping in the next release. I will confirm here with the measured before/after.

Colby Knox · 2mo ago

Thanks -- and the minimal-projection choice is the right one; install.path leaking C:\\Users\\<username>\\... (the Windows username) to any origin is the part that makes this urgent, so cutting the cross-origin body to service+version+webControl.{enabled,requireHandshake} closes it cleanly. Appreciate you acting on it fast.

On your 24681 candidate -- I can rule it out from the client side. probeStatus fetches each port independently and every result carries its OWN port ({ port: port, raw: s, ... }); it then takes the first entry whose body has service === "adom-bridge", in array order (24680 first). A 24681 result is never attributed to 24680. And in the DevTools capture the 24680 request itself is the one flagged CORS Failed; 24681-24689 are NS_Binding_aborted (the parallel probe cancelling idle ports). So the 24680 failure is genuine on that box, not a demo-server mixup.

Given you can't reproduce and his /status body reported version: 2.0.63 (same as your box), it looks environment-specific to k-zephyrus. I'll get you the exact response headers for the failing GET /status there (present-but-mismatched vs absent access-control-allow-origin), and rule out a header-stripping intermediary (VPN / proxy / endpoint-security). Will post what I find.

Colby Knox · 2mo ago

Confirmed from k-zephyrus itself: ab is NOT the problem. curl.exe -H "Origin: https://wiki.adom.inc" -i http://127.0.0.1:24680/status on that box returns:

HTTP/1.1 200 OK
content-type: application/json
access-control-allow-origin: https://wiki.adom.inc
vary: Origin
content-length: 4619

version 2.0.63, same as yours. So ab sends the correct reflected ACAO on the real GET on this box too -- your repro was right and mine ("ab is missing the header") was wrong. Firefox showed "0 B / no response headers" only because it hides headers on a CORS-blocked response.

The live puzzle now: curl reaches ab and sees correct CORS, but BOTH Firefox and Edge on this one machine fail the fetch. curl-works-both-browsers-fail points at something in the BROWSER network path that curl bypasses -- a system/VPN proxy or a security product intercepting loopback and returning a response without the ACAO. Chasing that on the user's box (netsh winhttp proxy, Firefox proxy config, and the OPTIONS/PNA preflight via curl to be thorough). Will report which it is. Nothing for you to fix on the CORS symptom; your minimal-projection PII fix stands on its own.

Colby Knox · 2mo ago

Resolved, and it is NOT a CORS/ab issue -- ab is perfect (curl GET returns the reflected ACAO, OPTIONS returns 204 + access-control-allow-private-network:true, no proxy). The real cause, straight from the browser console on the failing box:

Local Network Access permission required: top-level site "https://wiki.adom.inc/...",
  attempting to access target "http://127.0.0.1:24680/status" via fetch. Secure context: True
Local Network Access detected: ... prompt action: auto_allow
OK 4581

It is the browser's Local Network Access gate (Firefox 153's flavor of the public->loopback permission; Chromium has the equivalent). Run interactively in the console it auto-allowed and succeeded (OK 4581). Our failure is that the wiki bar's detection is a SILENT on-page-load fetch with no user gesture, so the browser blocks it without ever prompting -> 'no bridge'. That is also why every puppeteer run 'worked' -- pup disables this gate.

So nothing for you here on the CORS symptom -- that was my misread. The fix is wiki-side: gate detection behind a user click so the LNA prompt fires and the grant persists. The lasting value from this thread is your minimal-/status-projection PII fix, which stands on its own regardless. Thanks for the fast measurements that cleared ab -- they saved us chasing a header that was never missing.

John Lauer · 2mo ago

2.0.65 is published and the disclosure fix is verified live.

Measured against the running build, from a browser-shaped request and a local one:

GET /status   Origin: https://wiki.adom.inc          (a browser)
  keys: ok, schema, service, version, webControl
  webControl: {enabled:true, requireHandshake:false, demoUrl:"http://127.0.0.1:24681/"}
  machine / install / permissions / wsl / bridgeUpdates : ABSENT
  username anywhere in the body                        : no

GET /status   (no Origin - the CLI, Hydrogen, a bridge)
  15 keys, install + machine present, version 2.0.65   -> unchanged

So detection has everything it needs and nothing that identifies the machine. The webControl.grants list is gone from the browser view too, which mattered more than I first credited: it named every OTHER origin the user had approved, so one site could enumerate the others.

Two implementation notes, since they change what a future change can break:

It is an allowlist projection of the REAL response, not a rebuilt body. A field added to /status later is excluded from browsers by DEFAULT, so forgetting to update that code is the safe outcome rather than a new leak. And because it filters the handler's actual output, the public view cannot drift from what /status really reports. Four unit tests, one of which asserts exactly that (an unknown future field stays out).

On your CORS report itself: still not reproducible here, and 2.0.65 does not change any CORS header, so if your box was genuinely missing Access-Control-Allow-Origin this release will NOT fix it and I do not want you to read a green result as proof. When you get a chance:

  • the ab version on k-zephyrus (GET /status -> version), and
  • the exact response headers on the failing GET /status - specifically whether access-control-allow-origin is absent or present-but-mismatched.

Worth eliminating on your side first: your client sweeps 24680-24689, and 24681 is ab's built-in demo server, a deliberately separate origin. A probe outcome from 24681 attributed to 24680 would look exactly like this. If your headers show ACAO genuinely missing on 24680, send them and I will treat it as a real second bug rather than an environment difference.

Thank you for the amendment. * would have made the disclosure worse, and you caught that yourself before I acted on it.

John Lauer · 2mo ago

Closing: the disclosure fix shipped in 2.0.65 and was verified live against the running build, and your own measurement from k-zephyrus established that the CORS half was never ab's to fix.

Worth recording which part was real, because the title of this issue points at the wrong thing. The reflected-origin behaviour was correct all along. What was genuinely wrong was that /status returned the full document to any origin, including the machine name, the install path with the username in it, the permission state and the list of every approved origin. That is now local-callers-only, with a cross-origin caller getting the narrow webControl block.

Reopen if any consumer finds a field it legitimately needs that the narrowed block no longer carries.

John Lauer closed this · 2mo ago

Log in to reply.