@open-mercato/web-research 0.7.1-develop.7194.1.ab4fc81f82

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.
Files changed (121) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +67 -0
  3. package/build.mjs +10 -0
  4. package/dist/contract/adapter.js +13 -0
  5. package/dist/contract/adapter.js.map +7 -0
  6. package/dist/contract/errors.js +28 -0
  7. package/dist/contract/errors.js.map +7 -0
  8. package/dist/contract/http.js +7 -0
  9. package/dist/contract/http.js.map +7 -0
  10. package/dist/contract/index.js +9 -0
  11. package/dist/contract/index.js.map +7 -0
  12. package/dist/contract/outcomes.js +58 -0
  13. package/dist/contract/outcomes.js.map +7 -0
  14. package/dist/contract/policy.js +91 -0
  15. package/dist/contract/policy.js.map +7 -0
  16. package/dist/contract/results.js +13 -0
  17. package/dist/contract/results.js.map +7 -0
  18. package/dist/contract/steps.js +1 -0
  19. package/dist/contract/steps.js.map +7 -0
  20. package/dist/contract/version.js +11 -0
  21. package/dist/contract/version.js.map +7 -0
  22. package/dist/engine/cache.js +29 -0
  23. package/dist/engine/cache.js.map +7 -0
  24. package/dist/engine/deadline.js +54 -0
  25. package/dist/engine/deadline.js.map +7 -0
  26. package/dist/engine/engine.js +481 -0
  27. package/dist/engine/engine.js.map +7 -0
  28. package/dist/engine/index.js +20 -0
  29. package/dist/engine/index.js.map +7 -0
  30. package/dist/engine/runAdapter.js +57 -0
  31. package/dist/engine/runAdapter.js.map +7 -0
  32. package/dist/engine/types.js +1 -0
  33. package/dist/engine/types.js.map +7 -0
  34. package/dist/extract/classify.js +51 -0
  35. package/dist/extract/classify.js.map +7 -0
  36. package/dist/extract/content.js +104 -0
  37. package/dist/extract/content.js.map +7 -0
  38. package/dist/extract/entities.js +95 -0
  39. package/dist/extract/entities.js.map +7 -0
  40. package/dist/extract/index.js +24 -0
  41. package/dist/extract/index.js.map +7 -0
  42. package/dist/extract/text.js +114 -0
  43. package/dist/extract/text.js.map +7 -0
  44. package/dist/extract/tokenizer.js +141 -0
  45. package/dist/extract/tokenizer.js.map +7 -0
  46. package/dist/fusion/canonicalize.js +88 -0
  47. package/dist/fusion/canonicalize.js.map +7 -0
  48. package/dist/fusion/fuse.js +88 -0
  49. package/dist/fusion/fuse.js.map +7 -0
  50. package/dist/fusion/index.js +11 -0
  51. package/dist/fusion/index.js.map +7 -0
  52. package/dist/index.js +21 -0
  53. package/dist/index.js.map +7 -0
  54. package/dist/loader/describe.js +92 -0
  55. package/dist/loader/describe.js.map +7 -0
  56. package/dist/loader/index.js +31 -0
  57. package/dist/loader/index.js.map +7 -0
  58. package/dist/loader/manifest.js +32 -0
  59. package/dist/loader/manifest.js.map +7 -0
  60. package/dist/loader/registry.js +98 -0
  61. package/dist/loader/registry.js.map +7 -0
  62. package/dist/net/client.js +298 -0
  63. package/dist/net/client.js.map +7 -0
  64. package/dist/net/ssrf.js +165 -0
  65. package/dist/net/ssrf.js.map +7 -0
  66. package/dist/net/url.js +9 -0
  67. package/dist/net/url.js.map +7 -0
  68. package/dist/testing/contract.js +118 -0
  69. package/dist/testing/contract.js.map +7 -0
  70. package/dist/testing/fakeAdapter.js +44 -0
  71. package/dist/testing/fakeAdapter.js.map +7 -0
  72. package/dist/testing/index.js +15 -0
  73. package/dist/testing/index.js.map +7 -0
  74. package/jest.config.cjs +24 -0
  75. package/package.json +65 -0
  76. package/src/contract/adapter.ts +100 -0
  77. package/src/contract/errors.ts +48 -0
  78. package/src/contract/http.ts +46 -0
  79. package/src/contract/index.ts +8 -0
  80. package/src/contract/outcomes.ts +91 -0
  81. package/src/contract/policy.ts +157 -0
  82. package/src/contract/results.ts +79 -0
  83. package/src/contract/steps.ts +45 -0
  84. package/src/contract/version.ts +25 -0
  85. package/src/engine/__tests__/engine.test.ts +597 -0
  86. package/src/engine/cache.ts +46 -0
  87. package/src/engine/deadline.ts +65 -0
  88. package/src/engine/engine.ts +633 -0
  89. package/src/engine/index.ts +22 -0
  90. package/src/engine/runAdapter.ts +89 -0
  91. package/src/engine/types.ts +137 -0
  92. package/src/extract/__tests__/content.test.ts +81 -0
  93. package/src/extract/__tests__/text.test.ts +61 -0
  94. package/src/extract/__tests__/tokenizer.test.ts +85 -0
  95. package/src/extract/classify.ts +81 -0
  96. package/src/extract/content.ts +136 -0
  97. package/src/extract/entities.ts +98 -0
  98. package/src/extract/index.ts +11 -0
  99. package/src/extract/text.ts +124 -0
  100. package/src/extract/tokenizer.ts +197 -0
  101. package/src/fusion/__tests__/fusion.test.ts +198 -0
  102. package/src/fusion/canonicalize.ts +119 -0
  103. package/src/fusion/fuse.ts +145 -0
  104. package/src/fusion/index.ts +8 -0
  105. package/src/index.ts +15 -0
  106. package/src/loader/__tests__/describe.test.ts +119 -0
  107. package/src/loader/__tests__/registry.test.ts +134 -0
  108. package/src/loader/describe.ts +145 -0
  109. package/src/loader/index.ts +25 -0
  110. package/src/loader/manifest.ts +44 -0
  111. package/src/loader/registry.ts +156 -0
  112. package/src/net/__tests__/client.test.ts +273 -0
  113. package/src/net/__tests__/ssrf.test.ts +165 -0
  114. package/src/net/client.ts +434 -0
  115. package/src/net/ssrf.ts +235 -0
  116. package/src/net/url.ts +13 -0
  117. package/src/testing/contract.ts +172 -0
  118. package/src/testing/fakeAdapter.ts +69 -0
  119. package/src/testing/index.ts +9 -0
  120. package/tsconfig.json +10 -0
  121. package/watch.mjs +7 -0
