diff --git a/apps/desktop/electron/main/public-https-direct.ts b/apps/desktop/electron/main/public-https-direct.ts new file mode 100644 index 0000000000..19db7356c1 --- /dev/null +++ b/apps/desktop/electron/main/public-https-direct.ts @@ -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 { + 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); + resolve(new Response(body, { status, headers })); + }, + ); + req.once("error", reject); + req.end(); + }); +} diff --git a/apps/desktop/electron/main/public-https-fetch.ts b/apps/desktop/electron/main/public-https-fetch.ts index 9d560a76a3..35a4538564 100644 --- a/apps/desktop/electron/main/public-https-fetch.ts +++ b/apps/desktop/electron/main/public-https-fetch.ts @@ -1,4 +1,5 @@ import { lookup as dnsLookup } from "node:dns/promises"; +import { isIP } from "node:net"; import { ErrorCodes, PUBLIC_NETWORK_POLICY_ERROR, @@ -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; @@ -26,6 +28,12 @@ export type PublicHttpsFetch = ( init: { redirect: "manual"; signal: AbortSignal }, ) => Promise; +export type PublicHttpsPinnedFetch = ( + url: string, + init: { redirect: "manual"; signal: AbortSignal }, + address: PinnedNetworkAddress, +) => Promise; + export type PublicHttpsLookup = (host: string) => Promise>; /** @@ -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; @@ -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 { + allowPinnedSelection = false, + ): Promise<{ route: PublicNetworkRoute; address?: PinnedNetworkAddress }> { const userSupplied = origin === "user"; const accepted = userSupplied ? isSafeUserEndpointUrl(url, { allowInsecureHttp: insecureUserEndpointsAllowed() }) @@ -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 { + await inspectPublicUrl(url, origin); } async function requestOnce( @@ -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"); diff --git a/apps/desktop/electron/main/skill-market-catalog.ts b/apps/desktop/electron/main/skill-market-catalog.ts index bd285b95da..f0e48a3e49 100644 --- a/apps/desktop/electron/main/skill-market-catalog.ts +++ b/apps/desktop/electron/main/skill-market-catalog.ts @@ -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, @@ -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 diff --git a/apps/desktop/test/public-https-fetch-route.test.mjs b/apps/desktop/test/public-https-fetch-route.test.mjs index 557c2214c7..2b5de21d0b 100644 --- a/apps/desktop/test/public-https-fetch-route.test.mjs +++ b/apps/desktop/test/public-https-fetch-route.test.mjs @@ -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, @@ -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 @@ -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( @@ -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 () => { diff --git a/docs/adr/0243-skill-market-public-https-catalog.md b/docs/adr/0243-skill-market-public-https-catalog.md index 043041dbf7..b4f224bd6c 100644 --- a/docs/adr/0243-skill-market-public-https-catalog.md +++ b/docs/adr/0243-skill-market-public-https-catalog.md @@ -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 diff --git a/docs/adr/0272-connection-time-public-network-route.md b/docs/adr/0272-connection-time-public-network-route.md index bb73c1be4c..9c91eee4e3 100644 --- a/docs/adr/0272-connection-time-public-network-route.md +++ b/docs/adr/0272-connection-time-public-network-route.md @@ -55,8 +55,11 @@ boundary change. This ADR is that change. the resolved address itself there, so only `public` passes. An explicit `allowFakeIp` setting may additionally permit the `benchmark` placeholder class for transparent router/TUN deployments; it never permits any other - non-public class. `unknown` covers no route resolver wired at all, a resolver - that throws, an empty or unparsable answer, and a list that offers `DIRECT` + non-public class. For a direct request whose DNS answer mixes an acceptable + address with rejected addresses, the transport may select and pin one + acceptable address so the connection cannot use a rejected result (ADR + 0321). `unknown` covers no route resolver wired at all, a resolver that + throws, an empty or unparsable answer, and a list that offers `DIRECT` anywhere. Fail closed unless that narrow opt-in is enabled. 4. **The syntactic guard is untouched.** `isSafePublicHttpsUrl` still runs first @@ -77,15 +80,16 @@ boundary change. This ADR is that change. What the guard stopped before this ADR: - Non-HTTPS, credential-bearing, and non-public-literal URLs — unchanged. -- A hop whose **local** resolver answers with a private, loopback, link-local, - CGNAT, ULA, site-local, multicast, reserved, or documentation address. On a - direct route that is the address the app dials, so this was real protection and - stays exactly as it was. +- A hop whose **local** resolver answers only with a private, loopback, + link-local, CGNAT, ULA, site-local, multicast, reserved, or documentation + address. On a direct route that is the address the app dials, so this was real + protection and stays exactly as it was. What changes: -- **Still stopped:** everything above on `direct` / `unknown`, plus every address - class except `benchmark` on `proxied`. +- **Still stopped:** every refused address when it is the only possible direct + target; everything above on `unknown`; plus every address class except + `benchmark` on `proxied`. - **Weakened, precisely:** on a `proxied` route, a public hostname whose *local* answer is inside `198.18.0.0/15` is no longer refused. Consequences: - **SSRF to an internal service through the proxy.** If the proxy — or the TUN diff --git a/docs/adr/0321-skill-market-direct-dns-pinning.md b/docs/adr/0321-skill-market-direct-dns-pinning.md new file mode 100644 index 0000000000..f559685a09 --- /dev/null +++ b/docs/adr/0321-skill-market-direct-dns-pinning.md @@ -0,0 +1,52 @@ +# ADR 0321: Pin an acceptable address for mixed direct DNS answers + +- Status: Accepted +- Date: 2026-10-06 +- Deciders: PI-Desktop core +- Related: ADR 0243, ADR 0272 + +## Context + +The Skill Market validates each address returned by the local resolver, then +uses Chromium `net.fetch`. On a direct route, a DNS response that contains a +usable public IPv4 address plus a synthetic ULA IPv6 address is rejected, even +though Main can safely connect to the public IPv4 address. Under a TUN fake-IP +resolver, the accepted `198.18.0.0/15` placeholder may also appear beside a ULA +IPv6 placeholder; `net.fetch` can select the rejected IPv6 result after the +guard has accepted the IPv4 result. + +## Decision + +1. On a `direct` route only, if at least one DNS result is rejected and an + acceptable result exists, the Skill Market may select one acceptable result + and pin the direct request to that exact address. For third-party content, + prefer a public address; the `benchmark` range is eligible only under the + existing fake-IP opt-in. A user-supplied first-hop endpoint keeps its + existing user-endpoint address policy. +2. The pinned transport preserves the original hostname for TLS SNI and the + HTTP `Host` header, disables automatic redirects, and respects the existing + request abort signal. Every redirect is resolved, checked, and pinned again. +3. A ULA-only DNS result, or any other answer with no acceptable address, + remains refused. Proxied and `unknown` routes keep ADR 0272's existing + address verdict. + +## Consequences + +- Dual-stack and transparent-proxy answers no longer fail solely because an + unused ULA result accompanies a public address or an explicitly permitted + benchmark fake-IP. Main never connects to the ignored result. +- Direct mixed-answer requests use Node's HTTP(S) transport with the selected + address pinned. Proxied requests remain on Chromium's `net.fetch` stack. +- This does not permit internal destinations, alter the persisted network + policy, or change the URL/redirect rules. A resolver that returns only a + private address still fails closed. + +## Alternatives + +- Permit all ULA answers on a direct route: rejected because ULA can name a real + LAN or VPN service and would weaken the third-party network boundary. +- Keep using `net.fetch` after accepting one address: rejected because Chromium + may resolve the hostname again and connect to the rejected address. +- Require users to disable IPv6 or change proxy DNS: rejected as the sole fix + because Main can safely pin an already acceptable address without requiring + system network changes. diff --git a/docs/adr/README.md b/docs/adr/README.md index c09ca3e35e..7a1b35696b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -362,3 +362,4 @@ Each ADR includes: | 0318 | [Publish native Linux arm64 artifacts](0318-linux-arm64-release-lane.md) | Accepted (D638) | | 0319 | [Inline external imports in owning Settings destinations](0319-settings-inline-imports.md) | Accepted (D645) | | 0320 | [Host-owned OAuth lifecycle for plugin providers](0320-plugin-oauth-provider-callbacks.md) | Accepted for implementation (D647; amends ADR 0259) | +| 0321 | [Pin an acceptable address for mixed direct DNS answers](0321-skill-market-direct-dns-pinning.md) | Accepted (D648; amends ADR 0272) | diff --git a/docs/spec/05-security/01-security.md b/docs/spec/05-security/01-security.md index f08f8572ed..e4fad39288 100644 --- a/docs/spec/05-security/01-security.md +++ b/docs/spec/05-security/01-security.md @@ -113,11 +113,13 @@ request will actually take. Before each hop the client asks the session that carries `net.fetch` for its own proxy decision (`Session.resolveProxy`): on a proxied route the hop is judged on its route rather than on a local address the app would never dial, so only the resolver-artifact class (`benchmark`, a TUN -fake-IP) is tolerated there, while a direct or unreadable route keeps the full -local classification and rejects loopback, RFC1918, ULA, link-local, mapped -IPv6, and every other non-public class by default. The explicit `allowFakeIp` -setting may additionally permit only the `benchmark` placeholder for a -transparent router/TUN deployment. Install writes markdown only through +fake-IP) is tolerated there. A direct route rejects non-public answers when +none of the DNS results is acceptable; if a mixed answer contains an acceptable +public address, Main pins the request to that address and never connects to the +rejected result (ADR 0321). The explicit `allowFakeIp` setting may additionally +permit only the `benchmark` placeholder for a transparent router/TUN deployment, +and only an accepted address is pinned. ULA-only answers and every other +non-public-only result remain blocked. Install writes markdown only through `skills.create`. The host document cap remains 128 KiB after sibling markdown is inlined. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index cb5d66a1b2..90290e0cbe 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -15528,32 +15528,31 @@ plugin-form fixtures in an isolated temporary directory at runtime. is `https://127.0.0.1/`. 4) Report a proxied route and a TUN fake-IP answer (`198.18.0.1`), the same answer on a `DIRECT` route, on an unreadable route, and on a route list that offers `DIRECT`. 5) Let a first hop be proxied and - its redirect target direct. + its redirect target direct. 6) On a direct route, return both a public address + and a ULA address, then return a TUN `benchmark` fake-IP with a ULA address + under the existing fake-IP opt-in. 7) Return a ULA address without any + acceptable companion address. - **Expected**: A source URL the user typed may be a loopback or LAN catalog — `https` always, `http` only under `networkPolicy.allowInsecureUserEndpoints` — while the same address as a - *document* URL inside a catalog, or as a redirect target, is rejected; cloud - metadata, `unspecified`, multicast and reserved addresses are rejected on every - input. A public CDN URL is accepted. A source that resolves to a private - address is fetched rather than refused, and a third-party hop that resolves to - one throws a policy error without fetching the private target. - retried; a local resolver that answered nothing is, and is reported as - `NETWORK_RESOLVE_FAILED` (`kind` `unresolved`) rather than as an address-check - refusal — the guard reached no verdict, so nothing may claim it did. An address - in a proxy's fake-IP range (`198.18.0.0/15`, Clash's default) is refused and not - retried where the guard judged it — a direct or unreadable route — and is - accepted on the proxied one, and is reported as `kind` `fake-ip` with - `addressKind` `benchmark` and `reason` `non-public-address` — distinct from a - real private target (`kind` `policy`, `addressKind` `private`), because the guard - judged the target in the second case and only the proxy's placeholder in the - first. Every other refusal carries `NETWORK_POLICY_BLOCKED` (spec 08 §3.1) with - its `reason`, the address it resolved to, the class of that address, and the - route it was judged on, so the install sheet can name the reason and offer a - retry instead of leaving the install button disabled with no explanation, and the - market list can tell a refused source apart from a merely unreachable one. Every - other non-public class still refuses on all routes, and each redirect hop is - judged on its own route (ADR 0272). -- **Specs linked**: `05-security/01-security.md`, ADR 0243, ADR 0272, + *document* URL inside a catalog, or as a redirect target, is rejected. Cloud + metadata, `unspecified`, multicast, and reserved addresses are rejected on + every input. A user-supplied source resolving to a private address is fetched; + a third-party hop resolving only to a private address throws a policy error + without fetching that target. A local resolver with no answer is retried and + reported as `NETWORK_RESOLVE_FAILED` (`kind` `unresolved`), not as an + address-check refusal. A direct request with an acceptable address beside a + rejected ULA pins the acceptable address; a ULA-only answer stays refused. An + address in a proxy's fake-IP range (`198.18.0.0/15`, Clash's default) is + accepted on a proxied route. On a direct route it is refused unless the + existing fake-IP opt-in allows Main to pin that benchmark address; unreadable + routes remain strict. A refusal for a benchmark address is reported as `kind` + `fake-ip`, `addressKind` `benchmark`, and `reason` `non-public-address`; + refusals for real private targets remain `kind` `policy`, `addressKind` + `private`. Every refusal carries the host, reason, address class, and route so + the install sheet and market list can distinguish policy blocks from network + failures. Every redirect hop is judged on its own route (ADR 0272, ADR 0321). +- **Specs linked**: `05-security/01-security.md`, ADR 0243, ADR 0272, ADR 0321, `03-runtime/01-ipc-protocol.md` §12b - **Acceptance**: Security, Quality - **Milestone**: M6+ diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 2849ee1208..73c32f16fd 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -1,7 +1,7 @@ # Decisions Log > Baseline delta: `0.3.0` → `0.4.20` -> Date: `2026-10-02` +> Date: `2026-10-06` > Status: Accepted for implementation This log freezes previously open questions into concrete decisions. @@ -41,6 +41,8 @@ This log freezes previously open questions into concrete decisions. | D644 | Portable instruction files have no size cap | **Remove the 32 KiB per-file cap Host enforced on portable instruction files. Global and project instruction content is bounded only by the same portable-entity payload bound every other domain already has, checked when a revision is uploaded and when a remote one is validated. UTF-8 validation, symlink rejection, scope selection, mapping, and approval rules are unchanged. See `03-runtime/22-config-sync.md` §2.** | A 33 KiB project `AGENTS.md` failed the entire capture with `CONFIG_SYNC_LIMIT_EXCEEDED: instruction file is too large`, which the Settings page could only show as a generic backup-size error. | | D450 | Signed macOS GitHub Releases | **Amend D078 / ADR 0022: GitHub tag releases Developer ID-sign, notarize (`notarytool` via electron-builder 26), staple, and Gatekeeper-verify macOS DMG/ZIP before upload, using identity `Developer ID Application: XingYu Liu (DUV63RKYTW)` / team `DUV63RKYTW` from Actions secrets (`CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`). Missing secrets fail the job. Local unsigned packaging without a certificate remains. `workflow_dispatch` may set `sign_macos: false` only for unsigned debug artifacts. Packaged macOS uses in-app `electron-updater` (ZIP + merged `latest-mac.yml`); Linux deb/rpm and Windows portable ZIP stay notify-and-link. No afterPack/afterSign adhoc codesign (ADR 0278).** | Production DMGs must open without a Gatekeeper warning, and signed macOS installs can download and restart into a new tag. See ADR 0289, E2E-196c, E2E-067A. | +| D648 | Skill Market pins an acceptable address for mixed direct DNS answers | **Amend ADR 0272: on a direct route, when DNS includes both rejected and acceptable answers, Skill Market selects and pins one acceptable address instead of letting Chromium choose among them. Third-party content prefers a public answer; the benchmark fake-IP is eligible only under the existing opt-in. ULA-only and other non-public-only answers remain blocked. Proxied and unreadable routes keep the existing policy. See ADR 0321 and E2E-SKILL-MARKET-NET-BOUNDARY.** | Dual-stack and transparent-proxy DNS can include an unused synthetic ULA answer beside an address the request can safely use; pinning prevents the rejected address from being dialed while avoiding the false refusal. | + ## B. Secondary implementation defaults | ID | Topic | Decision | @@ -7501,3 +7503,17 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. provider OAuth scenario in E2E-PLUGIN-declared-provider-appears-in-the-native-provider-list. See ADR 0320, the plugin OAuth API and permission specs, and `03-runtime/14-secrets-storage.md`. + +## 2026-10-06 — Skill Market pins an acceptable address for mixed direct DNS answers (D648) + +- On a direct route, the Skill Market selects and pins an acceptable address + when a DNS response mixes acceptable and rejected addresses. A public + third-party address is preferred; the existing `benchmark` fake-IP opt-in is + the only non-public choice. ULA-only results remain blocked, and proxied or + unreadable routes keep the ADR 0272 policy. +- The pinned direct request preserves the requested hostname for TLS SNI and + `Host`, and every redirect receives its own DNS check and connection pin. +- Covered by the direct transport integration test and the mixed public/ULA and + benchmark/ULA cases in `apps/desktop/test/public-https-fetch-route.test.mjs`. + See ADR 0321, `05-security/01-security.md` §4.1, and + E2E-SKILL-MARKET-NET-BOUNDARY. diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index 435114f0a4..cf210b47a7 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -4,7 +4,7 @@ > 基线增量:`0.3.0` → `0.4.16` -> 日期:`2026-08-05` +> 日期:`2026-10-06` > 状态:已接受实施 该日志将以前未解决的问题冻结为具体的决策。 @@ -42,6 +42,7 @@ | D642 | 云同步是对所有用户开放的实验性目的地 *(由 D643 修订)* | **移除设置中 `sync` 目的地的开发者模式与打包构建门控:其导轨行、页面和设置搜索命中在任何构建中对所有用户存在,已保存的 `sync` 标签页也不再回落到常规。远程主机保留这两道门控和它自己的徽章。该目的地继续在导轨行与页面标题上保留实验性徽章;同步行为、协议、Host schema 与持久化数据均不变。见 `04-ux/06-settings-ia.md` 与 E2E-CONFIG-SYNC-webdav-portable-configuration。** | 加密 WebDAV 备份是应用唯一的多设备配置路径,而开发者模式门控让需要它的用户无法发现该功能。 | | D643 | 云同步不再带实验性徽章 | **修订 D642:设置中的 `sync` 目的地不再有 `experimentalBadgeKey`,各内置语言包中的 `settings.configSync.experimental` 键也已删除。云同步在任何构建中对所有用户保持可用。远程主机保留自己的徽章和两道门控。同步行为、协议、Host schema 与持久化数据均不变。见 `04-ux/06-settings-ia.md` 与 E2E-CONFIG-SYNC-webdav-portable-configuration。** | 云同步是应用已发布的多设备路径,实验性标签已不再描述它,只会让该目的地看起来尚未完成。 | | D644 | 便携指令文件没有体积上限 | **移除 Host 对便携指令文件施加的 32 KiB 单文件上限。全局与项目指令内容只受其他域同样拥有的便携实体负载上限约束,并在上传修订与校验远端修订时检查。UTF-8 校验、symlink 拒绝、作用域选择、映射与审批规则均不变。见 `03-runtime/22-config-sync.md` §2。** | 一个 33 KiB 的项目 `AGENTS.md` 会让整次采集以 `CONFIG_SYNC_LIMIT_EXCEEDED: instruction file is too large` 失败,而设置页只能把它显示为泛化的备份体积错误。 | +| D648 | 混合直接 DNS 结果时固定使用可接受地址 | **修订 ADR 0272:在直连路由上,当 DNS 同时包含被拒绝与可接受的结果时,技能市场会选择并固定到一个可接受地址,而不会让 Chromium 在这些地址中自行选择。第三方内容优先使用公网地址;仅在现有策略允许时使用 `benchmark` 假 IP。仅返回 ULA 或其他非公网地址时仍会拦截。代理与无法读取的路由保持现有策略。见 ADR 0321 与 E2E-SKILL-MARKET-NET-BOUNDARY。** | 双栈与透明代理 DNS 可能在可安全使用的地址旁返回未使用的合成 ULA 地址;固定已通过校验的地址可避免连接到被拒绝结果并消除误拦截。 | | D450 | 签名的 macOS GitHub Release | **修订 D078 / ADR 0022:GitHub tag 发布使用身份 `Developer ID Application: XingYu Liu (DUV63RKYTW)` / 团队 `DUV63RKYTW`,通过 Actions 密钥(`CSC_LINK`、`CSC_KEY_PASSWORD`、`APPLE_ID`、`APPLE_APP_SPECIFIC_PASSWORD`、`APPLE_TEAM_ID`)对 macOS DMG/ZIP 做 Developer ID 签名、`notarytool` 公证、装订和 Gatekeeper 校验;缺少密钥则失败。无证书的本地未签名打包仍可用。`workflow_dispatch` 仅可把 `sign_macos: false` 用于未签名调试产物。打包的 macOS 走应用内 `electron-updater`(ZIP + 合并后的 `latest-mac.yml`);Linux deb/rpm 与 Windows 便携版 ZIP 仍为通知并打开发布页。禁止 afterPack/afterSign adhoc 签名(ADR 0278)。** | 正式 DMG 应无需 Gatekeeper 警告即可打开,已签名 macOS 安装可下载并重启到新 tag。见 ADR 0289、E2E-196c、E2E-067A。 | ## B. 辅助实现默认值 @@ -5273,3 +5274,14 @@ Markdown 源码,不是 `text/html` 负载;对禁用行内 HTML 的外部编 的动作均不变。 - 由 `apps/desktop/test/home-project-name.test.mjs` 覆盖:它用真实 store 渲染真实界面。 见 `04-ux/01-ui-ia.md`、`04-ux/08-component-spec.md` 与 E2E-256。 + +## 2026-10-06 —— 混合直接 DNS 结果时固定使用可接受地址(D648) + +- 在直连路由上,若 DNS 同时返回可接受和被拒绝的地址,技能市场会选择并固定连接到 + 可接受地址。第三方内容优先使用公网地址;现有 `benchmark` 假 IP 选项是唯一允许的 + 非公网选择。仅返回 ULA 的结果仍会拦截,代理或无法读取的路由继续遵循 ADR 0272。 +- 固定地址请求会保留原主机名用于 TLS SNI 和 `Host`,并为每个重定向重新解析、校验和 + 固定连接地址。 +- 由 `apps/desktop/test/public-https-fetch-route.test.mjs` 中的固定地址传输集成测试、 + 公网/ULA 混合结果与 `benchmark`/ULA 测试覆盖。见 ADR 0321、`05-security/01-security.md` + §4.1 与 E2E-SKILL-MARKET-NET-BOUNDARY。