@jinn-network/core 0.1.1 → 0.1.2-canary.15837edd

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  * Captured-task schema — the raw, pre-scrub input to the harness capture path.
3
3
  *
4
4
  * Extracted from the former client harness layer in C2 (#1833):
5
- * the parse half of capture.ts has no scrub / `client/src` dependency (only
5
+ * the parse half of capture.ts has no scrub / `operator/src` dependency (only
6
6
  * zod + the two envelope enums), so it moved into `@jinn-network/core`.
7
7
  * C5 subsequently moved the scrub implementation alongside it while leaving
8
8
  * capture orchestration in the layer.
@@ -2,7 +2,7 @@
2
2
  * Captured-task schema — the raw, pre-scrub input to the harness capture path.
3
3
  *
4
4
  * Extracted from the former client harness layer in C2 (#1833):
5
- * the parse half of capture.ts has no scrub / `client/src` dependency (only
5
+ * the parse half of capture.ts has no scrub / `operator/src` dependency (only
6
6
  * zod + the two envelope enums), so it moved into `@jinn-network/core`.
7
7
  * C5 subsequently moved the scrub implementation alongside it while leaving
8
8
  * capture orchestration in the layer.
@@ -8,14 +8,59 @@
8
8
  *
9
9
  * Keys artifacts by sha256 (not IPFS CID) — artifacts no longer have IPFS
10
10
  * CIDs (post jinn-mono-vy37.1.2).
11
+ *
12
+ * The endpoint is manifest-supplied and therefore attacker-controlled (#1901),
13
+ * so this fetch is both destination-restricted and resource-bounded *before*
14
+ * the hash check in `acquire.ts` ever runs:
15
+ *
16
+ * - every destination — the origin and each redirect hop — must pass
17
+ * `resolvePublicHttpDestination` (see `origin-guard.ts`), and the socket is
18
+ * pinned to the exact address that check approved, so a name whose DNS
19
+ * answer flips between validation and connect cannot escape the policy;
20
+ * - redirects are followed manually and capped;
21
+ * - one deadline bounds the whole chain, including the body read, so a
22
+ * stalled peer cannot hold a worker;
23
+ * - the body is streamed through a byte counter and abandoned the moment it
24
+ * exceeds the cap, so an oversized response is never fully buffered.
11
25
  */
26
+ import { type HostnameResolver } from './origin-guard.js';
27
+ import { type PinnedFetch } from './pinned-fetch.js';
12
28
  export type AcquireResult = {
13
29
  ok: true;
14
30
  content: Buffer;
15
31
  } | {
16
32
  ok: false;
17
- reason: 'not_found' | 'network_error';
33
+ /**
34
+ * `blocked` — destination policy refused the origin or a redirect hop.
35
+ * `too_large` — the response exceeded the byte cap.
36
+ * `timeout` — the response stalled past the deadline.
37
+ */
38
+ reason: 'not_found' | 'network_error' | 'blocked' | 'too_large' | 'timeout';
18
39
  message?: string;
19
40
  };
41
+ export interface FetchArtifactOptions {
42
+ /**
43
+ * Injection seam for tests. The default connects over `node:http(s)` with
44
+ * the socket pinned to the guard-approved address; a plain `fetch` fake is
45
+ * accepted here but does not pin, so production must not supply one.
46
+ */
47
+ fetchImpl?: PinnedFetch;
48
+ /** Injection seam for tests; defaults to `dns.lookup(..., { all: true })`. */
49
+ resolveHostname?: HostnameResolver;
50
+ /**
51
+ * Permit loopback/private destinations, and skip address pinning with them.
52
+ * Off by default and set nowhere in-repo; it exists for an operator running
53
+ * against a local origin, because `operator.publicEndpoint` falls back to
54
+ * `http://localhost:<apiPort>` when unset. Scheme and credential checks
55
+ * still apply. Env: `JINN_CORPUS_ALLOW_PRIVATE_ORIGINS`.
56
+ */
57
+ allowPrivateDestinations?: boolean;
58
+ /** Byte cap. Env: `JINN_CORPUS_ARTIFACT_MAX_BYTES`. */
59
+ maxBytes?: number;
60
+ /** Whole-operation deadline in ms; `0` disables. Env: `JINN_CORPUS_ARTIFACT_FETCH_TIMEOUT_MS`. */
61
+ timeoutMs?: number;
62
+ /** Redirect hop cap. Env: `JINN_CORPUS_ARTIFACT_MAX_REDIRECTS`. */
63
+ maxRedirects?: number;
64
+ }
20
65
  export declare function buildArtifactUrl(endpoint: string, sha256: string): string;
21
- export declare function fetchArtifactContent(endpoint: string, sha256: string): Promise<AcquireResult>;
66
+ export declare function fetchArtifactContent(endpoint: string, sha256: string, options?: FetchArtifactOptions): Promise<AcquireResult>;
@@ -8,25 +8,206 @@
8
8
  *
9
9
  * Keys artifacts by sha256 (not IPFS CID) — artifacts no longer have IPFS
10
10
  * CIDs (post jinn-mono-vy37.1.2).
11
+ *
12
+ * The endpoint is manifest-supplied and therefore attacker-controlled (#1901),
13
+ * so this fetch is both destination-restricted and resource-bounded *before*
14
+ * the hash check in `acquire.ts` ever runs:
15
+ *
16
+ * - every destination — the origin and each redirect hop — must pass
17
+ * `resolvePublicHttpDestination` (see `origin-guard.ts`), and the socket is
18
+ * pinned to the exact address that check approved, so a name whose DNS
19
+ * answer flips between validation and connect cannot escape the policy;
20
+ * - redirects are followed manually and capped;
21
+ * - one deadline bounds the whole chain, including the body read, so a
22
+ * stalled peer cannot hold a worker;
23
+ * - the body is streamed through a byte counter and abandoned the moment it
24
+ * exceeds the cap, so an oversized response is never fully buffered.
25
+ */
26
+ import { resolvePublicHttpDestination, ProhibitedDestinationError, } from './origin-guard.js';
27
+ import { pinnedFetch } from './pinned-fetch.js';
28
+ /** 32 MiB. Artifacts are solution/evidence payloads, not disk images. */
29
+ const DEFAULT_MAX_BYTES = 32 * 1024 * 1024;
30
+ const DEFAULT_TIMEOUT_MS = 30_000;
31
+ const DEFAULT_MAX_REDIRECTS = 3;
32
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
33
+ class TimeoutError extends Error {
34
+ }
35
+ class TooLargeError extends Error {
36
+ }
37
+ /**
38
+ * `minimum` is 0 where the bound reads `0` as "disabled" (the timeout, and a
39
+ * redirect cap of zero meaning "follow none"), and 1 for the byte cap, where
40
+ * zero would not disable anything — it would reject every artifact as
41
+ * `too_large`. A foot-gun that silently stops all acquisition is worse than
42
+ * ignoring the value, so an out-of-range setting falls back to the default.
11
43
  */
44
+ function envInteger(name, fallback, minimum = 0) {
45
+ const raw = process.env[name];
46
+ if (raw === undefined || raw.trim() === '')
47
+ return fallback;
48
+ const parsed = Number(raw);
49
+ return Number.isInteger(parsed) && parsed >= minimum ? parsed : fallback;
50
+ }
51
+ function envFlag(name) {
52
+ const raw = process.env[name];
53
+ if (raw === undefined)
54
+ return false;
55
+ return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
56
+ }
12
57
  export function buildArtifactUrl(endpoint, sha256) {
13
58
  return `${endpoint.replace(/\/$/, '')}/v1/artifacts/${sha256}/content`;
14
59
  }
15
- export async function fetchArtifactContent(endpoint, sha256) {
16
- const url = buildArtifactUrl(endpoint, sha256);
60
+ /** Stream `response` into a Buffer, abandoning it the moment it exceeds `maxBytes`. */
61
+ async function readBounded(response, maxBytes) {
62
+ const declared = response.headers.get('content-length');
63
+ if (declared !== null && Number(declared) > maxBytes) {
64
+ await response.body?.cancel().catch(() => { });
65
+ throw new TooLargeError(`content-length ${declared} exceeds the ${maxBytes}-byte cap`);
66
+ }
67
+ // A null body carries no bytes. We deliberately do not fall back to
68
+ // `arrayBuffer()` here: that materializes the whole response before any
69
+ // size check, which is the exact behavior the byte cap exists to prevent.
70
+ if (!response.body)
71
+ return Buffer.alloc(0);
72
+ const reader = response.body.getReader();
73
+ const chunks = [];
74
+ let total = 0;
75
+ try {
76
+ for (;;) {
77
+ const { done, value } = await reader.read();
78
+ if (done)
79
+ break;
80
+ if (!value)
81
+ continue;
82
+ total += value.byteLength;
83
+ if (total > maxBytes) {
84
+ throw new TooLargeError(`response exceeds the ${maxBytes}-byte cap`);
85
+ }
86
+ chunks.push(value);
87
+ }
88
+ }
89
+ catch (err) {
90
+ await reader.cancel().catch(() => { });
91
+ throw err;
92
+ }
93
+ return Buffer.concat(chunks, total);
94
+ }
95
+ export async function fetchArtifactContent(endpoint, sha256, options = {}) {
96
+ const fetchImpl = options.fetchImpl ?? pinnedFetch;
97
+ const maxBytes = options.maxBytes
98
+ ?? envInteger('JINN_CORPUS_ARTIFACT_MAX_BYTES', DEFAULT_MAX_BYTES, 1);
99
+ const timeoutMs = options.timeoutMs
100
+ ?? envInteger('JINN_CORPUS_ARTIFACT_FETCH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS);
101
+ const maxRedirects = options.maxRedirects
102
+ ?? envInteger('JINN_CORPUS_ARTIFACT_MAX_REDIRECTS', DEFAULT_MAX_REDIRECTS);
103
+ const guard = {
104
+ resolveHostname: options.resolveHostname,
105
+ allowPrivateDestinations: options.allowPrivateDestinations ?? envFlag('JINN_CORPUS_ALLOW_PRIVATE_ORIGINS'),
106
+ };
107
+ const raw = buildArtifactUrl(endpoint, sha256);
108
+ let target;
17
109
  try {
18
- const response = await globalThis.fetch(url);
19
- if (!response.ok) {
20
- if (response.status === 404) {
21
- return { ok: false, reason: 'not_found', message: `HTTP 404 for ${url}` };
110
+ target = new URL(raw);
111
+ }
112
+ catch {
113
+ return { ok: false, reason: 'blocked', message: `artifact endpoint is not a valid URL: ${raw}` };
114
+ }
115
+ // One controller and one timer bound the whole chain — connect, every
116
+ // redirect hop, and the body read. The timeout both aborts the transport
117
+ // and loses the race, so a transport that ignores the signal still cannot
118
+ // leave the caller waiting past the deadline.
119
+ const controller = new AbortController();
120
+ let timer;
121
+ const expiry = timeoutMs > 0
122
+ ? new Promise((_resolve, reject) => {
123
+ timer = setTimeout(() => {
124
+ controller.abort();
125
+ reject(new TimeoutError(`artifact fetch timed out after ${timeoutMs}ms`));
126
+ }, timeoutMs);
127
+ })
128
+ : undefined;
129
+ // Attach a sink so the rejection is never unhandled — under Node's default
130
+ // `--unhandled-rejections=throw`, a timer firing before the first `bounded()`
131
+ // call would otherwise kill the process outright.
132
+ expiry?.catch(() => { });
133
+ const bounded = (work) => expiry === undefined ? work : Promise.race([work, expiry]);
134
+ const chain = async () => {
135
+ for (let hop = 0;; hop += 1) {
136
+ // Bounded like every other await: the guard resolves DNS for an
137
+ // attacker-supplied name, so a nameserver that simply never answers
138
+ // must not be able to hold the caller past the deadline.
139
+ const pin = await bounded(resolvePublicHttpDestination(target, guard));
140
+ const response = await bounded(fetchImpl(target, {
141
+ signal: controller.signal,
142
+ ...(pin === null ? {} : { pinnedAddresses: pin.addresses }),
143
+ }));
144
+ if (REDIRECT_STATUSES.has(response.status)) {
145
+ const location = response.headers.get('location');
146
+ if (location === null) {
147
+ await response.body?.cancel().catch(() => { });
148
+ return {
149
+ ok: false,
150
+ reason: 'network_error',
151
+ message: `HTTP ${response.status} without a Location header for ${target.href}`,
152
+ };
153
+ }
154
+ if (hop >= maxRedirects) {
155
+ await response.body?.cancel().catch(() => { });
156
+ return {
157
+ ok: false,
158
+ reason: 'blocked',
159
+ message: `artifact fetch exceeded ${maxRedirects} redirects at ${target.href}`,
160
+ };
161
+ }
162
+ let next;
163
+ try {
164
+ next = new URL(location, target);
165
+ }
166
+ catch {
167
+ await response.body?.cancel().catch(() => { });
168
+ return {
169
+ ok: false,
170
+ reason: 'blocked',
171
+ message: `redirect Location is not a valid URL: ${location}`,
172
+ };
173
+ }
174
+ await response.body?.cancel().catch(() => { });
175
+ target = next;
176
+ continue;
22
177
  }
23
- return { ok: false, reason: 'network_error', message: `HTTP ${response.status} for ${url}` };
178
+ if (!response.ok) {
179
+ await response.body?.cancel().catch(() => { });
180
+ if (response.status === 404) {
181
+ return { ok: false, reason: 'not_found', message: `HTTP 404 for ${target.href}` };
182
+ }
183
+ return {
184
+ ok: false,
185
+ reason: 'network_error',
186
+ message: `HTTP ${response.status} for ${target.href}`,
187
+ };
188
+ }
189
+ return { ok: true, content: await bounded(readBounded(response, maxBytes)) };
24
190
  }
25
- const buf = Buffer.from(await response.arrayBuffer());
26
- return { ok: true, content: buf };
191
+ };
192
+ try {
193
+ return await chain();
27
194
  }
28
195
  catch (err) {
196
+ if (err instanceof ProhibitedDestinationError) {
197
+ return { ok: false, reason: 'blocked', message: err.message };
198
+ }
199
+ if (err instanceof TimeoutError) {
200
+ return { ok: false, reason: 'timeout', message: err.message };
201
+ }
202
+ if (err instanceof TooLargeError) {
203
+ return { ok: false, reason: 'too_large', message: err.message };
204
+ }
29
205
  const message = err instanceof Error ? err.message : String(err);
30
206
  return { ok: false, reason: 'network_error', message };
31
207
  }
208
+ finally {
209
+ if (timer !== undefined)
210
+ clearTimeout(timer);
211
+ controller.abort();
212
+ }
32
213
  }
@@ -6,5 +6,7 @@ export * from './fetch-artifact.js';
6
6
  export * from './fetch.js';
7
7
  export * from './http-discovery.js';
8
8
  export * from './ipfs.js';
9
+ export * from './origin-guard.js';
10
+ export * from './pinned-fetch.js';
9
11
  export * from './route-resolver.js';
10
12
  export * from './types.js';
@@ -6,5 +6,7 @@ export * from './fetch-artifact.js';
6
6
  export * from './fetch.js';
7
7
  export * from './http-discovery.js';
8
8
  export * from './ipfs.js';
9
+ export * from './origin-guard.js';
10
+ export * from './pinned-fetch.js';
9
11
  export * from './route-resolver.js';
10
12
  export * from './types.js';
@@ -12,3 +12,9 @@ export type FetchFromIpfsOptions = {
12
12
  };
13
13
  /** Read-only multi-codec, primary-plus-fallback IPFS JSON fetch. */
14
14
  export declare function fetchFromIpfs(gatewayUrl: string, cid: string, opts?: FetchFromIpfsOptions): Promise<unknown>;
15
+ /**
16
+ * Read-only multi-codec, primary-plus-fallback IPFS fetch returning the exact
17
+ * bytes stored at the CID — no JSON parse/re-encode roundtrip. Use this
18
+ * whenever the bytes will be hashed, or whenever the content is not JSON.
19
+ */
20
+ export declare function fetchBytesFromIpfs(gatewayUrl: string, cid: string, opts?: FetchFromIpfsOptions): Promise<Uint8Array>;
@@ -1,9 +1,44 @@
1
1
  const IPFS_FETCH_TIMEOUT_MS = 15_000;
2
+ /**
3
+ * Bound on one whole `fetchFromIpfs` / `fetchBytesFromIpfs` call. The
4
+ * per-attempt timer above is re-armed for every gateway x CID candidate, so
5
+ * without this a single call could legitimately run for the product of the
6
+ * two. Every candidate is still attempted: the per-attempt timer is clamped
7
+ * to an equal share of whatever budget remains.
8
+ */
9
+ const IPFS_TOTAL_FETCH_TIMEOUT_MS = 45_000;
10
+ /**
11
+ * Cap on any single gateway response, JSON or raw. Corpus manifests and
12
+ * donation artifacts are JSON envelopes orders of magnitude smaller than this
13
+ * (#3410); the raw-bytes path added in #3438 also carries source-bundle files
14
+ * and sealed documents, which are larger but nowhere near this. A response
15
+ * above it is hostile or broken, and buffering it would exhaust memory — so
16
+ * size this against the largest legitimate *source file*, not against the
17
+ * JSON envelopes alone.
18
+ */
19
+ const MAX_IPFS_RESPONSE_BYTES = 8 * 1024 * 1024;
20
+ const MAX_IPFS_REDIRECT_HOPS = 3;
21
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
2
22
  const FALLBACK_IPFS_GATEWAY_BASE = 'https://ipfs.io/ipfs/';
3
23
  export function normalizeIpfsGatewayBase(gatewayUrl) {
4
24
  let normalized = gatewayUrl.trim();
5
25
  if (normalized === '')
6
26
  normalized = 'https://gateway.autonolas.tech';
27
+ // Drop any userinfo at the source. `fetch` rejects a credentialed URL
28
+ // outright, and its own error message quotes the URL back — so a gateway
29
+ // configured Infura-style would otherwise put its secret into every
30
+ // aggregated fetch error, which callers log.
31
+ try {
32
+ const parsed = new URL(normalized);
33
+ if (parsed.username !== '' || parsed.password !== '') {
34
+ parsed.username = '';
35
+ parsed.password = '';
36
+ normalized = parsed.toString();
37
+ }
38
+ }
39
+ catch {
40
+ // Not an absolute URL; leave it to the caller's own failure path.
41
+ }
7
42
  normalized = normalized.replace(/\/+$/, '');
8
43
  if (!normalized.toLowerCase().endsWith('/ipfs'))
9
44
  normalized = `${normalized}/ipfs`;
@@ -29,15 +64,145 @@ export function buildIpfsFetchCidPathCandidates(cidOrPath) {
29
64
  }
30
65
  return [value];
31
66
  }
32
- async function fetchJson(url, signal) {
33
- const response = await fetch(url, { method: 'GET', signal });
34
- if (!response.ok) {
35
- throw new Error(`IPFS fetch failed: ${response.status} ${response.statusText} (${url.slice(0, 80)}…)`);
67
+ function isHostInGatewayFamily(host, gatewayHost) {
68
+ const candidate = host.toLowerCase();
69
+ const gateway = gatewayHost.toLowerCase();
70
+ return candidate === gateway || candidate.endsWith(`.${gateway}`);
71
+ }
72
+ /**
73
+ * A gateway may legitimately redirect the path form of a CID to its subdomain
74
+ * form, so hops are allowed inside the configured gateway's host family. They
75
+ * are never allowed to leave it, to reach a different port on it, to downgrade
76
+ * the transport, or to smuggle credentials.
77
+ */
78
+ function assertRedirectAllowed(next, current, gateway) {
79
+ if (next.protocol !== 'http:' && next.protocol !== 'https:') {
80
+ throw new Error(`IPFS redirect uses an unsupported scheme (${next.protocol})`);
81
+ }
82
+ if (current.protocol === 'https:' && next.protocol === 'http:') {
83
+ throw new Error('IPFS redirect downgrades https to http');
84
+ }
85
+ if (next.username !== '' || next.password !== '') {
86
+ throw new Error('IPFS redirect carries embedded credentials');
87
+ }
88
+ if (!isHostInGatewayFamily(next.hostname, gateway.hostname)) {
89
+ throw new Error(`IPFS redirect leaves the configured gateway (${next.hostname})`);
90
+ }
91
+ // Host family alone would let a self-hosted gateway (`http://127.0.0.1:8080/ipfs/`)
92
+ // pivot onto any other service on the same host. `URL.port` is '' for the
93
+ // scheme default, so the comparison is already normalized.
94
+ if (next.port !== gateway.port) {
95
+ throw new Error(`IPFS redirect changes the gateway port (${next.port === '' ? 'default' : next.port})`);
96
+ }
97
+ }
98
+ /** Location for an error message, with any configured gateway credentials dropped. */
99
+ function displayUrl(url) {
100
+ return `${url.origin}${url.pathname}`.slice(0, 100);
101
+ }
102
+ async function discardBody(response) {
103
+ try {
104
+ await response.body?.cancel();
105
+ }
106
+ catch {
107
+ // A body that refuses to cancel is not worth failing the request over.
108
+ }
109
+ }
110
+ /** Read a response body as bytes, refusing anything past the byte cap. */
111
+ async function readBoundedBytes(response) {
112
+ const declared = Number(response.headers.get('content-length'));
113
+ if (Number.isFinite(declared) && declared > MAX_IPFS_RESPONSE_BYTES) {
114
+ await discardBody(response);
115
+ throw new Error(`IPFS response exceeds the ${MAX_IPFS_RESPONSE_BYTES}-byte cap ` +
116
+ `(content-length ${declared})`);
117
+ }
118
+ const body = response.body;
119
+ if (!body)
120
+ return new Uint8Array(0);
121
+ const reader = body.getReader();
122
+ const chunks = [];
123
+ let total = 0;
124
+ try {
125
+ for (;;) {
126
+ const { done, value } = await reader.read();
127
+ if (done)
128
+ break;
129
+ if (!value)
130
+ continue;
131
+ total += value.byteLength;
132
+ if (total > MAX_IPFS_RESPONSE_BYTES) {
133
+ throw new Error(`IPFS response exceeds the ${MAX_IPFS_RESPONSE_BYTES}-byte cap`);
134
+ }
135
+ chunks.push(value);
136
+ }
137
+ }
138
+ finally {
139
+ try {
140
+ await reader.cancel();
141
+ }
142
+ catch {
143
+ // Already terminal; the read result (or throw) above is what matters.
144
+ }
145
+ }
146
+ const joined = new Uint8Array(total);
147
+ let offset = 0;
148
+ for (const chunk of chunks) {
149
+ joined.set(chunk, offset);
150
+ offset += chunk.byteLength;
151
+ }
152
+ return joined;
153
+ }
154
+ /** Read a response body as text, refusing anything past the byte cap. */
155
+ async function readBoundedText(response) {
156
+ const joined = await readBoundedBytes(response);
157
+ try {
158
+ return new TextDecoder('utf-8', { fatal: true }).decode(joined);
159
+ }
160
+ catch {
161
+ throw new Error('IPFS response is not valid UTF-8');
162
+ }
163
+ }
164
+ /**
165
+ * Request `url`, resolving redirects here rather than in `fetch`, so every hop
166
+ * is revalidated against the configured gateway before it is requested.
167
+ * Returns the first non-redirect, ok response; its body is still unread.
168
+ */
169
+ async function fetchThroughGateway(url, signal) {
170
+ const gateway = url;
171
+ let current = new URL(url);
172
+ for (let hop = 0;; hop += 1) {
173
+ const response = await fetch(current, { method: 'GET', redirect: 'manual', signal });
174
+ if (REDIRECT_STATUSES.has(response.status)) {
175
+ await discardBody(response);
176
+ if (hop >= MAX_IPFS_REDIRECT_HOPS) {
177
+ throw new Error(`IPFS fetch exceeded ${MAX_IPFS_REDIRECT_HOPS} redirects`);
178
+ }
179
+ const location = response.headers.get('location');
180
+ if (location === null || location.trim() === '') {
181
+ throw new Error(`IPFS gateway returned ${response.status} without a Location header`);
182
+ }
183
+ let next;
184
+ try {
185
+ next = new URL(location, current);
186
+ }
187
+ catch {
188
+ throw new Error('IPFS redirect Location is not a valid URL');
189
+ }
190
+ assertRedirectAllowed(next, current, gateway);
191
+ current = next;
192
+ continue;
193
+ }
194
+ if (!response.ok) {
195
+ await discardBody(response);
196
+ throw new Error(`IPFS fetch failed: ${response.status} ${response.statusText} ` +
197
+ `(${displayUrl(current)}…)`);
198
+ }
199
+ return response;
36
200
  }
201
+ }
202
+ async function fetchJson(url, signal) {
203
+ const response = await fetchThroughGateway(url, signal);
37
204
  const contentType = response.headers.get('content-type') ?? '';
38
- if (contentType.includes('application/json'))
39
- return response.json();
40
- const text = await response.text();
205
+ const text = await readBoundedText(response);
41
206
  try {
42
207
  return JSON.parse(text);
43
208
  }
@@ -45,6 +210,9 @@ async function fetchJson(url, signal) {
45
210
  throw new Error(`IPFS response is not JSON (content-type: ${contentType || 'none'})`);
46
211
  }
47
212
  }
213
+ async function fetchBytes(url, signal) {
214
+ return readBoundedBytes(await fetchThroughGateway(url, signal));
215
+ }
48
216
  function resolveFallbackGatewayBases(opts) {
49
217
  if (opts?.fallbackGatewayBase === false)
50
218
  return [];
@@ -53,29 +221,72 @@ function resolveFallbackGatewayBases(opts) {
53
221
  }
54
222
  return [['fallback', FALLBACK_IPFS_GATEWAY_BASE]];
55
223
  }
56
- /** Read-only multi-codec, primary-plus-fallback IPFS JSON fetch. */
57
- export async function fetchFromIpfs(gatewayUrl, cid, opts) {
224
+ /**
225
+ * Run every gateway x CID candidate through `read`, under one whole-operation
226
+ * deadline. Shared by the JSON and raw-bytes entry points so both inherit the
227
+ * same redirect revalidation, byte cap, and deadline (#3438).
228
+ */
229
+ async function fetchCandidatesFromIpfs(gatewayUrl, cid, opts, read, failureLabel) {
58
230
  const primary = normalizeIpfsGatewayBase(gatewayUrl);
59
231
  const gateways = [
60
232
  ['primary', primary],
61
233
  ...resolveFallbackGatewayBases(opts),
62
234
  ];
63
- const errors = [];
235
+ const attempts = [];
64
236
  for (const cidPath of buildIpfsFetchCidPathCandidates(cid)) {
65
- for (const [name, baseUrl] of gateways) {
66
- const url = `${baseUrl}${cidPath}`;
67
- const controller = new AbortController();
68
- const timer = setTimeout(() => controller.abort(), IPFS_FETCH_TIMEOUT_MS);
69
- try {
70
- return await fetchJson(url, controller.signal);
71
- }
72
- catch (error) {
73
- errors.push(`${name}:${url.slice(0, 100)}: ${error instanceof Error ? error.message : String(error)}`);
74
- }
75
- finally {
76
- clearTimeout(timer);
77
- }
237
+ for (const [name, baseUrl] of gateways)
238
+ attempts.push([name, `${baseUrl}${cidPath}`]);
239
+ }
240
+ const errors = [];
241
+ const deadline = Date.now() + IPFS_TOTAL_FETCH_TIMEOUT_MS;
242
+ for (let index = 0; index < attempts.length; index += 1) {
243
+ const [name, url] = attempts[index];
244
+ const remainingMs = deadline - Date.now();
245
+ if (remainingMs <= 0) {
246
+ errors.push(`whole-operation timeout after ${IPFS_TOTAL_FETCH_TIMEOUT_MS}ms`);
247
+ break;
248
+ }
249
+ // Share what is left of the budget across the candidates still to try, so a
250
+ // run of slow early candidates cannot starve a later one that would have
251
+ // succeeded. Without this the whole-operation bound would silently narrow
252
+ // the candidate matrix instead of only bounding it.
253
+ const attemptMs = Math.min(IPFS_FETCH_TIMEOUT_MS, Math.ceil(remainingMs / (attempts.length - index)));
254
+ const controller = new AbortController();
255
+ const timer = setTimeout(() => controller.abort(), attemptMs);
256
+ // Parsed once here rather than inside the catch, so a URL this candidate
257
+ // cannot even parse is reported as a candidate failure instead of escaping
258
+ // as a bare TypeError that discards the other candidates' errors.
259
+ let target;
260
+ try {
261
+ target = new URL(url);
262
+ }
263
+ catch {
264
+ errors.push(`${name}: candidate URL could not be parsed`);
265
+ clearTimeout(timer);
266
+ continue;
267
+ }
268
+ try {
269
+ return await read(target, controller.signal);
270
+ }
271
+ catch (error) {
272
+ errors.push(`${name}:${displayUrl(target)}: ` +
273
+ `${error instanceof Error ? error.message : String(error)}`);
274
+ }
275
+ finally {
276
+ clearTimeout(timer);
78
277
  }
79
278
  }
80
- throw new Error(`IPFS JSON fetch failed after all candidates: ${errors.join(' | ')}`);
279
+ throw new Error(`${failureLabel}: ${errors.join(' | ')}`);
280
+ }
281
+ /** Read-only multi-codec, primary-plus-fallback IPFS JSON fetch. */
282
+ export async function fetchFromIpfs(gatewayUrl, cid, opts) {
283
+ return fetchCandidatesFromIpfs(gatewayUrl, cid, opts, fetchJson, 'IPFS JSON fetch failed after all candidates');
284
+ }
285
+ /**
286
+ * Read-only multi-codec, primary-plus-fallback IPFS fetch returning the exact
287
+ * bytes stored at the CID — no JSON parse/re-encode roundtrip. Use this
288
+ * whenever the bytes will be hashed, or whenever the content is not JSON.
289
+ */
290
+ export async function fetchBytesFromIpfs(gatewayUrl, cid, opts) {
291
+ return fetchCandidatesFromIpfs(gatewayUrl, cid, opts, fetchBytes, 'IPFS raw bytes fetch failed after all candidates');
81
292
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Destination policy for manifest-supplied artifact origins (#1901).
3
+ *
4
+ * A corpus manifest is attacker-controlled input: `artifact.access.endpoint`
5
+ * arrives from whoever published the envelope. Fetching it unguarded turns
6
+ * every daemon into an SSRF proxy for loopback, RFC1918, link-local, and
7
+ * cloud metadata services. This module is the single place that decides
8
+ * whether a destination is allowed to be contacted at all.
9
+ *
10
+ * The policy is deny-by-default: a destination is public only if we can
11
+ * positively classify every address behind it as public.
12
+ *
13
+ * Validation alone is not enough. Revalidating a hostname and then handing
14
+ * the *name* to the transport lets an attacker who controls its DNS answer
15
+ * one address to us and a different one to the socket (rebinding), which
16
+ * bypasses the check entirely. So this module does not merely approve a
17
+ * destination — it returns the exact address it approved, and the transport
18
+ * pins the connection to that address. The value we classify is the value we
19
+ * connect to.
20
+ */
21
+ /** Why a destination was refused, or `'public'` when it is allowed. */
22
+ export type AddressClass = 'public' | ProhibitedReason;
23
+ export type ProhibitedReason = 'loopback' | 'private' | 'link-local' | 'multicast' | 'unspecified' | 'broadcast' | 'reserved' | 'carrier-nat' | 'unique-local' | 'documentation' | 'unparsable';
24
+ export declare class ProhibitedDestinationError extends Error {
25
+ readonly detail: string;
26
+ readonly reason: ProhibitedReason | 'scheme' | 'credentials' | 'malformed-url' | 'redirect-cap';
27
+ constructor(detail: string, reason: ProhibitedReason | 'scheme' | 'credentials' | 'malformed-url' | 'redirect-cap');
28
+ }
29
+ /** Classify a dotted-quad IPv4 address against the prohibited-range table. */
30
+ export declare function classifyIpv4(ip: string): AddressClass;
31
+ /**
32
+ * Classify an IPv6 address. Embedded-IPv4 forms (IPv4-mapped, NAT64, 6to4)
33
+ * are unwrapped and classified as IPv4 so `::ffff:127.0.0.1` cannot smuggle
34
+ * loopback past the IPv4 table.
35
+ */
36
+ export declare function classifyIpv6(ip: string): AddressClass;
37
+ /** Classify any IP literal. Anything we cannot parse is refused. */
38
+ export declare function classifyIpAddress(ip: string): AddressClass;
39
+ /** Resolve a hostname to every A/AAAA address the resolver knows. */
40
+ export type HostnameResolver = (hostname: string) => Promise<string[]>;
41
+ export interface OriginGuardOptions {
42
+ /** Injection seam for tests; defaults to `dns.lookup(..., { all: true })`. */
43
+ resolveHostname?: HostnameResolver;
44
+ /**
45
+ * Escape hatch for an operator pointing at a local origin, where
46
+ * `publicEndpoint` legitimately defaults to `http://localhost:<apiPort>`.
47
+ * Off unless explicitly enabled — the default is fail-closed. It waives
48
+ * only the address policy; scheme and credential checks still run.
49
+ */
50
+ allowPrivateDestinations?: boolean;
51
+ }
52
+ /** The exact destinations the guard approved, for the transport to pin to. */
53
+ export interface PinnedDestination {
54
+ /**
55
+ * Every address the connection may use, in resolver order. All of them
56
+ * passed the policy, so the transport is free to fail over between them —
57
+ * pinning to just the first would break dual-stack and multi-A origins.
58
+ */
59
+ readonly addresses: ReadonlyArray<{
60
+ readonly address: string;
61
+ readonly family: 4 | 6;
62
+ }>;
63
+ }
64
+ /**
65
+ * Validate one destination and return the address it is allowed to reach.
66
+ *
67
+ * Throws `ProhibitedDestinationError` unless the URL is a credential-free
68
+ * public `http:`/`https:` destination. On success the caller MUST connect to
69
+ * the returned address rather than re-resolving the hostname — re-resolving
70
+ * reopens the rebinding window this function exists to close.
71
+ *
72
+ * Returns `null` only when `allowPrivateDestinations` is set, where there is
73
+ * no policy to pin to.
74
+ */
75
+ export declare function resolvePublicHttpDestination(url: URL, options?: OriginGuardOptions): Promise<PinnedDestination | null>;
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Destination policy for manifest-supplied artifact origins (#1901).
3
+ *
4
+ * A corpus manifest is attacker-controlled input: `artifact.access.endpoint`
5
+ * arrives from whoever published the envelope. Fetching it unguarded turns
6
+ * every daemon into an SSRF proxy for loopback, RFC1918, link-local, and
7
+ * cloud metadata services. This module is the single place that decides
8
+ * whether a destination is allowed to be contacted at all.
9
+ *
10
+ * The policy is deny-by-default: a destination is public only if we can
11
+ * positively classify every address behind it as public.
12
+ *
13
+ * Validation alone is not enough. Revalidating a hostname and then handing
14
+ * the *name* to the transport lets an attacker who controls its DNS answer
15
+ * one address to us and a different one to the socket (rebinding), which
16
+ * bypasses the check entirely. So this module does not merely approve a
17
+ * destination — it returns the exact address it approved, and the transport
18
+ * pins the connection to that address. The value we classify is the value we
19
+ * connect to.
20
+ */
21
+ import { isIPv4, isIPv6 } from 'node:net';
22
+ import { lookup } from 'node:dns/promises';
23
+ export class ProhibitedDestinationError extends Error {
24
+ detail;
25
+ reason;
26
+ constructor(detail, reason) {
27
+ super(detail);
28
+ this.detail = detail;
29
+ this.reason = reason;
30
+ this.name = 'ProhibitedDestinationError';
31
+ }
32
+ }
33
+ function parseIpv4(ip) {
34
+ const parts = ip.split('.');
35
+ if (parts.length !== 4)
36
+ return null;
37
+ const octets = [];
38
+ for (const part of parts) {
39
+ if (!/^\d{1,3}$/u.test(part))
40
+ return null;
41
+ const value = Number(part);
42
+ if (value > 255)
43
+ return null;
44
+ octets.push(value);
45
+ }
46
+ return octets;
47
+ }
48
+ /** Classify a dotted-quad IPv4 address against the prohibited-range table. */
49
+ export function classifyIpv4(ip) {
50
+ const octets = parseIpv4(ip);
51
+ if (!octets)
52
+ return 'unparsable';
53
+ const [a, b, c] = octets;
54
+ if (a === 0)
55
+ return 'unspecified'; // 0.0.0.0/8 "this network"
56
+ if (a === 10)
57
+ return 'private'; // RFC1918
58
+ if (a === 127)
59
+ return 'loopback'; // RFC1122
60
+ if (a === 100 && b >= 64 && b <= 127)
61
+ return 'carrier-nat'; // RFC6598 100.64/10
62
+ if (a === 169 && b === 254)
63
+ return 'link-local'; // incl. 169.254.169.254 metadata
64
+ if (a === 172 && b >= 16 && b <= 31)
65
+ return 'private'; // RFC1918
66
+ if (a === 192 && b === 0 && c === 0)
67
+ return 'reserved'; // IETF protocol assignments
68
+ if (a === 192 && b === 0 && c === 2)
69
+ return 'documentation'; // TEST-NET-1
70
+ if (a === 192 && b === 88 && c === 99)
71
+ return 'reserved'; // 6to4 relay anycast
72
+ if (a === 192 && b === 168)
73
+ return 'private'; // RFC1918
74
+ if (a === 198 && (b === 18 || b === 19))
75
+ return 'reserved'; // benchmarking
76
+ if (a === 198 && b === 51 && c === 100)
77
+ return 'documentation'; // TEST-NET-2
78
+ if (a === 203 && b === 0 && c === 113)
79
+ return 'documentation'; // TEST-NET-3
80
+ if (a >= 224 && a <= 239)
81
+ return 'multicast';
82
+ if (ip === '255.255.255.255')
83
+ return 'broadcast';
84
+ if (a >= 240)
85
+ return 'reserved'; // 240/4 future use
86
+ return 'public';
87
+ }
88
+ /** Expand an IPv6 literal (with optional `::` and trailing IPv4) to 16 bytes. */
89
+ function parseIpv6(ip) {
90
+ const zoneless = ip.split('%')[0];
91
+ const halves = zoneless.split('::');
92
+ if (halves.length > 2)
93
+ return null;
94
+ const expand = (segment) => {
95
+ if (segment === '')
96
+ return [];
97
+ const bytes = [];
98
+ const groups = segment.split(':');
99
+ for (let i = 0; i < groups.length; i += 1) {
100
+ const group = groups[i];
101
+ if (group.includes('.')) {
102
+ // Trailing dotted-quad form is only legal in the last position.
103
+ if (i !== groups.length - 1)
104
+ return null;
105
+ const octets = parseIpv4(group);
106
+ if (!octets)
107
+ return null;
108
+ bytes.push(...octets);
109
+ continue;
110
+ }
111
+ if (!/^[0-9a-fA-F]{1,4}$/u.test(group))
112
+ return null;
113
+ const value = Number.parseInt(group, 16);
114
+ bytes.push((value >> 8) & 0xff, value & 0xff);
115
+ }
116
+ return bytes;
117
+ };
118
+ const head = expand(halves[0]);
119
+ const tail = halves.length === 2 ? expand(halves[1]) : [];
120
+ if (head === null || tail === null)
121
+ return null;
122
+ if (halves.length === 1)
123
+ return head.length === 16 ? head : null;
124
+ const gap = 16 - head.length - tail.length;
125
+ if (gap < 0)
126
+ return null;
127
+ return [...head, ...new Array(gap).fill(0), ...tail];
128
+ }
129
+ /**
130
+ * Classify an IPv6 address. Embedded-IPv4 forms (IPv4-mapped, NAT64, 6to4)
131
+ * are unwrapped and classified as IPv4 so `::ffff:127.0.0.1` cannot smuggle
132
+ * loopback past the IPv4 table.
133
+ */
134
+ export function classifyIpv6(ip) {
135
+ const bytes = parseIpv6(ip);
136
+ if (!bytes)
137
+ return 'unparsable';
138
+ const dotted = (offset) => bytes.slice(offset, offset + 4).join('.');
139
+ if (bytes.every((byte) => byte === 0))
140
+ return 'unspecified';
141
+ if (bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1)
142
+ return 'loopback';
143
+ // ::ffff:0:0/96 — IPv4-mapped.
144
+ if (bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff) {
145
+ return classifyIpv4(dotted(12));
146
+ }
147
+ // 64:ff9b::/96 — NAT64 well-known prefix.
148
+ if (bytes[0] === 0x00 && bytes[1] === 0x64 && bytes[2] === 0xff && bytes[3] === 0x9b
149
+ && bytes.slice(4, 12).every((byte) => byte === 0)) {
150
+ return classifyIpv4(dotted(12));
151
+ }
152
+ // 2002::/16 — 6to4, embeds the IPv4 relay address.
153
+ if (bytes[0] === 0x20 && bytes[1] === 0x02) {
154
+ const embedded = classifyIpv4(dotted(2));
155
+ return embedded === 'public' ? 'reserved' : embedded;
156
+ }
157
+ // 64:ff9b:1::/48 — RFC 8215 local-use NAT64, any embedding.
158
+ if (bytes[0] === 0x00 && bytes[1] === 0x64 && bytes[2] === 0xff && bytes[3] === 0x9b
159
+ && bytes[4] === 0x00 && bytes[5] === 0x01) {
160
+ return 'reserved';
161
+ }
162
+ // ::/8 — everything else in the all-zero high byte, which is where the
163
+ // IPv4-compatible (`::127.0.0.1`) and IPv4-translated (`::ffff:0:7f00:1`)
164
+ // forms live. The genuinely useful embeddings are unwrapped above; the
165
+ // rest is refused rather than left to fall through as public.
166
+ if (bytes[0] === 0x00)
167
+ return 'reserved';
168
+ // 100::/64 — discard-only.
169
+ if (bytes[0] === 0x01 && bytes[1] === 0x00 && bytes.slice(2, 8).every((byte) => byte === 0)) {
170
+ return 'reserved';
171
+ }
172
+ if ((bytes[0] & 0xfe) === 0xfc)
173
+ return 'unique-local'; // fc00::/7
174
+ if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80)
175
+ return 'link-local'; // fe80::/10
176
+ if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0xc0)
177
+ return 'private'; // fec0::/10 site-local
178
+ if (bytes[0] === 0xff)
179
+ return 'multicast'; // ff00::/8
180
+ if (bytes[0] === 0x20 && bytes[1] === 0x01 && bytes[2] === 0x0d && bytes[3] === 0xb8) {
181
+ return 'documentation'; // 2001:db8::/32
182
+ }
183
+ if (bytes[0] === 0x20 && bytes[1] === 0x01 && bytes[2] === 0x00 && bytes[3] === 0x00) {
184
+ return 'reserved'; // 2001::/32 Teredo
185
+ }
186
+ // Deny-by-default: only global unicast (2000::/3) can be public, and only
187
+ // after the special-purpose prefixes carved out of it above.
188
+ if ((bytes[0] & 0xe0) !== 0x20)
189
+ return 'reserved';
190
+ return 'public';
191
+ }
192
+ /** Classify any IP literal. Anything we cannot parse is refused. */
193
+ export function classifyIpAddress(ip) {
194
+ if (isIPv4(ip))
195
+ return classifyIpv4(ip);
196
+ if (isIPv6(ip))
197
+ return classifyIpv6(ip);
198
+ return 'unparsable';
199
+ }
200
+ const defaultResolver = async (hostname) => {
201
+ const records = await lookup(hostname, { all: true, verbatim: true });
202
+ return records.map((record) => record.address);
203
+ };
204
+ /** `[::1]` → `::1`; other hostnames are returned unchanged. */
205
+ function unbracket(hostname) {
206
+ return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
207
+ }
208
+ /**
209
+ * Validate one destination and return the address it is allowed to reach.
210
+ *
211
+ * Throws `ProhibitedDestinationError` unless the URL is a credential-free
212
+ * public `http:`/`https:` destination. On success the caller MUST connect to
213
+ * the returned address rather than re-resolving the hostname — re-resolving
214
+ * reopens the rebinding window this function exists to close.
215
+ *
216
+ * Returns `null` only when `allowPrivateDestinations` is set, where there is
217
+ * no policy to pin to.
218
+ */
219
+ export async function resolvePublicHttpDestination(url, options = {}) {
220
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
221
+ throw new ProhibitedDestinationError(`artifact origin scheme ${url.protocol} is not http(s): ${url.href}`, 'scheme');
222
+ }
223
+ // Credentials are refused regardless of the private-destination hatch:
224
+ // leaking them to a peer is orthogonal to whether the peer is public.
225
+ if (url.username !== '' || url.password !== '') {
226
+ throw new ProhibitedDestinationError(`artifact origin must not carry credentials: ${url.host}`, 'credentials');
227
+ }
228
+ if (options.allowPrivateDestinations === true)
229
+ return null;
230
+ const host = unbracket(url.hostname);
231
+ if (host === '') {
232
+ throw new ProhibitedDestinationError('artifact origin has no host', 'malformed-url');
233
+ }
234
+ if (isIPv4(host) || isIPv6(host)) {
235
+ const verdict = classifyIpAddress(host);
236
+ if (verdict !== 'public') {
237
+ throw new ProhibitedDestinationError(`artifact origin address ${host} is ${verdict}, not a public destination`, verdict);
238
+ }
239
+ return { addresses: [{ address: host, family: isIPv4(host) ? 4 : 6 }] };
240
+ }
241
+ let addresses;
242
+ try {
243
+ addresses = await (options.resolveHostname ?? defaultResolver)(host);
244
+ }
245
+ catch (err) {
246
+ throw new ProhibitedDestinationError(`artifact origin ${host} did not resolve: ${err instanceof Error ? err.message : String(err)}`, 'unparsable');
247
+ }
248
+ if (addresses.length === 0) {
249
+ throw new ProhibitedDestinationError(`artifact origin ${host} resolved to no addresses`, 'unparsable');
250
+ }
251
+ // Every answer must be public — a name that mixes a public address with a
252
+ // private one is refused outright rather than cherry-picked.
253
+ for (const address of addresses) {
254
+ const verdict = classifyIpAddress(address);
255
+ if (verdict !== 'public') {
256
+ throw new ProhibitedDestinationError(`artifact origin ${host} resolves to ${address} (${verdict}), not a public destination`, verdict);
257
+ }
258
+ }
259
+ return {
260
+ addresses: addresses.map((address) => ({
261
+ address,
262
+ family: isIPv4(address) ? 4 : 6,
263
+ })),
264
+ };
265
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * A `fetch`-shaped transport that connects to a caller-chosen address (#1901).
3
+ *
4
+ * `globalThis.fetch` resolves the hostname itself, at connect time, with no
5
+ * hook to constrain the result. That is fatal for a destination policy: an
6
+ * attacker who controls the name's DNS can answer a public address to the
7
+ * policy's lookup and a private one to the socket's. Validating the name and
8
+ * then handing the name to `fetch` checks one thing and connects to another.
9
+ *
10
+ * So the production path uses `node:http`/`node:https` with the `lookup`
11
+ * option pinned to the address `origin-guard` already approved. The URL keeps
12
+ * its hostname, so TLS still does SNI and certificate validation against the
13
+ * real name; only address selection is taken away from the resolver.
14
+ *
15
+ * The result is adapted back to a web `Response` so callers — and the tests
16
+ * that inject a plain `fetch` fake — see one shape either way. The body is
17
+ * exposed as a stream, never buffered here, so the caller's byte cap still
18
+ * governs.
19
+ *
20
+ * One deliberate difference from `fetch`: there is no transparent content
21
+ * decoding. We send no `Accept-Encoding`, so a compliant origin answers with
22
+ * identity bytes — and the bytes the byte cap counts are then exactly the
23
+ * bytes the caller hashes. An origin that compresses anyway delivers bytes
24
+ * that fail the SHA-256 check, which is the safe direction: nothing
25
+ * unverified is ever admitted.
26
+ */
27
+ export interface PinnedAddress {
28
+ readonly address: string;
29
+ readonly family: 4 | 6;
30
+ }
31
+ export interface PinnedFetchInit {
32
+ /**
33
+ * Addresses the socket may connect to, in preference order. Every one has
34
+ * already passed the destination policy, so Node is free to fail over
35
+ * between them. Omit to use ordinary DNS.
36
+ */
37
+ readonly pinnedAddresses?: readonly PinnedAddress[];
38
+ readonly signal?: AbortSignal;
39
+ }
40
+ export type PinnedFetch = (url: URL, init?: PinnedFetchInit) => Promise<Response>;
41
+ /** Perform one GET, pinned to `init.pinnedAddress` when supplied. */
42
+ export declare const pinnedFetch: PinnedFetch;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * A `fetch`-shaped transport that connects to a caller-chosen address (#1901).
3
+ *
4
+ * `globalThis.fetch` resolves the hostname itself, at connect time, with no
5
+ * hook to constrain the result. That is fatal for a destination policy: an
6
+ * attacker who controls the name's DNS can answer a public address to the
7
+ * policy's lookup and a private one to the socket's. Validating the name and
8
+ * then handing the name to `fetch` checks one thing and connects to another.
9
+ *
10
+ * So the production path uses `node:http`/`node:https` with the `lookup`
11
+ * option pinned to the address `origin-guard` already approved. The URL keeps
12
+ * its hostname, so TLS still does SNI and certificate validation against the
13
+ * real name; only address selection is taken away from the resolver.
14
+ *
15
+ * The result is adapted back to a web `Response` so callers — and the tests
16
+ * that inject a plain `fetch` fake — see one shape either way. The body is
17
+ * exposed as a stream, never buffered here, so the caller's byte cap still
18
+ * governs.
19
+ *
20
+ * One deliberate difference from `fetch`: there is no transparent content
21
+ * decoding. We send no `Accept-Encoding`, so a compliant origin answers with
22
+ * identity bytes — and the bytes the byte cap counts are then exactly the
23
+ * bytes the caller hashes. An origin that compresses anyway delivers bytes
24
+ * that fail the SHA-256 check, which is the safe direction: nothing
25
+ * unverified is ever admitted.
26
+ */
27
+ import { request as httpRequest } from 'node:http';
28
+ import { request as httpsRequest } from 'node:https';
29
+ import { Readable } from 'node:stream';
30
+ import { isIPv4, isIPv6 } from 'node:net';
31
+ /** Statuses the `Response` constructor refuses to pair with a body. */
32
+ const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
33
+ /** Perform one GET, pinned to `init.pinnedAddress` when supplied. */
34
+ export const pinnedFetch = (url, init = {}) => new Promise((resolve, reject) => {
35
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
36
+ reject(new Error(`pinnedFetch supports http(s) only, got ${url.protocol}`));
37
+ return;
38
+ }
39
+ if (init.signal?.aborted) {
40
+ reject(new Error('aborted'));
41
+ return;
42
+ }
43
+ // `undefined` means "no pin requested"; an empty list means "pin requested,
44
+ // nothing permitted". Letting the latter fall through to ordinary DNS would
45
+ // turn an empty allowlist into no restriction at all — the wrong default
46
+ // for a security primitive, and invisible at the call site.
47
+ if (init.pinnedAddresses !== undefined && init.pinnedAddresses.length === 0) {
48
+ reject(new Error('pinnedAddresses must not be empty; omit it to use ordinary DNS'));
49
+ return;
50
+ }
51
+ for (const entry of init.pinnedAddresses ?? []) {
52
+ const matches = entry.family === 4 ? isIPv4(entry.address) : isIPv6(entry.address);
53
+ if (!matches) {
54
+ reject(new Error(`pinned address ${entry.address} is not a numeric IPv${entry.family} address`));
55
+ return;
56
+ }
57
+ }
58
+ const send = url.protocol === 'https:' ? httpsRequest : httpRequest;
59
+ const pinned = init.pinnedAddresses;
60
+ const outgoing = send(url, {
61
+ method: 'GET',
62
+ // A pooled keep-alive socket is keyed on host:port and ignores `lookup`,
63
+ // so a reused connection would skip the pin entirely. Opting out of the
64
+ // shared agent keeps "we connect to the address we validated" literally
65
+ // true, at the cost of a handshake per artifact fetch.
66
+ ...(pinned === undefined ? {} : { agent: false }),
67
+ // Redirects are never followed here; the caller revalidates each hop.
68
+ ...(pinned === undefined ? {} : {
69
+ // Node calls `lookup` with `{ all: true }` from some connect paths
70
+ // and expects the array shape back there; answering the wrong shape
71
+ // throws "Invalid IP address: undefined".
72
+ lookup: (_hostname, options, callback) => {
73
+ if (options?.all === true)
74
+ callback(null, pinned.map((entry) => ({ ...entry })));
75
+ else
76
+ callback(null, pinned[0].address, pinned[0].family);
77
+ },
78
+ }),
79
+ }, (incoming) => {
80
+ // Everything here runs inside http.request's response callback, where a
81
+ // throw is an UNCAUGHT EXCEPTION — no promise rejection, nothing the
82
+ // caller's try/catch can see, process dead. The response is
83
+ // attacker-shaped, so the whole callback is guarded and every failure
84
+ // is converted into a rejection this function's caller can handle.
85
+ try {
86
+ const headers = new Headers();
87
+ for (const [name, value] of Object.entries(incoming.headers)) {
88
+ if (value === undefined)
89
+ continue;
90
+ headers.set(name, Array.isArray(value) ? value.join(', ') : value);
91
+ }
92
+ // Node's parser accepts any status up to 999, but `Response` throws
93
+ // outside 200-599. Check it explicitly rather than leaning on the
94
+ // catch below, so a hostile `HTTP/1.1 999 Nope` reads as a bad
95
+ // response instead of an internal error.
96
+ const status = incoming.statusCode ?? 502;
97
+ if (status < 200 || status > 599) {
98
+ incoming.resume();
99
+ reject(new Error(`origin returned an out-of-range HTTP status ${status}`));
100
+ return;
101
+ }
102
+ const bodyless = NULL_BODY_STATUSES.has(status);
103
+ if (bodyless)
104
+ incoming.resume();
105
+ resolve(new Response(bodyless ? null : Readable.toWeb(incoming), { status, headers }));
106
+ }
107
+ catch (err) {
108
+ incoming.resume();
109
+ reject(err instanceof Error ? err : new Error(String(err)));
110
+ }
111
+ });
112
+ outgoing.on('error', reject);
113
+ if (init.signal) {
114
+ const abort = () => { outgoing.destroy(new Error('aborted')); };
115
+ if (init.signal.aborted)
116
+ abort();
117
+ else
118
+ init.signal.addEventListener('abort', abort, { once: true });
119
+ }
120
+ outgoing.end();
121
+ });
@@ -15,7 +15,7 @@
15
15
  * doc are parsed against this schema in `test/envelope.test.ts`).
16
16
  *
17
17
  * Step shape mirrors the capture-span shape (`SpanRow`,
18
- * `client/src/store/captures.ts`) minus session/trace IDs, which are hoisted
18
+ * `operator/src/store/captures.ts`) minus session/trace IDs, which are hoisted
19
19
  * to `session`.
20
20
  *
21
21
  * Plan: docs/superpowers/plans/2026-07-02-jinn-harness-network-v0-plan.md
@@ -56,7 +56,7 @@ export declare const OutcomeStatusSchema: z.ZodEnum<{
56
56
  export type OutcomeStatus = z.infer<typeof OutcomeStatusSchema>;
57
57
  /**
58
58
  * One compressed trace step. Mirrors `SpanRow`
59
- * (client/src/store/captures.ts): scrubbed `attributes` plus the
59
+ * (operator/src/store/captures.ts): scrubbed `attributes` plus the
60
60
  * `redactedKeys` receipt of what the scrub pipeline removed.
61
61
  */
62
62
  export declare const TraceStepSchema: z.ZodObject<{
package/dist/envelope.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * doc are parsed against this schema in `test/envelope.test.ts`).
16
16
  *
17
17
  * Step shape mirrors the capture-span shape (`SpanRow`,
18
- * `client/src/store/captures.ts`) minus session/trace IDs, which are hoisted
18
+ * `operator/src/store/captures.ts`) minus session/trace IDs, which are hoisted
19
19
  * to `session`.
20
20
  *
21
21
  * Plan: docs/superpowers/plans/2026-07-02-jinn-harness-network-v0-plan.md
@@ -53,7 +53,7 @@ const UnixNanoSchema = z.string().regex(/^\d+$/, 'unix-nanosecond digit string')
53
53
  const TrimmedString = (max) => z.string().min(1).max(max).regex(/^\S(.*\S)?$/s, 'no surrounding whitespace');
54
54
  /**
55
55
  * One compressed trace step. Mirrors `SpanRow`
56
- * (client/src/store/captures.ts): scrubbed `attributes` plus the
56
+ * (operator/src/store/captures.ts): scrubbed `attributes` plus the
57
57
  * `redactedKeys` receipt of what the scrub pipeline removed.
58
58
  */
59
59
  export const TraceStepSchema = z.strictObject({
@@ -11,7 +11,7 @@ import { z } from 'zod/v3';
11
11
  * so they publish as a skill-only wrapper (`publishSkill()`,
12
12
  * packages/layer/src/publish-skill.ts, DR-2026-07-06).
13
13
  * Access/pricing come from the enclosing Artifact
14
- * entry (client/src/types/envelope.ts ArtifactSchema: sha256,
14
+ * entry (operator/src/types/envelope.ts ArtifactSchema: sha256,
15
15
  * access.endpoint, access.priceUsdc, metadata.tags).
16
16
  *
17
17
  * Frozen-caps review (AC3, additive): this schema is a NEW artifact payload
@@ -22,7 +22,7 @@ import { z } from 'zod/v3';
22
22
  * any artifactType string. Companion-file content lives here, free of the
23
23
  * trace envelope's 16 KiB step-attribute cap, under its own 1 MiB total cap.
24
24
  *
25
- * Precedent: client/src/trajectory/harness-bundle-schema.ts.
25
+ * Precedent: operator/src/trajectory/harness-bundle-schema.ts.
26
26
  */
27
27
  export declare const SKILL_ARTIFACT_TYPE: "jinn.skill.v1";
28
28
  /** Max companion files per skill. */
@@ -11,7 +11,7 @@ import { z } from 'zod/v3';
11
11
  * so they publish as a skill-only wrapper (`publishSkill()`,
12
12
  * packages/layer/src/publish-skill.ts, DR-2026-07-06).
13
13
  * Access/pricing come from the enclosing Artifact
14
- * entry (client/src/types/envelope.ts ArtifactSchema: sha256,
14
+ * entry (operator/src/types/envelope.ts ArtifactSchema: sha256,
15
15
  * access.endpoint, access.priceUsdc, metadata.tags).
16
16
  *
17
17
  * Frozen-caps review (AC3, additive): this schema is a NEW artifact payload
@@ -22,7 +22,7 @@ import { z } from 'zod/v3';
22
22
  * any artifactType string. Companion-file content lives here, free of the
23
23
  * trace envelope's 16 KiB step-attribute cap, under its own 1 MiB total cap.
24
24
  *
25
- * Precedent: client/src/trajectory/harness-bundle-schema.ts.
25
+ * Precedent: operator/src/trajectory/harness-bundle-schema.ts.
26
26
  */
27
27
  export const SKILL_ARTIFACT_TYPE = 'jinn.skill.v1';
28
28
  /** Max companion files per skill. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jinn-network/core",
3
- "version": "0.1.1",
4
- "description": "Domain core for Jinn — evidence/contribution stores, scrub, trajectory parsing, and corpus reads. Independent of client/src.",
3
+ "version": "0.1.2-canary.15837edd",
4
+ "description": "Domain core for Jinn — evidence/contribution stores, scrub, trajectory parsing, and corpus reads. Independent of operator/src.",
5
5
  "type": "module",
6
6
  "packageManager": "yarn@4.13.0",
7
7
  "license": "MIT",
@@ -43,14 +43,14 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@huggingface/transformers": "^4.2.0",
46
- "@jinn-network/plugin": "0.1.1",
46
+ "@jinn-network/plugin": "0.1.2-canary.15837edd",
47
47
  "@lmoe/gliner-onnx": "0.1.0",
48
48
  "@noble/hashes": "^2.2.0",
49
49
  "@secretlint/core": "^13.0.2",
50
50
  "@secretlint/secretlint-rule-preset-recommend": "^13.0.2",
51
51
  "better-sqlite3": "^12.10.0",
52
52
  "canonicalize": "^3.0.0",
53
- "zod": "^4.4.3"
53
+ "zod": "4.4.3"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "vitest": "^4.1.8"