security: close capability, CSP and auth-bridge holes

capabilities/default.json
- Drop every `store:*` grant. `@tauri-apps/plugin-store` has no caller in
  `app/src`, and the plugin's `resolve_store_path` is a `PathBuf::push` against
  AppData — `push` discards the base for an absolute path, so the grant was an
  arbitrary host read/write from the webview.
- Replace `opener:default` with a scoped `opener:allow-open-url` (http/https
  only). That drops `reveal_item_in_dir`, which the plugin does not scope-check
  and nothing here calls, and the unused mailto:/tel: scope.
- Record the unscopable `drag:allow-start-drag` residual risk in `description`.

tauri.conf.json
- Add `form-action 'none'`, `base-uri 'none'`, `object-src 'none'`.
  `form-action` has no `default-src` fallback, so an injected auto-submitting
  form was unblocked even though `script-src 'self'` blocks XSS.
- Remove the dead `asset:` / `https://asset.localhost` img-src and `data:`
  font-src grants; `blob:` stays (the file viewer uses it).

auth_bridge
- The reserved-port set covered only this project's mappings and the two
  browser-view ranges. It now also covers the gateway, STT and web-terminal
  host ports (configured value and shipped default, read off the settings
  models) and every other project's published host port. A container binding
  container-loopback 4000 / 9876 / 7681 while those services were stopped had
  that port mirrored onto the host, unauthenticated, within one poll.
- Gate the host listener on fetch metadata: refuse a request that is a
  cross-site sub-resource, allow navigations (the OAuth redirect) and anything
  without `Sec-Fetch-*`. Non-HTTP connections are classified from their first
  line and forwarded verbatim. Residual risk is spelled out in the module docs.
- Bound the forwards: max concurrent connections per port, a first-byte
  deadline enforced before any `docker exec` is created, and an idle timeout.

browser_view/mod.rs
- `pick_viewer_port` reads procfs with `/usr/bin/cat`, not a bare `cat` the
  container can shim via its writable PATH entry.
- Treat port choice as check-then-bind: walk to the next free candidate when
  the viewer does not come up, instead of failing the start.

BrowserTab.tsx
- Sandbox the viewer iframe. Container-controlled content could `top.location`
  the app's webview away. `allow-top-navigation*` and
  `allow-popups-to-escape-sandbox` are deliberately absent.

HelpDialog.tsx
- Escape the quote characters in the entity pass and escape captured attribute
  values. `href="$2"` with `$2` = `[^)]+` let remote GitHub markdown close the
  attribute and open another, in a document rendered with
  `dangerouslySetInnerHTML`.

web_terminal/terminal.html
- SRI hashes plus `crossorigin` on the three jsdelivr bundles and the
  stylesheet, and a CSP for the page — it is served 0.0.0.0 behind a permissive
  CORS layer and nothing else gives it one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 11:13:15 -07:00
co-authored by Claude Opus 5
parent 0003793abb
commit 092972fe92
10 changed files with 1066 additions and 72 deletions
+71 -4
View File
@@ -3,11 +3,78 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<!--
This page is served by an axum server bound 0.0.0.0 (remote access is the
feature) behind a permissive CORS layer, and it fronts a shell in a container.
It gets a CSP of its own because nothing else gives it one: the app's
`tauri.conf.json` CSP covers the desktop webview, never this document.
`default-src 'none'` is the base, so anything not named below is refused
outright. What is named:
script-src jsdelivr for the three xterm bundles, plus 'unsafe-inline' for
this page's own inline <script>. Nonces/hashes were considered
and rejected: the file is a static `include_str!()` asset, so a
hash would have to be recomputed by hand on every edit to the
script, and the failure mode of getting that wrong is a terminal
that silently will not start.
style-src the xterm stylesheet, this page's <style>, and the one inline
`style=` attribute below (style attributes need 'unsafe-inline').
connect-src the WebSocket back to this same server. `ws:`/`wss:` as schemes
rather than an origin, because the host and port are whatever
the user reached this page on and are not knowable at build time.
form-action / base-uri / object-src / frame-ancestors — all 'none'. Note
`frame-ancestors` is ignored in a <meta> CSP; it is here as a
statement of intent, and the real protection would be a response
header from `server.rs`.
Deliberately absent: 'unsafe-eval', and any origin other than jsdelivr.
-->
<meta http-equiv="Content-Security-Policy" content="
default-src 'none';
script-src 'unsafe-inline' https://cdn.jsdelivr.net;
style-src 'unsafe-inline' https://cdn.jsdelivr.net;
img-src 'self' data:;
font-src 'self' data:;
connect-src 'self' ws: wss:;
form-action 'none';
base-uri 'none';
object-src 'none';
frame-ancestors 'none';
">
<title>Triple-C Web Terminal</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
<!--
Subresource Integrity on every CDN asset.
Without it this page executes whatever jsdelivr returns, inside a document
that holds the web terminal's access token and drives a shell in a container —
an upstream compromise, a hijacked package version or a MITM on a phone's
network is arbitrary code with that reach. The hashes below were computed
from the exact bytes at these pinned versions. `crossorigin="anonymous"` is
required for SRI to be checked on a cross-origin fetch.
Bumping a version means recomputing its hash:
curl -sS <url> | openssl dgst -sha384 -binary | openssl base64 -A
A mismatched hash blocks the asset, so a stale hash shows up immediately as a
terminal that does not render — never as an unverified load.
These are still remote loads: the remote terminal does not work with no
internet on the client side, and vendoring the ~300 KB of minified xterm into
this file would fix that. It was not done here — SRI already closes the
integrity half, which is the security half, and the availability half is a
separate call about binary size and diff readability.
-->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"
integrity="sha384-tStR1zLfWgsiXCF3IgfB3lBa8KmBe/lG287CL9WCeKgQYcp1bjb4/+mwN6oti4Co"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"
integrity="sha384-J4qzUjBl1FxyLsl/kQPQIOeINsmp17OHYXDOMpMxlKX53ZfYsL+aWHpgArvOuof9"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"
integrity="sha384-XGqKrV8Jrukp1NITJbOEHwg01tNkuXr6uB6YEj69ebpYU3v7FvoGgEg23C1Gcehk"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"
integrity="sha384-S1biLeI8L/bFduIVvCxbn/l4EtaG4nTqQjGF7qCYTbsGXGFe8KgIKXtw4+UWxprv"
crossorigin="anonymous"></script>
<style>
:root {
--bg-primary: #1a1b26;