@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,273 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
|
2
|
+
import type { AddressInfo } from 'node:net'
|
|
3
|
+
import { isWebResearchError } from '../../contract/errors'
|
|
4
|
+
import { createHttpClient } from '../client'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Real sockets, not a stubbed transport.
|
|
8
|
+
*
|
|
9
|
+
* The behaviour worth pinning here lives below `fetch`: redirect re-vetting,
|
|
10
|
+
* what happens to a caller's credentials on a hop, and whether a stalled body
|
|
11
|
+
* read can ever be interrupted. A double of the transport would assert the
|
|
12
|
+
* double. The SSRF guard blocks loopback by design, so these tests use the
|
|
13
|
+
* `allowPrivateHosts` allowlist to name the one host they serve from.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
type Handler = (req: IncomingMessage, res: ServerResponse) => void
|
|
17
|
+
|
|
18
|
+
const LOOPBACK = ['127.0.0.1']
|
|
19
|
+
|
|
20
|
+
function listen(handler: Handler): Promise<{ server: Server; origin: string; port: number }> {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const server = createServer(handler)
|
|
23
|
+
server.listen(0, '127.0.0.1', () => {
|
|
24
|
+
const { port } = server.address() as AddressInfo
|
|
25
|
+
resolve({ server, origin: `http://127.0.0.1:${port}`, port })
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function close(server: Server): Promise<void> {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
server.closeAllConnections?.()
|
|
33
|
+
server.close(() => resolve())
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const client = (overrides = {}) => createHttpClient({ allowPrivateHosts: LOOPBACK, ...overrides })
|
|
38
|
+
|
|
39
|
+
describe('http client against a real server', () => {
|
|
40
|
+
const servers: Server[] = []
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
await Promise.all(servers.splice(0).map(close))
|
|
43
|
+
})
|
|
44
|
+
const track = <T extends { server: Server }>(started: T): T => {
|
|
45
|
+
servers.push(started.server)
|
|
46
|
+
return started
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
it('refuses a private host that is not on the allowlist', async () => {
|
|
50
|
+
const { origin } = track(await listen((_req, res) => res.end('ok')))
|
|
51
|
+
|
|
52
|
+
await expect(createHttpClient().request(origin)).rejects.toMatchObject({ code: 'ssrf_blocked' })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('reads a body and reports the final url', async () => {
|
|
56
|
+
const { origin } = track(
|
|
57
|
+
await listen((_req, res) => {
|
|
58
|
+
res.writeHead(200, { 'content-type': 'text/html' })
|
|
59
|
+
res.end('<html><body>hello</body></html>')
|
|
60
|
+
}),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const response = await client().request(`${origin}/page`)
|
|
64
|
+
|
|
65
|
+
expect(response.status).toBe(200)
|
|
66
|
+
expect(response.body).toContain('hello')
|
|
67
|
+
expect(response.url).toBe(`${origin}/page`)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('keeps caller headers on a same-origin redirect', async () => {
|
|
71
|
+
const seen: Array<string | undefined> = []
|
|
72
|
+
const { origin } = track(
|
|
73
|
+
await listen((req, res) => {
|
|
74
|
+
seen.push(req.headers.authorization)
|
|
75
|
+
if (req.url === '/start') {
|
|
76
|
+
res.writeHead(302, { location: '/end' })
|
|
77
|
+
res.end()
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
res.writeHead(200, { 'content-type': 'text/plain' })
|
|
81
|
+
res.end('done')
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
await client().request(`${origin}/start`, { headers: { authorization: 'Bearer secret' } })
|
|
86
|
+
|
|
87
|
+
expect(seen).toEqual(['Bearer secret', 'Bearer secret'])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('drops caller headers when a redirect changes origin', async () => {
|
|
91
|
+
let received: string | undefined = 'not-called'
|
|
92
|
+
const target = track(
|
|
93
|
+
await listen((req, res) => {
|
|
94
|
+
received = req.headers.authorization
|
|
95
|
+
res.writeHead(200, { 'content-type': 'text/plain' })
|
|
96
|
+
res.end('done')
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
const { origin } = track(
|
|
100
|
+
await listen((_req, res) => {
|
|
101
|
+
// A different port is a different origin, which is all the rule keys on.
|
|
102
|
+
res.writeHead(302, { location: `${target.origin}/landed` })
|
|
103
|
+
res.end()
|
|
104
|
+
}),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const response = await client().request(`${origin}/start`, {
|
|
108
|
+
headers: { authorization: 'Bearer secret' },
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// The whole point: a vendor key must not be replayed to wherever a 302 points.
|
|
112
|
+
expect(received).toBeUndefined()
|
|
113
|
+
expect(response.body).toBe('done')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('re-vets every redirect hop against the SSRF guard', async () => {
|
|
117
|
+
const { origin } = track(
|
|
118
|
+
await listen((_req, res) => {
|
|
119
|
+
res.writeHead(302, { location: 'http://169.254.169.254/latest/meta-data/' })
|
|
120
|
+
res.end()
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
// The allowlist named 127.0.0.1 only, so the metadata endpoint is still refused.
|
|
125
|
+
await expect(client().request(`${origin}/start`)).rejects.toMatchObject({ code: 'ssrf_blocked' })
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('stops after the redirect limit instead of looping', async () => {
|
|
129
|
+
const { origin } = track(
|
|
130
|
+
await listen((_req, res) => {
|
|
131
|
+
res.writeHead(302, { location: '/again' })
|
|
132
|
+
res.end()
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
await expect(client({ maxRedirects: 2 }).request(`${origin}/start`)).rejects.toMatchObject({
|
|
137
|
+
code: 'too_many_redirects',
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('caps the body and reports truncation', async () => {
|
|
142
|
+
const { origin } = track(
|
|
143
|
+
await listen((_req, res) => {
|
|
144
|
+
res.writeHead(200, { 'content-type': 'text/plain' })
|
|
145
|
+
res.end('x'.repeat(100_000))
|
|
146
|
+
}),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const response = await client().request(origin, { maxBytes: 1024 })
|
|
150
|
+
|
|
151
|
+
expect(response.truncated).toBe(true)
|
|
152
|
+
expect(response.body.length).toBeLessThanOrEqual(1024)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('refuses to decode a content type the caller did not accept', async () => {
|
|
156
|
+
const { origin } = track(
|
|
157
|
+
await listen((_req, res) => {
|
|
158
|
+
res.writeHead(200, { 'content-type': 'image/png' })
|
|
159
|
+
res.end('binary')
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
await expect(client().request(origin, { accept: ['text/html'] })).rejects.toMatchObject({
|
|
164
|
+
code: 'unsupported_content_type',
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('reports a 429 as blocked and carries retry-after', async () => {
|
|
169
|
+
const { origin } = track(
|
|
170
|
+
await listen((_req, res) => {
|
|
171
|
+
res.writeHead(429, { 'retry-after': '7' })
|
|
172
|
+
res.end('slow down')
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
const error = await client({ maxRetries: 0 }).request(origin).catch((caught: unknown) => caught)
|
|
177
|
+
|
|
178
|
+
expect(isWebResearchError(error) && error.code).toBe('blocked')
|
|
179
|
+
expect(isWebResearchError(error) && error.retryAfterMs).toBe(7000)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('retries a 5xx and succeeds on a later attempt', async () => {
|
|
183
|
+
let attempts = 0
|
|
184
|
+
const { origin } = track(
|
|
185
|
+
await listen((_req, res) => {
|
|
186
|
+
attempts += 1
|
|
187
|
+
if (attempts < 3) {
|
|
188
|
+
res.writeHead(503)
|
|
189
|
+
res.end('nope')
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
res.writeHead(200, { 'content-type': 'text/plain' })
|
|
193
|
+
res.end('recovered')
|
|
194
|
+
}),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
const response = await client({ sleep: async () => {} }).request(origin)
|
|
198
|
+
|
|
199
|
+
expect(attempts).toBe(3)
|
|
200
|
+
expect(response.body).toBe('recovered')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('does not retry a 404', async () => {
|
|
204
|
+
let attempts = 0
|
|
205
|
+
const { origin } = track(
|
|
206
|
+
await listen((_req, res) => {
|
|
207
|
+
attempts += 1
|
|
208
|
+
res.writeHead(404)
|
|
209
|
+
res.end('gone')
|
|
210
|
+
}),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
await expect(client({ sleep: async () => {} }).request(origin)).rejects.toMatchObject({
|
|
214
|
+
code: 'bad_response',
|
|
215
|
+
})
|
|
216
|
+
expect(attempts).toBe(1)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('gives up once one call has used its whole budget', async () => {
|
|
220
|
+
let hops = 0
|
|
221
|
+
const { origin } = track(
|
|
222
|
+
await listen((_req, res) => {
|
|
223
|
+
hops += 1
|
|
224
|
+
res.writeHead(302, { location: '/again' })
|
|
225
|
+
res.end()
|
|
226
|
+
}),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
// Each hop is fast, but the budget is what bounds the call — a chain of
|
|
230
|
+
// redirects and retries could otherwise run for minutes inside an adapter
|
|
231
|
+
// budget of seconds.
|
|
232
|
+
let clock = 0
|
|
233
|
+
await expect(
|
|
234
|
+
client({ maxRedirects: 50, maxTotalMs: 100, now: () => (clock += 60) }).request(`${origin}/start`),
|
|
235
|
+
).rejects.toMatchObject({ code: 'timeout' })
|
|
236
|
+
expect(hops).toBeLessThan(50)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('times out a body that never finishes', async () => {
|
|
240
|
+
const { origin } = track(
|
|
241
|
+
await listen((_req, res) => {
|
|
242
|
+
// Headers land, then the server stalls forever. Before the read timeout
|
|
243
|
+
// existed this pinned a socket and an adapter promise for the process
|
|
244
|
+
// lifetime: the engine walked away on its deadline and cancelled nothing.
|
|
245
|
+
res.writeHead(200, { 'content-type': 'text/plain' })
|
|
246
|
+
res.write('partial')
|
|
247
|
+
}),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const error = await client({ timeoutMs: 300 }).request(origin).catch((caught: unknown) => caught)
|
|
251
|
+
|
|
252
|
+
expect(isWebResearchError(error) && error.code).toBe('timeout')
|
|
253
|
+
}, 10_000)
|
|
254
|
+
|
|
255
|
+
it('aborts a body read that is already in flight', async () => {
|
|
256
|
+
const controller = new AbortController()
|
|
257
|
+
const { origin } = track(
|
|
258
|
+
await listen((_req, res) => {
|
|
259
|
+
res.writeHead(200, { 'content-type': 'text/plain' })
|
|
260
|
+
res.write('partial')
|
|
261
|
+
setTimeout(() => controller.abort(), 50).unref?.()
|
|
262
|
+
}),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
const error = await client({ timeoutMs: 30_000 })
|
|
266
|
+
.request(origin, { signal: controller.signal })
|
|
267
|
+
.catch((caught: unknown) => caught)
|
|
268
|
+
|
|
269
|
+
// The abort listener used to be removed once headers arrived, which left the
|
|
270
|
+
// read uninterruptible by any signal or deadline.
|
|
271
|
+
expect(isWebResearchError(error) && error.code).toBe('aborted')
|
|
272
|
+
}, 10_000)
|
|
273
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { assertPublicUrl, isLoopbackHostname, isPrivateAddress, type LookupFn } from '../ssrf'
|
|
2
|
+
import { isWebResearchError } from '../../contract/errors'
|
|
3
|
+
|
|
4
|
+
const publicLookup: LookupFn = async () => [{ address: '93.184.216.34', family: 4 }]
|
|
5
|
+
const privateLookup: LookupFn = async () => [{ address: '10.0.0.5', family: 4 }]
|
|
6
|
+
const failingLookup: LookupFn = async () => {
|
|
7
|
+
throw new Error('ENOTFOUND')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('isPrivateAddress', () => {
|
|
11
|
+
it.each([
|
|
12
|
+
'127.0.0.1',
|
|
13
|
+
'10.1.2.3',
|
|
14
|
+
'172.16.0.1',
|
|
15
|
+
'192.168.1.1',
|
|
16
|
+
'169.254.169.254',
|
|
17
|
+
'100.64.0.1',
|
|
18
|
+
'0.0.0.0',
|
|
19
|
+
'224.0.0.1',
|
|
20
|
+
'240.0.0.1',
|
|
21
|
+
'198.18.0.1',
|
|
22
|
+
])('blocks IPv4 %s', (address) => {
|
|
23
|
+
expect(isPrivateAddress(address)).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it.each(['93.184.216.34', '8.8.8.8', '1.1.1.1'])('allows public IPv4 %s', (address) => {
|
|
27
|
+
expect(isPrivateAddress(address)).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it.each(['::1', '::', 'fc00::1', 'fd12:3456::1', 'fe80::1', 'ff02::1'])(
|
|
31
|
+
'blocks IPv6 %s',
|
|
32
|
+
(address) => {
|
|
33
|
+
expect(isPrivateAddress(address)).toBe(true)
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
it('allows a public IPv6 address', () => {
|
|
38
|
+
expect(isPrivateAddress('2606:2800:220:1:248:1893:25c8:1946')).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Regression: `new URL('http://[::ffff:127.0.0.1]/').hostname` is '::ffff:7f00:1',
|
|
42
|
+
// so a guard matching only the dotted-quad spelling lets loopback through.
|
|
43
|
+
it.each(['::ffff:7f00:1', '::ffff:127.0.0.1', '::ffff:a9fe:a9fe', '::ffff:169.254.169.254'])(
|
|
44
|
+
'blocks IPv4-mapped IPv6 %s in either spelling',
|
|
45
|
+
(address) => {
|
|
46
|
+
expect(isPrivateAddress(address)).toBe(true)
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
it('blocks NAT64-embedded private IPv4', () => {
|
|
51
|
+
expect(isPrivateAddress('64:ff9b::a00:1')).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('treats an unparseable address as unsafe', () => {
|
|
55
|
+
expect(isPrivateAddress('not-an-ip')).toBe(true)
|
|
56
|
+
expect(isPrivateAddress('12345::zz')).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('isLoopbackHostname', () => {
|
|
61
|
+
it.each(['localhost', 'LOCALHOST', 'api.localhost'])('matches %s', (host) => {
|
|
62
|
+
expect(isLoopbackHostname(host)).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('does not match a lookalike', () => {
|
|
66
|
+
expect(isLoopbackHostname('notlocalhost.com')).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('assertPublicUrl', () => {
|
|
71
|
+
it('returns the vetted addresses so the caller can pin them', async () => {
|
|
72
|
+
const vetted = await assertPublicUrl('https://example.com/a', 'test', { lookup: publicLookup })
|
|
73
|
+
expect(vetted.addresses).toEqual(['93.184.216.34'])
|
|
74
|
+
expect(vetted.url.href).toBe('https://example.com/a')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it.each(['file:///etc/passwd', 'javascript:alert(1)', 'ftp://example.com'])(
|
|
78
|
+
'rejects scheme in %s',
|
|
79
|
+
async (url) => {
|
|
80
|
+
await expect(assertPublicUrl(url, 'test', { lookup: publicLookup })).rejects.toThrow()
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
it('rejects credentials in the URL', async () => {
|
|
85
|
+
await expect(
|
|
86
|
+
assertPublicUrl('https://user:pass@example.com/', 'test', { lookup: publicLookup }),
|
|
87
|
+
).rejects.toThrow(/Credentials/)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('rejects a loopback hostname before consulting DNS', async () => {
|
|
91
|
+
const lookup = jest.fn<ReturnType<LookupFn>, [string]>()
|
|
92
|
+
await expect(assertPublicUrl('http://localhost:3000/', 'test', { lookup })).rejects.toThrow()
|
|
93
|
+
expect(lookup).not.toHaveBeenCalled()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('rejects a host resolving to a private address', async () => {
|
|
97
|
+
await expect(
|
|
98
|
+
assertPublicUrl('https://internal.example.com/', 'test', { lookup: privateLookup }),
|
|
99
|
+
).rejects.toThrow(/non-public address/)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('rejects a bracketed IPv4-mapped loopback literal', async () => {
|
|
103
|
+
await expect(
|
|
104
|
+
assertPublicUrl('http://[::ffff:127.0.0.1]/', 'test', { lookup: publicLookup }),
|
|
105
|
+
).rejects.toThrow(/non-public address/)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('fails closed on DNS failure by default, because it cannot pin without addresses', async () => {
|
|
109
|
+
await expect(
|
|
110
|
+
assertPublicUrl('https://example.com/', 'test', { lookup: failingLookup }),
|
|
111
|
+
).rejects.toThrow(/Could not resolve/)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('can fail open for callers that do not pin', async () => {
|
|
115
|
+
const vetted = await assertPublicUrl('https://example.com/', 'test', {
|
|
116
|
+
lookup: failingLookup,
|
|
117
|
+
failClosed: false,
|
|
118
|
+
})
|
|
119
|
+
expect(vetted.addresses).toEqual([])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('throws a branded error that survives realm boundaries', async () => {
|
|
123
|
+
const error = await assertPublicUrl('http://127.0.0.1/', 'test').catch((caught: unknown) => caught)
|
|
124
|
+
expect(isWebResearchError(error)).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('allowPrivateHosts', () => {
|
|
128
|
+
it('lets a named host resolve privately', async () => {
|
|
129
|
+
const vetted = await assertPublicUrl('http://searxng:8080/search', 'test', {
|
|
130
|
+
lookup: async () => [{ address: '10.1.2.3', family: 4 }],
|
|
131
|
+
allowPrivateHosts: ['searxng'],
|
|
132
|
+
})
|
|
133
|
+
expect(vetted.addresses).toEqual(['10.1.2.3'])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('matches on a dot boundary, never a bare suffix', async () => {
|
|
137
|
+
const options = {
|
|
138
|
+
lookup: async () => [{ address: '10.1.2.3', family: 4 }],
|
|
139
|
+
allowPrivateHosts: ['internal.example.com'],
|
|
140
|
+
}
|
|
141
|
+
await expect(
|
|
142
|
+
assertPublicUrl('http://search.internal.example.com/', 'test', options),
|
|
143
|
+
).resolves.toBeDefined()
|
|
144
|
+
await expect(
|
|
145
|
+
assertPublicUrl('http://notinternal.example.com/', 'test', options),
|
|
146
|
+
).rejects.toMatchObject({ code: 'ssrf_blocked' })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('still refuses a host nobody named', async () => {
|
|
150
|
+
await expect(
|
|
151
|
+
assertPublicUrl('http://169.254.169.254/latest/meta-data/', 'test', {
|
|
152
|
+
allowPrivateHosts: ['searxng'],
|
|
153
|
+
}),
|
|
154
|
+
).rejects.toMatchObject({ code: 'ssrf_blocked' })
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('unblocks loopback only for the named host', async () => {
|
|
158
|
+
await expect(assertPublicUrl('http://127.0.0.1/', 'test', { allowPrivateHosts: ['127.0.0.1'] }))
|
|
159
|
+
.resolves.toBeDefined()
|
|
160
|
+
await expect(
|
|
161
|
+
assertPublicUrl('http://localhost/', 'test', { allowPrivateHosts: ['127.0.0.1'] }),
|
|
162
|
+
).rejects.toMatchObject({ code: 'ssrf_blocked' })
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
})
|