mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
docs: design note for narrowing Tauri HTTP capabilities
Investigates whether the https wildcard on the three http:allow-fetch* identifiers can be enumerated now that the HTTP TOFU proxy has landed. Two findings change the answer: - Only http:allow-fetch is URL-scoped. tauri-plugin-http validates the URL in the `fetch` command only; `fetch_send` and `fetch_read_body` take a ResourceId and never consult a scope, and Tauri's ACL resolver keeps a command-declaring permission's scope as command scope. The allow blocks on the other two identifiers are inert. - The host set is not enumerable: api.ts, profiles.ts and attachments.ts are loopback-only (attachment URLs are always server-generated /api/v1/files/<id>) and media.ts hits one fixed YouTube oEmbed URL, but embeds.ts fetches arbitrary user-posted URLs by design. Decision: keep the https wildcard with a loopback deny list, drop the two inert scope blocks, and record the Rust-side link-preview command as the follow-up that would actually make the set enumerable. Residual risk (CSP connect-src already allows https:) stated explicitly.
This commit is contained in:
@@ -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/<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` 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.
|
||||
Reference in New Issue
Block a user