Conversation
…M-682)
`assertPublicUrl` resolved a user-supplied hostname, confirmed every answer
was public, then returned the URL and threw the addresses away. `fetch` then
resolved the same name a second time, and nothing tied the two answers
together. A host that replies with a public address to the check and a
private one to the connection walks through — DNS rebinding, which the old
docstring named as a known gap. Every redirect repeated it.
`fetch` cannot express the fix: it takes a URL and does its own resolution.
Send the request with `node:http`/`node:https` instead, which take the
connect address and the TLS identity separately:
- the socket goes to an address the check approved,
- `Host` stays the original host, including a non-default port,
- `servername` stays the hostname, so SNI and certificate verification are
unchanged and pinning costs nothing in TLS terms,
- an IP literal gets no SNI, because the certificate must then carry the
address itself; `rejectUnauthorized` stays on either way.
The second resolution is simply gone, so there is no longer a window to win.
Redirects keep being followed by hand and now re-pin as well as re-check on
each hop. Redirect bodies are cancelled rather than left streaming, and a
30s timeout replaces `fetch`'s absence of one.
`fetchPublicUrl` takes an injectable transport. Two existing tests staged a
public first hop by stubbing `globalThis.fetch`, which this no longer goes
through; the seam lets them drive the real redirect loop instead of a
monkeypatched global.
Not addressed here: the ticket's preferred shape, a server-issued upload id
replacing the arbitrary download URL. That removes the surface rather than
fencing it, but it changes the chat file flow, so it stays open.
Verified: 20 tests in this file, the 15 that existed plus 5 for the pin —
including one that dials a loopback server through a URL naming example.com,
where the handler running at all is what proves the name was never
re-resolved. Researcher suite 49 pass (44 before), build clean.
| : (Readable.toWeb(res) as ReadableStream<Uint8Array>); | ||
|
|
||
| resolve( | ||
| new Response(body, { |
There was a problem hiding this comment.
[bug] new Response(...) runs in the http/https callback with no try/catch. Status must be 200–599 and statusText must be a valid reason-phrase. Node still surfaces the rest: HTTP/1.1 600 and HTTP/1.1 101 throw RangeError, and a reason-phrase with \x01 throws TypeError: Invalid statusText. The throw is not on this promise. Reproduced on Node 26: uncaughtException (default is to exit the process) and the promise never settles — the status line is already parsed, so the 30s socket timeout does not reject. NULL_BODY_STATUS does not help 101; the body is dropped and status: 101 still throws. The previous fetch rejected into processSource. A chat pdf-url can crash the worker or hang ingest.
Suggestion: Construct the Response inside try/catch, res.destroy(), and reject with ChatbotError. Map any status outside 200–599 to that error before calling Response. If statusMessage is not tab/SP/VCHAR/obs-text, pass "".
There was a problem hiding this comment.
Fixed in 03507ed. Confirmed first: new Response() throws RangeError for 101/199/600 and TypeError for a \x01 reason-phrase, and inside the callback that was an uncaughtException with the promise left hanging. Now a status outside 200–599 destroys the response and rejects with ChatbotError, an invalid reason-phrase becomes "", and construction is wrapped. 101/103 are out of NULL_BODY_STATUS since they can no longer reach it. Raw-socket tests send HTTP/1.1 600, a bare 101, and a \x01 phrase. All three fail on the previous commit with your errors and pass now.
| const { url, addresses } = await assertPublicDestination(target); | ||
| const response = await transport( | ||
| url, | ||
| addresses[0], |
There was a problem hiding this comment.
[suggestion] Only addresses[0] is dialed. lookup(..., { all: true }) does not set ADDRCONFIG and defaults to verbatim order. Here example.com resolved AAAA-first, then IPv4. Setting connect host to that one IP is what drops the second lookup, and it also disables autoSelectFamily (on by default), which undici fetch used to fall over to the other family. The app image is node:22-alpine, which often has no IPv6 egress, so a dual-stack PDF URL can fail even though a later A record was already public. Not a rebinding bypass — every entry was checked.
Suggestion: On ENETUNREACH / EHOSTUNREACH / connect timeout, try the next address from this same list. Do not lookup again and do not put the hostname back in host.
There was a problem hiding this comment.
Fixed in 03507ed, your way: each validated address is tried in order, no second lookup, and the hostname never goes back into host. It only falls through on failures before a connection exists (refused, unreachable, or a new 10s connect deadline, since req.setTimeout is idle-only). Once connected, a TLS/HTTP failure is the server's answer and isn't retried against a sibling address.
…ated addresses (WALM-682) Review follow-up on #984. `new Response()` ran inside the http/https callback with no guard. It throws on a status outside 200-599 and on a reason-phrase that is not HTAB/SP/VCHAR/obs-text, and a throw there is an uncaughtException — the process exits by default — while the promise never settles, because the status line is already parsed and the socket timeout no longer applies. A chat pdf-url pointing at a server answering `HTTP/1.1 600` could take the worker down. Out-of-range statuses now reject with ChatbotError and destroy the response, an invalid reason-phrase becomes "", and the construction itself is wrapped. 101/103 leave NULL_BODY_STATUS since they can no longer reach it. Only `addresses[0]` was dialled. Resolvers often put AAAA first, and pinning the connect host also switched off the `autoSelectFamily` fallback that fetch had, so on an image without IPv6 egress every dual-stack URL failed even though a public A record was already validated. Each validated address is now tried in turn, but only past failures that happen before a connection exists — refused, unreachable, or a new 10s connect deadline. Once connected, a TLS or HTTP failure is the server's answer and is not retried. The fallback never leaves the validated list: no second lookup, and the hostname does not go back into `host`. Verified: raw-socket tests for status 600, a bare 101, and a control character in the reason phrase; all three fail on the previous commit with the RangeError/TypeError from the review and pass here. Plus tests for refused-connection classification and the fallback order. File 27 pass; researcher suite and build clean.
ducnmm
left a comment
There was a problem hiding this comment.
Summary
The uncaught Response crash is fixed, and an immediate connect refusal (ECONNREFUSED / ENETUNREACH) now falls through the validated address list. The connect-timeout path does not. On Node 22 the global agent’s 5s socket timer fires while the handshake is still pending, req.setTimeout reports that as a plain ChatbotError, and fetchFirstReachable stops on addresses[0]. The 10s connect deadline never runs. A dual-stack host whose AAAA is dropped rather than refused still fails the whole PDF fetch on the first address.
The pin still holds: no second lookup, and servername / rejectUnauthorized stay on the original hostname.
Issue counts by severity
- bugs: 1
- suggestions: 0
- nits: 0
…(WALM-682) Review round 2 on #984 (Henry). An immediate refusal already fell through the validated address list; a connect that *hangs* did not — which is the case the 10s connect deadline was added for, and what a blackholed AAAA record looks like. Node >=19 creates http(s).globalAgent with `timeout: 5000`, armed on the socket while it is still connecting. It fired first, surfaced as the request's 'timeout', and the handler rejected with a plain ChatbotError, which `fetchFirstReachable` treats as a finished fetch. The connect deadline was cleared at 5s and never ran, and the next address was never dialled. Reproduced on Node 22.23.2 (the image's runtime) exactly as reported: rejected at 5005ms with the second address untried. Two changes: - The agent's timer is disarmed (`socket.setTimeout(0)`) while the socket is connecting; `req.setTimeout` re-arms the idle timeout once connected, so the connect deadline is the one that fires. - The request timeout handler classifies by whether a connection exists: before connect it rejects with PinnedConnectError, so the next address gets its turn whichever timer fires. Same pin as before: no second lookup, and the hostname stays out of `host`. Verified: same scenario on Node 22 with default timeouts now reaches the second address after the 10s deadline. Two new tests against a TEST-NET-1 address that blackholes — fallback with a short deadline, and the agent timer shrunk to 100ms to show it no longer ends the connect as a whole-fetch failure. Both fail on the previous commit. Researcher suite and build clean.
harrymove-ctrl
left a comment
There was a problem hiding this comment.
Approved. Follow-up commits address uncaught exception on out-of-range HTTP status lines and reason phrases when pinning outbound fetch to validated addresses. CI checks pass.
Closes WALM-682. Triage of the 22 Sep static security review (WALM-679); MW-03 confirmed against source — the old docstring named the gap itself.
What was wrong
assertPublicUrlresolved a user-supplied hostname, confirmed every answer was public, then returned the URL and threw the addresses away.fetchresolved the same name a second time, and nothing tied the two answers together. A host that replies with a public address to the check and a private one to the connection walks straight through — DNS rebinding. Every redirect repeated it.Reachable from chat: the schema takes
z.string().url(), and anapplication/pdffile part carries that URL tofetchPublicUrlunchanged.The fix
fetchcannot express this — it takes a URL and does its own resolution. The request now goes out throughnode:http/node:https, which take the connect address and the TLS identity separately:Hoststays the original host, including a non-default port;servernamestays the hostname, so SNI and certificate verification are unchanged — pinning costs nothing in TLS terms;rejectUnauthorizedstays on either way.The second resolution is simply gone, so there is no longer a window to win. Redirects keep being followed by hand and now re-pin as well as re-check on each hop; redirect bodies are cancelled rather than left streaming, and a 30s timeout replaces
fetch's absence of one.fetchPublicUrltakes an injectable transport. Two existing tests staged a public first hop by stubbingglobalThis.fetch, which this no longer goes through; the seam lets them drive the real redirect loop instead of a monkeypatched global.Scope note
The ticket's preferred shape — a server-issued upload id replacing the arbitrary download URL — removes the surface rather than fencing it, but it changes the chat file flow. Not done here.
Worth recording for severity: only the
pdf-urlbranch fetches server-side. The plainurlbranch goes out throughr.jina.ai, so Jina does that fetching, not us, and the response must parse as a PDF before any of it returns as text. Closer to blind SSRF than to clean internal-response exfiltration.Verification
20 tests in this file — the 15 that already existed plus 5 for the pin, including one that dials a loopback server through a URL naming
example.com, where the handler running at all is what proves the name was never re-resolved. Researcher suite 49 pass (44 before), build clean.