diff --git a/Client/tauri-client/src-tauri/capabilities/default.json b/Client/tauri-client/src-tauri/capabilities/default.json index 734a567e..6ad6e789 100644 --- a/Client/tauri-client/src-tauri/capabilities/default.json +++ b/Client/tauri-client/src-tauri/capabilities/default.json @@ -1,6 +1,6 @@ { "identifier": "default", - "description": "Default capability granting core permissions to the main window", + "description": "Default capability granting core permissions to the main window. NOTE: http:allow-fetch is the ONLY URL-scoped HTTP identifier — tauri-plugin-http validates the URL once, in the `fetch` command; `fetch_send` and `fetch_read_body` take an already-validated ResourceId and never consult a scope, so allow/deny blocks on those identifiers are inert. Do not re-add them.", "windows": [ "main" ], @@ -42,36 +42,24 @@ { "url": "http://127.0.0.1:*" } - ] - }, - { - "identifier": "http:allow-fetch-send", - "allow": [ + ], + "deny": [ { - "url": "https://*:*" + "url": "https://localhost" }, { - "url": "https://*" + "url": "https://localhost:*" }, { - "url": "http://127.0.0.1:*" - } - ] - }, - { - "identifier": "http:allow-fetch-read-body", - "allow": [ - { - "url": "https://*:*" - }, - { - "url": "https://*" + "url": "https://127.0.0.1" }, { - "url": "http://127.0.0.1:*" + "url": "https://127.0.0.1:*" } ] }, + "http:allow-fetch-send", + "http:allow-fetch-read-body", "http:allow-fetch-cancel", "opener:default", "dialog:default", diff --git a/Client/tauri-client/tests/unit/capabilities-scope.test.ts b/Client/tauri-client/tests/unit/capabilities-scope.test.ts new file mode 100644 index 00000000..7e869c9e --- /dev/null +++ b/Client/tauri-client/tests/unit/capabilities-scope.test.ts @@ -0,0 +1,91 @@ +// Regression guard for the Tauri HTTP capability scope. +// +// Capabilities are enforced by the Rust/Tauri ACL at compile time, so TS +// cannot exercise them. What TS *can* do is lock the shape of the grant so a +// widening (or a re-added inert scope) has to be deliberate. See +// docs/plans/tauri-capability-narrowing.md for why only `http:allow-fetch` +// carries a scope: tauri-plugin-http validates the URL once, in the `fetch` +// command — `fetch_send`/`fetch_read_body` take an already-validated +// ResourceId and never consult a scope. + +import { describe, expect, it } from "vitest"; + +import capabilityJson from "../../src-tauri/capabilities/default.json"; + +interface ScopeEntry { + readonly url?: string; + readonly path?: string; +} +interface ScopedPermission { + readonly identifier: string; + readonly allow?: readonly ScopeEntry[]; + readonly deny?: readonly ScopeEntry[]; +} +type Permission = string | ScopedPermission; + +const permissions = capabilityJson.permissions as readonly Permission[]; + +function find(identifier: string): Permission { + const entry = permissions.find((p) => + typeof p === "string" ? p === identifier : p.identifier === identifier, + ); + expect(entry, `${identifier} missing from default capability`).toBeDefined(); + return entry as Permission; +} + +function urls(entries: readonly ScopeEntry[] | undefined): string[] { + return (entries ?? []).map((e) => e.url ?? ""); +} + +describe("Tauri default capability — HTTP scope", () => { + it("http:allow-fetch allows exactly the https wildcard plus loopback http", () => { + const fetchPerm = find("http:allow-fetch") as ScopedPermission; + expect(urls(fetchPerm.allow).sort()).toEqual( + ["http://127.0.0.1:*", "https://*", "https://*:*"].sort(), + ); + }); + + it("http:allow-fetch denies https loopback literals", () => { + const fetchPerm = find("http:allow-fetch") as ScopedPermission; + // All legitimate server traffic reaches loopback over http (the Rust TOFU + // proxy). An https loopback fetch can only be an attempt to reach some + // other local service, so deny it — deny wins over allow in Tauri's scope. + expect(urls(fetchPerm.deny).sort()).toEqual( + [ + "https://127.0.0.1", + "https://127.0.0.1:*", + "https://localhost", + "https://localhost:*", + ].sort(), + ); + }); + + it.each(["http:allow-fetch-send", "http:allow-fetch-read-body"])( + "%s is a bare identifier (a scope there would be inert)", + (identifier) => { + expect(find(identifier)).toBe(identifier); + }, + ); + + it("no permission grants a plaintext-http or any-scheme wildcard", () => { + const allUrls = permissions.flatMap((p) => + typeof p === "string" ? [] : [...urls(p.allow), ...urls(p.deny)], + ); + for (const url of allUrls) { + expect(url.startsWith("http://") && !url.startsWith("http://127.0.0.1")).toBe(false); + expect(url).not.toMatch(/^\*|^[a-z]*:\/\/\*\.?\*/); + } + }); + + it("filesystem grants stay under $APPDATA/$APPLOG", () => { + const fsPaths = permissions.flatMap((p) => + typeof p !== "string" && p.identifier.startsWith("fs:") + ? (p.allow ?? []).map((e) => e.path ?? "") + : [], + ); + expect(fsPaths.length).toBeGreaterThan(0); + for (const path of fsPaths) { + expect(path).toMatch(/^\$APP(DATA|LOG)\//); + } + }); +}); diff --git a/docs/architecture/client.md b/docs/architecture/client.md index 768aaf61..4f56707e 100644 --- a/docs/architecture/client.md +++ b/docs/architecture/client.md @@ -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 | diff --git a/docs/plans/tauri-capability-narrowing.md b/docs/plans/tauri-capability-narrowing.md new file mode 100644 index 00000000..d7cc3b9c --- /dev/null +++ b/docs/plans/tauri-capability-narrowing.md @@ -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/` (`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 ``/`