Commit 4034380
authored
* fix(security): reject loopback for BYOK asset download URLs (#5478)
The assertExternalAssetUrl guard previously routed through
validateBaseUrlResolved without forbidLoopback, so asset URLs pointing
at 127.0.0.1, localhost, or ::1 passed validation and were fetched by
the daemon. This allowed a compromised or misconfigured upstream gateway
to exfiltrate data from internal services via SSRF.
Changes:
- Add forbidLoopback option to ValidateBaseUrlOptions (packages/contracts)
- validateBaseUrl rejects loopback hosts when forbidLoopback is set
- validateBaseUrlResolved skips the loopback early-return when forbidLoopback
- validateBaseUrlResolved: when forbidLoopback is true, DNS-resolved
loopback addresses are also rejected (not just literal loopback hosts)
- assertExternalAssetUrl now passes forbidLoopback: true
- User-configured provider endpoints (validateUserProviderBaseUrl) are
unaffected — they still allow loopback for local gateways
- Adds regression tests covering literal loopback rejection, DNS-resolved
loopback rejection (IPv4, IPv6, mixed results), and provider endpoint
exemption
Note: PR #5529 fixed the library-ingest SSRF path. This PR closes the
separate BYOK asset-download path (assertExternalAssetUrl) that retains
the loopback carve-out.
Closes #5478
* fix(security): pin DNS resolution to prevent rebinding in asset fetch (#5478)
The validation step (validateBaseUrlResolved) and the fetch step
(assertAndFetchExternalAsset) previously performed independent DNS
lookups. A DNS-rebinding domain could return a public IP for validation
and then 127.0.0.1 / ::1 at fetch time, bypassing the loopback guard.
Fix:
- validateBaseUrlResolved now attaches the DNS-resolved addresses that
passed validation to its return value (BaseUrlValidationResult.resolvedAddresses)
- assertExternalAssetUrl passes these addresses through on the ok branch
- assertAndFetchExternalAsset creates an Undici Agent with a custom
connect.lookup that returns only the validated addresses, pinning the
TCP connection regardless of any DNS rebind between validation and fetch
- 3 call sites in byok-tools.ts that used validate-then-fetch separately
now route through assertAndFetchExternalAsset for a single unified path
- 4 regression tests covering: single validated address, DNS-rebind mock
(public→loopback), round-robin DNS, and IP-literal short-circuit
* fix(security): fail-closed on DNS error + connection-time validating lookup (#5478)
Addresses mrcfps third review: the fallback to unpinned fetch when
validation did not attach resolved addresses left a DNS-rebinding hole
where an attacker could make the validation lookup throw (ENOTFOUND /
SERVFAIL) and then answer loopback for the fetch lookup.
Changes:
1. validateBaseUrlResolved: when forbidLoopback is true, DNS lookup
failures now return a forbidden result instead of the sync success.
This prevents the fail-then-rebind vector.
2. assertAndFetchExternalAsset: removed the unpinned-fetch fallback.
Non-IP-literal hostnames without resolvedAddresses now throw — the
fetch never happens. IP literals are safe because they were validated
synchronously and have no hostname to rebind.
3. Replaced the per-request pinned Agent with a long-lived Undici Agent
(assetDispatcher) whose connection-time lookup (createAssetValidatingLookup)
rejects any non-public address. This is defense-in-depth on top of the
pre-validation, following the same pattern as brands/safe-fetch.ts and
plugins/plugin-asset-cache.ts. A shared dispatcher also avoids the
keep-alive socket leak of per-request Agents.
4. createAssetValidatingLookup is exported for unit testing.
5. Expanded test coverage:
- DNS failure → fail-closed (forbidLoopback true and false)
- createAssetValidatingLookup: rejects loopback, RFC1918, metadata IP;
allows public
- assertAndFetchExternalAsset: throws on blocked URLs and internal IPs
- Updated existing public-CDN tests to use IP literals (fail-closed
behavior now correctly rejects hostnames when DNS is unavailable)
* fix(security): injectable lookup + testable fetch for asset SSRF guard (#5478)
Addresses mrcfps 4th review: daemon CI was red because fail-closed
behavior caused hostname-based test fixtures to NXDOMAIN, and
undiciFetch bypassed vi.stubGlobal('fetch') stubs.
Changes:
1. assertExternalAssetUrl and assertAndFetchExternalAsset now accept
an optional injectable `lookup` (DnsLookupFn) and `fetchImpl`
parameter. Production callers pass neither, so default DNS and
globalThis.fetch are used. Tests can inject mock lookups/fetch.
2. assertAndFetchExternalAsset uses globalThis.fetch (not undiciFetch)
so vi.stubGlobal('fetch', ...) stubs in existing test suites
(byok-tools, aihubmix, proxy-routes, senseaudio) still intercept.
3. Existing test fixtures: replaced cdn.example.test with 93.184.216.34
(public IP literal) in 6 test files (41 substitutions) so IP
literals skip DNS resolution and don't trigger fail-closed on
NXDOMAIN .test hostnames.
4. Added 3 TOCTOU regression tests driving assertAndFetchExternalAsset
with injected lookup + fetch:
- Validation lookup public → fetch called with redirect:error
- Validation lookup throws → assertAndFetchExternalAsset rejects
without invoking fetch (fail-then-rebind vector closed)
- IP literal → skips DNS, calls fetch directly
5. assetDispatcher + createAssetValidatingLookup kept for production
connection-time defense-in-depth (exported, available for future
wiring to a production fetch dispatcher).
Total: 31 tests in asset-ssrf-loopback.test.ts, all passing.
* fix(security): attach validating dispatcher to asset fetch (#5478)
mrcfps 5th review: assetDispatcher was built but never attached to the
fetch call. The validating lookup only runs at connect time through the
dispatcher, so without it the fetch re-resolves the attacker-controlled
hostname and a public-then-loopback rebind still reaches the daemon.
Fix: attach assetDispatcher to the RequestInit via the dispatcher key
(same pattern as plugins/plugin-asset-cache.ts safeExternalFetch).
Production globalThis.fetch (Node/undici) uses it to refuse connect-time
non-public addresses. Test stubs still see redirect:'error' and can
ignore dispatcher.
Also fixed the misnamed TOCTOU test to assert dispatcher is present on
the captured RequestInit, and removed unused undiciFetch import.
* fix(tests): update dispatcher assertions for assertAndFetchExternalAsset
assertAndFetchExternalAsset now attaches a validating assetDispatcher to
the fetch RequestInit (issue #5478). Tests that blanket-checked
init?.dispatcher === testMock fail because asset-download calls correctly
carry the validating dispatcher instead.
Fix: conditional assertion — when init.redirect === 'error' (asset download
path), check dispatcher exists rather than matching the test mock. API
calls still verify the caller's dispatcher is forwarded unchanged.
* fix(tests): URL-keyed dispatcher assertions + document asset dispatcher override
Address mrcfps's non-blocking review items:
1. Replace the redirect==='error' heuristic with URL-keyed assertions.
The heuristic silently dropped the submit dispatcher identity check
on the AIHubMix video path (its submit hop also sets redirect:'error').
Submit/poll hops now assert toBe(callerDispatcher); the asset hop
asserts toBe(getAssetValidatingDispatcher()) — exported from
connectionTest.ts so the exact validating instance is compared.
2. Document the intentional dispatcher override on the
BYOKToolContext.requestInit JSDoc: asset downloads through
assertAndFetchExternalAsset intentionally never ride the turn proxy
dispatcher; submit/poll hops keep it.
---------
Co-authored-by: CVE-Hunter-Leo <cve-hunter-leo@users.noreply.github.com>
1 parent 9475b98 commit 4034380
10 files changed
Lines changed: 685 additions & 77 deletions
File tree
- apps/daemon
- src
- tests
- media
- packages/contracts/src/api
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
18 | | - | |
| 18 | + | |
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
| |||
447 | 447 | | |
448 | 448 | | |
449 | 449 | | |
450 | | - | |
451 | | - | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
452 | 459 | | |
453 | 460 | | |
454 | 461 | | |
| |||
693 | 700 | | |
694 | 701 | | |
695 | 702 | | |
696 | | - | |
697 | | - | |
698 | | - | |
699 | 703 | | |
700 | 704 | | |
701 | | - | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
702 | 708 | | |
703 | 709 | | |
704 | 710 | | |
| |||
923 | 929 | | |
924 | 930 | | |
925 | 931 | | |
926 | | - | |
927 | | - | |
928 | | - | |
929 | | - | |
930 | | - | |
| 932 | + | |
| 933 | + | |
931 | 934 | | |
932 | 935 | | |
933 | 936 | | |
934 | | - | |
| 937 | + | |
935 | 938 | | |
936 | 939 | | |
937 | 940 | | |
| |||
1314 | 1317 | | |
1315 | 1318 | | |
1316 | 1319 | | |
1317 | | - | |
1318 | | - | |
1319 | 1320 | | |
1320 | | - | |
| 1321 | + | |
| 1322 | + | |
1321 | 1323 | | |
1322 | 1324 | | |
1323 | 1325 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
20 | | - | |
| 20 | + | |
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
| |||
143 | 143 | | |
144 | 144 | | |
145 | 145 | | |
146 | | - | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
147 | 149 | | |
148 | 150 | | |
149 | 151 | | |
| |||
155 | 157 | | |
156 | 158 | | |
157 | 159 | | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
158 | 168 | | |
159 | 169 | | |
160 | 170 | | |
161 | 171 | | |
162 | 172 | | |
163 | | - | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
164 | 185 | | |
165 | 186 | | |
166 | 187 | | |
| |||
170 | 191 | | |
171 | 192 | | |
172 | 193 | | |
173 | | - | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
174 | 199 | | |
175 | 200 | | |
176 | 201 | | |
| |||
211 | 236 | | |
212 | 237 | | |
213 | 238 | | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
214 | 243 | | |
215 | 244 | | |
216 | 245 | | |
217 | | - | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
218 | 251 | | |
219 | 252 | | |
220 | 253 | | |
221 | | - | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
222 | 260 | | |
223 | 261 | | |
224 | 262 | | |
| |||
227 | 265 | | |
228 | 266 | | |
229 | 267 | | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
230 | 273 | | |
231 | 274 | | |
232 | 275 | | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
233 | 337 | | |
234 | 338 | | |
235 | | - | |
236 | | - | |
237 | | - | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
238 | 351 | | |
239 | | - | |
240 | | - | |
241 | | - | |
242 | | - | |
243 | | - | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
244 | 355 | | |
245 | 356 | | |
246 | 357 | | |
247 | 358 | | |
| 359 | + | |
| 360 | + | |
248 | 361 | | |
249 | | - | |
| 362 | + | |
250 | 363 | | |
251 | | - | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
252 | 393 | | |
253 | 394 | | |
254 | 395 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
103 | | - | |
| 103 | + | |
104 | 104 | | |
105 | 105 | | |
106 | 106 | | |
107 | 107 | | |
108 | | - | |
| 108 | + | |
109 | 109 | | |
110 | 110 | | |
111 | 111 | | |
| |||
128 | 128 | | |
129 | 129 | | |
130 | 130 | | |
131 | | - | |
| 131 | + | |
132 | 132 | | |
133 | 133 | | |
134 | 134 | | |
135 | | - | |
| 135 | + | |
136 | 136 | | |
137 | 137 | | |
138 | 138 | | |
| |||
0 commit comments