diff --git a/docs/plans/tauri-capability-narrowing.md b/docs/plans/tauri-capability-narrowing.md new file mode 100644 index 00000000..b78cfff4 --- /dev/null +++ b/docs/plans/tauri-capability-narrowing.md @@ -0,0 +1,144 @@ +# Tauri HTTP Capability Narrowing — Design + +**Status:** design only (2026-07-20) — no code change in this PR +**Phase:** P3 "Client + plugin security parity" +**Follows:** [http-tofu-proxy.md](http-tofu-proxy.md) (A-2026-07-02), which moved +REST/health/attachment traffic onto a loopback origin and was expected to make +the remaining outbound host set enumerable. + +## Problem + +`Client/tauri-client/src-tauri/capabilities/default.json` grants three HTTP +identifiers — `http:allow-fetch`, `http:allow-fetch-send`, +`http:allow-fetch-read-body` — each scoped to `https://*`, `https://*:*` and +`http://127.0.0.1:*`. In practice that is "the renderer may reach any host on +the internet over TLS". The TOFU proxy landed, so the working assumption was +that the wildcard could now be replaced by an enumerated allowlist. + +**The assumption is wrong on both halves.** Two findings, both verified against +the code, change what this PR can achieve. + +### Finding 1 — only ONE of the three identifiers is actually scoped + +`tauri-plugin-http` validates the URL exactly once, in the `fetch` command +(`src/commands.rs:178`, `Scope::is_allowed(&url)` at ~line 229). `fetch_send` +(`:366`) and `fetch_read_body` (`:418`) take a `ResourceId` for an +already-validated request and never consult a scope at all. And in Tauri's ACL +resolver (`tauri-utils/src/acl/resolved.rs:105-125`) a permission that declares +`commands.allow` contributes its scope as *command* scope for those commands +only — it never merges into the plugin's global scope. `allow-fetch-send` and +`allow-fetch-read-body` each declare exactly one command +(`permissions/autogenerated/commands/fetch_send.toml`, `fetch_read_body.toml`). + +Net: the `allow` blocks on `http:allow-fetch-send` and +`http:allow-fetch-read-body` are **inert configuration**. Today's file has one +real control and two decorative copies of it that read like defence in depth. + +### Finding 2 — the host set is NOT enumerable + +Link previews fetch arbitrary user-posted URLs by design. No amount of proxy +work changes that; only moving the fetch out of the renderer does. + +## What each consumer actually needs + +| Consumer | Reachable hosts | Enumerable? | +|---|---|---| +| `src/lib/api.ts` | `http://127.0.0.1:{port}` only — `baseUrl()`/`adminBaseUrl()` (`:64-70`) and the health probe (`:467`) all resolve through `ensureHttpProxy`. Upload (`:374`) uses `baseUrl()`. | yes — loopback | +| `src/lib/profiles.ts` | `http://127.0.0.1:{port}` only — `resolveHealthOrigin` (`:200`) returns `ensureHttpProxy(host)`; the direct `https://{host}` branch is reachable only when a test injects `fetchFn`. | yes — loopback | +| `src/components/message-list/attachments.ts` | `http://127.0.0.1:{port}` only. Traced end-to-end: `chat_send`'s `attachments` are attachment **IDs**, not URLs (`Server/ws/command.go:259-281` → `service/message.go:188` `LinkAttachmentsToMessage`), and the only URL the client ever sees is server-generated `/api/v1/files/` (`Server/db/attachment_queries.go:170`). Relative → `resolveServerUrl` → `isServerUrl` → `toFetchUrl` (`:124`) → loopback. Both plugin fetches (image cache `:247`, download `:408`) go through `toFetchUrl`. | yes — loopback | +| `src/components/message-list/media.ts` | Exactly one URL shape: `https://www.youtube.com/oembed?url=…` (`:143`). Not a provider registry — YouTube is the only oEmbed provider in the client. Thumbnails and the player are ``/`