feat(client): narrow Tauri HTTP capability scope

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.
This commit is contained in:
J3vb
2026-07-20 14:51:14 +02:00
parent 2511af345e
commit 352b7c8cc1
2 changed files with 100 additions and 21 deletions
@@ -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",
@@ -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)\//);
}
});
});