From 352b7c8cc1d14fa1d466aaa2318834da92985da9 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:51:14 +0200 Subject: [PATCH] feat(client): narrow Tauri HTTP capability scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a deny list for https loopback literals (localhost, 127.0.0.1, both with and without an explicit port) to `http:allow-fetch`. All legitimate server traffic reaches loopback over http through the Rust TOFU proxy, so an https loopback fetch from the renderer can only be an attempt to probe some other local service. `deny` wins over `allow` in Tauri's scope check. Drop the scope objects from `http:allow-fetch-send` and `http:allow-fetch-read-body`, leaving bare identifiers. tauri-plugin-http validates the URL exactly once, in the `fetch` command; both of these take an already-validated ResourceId and never consult a scope, and a permission declaring `commands.allow` contributes command scope only — it never merges into the plugin's global scope. The blocks were inert configuration that read like defence in depth. The capability description now records why, so they are not re-added on reflex. The `https://*` wildcard on `http:allow-fetch` stays: link previews fetch arbitrary user-posted URLs by design, and Tauri scopes per command, not per JS caller. See docs/plans/tauri-capability-narrowing.md. Add tests/unit/capabilities-scope.test.ts as a regression guard on the shape of the grant. --- .../src-tauri/capabilities/default.json | 30 ++---- .../tests/unit/capabilities-scope.test.ts | 91 +++++++++++++++++++ 2 files changed, 100 insertions(+), 21 deletions(-) create mode 100644 Client/tauri-client/tests/unit/capabilities-scope.test.ts 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)\//); + } + }); +});