-
-
Notifications
You must be signed in to change notification settings - Fork 420
fix(ssrf): pin the socket to the validated IP (HTTP tier, #207 phase 1) #402
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
spartan8806
wants to merge
6
commits into
KnockOutEZ:main
Choose a base branch
from
spartan8806:fix/ssrf-pin-validated-ip
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
10a1622
fix(ssrf): pin the socket to the validated IP (HTTP tier)
spartan8806 e02edf1
docs: document the two functions this diff touches
spartan8806 159ccba
fix(ssrf): fail closed when there is nothing to pin to
spartan8806 f406923
fix(ssrf): scope the pinned dispatcher to a single redirect hop
spartan8806 2850a5d
fix(ssrf): release the response body on paths that abandon it
spartan8806 666906e
test: make the redirect-body test actually test the cancel
spartan8806 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import { Agent, type buildConnector } from 'undici'; | ||
| import { lookup as nodeLookup } from 'node:dns'; | ||
|
|
||
| /** | ||
| * Pin a connection to the addresses SSRF validation already cleared. | ||
| * | ||
| * `guardResolvedHost` resolves a hostname and checks every returned address, but the socket then | ||
| * resolves DNS *again* at connect time. An attacker who controls the authoritative resolver can | ||
| * answer with a public IP for our check and a private one for the connect, inside the TTL window — | ||
| * classic DNS rebinding, and the resolved-IP re-check cannot see it because it has already run. | ||
| * | ||
| * The fix is to stop the second resolution happening at all: hand the socket a `lookup` that | ||
| * returns the exact addresses we validated, so there is no window to race. | ||
| * | ||
| * WHY A LOOKUP HOOK RATHER THAN REWRITING THE URL TO THE IP | ||
| * -------------------------------------------------------- | ||
| * Rewriting `https://example.com/x` to `https://93.184.216.34/x` would also avoid re-resolution, | ||
| * and it would break TLS: the certificate is checked against the name in the URL, so the request | ||
| * would either fail verification or have to disable it. A `lookup` hook changes only which address | ||
| * the socket dials. The SNI name, the Host header and certificate verification all still use the | ||
| * real hostname, so pinning costs nothing in transport security. | ||
| */ | ||
|
|
||
| /** The `dns.lookup` shape `net.connect` accepts. Both callback arities are in play — see below. */ | ||
| export type PinnedLookup = ( | ||
| hostname: string, | ||
| options: { family?: number; all?: boolean; hints?: number }, | ||
| callback: ( | ||
| err: NodeJS.ErrnoException | null, | ||
| address: string | { address: string; family: number }[], | ||
| family?: number, | ||
| ) => void, | ||
| ) => void; | ||
|
|
||
| export interface ValidatedAddress { | ||
| address: string; | ||
| family: number; | ||
| } | ||
|
|
||
| /** | ||
| * Build the lookup hook. Exported separately from the Agent so it can be unit-tested without | ||
| * opening a socket. | ||
| * | ||
| * Two behaviours worth stating, because both are security-relevant: | ||
| * | ||
| * - **A different hostname is never pinned.** If the socket asks for a host we did not validate, | ||
| * we fall through to real DNS rather than handing back this host's addresses. Returning them | ||
| * would send a request for host B to host A's IP, which is a worse bug than the one being | ||
| * fixed. In practice the caller builds a fresh hook per validated host, so this is a guard | ||
| * against future misuse rather than a path we expect to hit. | ||
| * - **An empty address set is never pinned.** Callers must not construct this with `[]`; if they | ||
| * do, we fall back to real DNS instead of failing the connection in a way that looks like a | ||
| * network error. | ||
| */ | ||
| export function createPinnedLookup( | ||
| hostname: string, | ||
| addresses: ValidatedAddress[], | ||
| realLookup: typeof nodeLookup = nodeLookup, | ||
| ): PinnedLookup { | ||
| return (host, options, callback) => { | ||
| if (host !== hostname || addresses.length === 0) { | ||
| // Not ours to pin — defer to the real resolver. | ||
| (realLookup as unknown as PinnedLookup)(host, options, callback); | ||
| return; | ||
| } | ||
|
|
||
| // Honour an explicit family request; `family: 0` / undefined means "either". | ||
| const wanted = options?.family; | ||
| const matching = | ||
| wanted === 4 || wanted === 6 ? addresses.filter((a) => a.family === wanted) : addresses; | ||
|
|
||
| if (matching.length === 0) { | ||
| // We hold addresses, but none in the family the socket asked for. Report it as a resolution | ||
| // failure rather than silently widening to a family the caller rejected. | ||
| const err: NodeJS.ErrnoException = new Error( | ||
| `no validated IPv${wanted} address for ${host}`, | ||
| ); | ||
| err.code = 'ENOTFOUND'; | ||
| callback(err, ''); | ||
| return; | ||
| } | ||
|
|
||
| // `all: true` wants the array form; otherwise the (address, family) arity. | ||
| if (options?.all) { | ||
| callback(null, matching.map((a) => ({ address: a.address, family: a.family }))); | ||
| return; | ||
| } | ||
| callback(null, matching[0].address, matching[0].family); | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * An undici Agent whose sockets dial only the validated addresses. | ||
| * | ||
| * The caller owns the returned Agent and must `close()` (or `destroy()`) it once the response body | ||
| * has been consumed — an Agent holds a connection pool, so one per request that is never closed is | ||
| * a socket leak. | ||
| */ | ||
| export function createPinnedAgent(hostname: string, addresses: ValidatedAddress[]): Agent { | ||
| // `BuildOptions` is a union whose TCP member carries `lookup`; one cast at the boundary keeps | ||
| // that from leaking into the rest of the module, where the hook stays fully typed. | ||
| const connect = { | ||
| lookup: createPinnedLookup(hostname, addresses), | ||
| } as unknown as buildConnector.BuildOptions; | ||
| return new Agent({ connect }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.