# Tauri HTTP Capability Narrowing — Design

**Status:** implemented (2026-07-20) — the Decision below landed in
`Client/tauri-client/src-tauri/capabilities/default.json`, guarded by
`tests/unit/capabilities-scope.test.ts`. The follow-up at the end of this
document is still open.
**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/<id>` (`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 `<img>`/`<iframe>` under CSP, not plugin fetches. | yes — one host |
| `src/components/message-list/embeds.ts` | **Arbitrary public https hosts.** `fetchOgMeta` (`:160`) fetches any URL a user posts in a message. `isBlockedForPreview`/`isPrivateHost` (`:104-152`) bound it to non-private hostnames; the response is regex-scraped for `og:` tags only (`parseOgTags`), capped at 5 s and 50 KB, and never executed or injected as HTML. `og:image` is rendered via `<img src>` under CSP `img-src`, not fetched through the plugin. | **no** |

Not consumers, checked and excluded: `src/lib/gifProvider.ts` hits
`https://api.klipy.com` with the **webview's** `fetch`, not the plugin (so it is
governed by CSP `connect-src`, not by this capability); `updater.ts` makes no
HTTP calls (`pluginBridge.ts`, also checked then, was deleted as dead code
2026-07-23); the Rust side (`http_proxy.rs`,
`ws_proxy.rs`, `livekit_proxy.rs`, `update_commands.rs`) uses reqwest/rustls
directly and is not subject to plugin capabilities at all.

Note ports: `https://*` matches default-port URLs only (`scope.rs` forces
`pathname`/`search`/`hash` to `*` but leaves the port empty), which is why the
file carries both `https://*` and `https://*:*`. Any wildcard that stays needs
both forms.

## Decision

1. **`http:allow-fetch` — keep the https wildcard.** `embeds.ts` requires it.
   Add a `deny` list for loopback literals, which no legitimate flow uses over
   https (all server traffic reaches loopback over **http**):
   `https://localhost`, `https://localhost:*`, `https://127.0.0.1`,
   `https://127.0.0.1:*`. `deny` wins over `allow` (`scope.rs:78-92`).
   Keep `http://127.0.0.1:*` — already minimal.
2. **`http:allow-fetch-send` and `http:allow-fetch-read-body` — drop the scope
   objects**, leaving bare identifier strings. Per Finding 1 this is a no-op at
   runtime; the point is to stop the file from advertising a control that does
   not exist. A comment above `http:allow-fetch` records that it is the only
   URL-scoped identifier so the blocks are not re-added on reflex.

Deliberately **not** doing a full RFC1918 deny list: self-hosted OwnCord
servers legitimately live on `10./172.16-31./192.168.` addresses, `172.16/12`
needs a regex group to express without over-blocking public `172.1.x`, and it
would add zero coverage over `isPrivateHost` — which already blocks those for
the only arbitrary-URL consumer. The bracketed IPv6 form (`https://[::1]:*`) is
omitted for a different reason: a bad URL-pattern parse fails at
`tauri-build` time, and this month's CI outage means a Rust build cannot
validate it locally (`::1` stays covered by `isPrivateHost`). Add both when the
wildcard shrinks (below) and the patterns can be build-verified.

## What cannot be narrowed, and why

The https wildcard on `http:allow-fetch` stays. Tauri's ACL scopes **per
command**, not per JS caller — there is no way to hand `embeds.ts` a broad
scope and `api.ts` a narrow one inside one webview. Per-capability splitting
keys off window/webview labels, and OwnCord has exactly one window (`main`);
relocating link previews into a hidden webview to get a second capability would
mean cross-webview IPC for a feature whose output is already just parsed text.

**Residual risk.** Any XSS or compromised renderer dependency can use
`http:allow-fetch` to reach any public https host and exfiltrate whatever the
renderer can read (session token, message content). Narrowing this capability
alone would not close that even if the wildcard vanished: CSP `connect-src`
already permits `https:` and `wss:` (`tauri.conf.json:27`), so the webview's own
`fetch` reaches the same hosts without touching the plugin. Bounding
exfiltration requires narrowing CSP `connect-src` in the same change — out of
scope here, and constrained by `gifProvider.ts` (api.klipy.com) plus arbitrary
`img-src`. What this PR does buy: the file stops overstating its own
protections, and loopback services on the user's machine become unreachable
over https from the renderer.

**Follow-up that would actually remove the wildcard** (separate PR, sized
medium): move the OG fetch behind a Rust command `fetch_link_preview(url)` that
resolves DNS itself and rejects private/loopback/link-local **resolved** IPs
(closing DNS rebinding, which a hostname-based TS guard structurally cannot),
caps body/time in Rust, and returns a parsed `OgMeta` struct. `http:allow-fetch`
then narrows to `http://127.0.0.1:*` + `https://www.youtube.com/oembed*` and is
fully enumerable — and folding the oEmbed call in too would let the http plugin
drop to loopback-only.

## Non-goals

- No CSP change; no change to `ws_proxy`/`livekit_proxy`/`http_proxy`/updater.
- No change to the `fs:`, `global-shortcut:`, `notification:`, `store:` or
  `core:window:` grants (separate review).
- No new window or webview; no Rust code in this PR.
- Not touching the server-side WASM plugin HTTP allowlist
  (`plugins.http_allowlist`) — different subsystem, tracked separately.

## Test / smoke plan

- **Unit** (`tests/unit/capabilities-scope.test.ts`, new): parse
  `src-tauri/capabilities/default.json` and assert (a) `http:allow-fetch`'s
  `allow` is exactly the three expected patterns, (b) its `deny` contains the
  loopback literals, (c) `http:allow-fetch-send` / `http:allow-fetch-read-body`
  are plain strings — a regression guard so re-adding an inert scope has to be
  deliberate, (d) no `http://*` or `*://*` pattern anywhere. Mirrors the
  existing "no `acceptInvalidCerts` anywhere" regression test idiom.
- **Manual smoke** (needs a desktop build): login + health poll + profile
  quick-switch (loopback); image attachment render and download; YouTube embed
  title (oEmbed); OG card for a public URL; OG card for `https://localhost:9090`
  → no card and no request leaves the process; `http://` link → no plugin fetch.
- **Build caveat:** capability JSON is parsed at compile time by `tauri-build`,
  so a malformed URL pattern breaks the desktop build — which cannot be run
  locally and cannot be checked by CI while Actions minutes are depleted. The
  unit test covers shape, not pattern validity; the pattern strings need a
  careful maintainer read.
