← Commit history

web-control: honest design doc + harness snippet + renderer finding

John Lauer ·16362049f2 ·2mo ago ·parent 50ba1d4
1 file changed +78−128
docs/web-control.md+78−128
@@ -1,157 +1,107 @@ # Talking to Adom Bridge from a web page -This page is a **live demo**. If you are reading it on `https://wiki.adom.inc` in a-browser on a machine that is running Adom Bridge, the panel below actually reaches-into the copy of Adom Bridge on that machine and reports back. Nothing here is a-mock-up: the buttons make real `fetch()` calls to `http://127.0.0.1:24680`, Adom-Bridge's loopback API.+A page served from `https://wiki.adom.inc` can reach into the copy of Adom Bridge+running on the visitor's own machine, over Adom Bridge's loopback HTTP API at+`http://127.0.0.1:24680`. This is how the wiki can, for example, detect that Adom+Bridge and KiCad are both present before offering an "Open in KiCad" button.  ## What a web page can and cannot do (read this first) -Adom Bridge runs a small HTTP API on the loopback address `127.0.0.1:24680`. If any-website could reach it, a malicious page could drive your computer. So the surface a-web page is allowed to touch is deliberately tiny, and this demo shows exactly where-the line is.+If any website could reach the loopback API, a malicious page could drive your+computer. So the web-reachable surface is deliberately tiny: -- **Allowed from the web, today:** `GET /health` and `GET /status`. Both are-  read-only: liveness, version, which bridges are installed. That is enough for a-  page to light up an "Open in KiCad" button only when KiCad and Adom Bridge are-  actually present.+- **Allowed from the web, today:** `GET /health` and `GET /status`. Both read-only:+  liveness, version, which bridges are installed. - **Refused from the web, today:** `POST /command` (the verb-running endpoint) is-  hard-refused for every web origin with HTTP 403 `web_forbidden`. So no web page-  can change your machine. The "Try to open KiCad" button below deliberately hits-  that wall so you can watch it get refused.+  hard-refused for every web origin with HTTP 403 `web_forbidden`. No web page can+  change your machine. - **Only `https://wiki.adom.inc` gets in at all.** Adom Bridge returns the CORS-  headers that let the browser read the response *only* when the request's `Origin`-  is exactly `https://wiki.adom.inc`. Any other site gets nothing. This is why the-  demo only works when you are reading it on the real wiki.+  headers that let the browser read the response only when the request's `Origin`+  is exactly `https://wiki.adom.inc`. Any other site gets nothing. -Actually *running* a verb like `kicad_open_board` from a web button is a planned next+Actually running a verb like `kicad_open_board` from a web button is a planned next phase: it will require a native Adom Bridge consent dialog (you approve the specific action in Adom Bridge's own window, which no web page can script) layered under a-per-user cloud-key handshake. Until that ships, the answer to "can the wiki open-KiCad for me?" is: it can *detect* that it could, and it is *walled off* from doing-it. That wall is the feature.+per-user cloud-key handshake. Until that ships, a web page can *detect* that it+could, and is *walled off* from doing it. That wall is the feature. -<div id="ab-demo" style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;border:1px solid #30363d;border-radius:10px;background:#0d1117;color:#e6edf3;padding:18px;margin:18px 0;max-width:760px">+## The security gate++Implemented in `src-tauri/src/direct_api.rs` (`web_gate` middleware). Every layer+must pass:++1. **Exact origin allowlist.** CORS headers only for `Origin: https://wiki.adom.inc`+   (never `*`). The browser sets `Origin`; page JS cannot forge it.+2. **Never `*.adom.cloud`, never a wildcard.** Any Adom user can be handed an+   `*.adom.cloud` subdomain, so allowlisting that would let a nefarious user's page+   drive machines. The allowlist is exactly one first-party origin.+3. **Loopback Host check** (anti-DNS-rebind): the `Host` header must be a loopback+   literal.+4. **Private Network Access preflight:** a public-to-loopback fetch triggers the PNA+   preflight, answered only for the allowlisted origin.+5. **Path scope:** a web origin may reach only `/health` and `/status`; everything+   else, including `/command`, is `web_forbidden`.++The fixed loopback port is `24680` (reserved, stable across reboots). Browsers treat+`http://127.0.0.1` as a trustworthy secure context, so an HTTPS wiki page may fetch+it with no mixed-content error.++## The interactive harness (and why it is not embedded live yet)++The natural demo is a small panel on this page with Detect / Read-status / Try-open-+KiCad buttons that call the API and print the result. **The blocker is not the+security gate; it is the wiki renderer.** Verified live 2026-08-12 by driving the+real browser on a machine running Adom Bridge to the rendered page: the page's own+origin is correctly `https://wiki.adom.inc` (so the gate would allow it), but the+markdown renderer **strips raw HTML** from a README, and an ` ```html ` fenced block+renders as a **syntax-highlighted code block**, not a live iframe. Neither path+executes a `<script>`. So a live in-page harness needs a wiki-side "live HTML embed"+mechanism (a sandboxed iframe with `allow-scripts` AND a `wiki.adom.inc` origin, or+an equivalent widget block). That is a wiki-platform capability, filed to the wiki+thread rather than worked around here.++Below is the exact harness we would embed the moment such a block exists. It is+also runnable standalone: save it as an `.html` file and open it from any+`https://wiki.adom.inc`-served context.++```html+<div id="ab-demo" style="font-family:system-ui,Segoe UI,sans-serif;border:1px solid #30363d;border-radius:10px;background:#0d1117;color:#e6edf3;padding:18px;max-width:760px">   <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">     <span id="ab-dot" style="width:12px;height:12px;border-radius:50%;background:#484f58;display:inline-block"></span>-    <strong style="font-size:16px">Adom Bridge web control demo</strong>-  </div>-  <div style="font-size:12px;color:#8b949e;margin-bottom:14px">-    This iframe's origin: <code id="ab-origin" style="color:#00e6dc">checking…</code>-    &nbsp;·&nbsp; API: <code style="color:#8b949e">http://127.0.0.1:24680</code>+    <strong>Adom Bridge web control demo</strong>   </div>+  <div style="font-size:12px;color:#8b949e;margin-bottom:14px">This frame's origin: <code id="ab-origin">checking...</code> &middot; API: <code>http://127.0.0.1:24680</code></div>   <div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px">-    <button id="ab-detect" style="background:#00b8b0;color:#04211f;border:0;border-radius:6px;padding:8px 14px;font-weight:600;cursor:pointer">Detect Adom Bridge</button>-    <button id="ab-status" style="background:#1c2128;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:8px 14px;cursor:pointer">Read status</button>-    <button id="ab-kicad" style="background:#1c2128;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:8px 14px;cursor:pointer">Try to open KiCad (should be blocked)</button>+    <button id="ab-detect">Detect Adom Bridge</button>+    <button id="ab-status">Read status</button>+    <button id="ab-kicad">Try to open KiCad (should be blocked)</button>   </div>-  <pre id="ab-log" style="background:#010409;border:1px solid #21262d;border-radius:6px;padding:12px;font-size:12px;line-height:1.5;max-height:300px;overflow:auto;white-space:pre-wrap;margin:0;color:#c9d1d9">Ready. Click "Detect Adom Bridge" to begin.</pre>+  <pre id="ab-log" style="background:#010409;border:1px solid #21262d;border-radius:6px;padding:12px;font-size:12px;max-height:300px;overflow:auto;white-space:pre-wrap;margin:0">Ready. Click Detect Adom Bridge to begin.</pre>   <script>   (function () {-    var AB = "http://127.0.0.1:24680";-    var log = document.getElementById("ab-log");-    var dot = document.getElementById("ab-dot");-    document.getElementById("ab-origin").textContent = location.origin || "(null — sandboxed iframe)";-    function line(msg, color) {-      var t = new Date().toLocaleTimeString();-      log.textContent += "\n[" + t + "] " + msg;-      if (color) { log.style.color = color; }-      log.scrollTop = log.scrollHeight;-    }-    function reset() { log.textContent = "—"; log.style.color = "#c9d1d9"; }-+    var AB = "http://127.0.0.1:24680", log = document.getElementById("ab-log"), dot = document.getElementById("ab-dot");+    document.getElementById("ab-origin").textContent = location.origin || "(null: sandboxed frame)";+    function line(m){ log.textContent += "\n" + m; log.scrollTop = log.scrollHeight; }     document.getElementById("ab-detect").onclick = function () {-      reset();-      line("GET " + AB + "/health …");-      fetch(AB + "/health")-        .then(function (r) { return r.ok ? r.json() : Promise.reject("HTTP " + r.status); })-        .then(function (j) {-          if (j && j.service === "adom-bridge") {-            dot.style.background = "#00b8b0";-            line("Adom Bridge is RUNNING on this machine. service=" + j.service, "#7ee787");-            line(JSON.stringify(j, null, 2));-          } else {-            line("Reached something, but it is not Adom Bridge: " + JSON.stringify(j), "#e6c86c");-          }-        })-        .catch(function (e) {-          dot.style.background = "#484f58";-          line("No Adom Bridge reachable (" + e + ").", "#8b949e");-          line("Either it is not installed/running here, or you are not on https://wiki.adom.inc (the only origin it answers). Mixed-content note: browsers allow an HTTPS page to fetch http://127.0.0.1 as a trustworthy loopback, so that is not the blocker.");-        });+      log.textContent = "GET " + AB + "/health ...";+      fetch(AB + "/health").then(function(r){return r.ok?r.json():Promise.reject("HTTP "+r.status);})+        .then(function(j){ dot.style.background = j.service==="adom-bridge" ? "#00b8b0" : "#e6c86c"; line("RUNNING: " + JSON.stringify(j)); })+        .catch(function(e){ dot.style.background = "#484f58"; line("Not reachable (" + e + "). Not running here, or this origin is not https://wiki.adom.inc."); });     };-     document.getElementById("ab-status").onclick = function () {-      reset();-      line("GET " + AB + "/status …");-      fetch(AB + "/status")-        .then(function (r) { return r.ok ? r.json() : Promise.reject("HTTP " + r.status); })-        .then(function (j) {-          dot.style.background = "#00b8b0";-          line("Status (read-only, allowed from the web):", "#7ee787");-          line(JSON.stringify(j, null, 2));-        })-        .catch(function (e) { line("status failed: " + e, "#f0883e"); });+      log.textContent = "GET " + AB + "/status ...";+      fetch(AB + "/status").then(function(r){return r.json();}).then(function(j){ line(JSON.stringify(j, null, 2)); }).catch(function(e){ line("failed: " + e); });     };-     document.getElementById("ab-kicad").onclick = function () {-      reset();-      line("POST " + AB + "/command  {kicad_open_board}  — expecting this to be REFUSED …");-      fetch(AB + "/command", {-        method: "POST",-        headers: { "Content-Type": "application/json" },-        body: JSON.stringify({ app: "kicad", command: "kicad_open_board", args: {} })-      })-        .then(function (r) { return r.json().then(function (j) { return { status: r.status, body: j }; }); })-        .then(function (res) {-          if (res.status === 403 || (res.body && res.body.errorCode === "web_forbidden")) {-            line("REFUSED, as designed. HTTP " + res.status + " errorCode=" + (res.body && res.body.errorCode), "#7ee787");-            line("This is the safety wall: a web page cannot run a machine-changing verb. " +-                 "The consent-gated action phase will change this to 'ask the human in Adom Bridge first'.");-            line(JSON.stringify(res.body, null, 2));-          } else {-            line("UNEXPECTED — POST /command was NOT refused (HTTP " + res.status + "). " +-                 "That would be a security regression; please report it.", "#f85149");-            line(JSON.stringify(res.body, null, 2));-          }-        })-        .catch(function (e) {-          line("The browser blocked the POST before Adom Bridge answered (" + e + ").", "#e6c86c");-          line("That is also a refusal: with no CORS grant for a non-allowlisted origin, the browser will not even let page JS read the response.");-        });+      log.textContent = "POST " + AB + "/command {kicad_open_board} - expecting REFUSED ...";+      fetch(AB + "/command", { method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({app:"kicad", command:"kicad_open_board", args:{}}) })+        .then(function(r){ return r.json().then(function(j){ return {status:r.status, body:j}; }); })+        .then(function(res){ line((res.status===403||(res.body&&res.body.errorCode==="web_forbidden")) ? "REFUSED, as designed: HTTP " + res.status + " " + JSON.stringify(res.body) : "UNEXPECTED - NOT refused: " + JSON.stringify(res)); })+        .catch(function(e){ line("Browser blocked the POST before ab answered (" + e + ") - also a refusal for a non-allowlisted origin."); });     };   })();   </script> </div>+``` -## How it works under the hood--The gate lives in `src-tauri/src/direct_api.rs` (`web_gate` middleware). Every layer-must pass:--1. **Exact origin allowlist.** CORS headers are returned only for-   `Origin: https://wiki.adom.inc` (never `*`). The browser sets `Origin` and page-   JS cannot forge it, so it reliably identifies the site.-2. **Never `*.adom.cloud`, never a wildcard.** Any Adom user can be handed an-   `*.adom.cloud` subdomain, so allowlisting that pattern would let a nefarious-   user's page drive machines. The allowlist is exactly one first-party origin.-3. **Loopback Host check** (anti-DNS-rebind): the `Host` header must be a loopback-   literal.-4. **Private Network Access preflight:** a public-to-loopback fetch triggers the-   PNA preflight, which Adom Bridge answers only for the allowlisted origin.-5. **Path scope:** a web origin may reach only `/health` and `/status`. Everything-   else, including `/command`, is `web_forbidden`.--The fixed loopback port is `24680` (reserved and stable across reboots). For the-full ab-side reference, see the `adom-bridge-web-integration` skill.--## What this page is testing--Because it renders inside the wiki's HTML iframe, this page is also a live test of-whether that iframe's origin actually satisfies the gate. Watch the "This iframe's-origin" line: if it reads `https://wiki.adom.inc`, the CORS grant applies and the-detect/status buttons work. If it reads `null` (a sandboxed iframe), the browser-strips the origin and even the read-only calls are refused, which tells us the-harness has to run from a first-party origin rather than a sandboxed frame.+For the full ab-side reference, see the `adom-bridge-web-integration` skill.