knip findings (repo CI config), each verified including dynamic imports, HTML refs, and the Rust side: Files deleted: pluginBridge.ts (its documented PluginContainer.tsx collaborator never existed in the repo; the server plugin host stays per D11 — reinstate from git if client plugin UI work ever starts), message-input/file-upload.ts, message-input/picker-toggle.ts (dir now empty, removed), message-list/virtual-scroll.ts (MessageList does its own virtualization via FenwickTree). Dependencies removed: zod (zero imports; typegen uses validation_library none — stale CLAUDE.md claim fixed), @tauri-apps/plugin-store and plugin-updater npm halves (both features are Rust-driven via StoreExt/UpdaterExt — Rust halves stay), and tauri-plugin-global-shortcut on BOTH sides (PTT polls via device_query; zero GlobalShortcutExt use): Cargo.toml dep, lib.rs registration, and the 5 capability permission lines. Inert webview capability entries store:default/updater:default also dropped. @stryker-mutator/api added to devDependencies (stryker.config.mjs imports its types; core pins the same version, zero install delta). Exports removed: livekitSession clearOnError bound-const, ConnectPage/ MainPage ReturnType aliases, readAllPersistedLogs (never wired to any UI) with its test blocks. getLogDir kept as the suite's observability point, tagged @public for knip. protocolTypes.ts *Value types are generated surface — knip.json now ignores that file instead. Rust compile is CI-verified only (no MSVC toolchain here, same as the F4/F8 TOFU work); Cargo.lock resolution pruned cleanly. Client gate green: tsc, oxlint/eslint 0 errors, prettier, 3304/3304 vitest, knip clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.6 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-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
http:allow-fetch— keep the https wildcard.embeds.tsrequires it. Add adenylist 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:*.denywins overallow(scope.rs:78-92). Keephttp://127.0.0.1:*— already minimal.http:allow-fetch-sendandhttp: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 abovehttp:allow-fetchrecords 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:orcore: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): parsesrc-tauri/capabilities/default.jsonand assert (a)http:allow-fetch'sallowis exactly the three expected patterns, (b) itsdenycontains the loopback literals, (c)http:allow-fetch-send/http:allow-fetch-read-bodyare plain strings — a regression guard so re-adding an inert scope has to be deliberate, (d) nohttp://*or*://*pattern anywhere. Mirrors the existing "noacceptInvalidCertsanywhere" 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.