@@ -0,0 +1,434 @@
1
+ import { Agent as HttpAgent, request as sendHttp, type IncomingMessage } from 'node:http'
2
+ import { Agent as HttpsAgent, request as sendHttps } from 'node:https'
3
+ import { isIP, type LookupFunction } from 'node:net'
4
+ import type { Readable } from 'node:stream'
5
+ import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib'
6
+ import { WebResearchError, errorMessage, isWebResearchError } from '../contract/errors'
7
+ import type { HttpClient, HttpRequestInit, HttpResponse } from '../contract/http'
8
+ import { assertPublicUrl, type LookupFn } from './ssrf'
9
+
10
+ const DEFAULT_USER_AGENT =
11
+ 'Mozilla/5.0 (compatible; OpenMercatoResearch/1.0; +https://github.com/open-mercato/open-mercato)'
12
+ const DEFAULT_MAX_BYTES = 512 * 1024
13
+ const DEFAULT_TIMEOUT_MS = 10_000
14
+ const MAX_REDIRECTS = 5
15
+ const MAX_RETRIES = 2
16
+ const MAX_BACKOFF_MS = 5_000
17
+ /**
18
+ * Hard ceiling on a single `sleep()`. Backoff is already clamped to
19
+ * `MAX_BACKOFF_MS`, but the politeness delay is derived from configuration and
20
+ * a `Retry-After` header is remote input, so the timer itself is capped too —
21
+ * no caller can park a request for an unbounded stretch.
22
+ */
23
+ const MAX_SLEEP_MS = 60_000
24
+ /**
25
+ * Ceiling on one `request()` call, redirects and retries included.
26
+ *
27
+ * `timeoutMs` bounds a single attempt, so without this a call could legitimately
28
+ * run for redirects x retries x timeout plus backoff — minutes, inside an adapter
29
+ * budget of seconds. The adapter's signal would eventually cut it, but only after
30
+ * the work was already spent.
31
+ */
32
+ const DEFAULT_TOTAL_TIMEOUT_MS = 30_000
33
+
34
+ export type HttpClientOptions = {
35
+ readonly userAgent?: string
36
+ readonly acceptLanguage?: string
37
+ readonly maxBytes?: number
38
+ readonly timeoutMs?: number
39
+ readonly maxRedirects?: number
40
+ readonly maxRetries?: number
41
+ /** Ceiling on one call including every redirect hop and retry. */
42
+ readonly maxTotalMs?: number
43
+ /** Minimum gap between requests to the same host. */
44
+ readonly politenessMs?: number
45
+ readonly lookup?: LookupFn
46
+ /**
47
+ * Hosts allowed to resolve to a non-public address. Empty by default; see
48
+ * `assertPublicUrl` for why this is an allowlist and not a boolean.
49
+ */
50
+ readonly allowPrivateHosts?: readonly string[]
51
+ readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>
52
+ readonly now?: () => number
53
+ }
54
+
55
+ /**
56
+ * Resolves a hostname to exactly the addresses the SSRF guard already vetted.
57
+ * Without this the kernel re-resolves at connect time, reopening the rebinding
58
+ * window between check and connect. Only name resolution is overridden, so SNI
59
+ * and certificate validation still see the real hostname.
60
+ */
61
+ export function createPinnedLookup(addresses: readonly string[]): LookupFunction {
62
+ const entries = addresses.map((address) => ({ address, family: isIP(address) }))
63
+ return (hostname, options, callback) => {
64
+ if (entries.length === 0) {
65
+ callback(new Error(`No pinned address for ${hostname}`), '', 0)
66
+ return
67
+ }
68
+ const requestedFamily = typeof options === 'number' ? options : (options?.family ?? 0)
69
+ const matching = requestedFamily
70
+ ? entries.filter((entry) => entry.family === requestedFamily)
71
+ : entries
72
+ const selected = matching.length > 0 ? matching : entries
73
+ if (typeof options === 'object' && options?.all) {
74
+ callback(null, selected)
75
+ return
76
+ }
77
+ callback(null, selected[0].address, selected[0].family)
78
+ }
79
+ }
80
+
81
+ function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
82
+ // Explicit finite check + relational clamp rather than `Math.min`: a NaN
83
+ // duration must not reach `setTimeout`, and the upper bound has to be
84
+ // provable at the call site.
85
+ if (!Number.isFinite(ms) || ms <= 0) return Promise.resolve()
86
+ const bounded = ms > MAX_SLEEP_MS ? MAX_SLEEP_MS : ms
87
+ return new Promise((resolve) => {
88
+ const timer = setTimeout(finish, bounded)
89
+ function finish(): void {
90
+ clearTimeout(timer)
91
+ signal?.removeEventListener('abort', finish)
92
+ resolve()
93
+ }
94
+ signal?.addEventListener('abort', finish, { once: true })
95
+ })
96
+ }
97
+
98
+ function decompress(response: IncomingMessage): Readable {
99
+ const encoding = String(response.headers['content-encoding'] ?? '').toLowerCase()
100
+ if (encoding === 'gzip' || encoding === 'x-gzip') return response.pipe(createGunzip())
101
+ if (encoding === 'deflate') return response.pipe(createInflate())
102
+ if (encoding === 'br') return response.pipe(createBrotliDecompress())
103
+ return response
104
+ }
105
+
106
+ /**
107
+ * Reads at most `maxBytes`, decoding incrementally so a cap landing inside a
108
+ * multi-byte sequence does not produce a replacement character at the tail.
109
+ */
110
+ async function readCapped(
111
+ stream: Readable,
112
+ maxBytes: number,
113
+ ): Promise<{ text: string; truncated: boolean; bytes: number }> {
114
+ const decoder = new TextDecoder('utf-8')
115
+ let text = ''
116
+ let bytes = 0
117
+ let truncated = false
118
+ try {
119
+ for await (const chunk of stream) {
120
+ const buffer = chunk as Buffer
121
+ if (bytes >= maxBytes) {
122
+ truncated = true
123
+ break
124
+ }
125
+ const remaining = maxBytes - bytes
126
+ if (buffer.byteLength > remaining) {
127
+ text += decoder.decode(buffer.subarray(0, remaining), { stream: true })
128
+ bytes = maxBytes
129
+ truncated = true
130
+ break
131
+ }
132
+ text += decoder.decode(buffer, { stream: true })
133
+ bytes += buffer.byteLength
134
+ }
135
+ } finally {
136
+ stream.destroy()
137
+ }
138
+ text += decoder.decode()
139
+ return { text, truncated, bytes }
140
+ }
141
+
142
+ type SentRequest = {
143
+ readonly response: IncomingMessage
144
+ readonly dispose: () => void
145
+ /**
146
+ * Why we tore the exchange down, if we did.
147
+ *
148
+ * Destroying a request does not deliver our reason to the response stream —
149
+ * Node surfaces its own socket error there — so a read cut short by our own
150
+ * timeout or abort would otherwise be reported as a generic network fault,
151
+ * and an adapter would call a deadline breach a retriable error.
152
+ */
153
+ readonly terminalError: () => WebResearchError | null
154
+ }
155
+
156
+ type SendOptions = {
157
+ readonly method: string
158
+ readonly headers: Record<string, string>
159
+ readonly body?: string
160
+ readonly timeoutMs: number
161
+ readonly signal?: AbortSignal
162
+ }
163
+
164
+ function send(target: URL, addresses: readonly string[], options: SendOptions): Promise<SentRequest> {
165
+ return new Promise((resolve, reject) => {
166
+ const secure = target.protocol === 'https:'
167
+ const agent = secure
168
+ ? new HttpsAgent({ keepAlive: false, lookup: createPinnedLookup(addresses) })
169
+ : new HttpAgent({ keepAlive: false, lookup: createPinnedLookup(addresses) })
170
+ const transport = secure ? sendHttps : sendHttp
171
+ const request = transport(
172
+ {
173
+ protocol: target.protocol,
174
+ hostname: target.hostname,
175
+ port: target.port || (secure ? 443 : 80),
176
+ path: `${target.pathname}${target.search}`,
177
+ method: options.method,
178
+ headers: options.headers,
179
+ agent,
180
+ },
181
+ (response) => {
182
+ clearTimeout(headerTimer)
183
+ resolve({ response, dispose, terminalError: () => terminal })
184
+ },
185
+ )
186
+
187
+ let terminal: WebResearchError | null = null
188
+ const fail = (error: WebResearchError): void => {
189
+ terminal = error
190
+ request.destroy(error)
191
+ }
192
+
193
+ /**
194
+ * Teardown for the whole exchange, headers *and* body. The abort listener and
195
+ * the socket idle timer deliberately stay armed past the response callback:
196
+ * releasing them there leaves a body read that no signal and no deadline can
197
+ * interrupt, so a server that trickles bytes forever pins a socket and an
198
+ * adapter promise for the life of the process.
199
+ */
200
+ const dispose = (): void => {
201
+ clearTimeout(headerTimer)
202
+ request.setTimeout(0)
203
+ options.signal?.removeEventListener('abort', onAbort)
204
+ request.destroy()
205
+ agent.destroy()
206
+ }
207
+
208
+ const headerTimer = setTimeout(() => {
209
+ fail(new WebResearchError('timeout', `Request to ${target.href} timed out`))
210
+ }, options.timeoutMs)
211
+
212
+ request.setTimeout(options.timeoutMs, () => {
213
+ fail(new WebResearchError('timeout', `Request to ${target.href} stalled`))
214
+ })
215
+
216
+ function onAbort(): void {
217
+ fail(new WebResearchError('aborted', `Request to ${target.href} was aborted`))
218
+ }
219
+ options.signal?.addEventListener('abort', onAbort, { once: true })
220
+
221
+ request.on('error', (error) => {
222
+ dispose()
223
+ reject(
224
+ terminal ??
225
+ (isWebResearchError(error)
226
+ ? error
227
+ : new WebResearchError('network', `Request to ${target.href} failed: ${errorMessage(error)}`, {
228
+ cause: error,
229
+ })),
230
+ )
231
+ })
232
+
233
+ if (options.body !== undefined) request.write(options.body)
234
+ request.end()
235
+ })
236
+ }
237
+
238
+ function headerMap(response: IncomingMessage): Map<string, string> {
239
+ const headers = new Map<string, string>()
240
+ for (const [key, value] of Object.entries(response.headers)) {
241
+ if (value === undefined) continue
242
+ headers.set(key.toLowerCase(), Array.isArray(value) ? value.join(', ') : value)
243
+ }
244
+ return headers
245
+ }
246
+
247
+ function retryAfterMs(headers: Map<string, string>): number | undefined {
248
+ const raw = headers.get('retry-after')
249
+ if (!raw) return undefined
250
+ const seconds = Number.parseInt(raw.trim(), 10)
251
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000
252
+ const date = Date.parse(raw)
253
+ if (Number.isFinite(date)) return Math.max(0, date - Date.now())
254
+ return undefined
255
+ }
256
+
257
+ function contentTypeOf(headers: Map<string, string>): string | null {
258
+ const raw = headers.get('content-type')
259
+ if (!raw) return null
260
+ return raw.split(';')[0].trim().toLowerCase()
261
+ }
262
+
263
+ function isAcceptable(contentType: string | null, accept: readonly string[] | undefined): boolean {
264
+ if (!accept || accept.length === 0) return true
265
+ if (contentType === null) return true
266
+ return accept.some((prefix) => contentType.startsWith(prefix))
267
+ }
268
+
269
+ function statusError(status: number, url: string, headers: Map<string, string>): WebResearchError {
270
+ if (status === 429) {
271
+ return new WebResearchError('blocked', `Rate limited by ${url} (HTTP 429)`, {
272
+ status,
273
+ retryAfterMs: retryAfterMs(headers),
274
+ })
275
+ }
276
+ if (status === 403 || status === 401) {
277
+ return new WebResearchError('blocked', `Access denied by ${url} (HTTP ${status})`, { status })
278
+ }
279
+ return new WebResearchError('bad_response', `Request to ${url} failed with HTTP ${status}`, { status })
280
+ }
281
+
282
+ function isRetriable(error: WebResearchError): boolean {
283
+ if (error.code === 'blocked') return error.status === 429
284
+ if (error.code === 'network') return true
285
+ return error.code === 'bad_response' && (error.status ?? 0) >= 500
286
+ }
287
+
288
+ /**
289
+ * The single hardened egress path: SSRF-vetted, DNS-pinned per redirect hop,
290
+ * manually redirected so every hop is re-vetted, byte-capped, content-type
291
+ * gated, and polite to each host.
292
+ */
293
+ export function createHttpClient(options: HttpClientOptions = {}): HttpClient {
294
+ const userAgent = options.userAgent ?? DEFAULT_USER_AGENT
295
+ const acceptLanguage = options.acceptLanguage ?? 'en-US,en;q=0.9'
296
+ const defaultMaxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES
297
+ const defaultTimeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
298
+ const maxRedirects = options.maxRedirects ?? MAX_REDIRECTS
299
+ const maxRetries = options.maxRetries ?? MAX_RETRIES
300
+ const maxTotalMs = options.maxTotalMs ?? DEFAULT_TOTAL_TIMEOUT_MS
301
+ const politenessMs = options.politenessMs ?? 0
302
+ const sleep = options.sleep ?? defaultSleep
303
+ const now = options.now ?? Date.now
304
+ const nextAllowedAt = new Map<string, number>()
305
+
306
+ async function waitForHost(host: string, signal?: AbortSignal): Promise<void> {
307
+ if (politenessMs <= 0) return
308
+ const earliest = nextAllowedAt.get(host) ?? 0
309
+ const current = now()
310
+ const delay = earliest - current
311
+ nextAllowedAt.set(host, Math.max(current, earliest) + politenessMs)
312
+ if (delay > 0) await sleep(delay, signal)
313
+ }
314
+
315
+ async function attempt(
316
+ target: URL,
317
+ addresses: readonly string[],
318
+ init: HttpRequestInit,
319
+ carryCallerHeaders: boolean,
320
+ ) {
321
+ const headers: Record<string, string> = {
322
+ 'user-agent': userAgent,
323
+ 'accept-language': acceptLanguage,
324
+ 'accept-encoding': 'gzip, deflate, br',
325
+ accept: init.accept?.length ? `${init.accept.join(', ')}, */*;q=0.1` : '*/*',
326
+ ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
327
+ ...(carryCallerHeaders ? (init.headers ?? {}) : {}),
328
+ }
329
+ return send(target, addresses, {
330
+ method: init.method ?? 'GET',
331
+ headers,
332
+ ...(init.body === undefined ? {} : { body: init.body }),
333
+ timeoutMs: init.timeoutMs ?? defaultTimeoutMs,
334
+ ...(init.signal ? { signal: init.signal } : {}),
335
+ })
336
+ }
337
+
338
+ async function request(rawUrl: string, init: HttpRequestInit = {}): Promise<HttpResponse> {
339
+ const maxBytes = init.maxBytes ?? defaultMaxBytes
340
+ const expiresAt = now() + maxTotalMs
341
+ const outOfTime = (): boolean => now() >= expiresAt
342
+ let currentUrl = rawUrl
343
+ // Caller headers carry vendor credentials (`authorization`, an API-key header).
344
+ // A redirect to another origin must not replay them, or a compromised or
345
+ // merely sloppy endpoint can harvest a tenant's key with a single 302.
346
+ let sameOrigin = true
347
+
348
+ for (let hop = 0; hop <= maxRedirects; hop += 1) {
349
+ if (outOfTime()) {
350
+ throw new WebResearchError('timeout', `Request to ${rawUrl} exceeded its ${maxTotalMs}ms budget`)
351
+ }
352
+ const vetted = await assertPublicUrl(currentUrl, 'web research fetch', {
353
+ ...(options.lookup ? { lookup: options.lookup } : {}),
354
+ ...(options.allowPrivateHosts ? { allowPrivateHosts: options.allowPrivateHosts } : {}),
355
+ })
356
+ await waitForHost(vetted.url.host, init.signal)
357
+
358
+ let sent: SentRequest | null = null
359
+ let lastError: WebResearchError | null = null
360
+ for (let retry = 0; retry <= maxRetries; retry += 1) {
361
+ try {
362
+ sent = await attempt(vetted.url, vetted.addresses, init, sameOrigin)
363
+ const status = sent.response.statusCode ?? 0
364
+ if (status >= 400) {
365
+ const error = statusError(status, vetted.url.href, headerMap(sent.response))
366
+ sent.response.resume()
367
+ sent.dispose()
368
+ sent = null
369
+ throw error
370
+ }
371
+ break
372
+ } catch (error) {
373
+ // Branded rather than `instanceof`: this package typechecks as `src`
374
+ // and runs as `dist`, so the two class identities can differ.
375
+ const typed = isWebResearchError(error)
376
+ ? error
377
+ : new WebResearchError('network', errorMessage(error), { cause: error })
378
+ lastError = typed
379
+ if (typed.code === 'aborted' || retry === maxRetries || !isRetriable(typed)) throw typed
380
+ // Retrying past the budget only delays the failure the caller already earned.
381
+ if (outOfTime()) throw typed
382
+ const backoff = Math.min(typed.retryAfterMs ?? 250 * 2 ** retry, MAX_BACKOFF_MS)
383
+ await sleep(backoff, init.signal)
384
+ }
385
+ }
386
+ if (!sent) throw lastError ?? new WebResearchError('network', `Request to ${currentUrl} failed`)
387
+
388
+ const headers = headerMap(sent.response)
389
+ const status = sent.response.statusCode ?? 0
390
+
391
+ if (status >= 300 && status < 400) {
392
+ const location = headers.get('location')
393
+ sent.response.resume()
394
+ sent.dispose()
395
+ if (!location) {
396
+ throw new WebResearchError('bad_response', `Redirect from ${currentUrl} without a Location header`)
397
+ }
398
+ const next = new URL(location, vetted.url)
399
+ if (next.origin !== vetted.url.origin) sameOrigin = false
400
+ currentUrl = next.toString()
401
+ continue
402
+ }
403
+
404
+ const contentType = contentTypeOf(headers)
405
+ if (!isAcceptable(contentType, init.accept)) {
406
+ sent.response.resume()
407
+ sent.dispose()
408
+ throw new WebResearchError(
409
+ 'unsupported_content_type',
410
+ `Refusing to decode ${contentType ?? 'unknown'} from ${vetted.url.href} as text`,
411
+ )
412
+ }
413
+
414
+ try {
415
+ const { text, truncated } = await readCapped(decompress(sent.response), maxBytes)
416
+ return { url: vetted.url.href, status, headers, contentType, body: text, truncated }
417
+ } catch (error) {
418
+ const terminal = sent.terminalError()
419
+ if (terminal) throw terminal
420
+ throw isWebResearchError(error)
421
+ ? error
422
+ : new WebResearchError('network', `Reading ${vetted.url.href} failed: ${errorMessage(error)}`, {
423
+ cause: error,
424
+ })
425
+ } finally {
426
+ sent.dispose()
427
+ }
428
+ }
429
+
430
+ throw new WebResearchError('too_many_redirects', `Exceeded ${maxRedirects} redirects from ${rawUrl}`)
431
+ }
432
+
433
+ return { request }
434
+ }
@@ -0,0 +1,235 @@
1
+ import { lookup as dnsLookup } from 'node:dns/promises'
2
+ import { isIP } from 'node:net'
3
+ import { WebResearchError } from '../contract/errors'
4
+
5
+ export type LookupFn = (hostname: string) => Promise<Array<{ address: string; family: number }>>
6
+
7
+ const defaultLookup: LookupFn = async (hostname) => {
8
+ const records = await dnsLookup(hostname, { all: true })
9
+ return records.map((record) => ({ address: record.address, family: record.family }))
10
+ }
11
+
12
+ function ipv4ToInt(address: string): number | null {
13
+ const parts = address.split('.')
14
+ if (parts.length !== 4) return null
15
+ let value = 0
16
+ for (const part of parts) {
17
+ if (!/^\d{1,3}$/.test(part)) return null
18
+ const octet = Number(part)
19
+ if (octet > 255) return null
20
+ value = value * 256 + octet
21
+ }
22
+ return value >>> 0
23
+ }
24
+
25
+ function isPrivateIpv4(address: string): boolean {
26
+ const value = ipv4ToInt(address)
27
+ if (value === null) return true
28
+ const inRange = (base: string, maskBits: number): boolean => {
29
+ const baseValue = ipv4ToInt(base)
30
+ if (baseValue === null) return false
31
+ const mask = maskBits === 0 ? 0 : (0xffffffff << (32 - maskBits)) >>> 0
32
+ return (value & mask) === (baseValue & mask)
33
+ }
34
+ return (
35
+ inRange('0.0.0.0', 8) ||
36
+ inRange('10.0.0.0', 8) ||
37
+ inRange('100.64.0.0', 10) ||
38
+ inRange('127.0.0.0', 8) ||
39
+ inRange('169.254.0.0', 16) ||
40
+ inRange('172.16.0.0', 12) ||
41
+ inRange('192.0.0.0', 24) ||
42
+ inRange('192.168.0.0', 16) ||
43
+ inRange('198.18.0.0', 15) ||
44
+ inRange('224.0.0.0', 4) ||
45
+ inRange('240.0.0.0', 4)
46
+ )
47
+ }
48
+
49
+ function normalizeIpv6(address: string): string {
50
+ const zoneIndex = address.indexOf('%')
51
+ return (zoneIndex === -1 ? address : address.slice(0, zoneIndex)).toLowerCase()
52
+ }
53
+
54
+ function parseHextets(segment: string): number[] | null {
55
+ if (segment === '') return []
56
+ const parsed: number[] = []
57
+ for (const part of segment.split(':')) {
58
+ if (!/^[0-9a-f]{1,4}$/.test(part)) return null
59
+ parsed.push(Number.parseInt(part, 16))
60
+ }
61
+ return parsed
62
+ }
63
+
64
+ /** Expands any IPv6 spelling (`::`, embedded dotted-quad) to its eight hextets. */
65
+ function expandIpv6(raw: string): number[] | null {
66
+ let address = raw
67
+ const dotted = /:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(address)
68
+ if (dotted) {
69
+ const value = ipv4ToInt(dotted[1])
70
+ if (value === null) return null
71
+ const high = ((value >>> 16) & 0xffff).toString(16)
72
+ const low = (value & 0xffff).toString(16)
73
+ address = `${address.slice(0, dotted.index)}:${high}:${low}`
74
+ }
75
+ const halves = address.split('::')
76
+ if (halves.length > 2) return null
77
+ const head = parseHextets(halves[0] ?? '')
78
+ if (head === null) return null
79
+ if (halves.length === 1) return head.length === 8 ? head : null
80
+ const tail = parseHextets(halves[1] ?? '')
81
+ if (tail === null) return null
82
+ const missing = 8 - head.length - tail.length
83
+ if (missing < 0) return null
84
+ return [...head, ...Array<number>(missing).fill(0), ...tail]
85
+ }
86
+
87
+ function hextetsToIpv4(high: number, low: number): string {
88
+ return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`
89
+ }
90
+
91
+ /**
92
+ * Recovers the IPv4 address tunnelled inside an IPv6 one. This is load-bearing:
93
+ * `new URL('http://[::ffff:127.0.0.1]/').hostname` is `::ffff:7f00:1`, so a guard
94
+ * that only pattern-matches the dotted-quad spelling never fires and loopback —
95
+ * and the cloud metadata endpoint — walk straight through.
96
+ */
97
+ function embeddedIpv4(hextets: readonly number[]): string | null {
98
+ const [first, second, third, fourth, fifth, sixth, seventh, eighth] = hextets
99
+ const zeroPrefix = first === 0 && second === 0 && third === 0 && fourth === 0 && fifth === 0
100
+ if (zeroPrefix && (sixth === 0xffff || sixth === 0)) return hextetsToIpv4(seventh, eighth)
101
+ const nat64 =
102
+ first === 0x0064 && second === 0xff9b && third === 0 && fourth === 0 && fifth === 0 && sixth === 0
103
+ if (nat64) return hextetsToIpv4(seventh, eighth)
104
+ return null
105
+ }
106
+
107
+ function isPrivateIpv6(address: string): boolean {
108
+ const normalized = normalizeIpv6(address)
109
+ if (normalized === '::1' || normalized === '::') return true
110
+ const hextets = expandIpv6(normalized)
111
+ if (hextets === null) return true
112
+ const embedded = embeddedIpv4(hextets)
113
+ if (embedded !== null) return isPrivateIpv4(embedded)
114
+ const first = hextets[0]
115
+ if ((first & 0xfe00) === 0xfc00) return true
116
+ if ((first & 0xffc0) === 0xfe80) return true
117
+ if ((first & 0xff00) === 0xff00) return true
118
+ return false
119
+ }
120
+
121
+ export function isPrivateAddress(address: string): boolean {
122
+ const family = isIP(address)
123
+ if (family === 4) return isPrivateIpv4(address)
124
+ if (family === 6) return isPrivateIpv6(address)
125
+ return true
126
+ }
127
+
128
+ /** `localhost` and anything under it never reach DNS. */
129
+ export function isLoopbackHostname(hostname: string): boolean {
130
+ const host = hostname.toLowerCase()
131
+ return host === 'localhost' || host.endsWith('.localhost')
132
+ }
133
+
134
+ export type AssertPublicUrlOptions = {
135
+ readonly lookup?: LookupFn
136
+ /**
137
+ * Whether an unresolvable host is a hard failure. Defaults to `true` — unlike
138
+ * reference implementations that fail open here, we pin the connection to the
139
+ * addresses this check returns, so no addresses means no pinning, and letting
140
+ * the request proceed would silently drop a control rather than merely fail.
141
+ */
142
+ readonly failClosed?: boolean
143
+ /**
144
+ * Hosts permitted to resolve to a non-public address.
145
+ *
146
+ * An allowlist rather than a blanket "allow private", because the two are not
147
+ * remotely equivalent: a boolean opens every internal address to whatever URL
148
+ * a model or a search result happens to produce, while this only unblocks
149
+ * destinations an operator named. It exists because a service the operator
150
+ * runs themselves — a SearXNG instance on the container network, a self-hosted
151
+ * scraper — is otherwise unreachable by design.
152
+ *
153
+ * Matching is exact host or a dot-boundary suffix, so `internal.example.com`
154
+ * covers `search.internal.example.com` but never `notinternal.example.com`.
155
+ */
156
+ readonly allowPrivateHosts?: readonly string[]
157
+ }
158
+
159
+ /** Exact host or a dot-boundary suffix; never a bare substring. */
160
+ export function isPrivateHostAllowed(hostname: string, allowlist: readonly string[] | undefined): boolean {
161
+ if (!allowlist || allowlist.length === 0) return false
162
+ const host = hostname.toLowerCase()
163
+ return allowlist.some((entry) => {
164
+ const allowed = entry.trim().toLowerCase()
165
+ if (allowed.length === 0) return false
166
+ return host === allowed || host.endsWith(`.${allowed}`)
167
+ })
168
+ }
169
+
170
+ export type VettedUrl = {
171
+ readonly url: URL
172
+ /** Vetted addresses to pin the connection to; empty only when failClosed is off. */
173
+ readonly addresses: readonly string[]
174
+ }
175
+
176
+ export async function assertPublicUrl(
177
+ rawUrl: string,
178
+ label = 'request',
179
+ options: AssertPublicUrlOptions = {},
180
+ ): Promise<VettedUrl> {
181
+ let parsed: URL
182
+ try {
183
+ parsed = new URL(rawUrl)
184
+ } catch {
185
+ throw new WebResearchError('ssrf_blocked', `Invalid URL for ${label}`)
186
+ }
187
+
188
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
189
+ throw new WebResearchError('ssrf_blocked', `Blocked URL scheme for ${label}: ${parsed.protocol}`)
190
+ }
191
+ if (parsed.username || parsed.password) {
192
+ throw new WebResearchError('ssrf_blocked', `Credentials in URL are not allowed for ${label}`)
193
+ }
194
+
195
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, '')
196
+ const privateAllowed = isPrivateHostAllowed(hostname, options.allowPrivateHosts)
197
+
198
+ if (!privateAllowed && isLoopbackHostname(hostname)) {
199
+ throw new WebResearchError('ssrf_blocked', `Blocked loopback host for ${label}: ${hostname}`)
200
+ }
201
+
202
+ if (isIP(hostname)) {
203
+ if (!privateAllowed && isPrivateAddress(hostname)) {
204
+ throw new WebResearchError('ssrf_blocked', `Blocked non-public address for ${label}: ${hostname}`)
205
+ }
206
+ return { url: parsed, addresses: [hostname] }
207
+ }
208
+
209
+ const lookup = options.lookup ?? defaultLookup
210
+ const failClosed = options.failClosed ?? true
211
+ let records: Array<{ address: string; family: number }>
212
+ try {
213
+ records = await lookup(hostname)
214
+ } catch {
215
+ if (failClosed) {
216
+ throw new WebResearchError('ssrf_blocked', `Could not resolve host for ${label}: ${hostname}`)
217
+ }
218
+ return { url: parsed, addresses: [] }
219
+ }
220
+ if (records.length === 0) {
221
+ if (failClosed) {
222
+ throw new WebResearchError('ssrf_blocked', `Host did not resolve for ${label}: ${hostname}`)
223
+ }
224
+ return { url: parsed, addresses: [] }
225
+ }
226
+ for (const { address } of records) {
227
+ if (!privateAllowed && isPrivateAddress(address)) {
228
+ throw new WebResearchError(
229
+ 'ssrf_blocked',
230
+ `Host ${hostname} resolves to non-public address ${address} for ${label}`,
231
+ )
232
+ }
233
+ }
234
+ return { url: parsed, addresses: records.map((record) => record.address) }
235
+ }
package/src/net/url.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Trims every trailing `/` from a base URL.
3
+ *
4
+ * Written as a scan rather than `replace(/\/+$/, '')` on purpose: the regex
5
+ * form backtracks quadratically on a configured base URL that ends in a long
6
+ * run of slashes, which CodeQL flags as a polynomial ReDoS. This walks the
7
+ * string once from the end.
8
+ */
9
+ export function stripTrailingSlashes(value: string): string {
10
+ let end = value.length
11
+ while (end > 0 && value.charCodeAt(end - 1) === 0x2f) end -= 1
12
+ return value.slice(0, end)
13
+ }