@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.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +67 -0
- package/build.mjs +10 -0
- package/dist/contract/adapter.js +13 -0
- package/dist/contract/adapter.js.map +7 -0
- package/dist/contract/errors.js +28 -0
- package/dist/contract/errors.js.map +7 -0
- package/dist/contract/http.js +7 -0
- package/dist/contract/http.js.map +7 -0
- package/dist/contract/index.js +9 -0
- package/dist/contract/index.js.map +7 -0
- package/dist/contract/outcomes.js +58 -0
- package/dist/contract/outcomes.js.map +7 -0
- package/dist/contract/policy.js +91 -0
- package/dist/contract/policy.js.map +7 -0
- package/dist/contract/results.js +13 -0
- package/dist/contract/results.js.map +7 -0
- package/dist/contract/steps.js +1 -0
- package/dist/contract/steps.js.map +7 -0
- package/dist/contract/version.js +11 -0
- package/dist/contract/version.js.map +7 -0
- package/dist/engine/cache.js +29 -0
- package/dist/engine/cache.js.map +7 -0
- package/dist/engine/deadline.js +54 -0
- package/dist/engine/deadline.js.map +7 -0
- package/dist/engine/engine.js +481 -0
- package/dist/engine/engine.js.map +7 -0
- package/dist/engine/index.js +20 -0
- package/dist/engine/index.js.map +7 -0
- package/dist/engine/runAdapter.js +57 -0
- package/dist/engine/runAdapter.js.map +7 -0
- package/dist/engine/types.js +1 -0
- package/dist/engine/types.js.map +7 -0
- package/dist/extract/classify.js +51 -0
- package/dist/extract/classify.js.map +7 -0
- package/dist/extract/content.js +104 -0
- package/dist/extract/content.js.map +7 -0
- package/dist/extract/entities.js +95 -0
- package/dist/extract/entities.js.map +7 -0
- package/dist/extract/index.js +24 -0
- package/dist/extract/index.js.map +7 -0
- package/dist/extract/text.js +114 -0
- package/dist/extract/text.js.map +7 -0
- package/dist/extract/tokenizer.js +141 -0
- package/dist/extract/tokenizer.js.map +7 -0
- package/dist/fusion/canonicalize.js +88 -0
- package/dist/fusion/canonicalize.js.map +7 -0
- package/dist/fusion/fuse.js +88 -0
- package/dist/fusion/fuse.js.map +7 -0
- package/dist/fusion/index.js +11 -0
- package/dist/fusion/index.js.map +7 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +7 -0
- package/dist/loader/describe.js +92 -0
- package/dist/loader/describe.js.map +7 -0
- package/dist/loader/index.js +31 -0
- package/dist/loader/index.js.map +7 -0
- package/dist/loader/manifest.js +32 -0
- package/dist/loader/manifest.js.map +7 -0
- package/dist/loader/registry.js +98 -0
- package/dist/loader/registry.js.map +7 -0
- package/dist/net/client.js +298 -0
- package/dist/net/client.js.map +7 -0
- package/dist/net/ssrf.js +165 -0
- package/dist/net/ssrf.js.map +7 -0
- package/dist/net/url.js +9 -0
- package/dist/net/url.js.map +7 -0
- package/dist/testing/contract.js +118 -0
- package/dist/testing/contract.js.map +7 -0
- package/dist/testing/fakeAdapter.js +44 -0
- package/dist/testing/fakeAdapter.js.map +7 -0
- package/dist/testing/index.js +15 -0
- package/dist/testing/index.js.map +7 -0
- package/jest.config.cjs +24 -0
- package/package.json +65 -0
- package/src/contract/adapter.ts +100 -0
- package/src/contract/errors.ts +48 -0
- package/src/contract/http.ts +46 -0
- package/src/contract/index.ts +8 -0
- package/src/contract/outcomes.ts +91 -0
- package/src/contract/policy.ts +157 -0
- package/src/contract/results.ts +79 -0
- package/src/contract/steps.ts +45 -0
- package/src/contract/version.ts +25 -0
- package/src/engine/__tests__/engine.test.ts +597 -0
- package/src/engine/cache.ts +46 -0
- package/src/engine/deadline.ts +65 -0
- package/src/engine/engine.ts +633 -0
- package/src/engine/index.ts +22 -0
- package/src/engine/runAdapter.ts +89 -0
- package/src/engine/types.ts +137 -0
- package/src/extract/__tests__/content.test.ts +81 -0
- package/src/extract/__tests__/text.test.ts +61 -0
- package/src/extract/__tests__/tokenizer.test.ts +85 -0
- package/src/extract/classify.ts +81 -0
- package/src/extract/content.ts +136 -0
- package/src/extract/entities.ts +98 -0
- package/src/extract/index.ts +11 -0
- package/src/extract/text.ts +124 -0
- package/src/extract/tokenizer.ts +197 -0
- package/src/fusion/__tests__/fusion.test.ts +198 -0
- package/src/fusion/canonicalize.ts +119 -0
- package/src/fusion/fuse.ts +145 -0
- package/src/fusion/index.ts +8 -0
- package/src/index.ts +15 -0
- package/src/loader/__tests__/describe.test.ts +119 -0
- package/src/loader/__tests__/registry.test.ts +134 -0
- package/src/loader/describe.ts +145 -0
- package/src/loader/index.ts +25 -0
- package/src/loader/manifest.ts +44 -0
- package/src/loader/registry.ts +156 -0
- package/src/net/__tests__/client.test.ts +273 -0
- package/src/net/__tests__/ssrf.test.ts +165 -0
- package/src/net/client.ts +434 -0
- package/src/net/ssrf.ts +235 -0
- package/src/net/url.ts +13 -0
- package/src/testing/contract.ts +172 -0
- package/src/testing/fakeAdapter.ts +69 -0
- package/src/testing/index.ts +9 -0
- package/tsconfig.json +10 -0
- 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
|
+
}
|
package/src/net/ssrf.ts
ADDED
|
@@ -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
|
+
}
|