Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions apps/desktop/electron/main/public-https-direct.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { request as httpRequest } from "node:http";
import { isIP } from "node:net";
import { request as httpsRequest } from "node:https";
import { Readable } from "node:stream";

export type PinnedNetworkAddress = {
address: string;
family: 4 | 6;
};

/** Make a direct request to the exact address already accepted by the guard. */
export function fetchPinnedDirect(
url: string,
init: { signal: AbortSignal },
resolved: PinnedNetworkAddress,
): Promise<Response> {
const parsed = new URL(url);
const secure = parsed.protocol === "https:";
if (!secure && parsed.protocol !== "http:") {
return Promise.reject(new Error("unsupported protocol for a pinned request"));
}
const hostname = parsed.hostname.startsWith("[")
? parsed.hostname.slice(1, -1)
: parsed.hostname;
const request = secure ? httpsRequest : httpRequest;

return new Promise((resolve, reject) => {
const req = request(
{
protocol: parsed.protocol,
hostname: resolved.address,
family: resolved.family,
...(parsed.port ? { port: parsed.port } : {}),
path: `${parsed.pathname}${parsed.search}`,
method: "GET",
...(secure && !isIP(hostname) ? { servername: hostname } : {}),
headers: { Host: parsed.host, Accept: "*/*" },
lookup: (_hostname, _options, callback) =>
callback(null, resolved.address, resolved.family),
signal: init.signal,
},
(incoming) => {
const status = incoming.statusCode ?? 0;
if (status < 200 || status > 599) {
incoming.destroy();
req.destroy();
reject(new Error("invalid HTTP response status"));
return;
}
const headers = new Headers();
for (const [name, value] of Object.entries(incoming.headers)) {
if (typeof value === "string") {
headers.set(name, value);
} else if (Array.isArray(value)) {
for (const item of value) headers.append(name, item);
}
}
const body = [204, 205, 304].includes(status)
? null
: (Readable.toWeb(incoming) as ReadableStream<Uint8Array>);
resolve(new Response(body, { status, headers }));
},
);
req.once("error", reject);
req.end();
});
}
86 changes: 73 additions & 13 deletions apps/desktop/electron/main/public-https-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { lookup as dnsLookup } from "node:dns/promises";
import { isIP } from "node:net";
import {
ErrorCodes,
PUBLIC_NETWORK_POLICY_ERROR,
Expand All @@ -16,6 +17,7 @@ import {
type PublicNetworkRefusalReason,
type PublicNetworkRoute,
} from "@pi-desktop/shared";
import type { PinnedNetworkAddress } from "./public-https-direct";

const DEFAULT_TIMEOUT_MS = 8_000;
const MAX_HOPS = 5;
Expand All @@ -26,6 +28,12 @@ export type PublicHttpsFetch = (
init: { redirect: "manual"; signal: AbortSignal },
) => Promise<Response>;

export type PublicHttpsPinnedFetch = (
url: string,
init: { redirect: "manual"; signal: AbortSignal },
address: PinnedNetworkAddress,
) => Promise<Response>;

export type PublicHttpsLookup = (host: string) => Promise<Array<{ address: string }>>;

/**
Expand Down Expand Up @@ -147,6 +155,8 @@ export type PublicHttpsClient = {
*/
export function createPublicHttpsClient(options: {
fetchImpl: PublicHttpsFetch;
/** Optional direct transport that connects only to the selected address. */
pinnedFetchImpl?: PublicHttpsPinnedFetch;
lookupImpl?: PublicHttpsLookup;
/** Must be the session that carries `fetchImpl`; absent stays strict. */
routeImpl?: PublicHttpsRouteLookup;
Expand Down Expand Up @@ -196,10 +206,11 @@ export function createPublicHttpsClient(options: {
* `third-party` is every hop this app learned from someone else and keeps the
* public-only rule.
*/
async function assertPublicUrl(
async function inspectPublicUrl(
url: string,
origin: PublicHttpsEndpointOrigin = "third-party",
): Promise<void> {
allowPinnedSelection = false,
): Promise<{ route: PublicNetworkRoute; address?: PinnedNetworkAddress }> {
const userSupplied = origin === "user";
const accepted = userSupplied
? isSafeUserEndpointUrl(url, { allowInsecureHttp: insecureUserEndpointsAllowed() })
Expand Down Expand Up @@ -236,29 +247,74 @@ export function createPublicHttpsClient(options: {
route,
});
}
for (const address of addresses) {
const allowFakeIp =
typeof options.allowFakeIp === "function"
? options.allowFakeIp()
: options.allowFakeIp === true;
const classified = addresses.map((address) => {
const addressKind = classifyIpLiteral(address.address);
const allowFakeIp =
typeof options.allowFakeIp === "function"
? options.allowFakeIp()
: options.allowFakeIp === true;
// A user-supplied endpoint reaches the user's own loopback and LAN; a
// third-party hop keeps the public-only rule. Neither tolerates cloud
// metadata, and neither tolerates a fake-IP answer on a direct route.
const acceptable = userSupplied
? isAcceptableUserEndpointAddress(address.address, addressKind, route)
: isAcceptableResolvedAddress(addressKind, route);
if (!acceptable && !(allowFakeIp && addressKind === "benchmark")) {
return {
address,
addressKind,
acceptable: acceptable || (allowFakeIp && addressKind === "benchmark"),
};
});

// A direct request can safely ignore unrelated DNS answers only when the
// transport is given the exact accepted address to connect to. This lets a
// public A answer or an opted-in TUN fake-IP survive a synthetic ULA AAAA
// answer without ever dialing that ULA address.
if (
allowPinnedSelection &&
route === "direct" &&
options.pinnedFetchImpl &&
classified.some((item) => !item.acceptable)
) {
const selected = userSupplied
? classified.find((item) => item.acceptable)
: classified.find((item) => item.addressKind === "public") ??
(allowFakeIp ? classified.find((item) => item.addressKind === "benchmark") : undefined);
const family = selected ? isIP(selected.address.address) : 0;
if (selected && (family === 4 || family === 6)) {
return {
route,
address: { address: selected.address.address, family },
};
}
}

for (const item of classified) {
if (!item.acceptable) {
// The class travels with the refusal: `benchmark` is a TUN fake-IP
// (198.18.0.0/15) and `private` is a real RFC1918 target. The explicit
// fake-IP opt-in never changes the verdict for any other non-public
// class (ADR 0272).
throw new PublicNetworkPolicyError(
`hostname resolves to a non-public address: ${host} -> ${address.address} (${addressKind}, ${route} route)`,
{ reason: "non-public-address", host, address: address.address, addressKind, route },
`hostname resolves to a non-public address: ${host} -> ${item.address.address} (${item.addressKind}, ${route} route)`,
{
reason: "non-public-address",
host,
address: item.address.address,
addressKind: item.addressKind,
route,
},
);
}
}
return { route };
}

async function assertPublicUrl(
url: string,
origin: PublicHttpsEndpointOrigin = "third-party",
): Promise<void> {
await inspectPublicUrl(url, origin);
}

async function requestOnce(
Expand All @@ -271,11 +327,15 @@ export function createPublicHttpsClient(options: {
// Only the first hop is the address the user chose. Every redirect is a
// destination this app learned from someone else, so it is judged by the
// third-party policy without exception.
await assertPublicUrl(current, hop === 0 ? origin : "third-party");
const response = await options.fetchImpl(current, {
const target = await inspectPublicUrl(current, hop === 0 ? origin : "third-party", true);
const init = {
redirect: "manual",
signal: AbortSignal.timeout(timeoutMs),
});
} as const;
const response =
target.address && options.pinnedFetchImpl
? await options.pinnedFetchImpl(current, init, target.address)
: await options.fetchImpl(current, init);
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) throw new Error("redirect without a location header");
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/electron/main/skill-market-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/
import { net, session } from "electron";
import type { SkillCatalogEntry, SkillMarketSource } from "@pi-desktop/shared";
import { fetchPinnedDirect } from "./public-https-direct";
import { createPublicHttpsClient } from "./public-https-fetch";
import {
allowInsecureUserEndpointsEnabled,
Expand All @@ -31,6 +32,11 @@ export { guessSkillCategories } from "./skill-market-scan";
*/
const client = createPublicHttpsClient({
fetchImpl: (url, init) => net.fetch(url, init),
// A mixed DNS answer may include a public address (or an opted-in
// benchmark fake-IP) alongside a synthetic ULA IPv6 address. When the route
// is direct, pinning the selected acceptable address keeps net.fetch from
// choosing the rejected ULA result itself.
pinnedFetchImpl: (url, init, address) => fetchPinnedDirect(url, init, address),
routeImpl: (url) => session.defaultSession.resolveProxy(url),
// Fake-IP answers come from the network policy, not from the proxy switch:
// the relaxed mode is what tolerates a transparent router's placeholder
Expand Down
89 changes: 87 additions & 2 deletions apps/desktop/test/public-https-fetch-route.test.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { ErrorCodes } from "@pi-desktop/shared";
import { fetchPinnedDirect } from "../electron/main/public-https-direct.ts";
import {
createPublicHttpsClient,
PublicNetworkPolicyError,
Expand Down Expand Up @@ -40,15 +42,37 @@ function response(status, body, location) {
}

/** A client whose session reports `route` and whose resolver answers `address`. */
function clientFor({ route, address, fetchImpl, allowFakeIp }) {
function clientFor({ route, address, addresses, fetchImpl, pinnedFetchImpl, allowFakeIp }) {
return createPublicHttpsClient({
fetchImpl: fetchImpl ?? (async () => response(200, "# skill\n")),
lookupImpl: async () => (address ? [{ address }] : []),
lookupImpl: async () => addresses ?? (address ? [{ address }] : []),
...(route === undefined ? {} : { routeImpl: async () => route }),
...(pinnedFetchImpl ? { pinnedFetchImpl } : {}),
...(allowFakeIp === undefined ? {} : { allowFakeIp }),
});
}

test("a direct request pins the selected IP and retains the original Host header", async (t) => {
const server = createServer((request, outgoing) => {
assert.equal(request.headers.host, `localhost:${server.address().port}`);
outgoing.writeHead(200, { "content-type": "text/plain" });
outgoing.end("pinned response");
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
t.after(() => new Promise((resolve) => server.close(resolve)));

const response = await fetchPinnedDirect(
`http://localhost:${server.address().port}/document`,
{ signal: AbortSignal.timeout(2_000) },
{ address: "127.0.0.1", family: 4 },
);
assert.equal(response.status, 200);
assert.equal(await response.text(), "pinned response");
});

test("a proxied route stops treating a fake-IP answer as a refusal", async () => {
// The whole point of the change: `net.fetch` dials the proxy, so the local
// `198.18.0.1` describes no connection this app makes. Before ADR 0272 this
Expand Down Expand Up @@ -126,6 +150,66 @@ test("a direct route keeps the strict verdict for fake-IP and private answers al
);
}
});

test("a direct request pins its public answer when DNS also returns a ULA address", async () => {
const pinned = [];
const client = clientFor({
route: "DIRECT",
addresses: [{ address: "fd00::9c" }, { address: "151.101.1.229" }],
fetchImpl: async () => {
throw new Error("mixed DNS answers must use the pinned transport");
},
pinnedFetchImpl: async (_url, _init, address) => {
pinned.push(address);
return response(200, "# skill\n");
},
});

assert.equal(await client.request("https://cdn.jsdelivr.net/gh/x/SKILL.md", "text"), "# skill\n");
assert.deepEqual(pinned, [{ address: "151.101.1.229", family: 4 }]);
});

test("a direct TUN request pins the opted-in benchmark answer instead of a paired ULA answer", async () => {
const pinned = [];
const client = clientFor({
route: "DIRECT",
addresses: [{ address: "fd00::9c" }, { address: FAKE_IP }],
allowFakeIp: true,
fetchImpl: async () => {
throw new Error("paired fake-IP answers must use the pinned transport");
},
pinnedFetchImpl: async (_url, _init, address) => {
pinned.push(address);
return response(200, "# skill\n");
},
});

assert.equal(await client.request("https://cdn.jsdelivr.net/gh/x/SKILL.md", "text"), "# skill\n");
assert.deepEqual(pinned, [{ address: FAKE_IP, family: 4 }]);
});

test("a direct ULA-only answer remains refused even with fake-IP tolerance", async () => {
let fetched = false;
const client = clientFor({
route: "DIRECT",
address: "fd00::9c",
allowFakeIp: true,
pinnedFetchImpl: async () => {
fetched = true;
return response(200, "# skill\n");
},
fetchImpl: async () => {
fetched = true;
return response(200, "# skill\n");
},
});

await assert.rejects(
() => client.request("https://cdn.jsdelivr.net/gh/x/SKILL.md", "text"),
(error) => error instanceof PublicNetworkPolicyError && error.addressKind === "ula",
);
assert.equal(fetched, false);
});
test("an explicit fake-IP opt-in permits only the benchmark class on a direct route", async () => {
const client = clientFor({ route: "DIRECT", address: FAKE_IP, allowFakeIp: true });
assert.equal(
Expand Down Expand Up @@ -250,6 +334,7 @@ test("the market catalog client asks the session that carries its fetch", async
assert.match(source, /import \{ net, session \} from "electron"/);
assert.match(source, /fetchImpl: \(url, init\) => net\.fetch\(url, init\)/);
assert.match(source, /routeImpl: \(url\) => session\.defaultSession\.resolveProxy\(url\)/);
assert.match(source, /pinnedFetchImpl: \(url, init, address\) => fetchPinnedDirect\(url, init, address\)/);
});

test("a user-supplied endpoint reaches its own LAN on any route", async () => {
Expand Down
5 changes: 3 additions & 2 deletions docs/adr/0243-skill-market-public-https-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ User skills are a single markdown file capped at 128 KiB. Inlining sibling
`isPublicHostname`, `isPublicIpLiteral`). Skill market wraps it as
`isSafeSkillSourceUrl`. Future MCP market imports the same module.
2. Main-process fetches go through `createPublicHttpsClient`: HTTPS only,
DNS classification of every resolved address, `redirect: "manual"` with
per-hop re-validation, and retries only for non-policy failures.
route-aware DNS classification, direct-route address pinning when a mixed
DNS answer contains rejected addresses (ADR 0321), `redirect: "manual"`
with per-hop re-validation, and retries only for non-policy failures.
3. The renderer never fetches catalog or document URLs. Install remains
`skills.create`. Host-core stays unaware of the market.
4. Scanned and catalog ids are sanitized to host `valid_capability_id` before
Expand Down
Loading
Loading