mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Merge pull request #1200 from J3vb/feat/narrow-tauri-capabilities
feat(client): narrow Tauri HTTP capability scope
This commit is contained in:
@@ -96,6 +96,7 @@ other (auth→voice→members), and the Solid beachhead is dead weight.
|
||||
| Cert trust | `src-tauri/src/ws_proxy.rs` | TOFU: first fingerprint pinned per host (`certs.json`); mismatch → modal (`CertMismatchModal`) |
|
||||
| Credentials | `src-tauri/src/credentials.rs` | OS keychain per host; password field `serde(skip)` so it never crosses IPC back to JS |
|
||||
| Multi-server | `src/lib/profiles.ts` | Server profiles w/ 15s health polling and auto-connect; one active connection, quick-switch replaces WS + tunnels |
|
||||
| HTTP capability | `src-tauri/capabilities/default.json` | `http:allow-fetch` is the only URL-scoped identifier (the other two `fetch_*` commands take a validated `ResourceId`); allows `https://*` + `http://127.0.0.1:*`, denies https loopback. Wildcard is required by link previews — see [docs/plans/tauri-capability-narrowing.md](../plans/tauri-capability-narrowing.md) |
|
||||
| Updates | `src/lib/updater.ts` + `update_commands.rs` | Endpoint derived from the connected server URL, https-only, TLS pinned to TOFU fingerprint, minisign-verified |
|
||||
| Settings | `commands.rs` + `src/lib/preferences.ts` | Split persistence: Rust store (`settings.json`, key-allowlisted) *and* raw `localStorage` for UI prefs/themes |
|
||||
| Theming | `src/lib/themes.ts` + `styles/tokens.css` | CSS custom properties; 4 built-in themes + custom overrides |
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# 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` 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.
|
||||
+5
-2
@@ -51,7 +51,10 @@ The Tauri desktop client implements the following security measures:
|
||||
### Tauri Capabilities (Least Privilege)
|
||||
- Filesystem write access is scoped to `$APPDATA/**` and `$APPLOG/**` only
|
||||
- DevTools command is gated behind the `devtools` feature flag (excluded from release builds)
|
||||
- HTTP fetch permissions are restricted to `https://` origins
|
||||
- HTTP fetch is restricted to `https://` origins plus `http://127.0.0.1:*` (the Rust TOFU proxy's loopback tunnel), and **denies** `https://localhost[:*]` and `https://127.0.0.1[:*]` — no legitimate flow reaches loopback over https, so the deny list keeps the renderer from probing other local services
|
||||
- `http:allow-fetch` is the **only** URL-scoped HTTP identifier. `tauri-plugin-http` validates the URL exactly once, in the `fetch` command; `fetch_send` and `fetch_read_body` operate on an already-validated `ResourceId` and never consult a scope, so `allow`/`deny` blocks on those identifiers are inert and were removed rather than left in place advertising a control that does not exist
|
||||
- The `https://*` wildcard cannot be removed today: link previews (`embeds.ts`) fetch arbitrary user-posted URLs by design, and Tauri scopes per *command*, not per JS caller. Bounded in TypeScript by `isPrivateHost`/`isBlockedForPreview`, a 5 s timeout and a 50 KB body cap; the response is regex-scraped for `og:` tags and never executed
|
||||
- Regression-guarded by `tests/unit/capabilities-scope.test.ts`; rationale and the follow-up that would remove the wildcard are in [docs/plans/tauri-capability-narrowing.md](plans/tauri-capability-narrowing.md)
|
||||
|
||||
### TLS and Certificate Pinning (TOFU)
|
||||
- Self-signed certificates are supported via Trust-On-First-Use (TOFU) pinning
|
||||
@@ -85,7 +88,7 @@ The Tauri desktop client implements the following security measures:
|
||||
|
||||
- Server auto-updates depend on a dedicated pinned minisign/Ed25519 server release key in [Server/updater/server_update_public_key.txt](Server/updater/server_update_public_key.txt) and a signed release manifest that binds the shipped binary hash to the release version; Windows Authenticode/SmartScreen code signing is still separate work
|
||||
- The Tenor API key is hardcoded (Google's public anonymous key) — consider build-time injection for production
|
||||
- CSP `connect-src` allows `https:` to any host (necessary for self-hosted server URLs not known at build time)
|
||||
- CSP `connect-src` allows `https:` to any host (necessary for self-hosted server URLs not known at build time). Because of this, narrowing the Tauri `http:allow-fetch` scope alone would not bound exfiltration from a compromised renderer — the webview's own `fetch` reaches the same hosts without going through the plugin. Closing that requires narrowing `connect-src` and moving the link-preview fetch into Rust in the same change
|
||||
|
||||
## Security Hardening Checklist for Operators
|
||||
|
||||
|
||||
Reference in New Issue
Block a user