Files
OwnCord/docs/plans/tauri-capability-narrowing.md
T
J3vb 59ec405595 docs: record final Tauri capability posture and residual risk
Update the security doc's capability section with the deny list, the fact
that `http:allow-fetch` is the only URL-scoped HTTP identifier, and why
the https wildcard cannot be removed without moving the link-preview
fetch into Rust. Note under Known Limitations that narrowing the plugin
scope alone does not bound exfiltration while CSP `connect-src` allows
`https:` to any host. Add a capability row to the client architecture
doc and mark the design note implemented.
2026-07-20 14:51:23 +02:00

9.5 KiB

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 (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-281service/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 → resolveServerUrlisServerUrltoFetchUrl (: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 and pluginBridge.ts make no HTTP calls; 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.