Skip to content

Commit 7bda201

Browse files
committed
fix(dev): answer the proxy guard's DNS lookup in the shape the connector asked for
The SSRF guard behind the dev server's `__geolibre_*_proxy` endpoints pins each connection to a pre-validated address through a custom undici `connect.lookup`. It queried DNS with `all: true` (correct: every candidate must be checked) but always replied with the three-argument `(err, address, family)` form. Node's `net.Socket` enables `autoSelectFamily` by default on Node 20+, so it asks for `all: true` and then reads `addresses[0].address` off the reply. Given a string it indexes into that string and throws `ERR_INVALID_IP_ADDRESS: undefined`, so every proxied fetch failed. In practice that meant a 502 on each `__geolibre_raster_proxy` range request and STAC / COG imagery that never rendered in `npm run dev`, with the real cause buried in the terminal. Reply in whichever shape the caller asked for, still validating every resolved address first, so the rebinding window stays closed either way. The lookup is now a named export with an injectable resolver so the reply-shape and private-address paths are covered offline.
1 parent 3025abc commit 7bda201

2 files changed

Lines changed: 144 additions & 40 deletions

File tree

apps/geolibre-desktop/vite-proxy-guard.ts

Lines changed: 77 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import { lookup as dnsLookupCallback } from "node:dns";
1515
import { lookup as dnsLookup } from "node:dns/promises";
1616
import type { IncomingMessage, ServerResponse } from "node:http";
17+
import type { LookupFunction } from "node:net";
1718
import { Agent, fetch as undiciFetch } from "undici";
1819

1920
export const PROXY_MAX_REDIRECT_HOPS = 5;
@@ -179,50 +180,86 @@ export async function assertResolvedPublicHost(
179180
}
180181
}
181182

183+
/** One resolved address, as `dns.lookup(..., { all: true })` returns them. */
184+
export type LookupAddress = { address: string; family: number };
185+
182186
/**
183-
* undici Agent whose DNS lookup validates every address and only hands the
184-
* connector a previously-checked public address — closing the rebinding
185-
* window between check and connect. This lookup is the authoritative SSRF
186-
* gate for production fetches (no separate pre-resolve).
187+
* The reply a `net`/undici connector lookup accepts: the single-address form
188+
* `(err, address, family)`, or — when the caller asked for `all: true` — the
189+
* array form `(err, addresses)`.
187190
*/
191+
export type LookupReply = (
192+
err: NodeJS.ErrnoException | null,
193+
address: string | LookupAddress[],
194+
family?: number,
195+
) => void;
196+
197+
/**
198+
* DNS lookup that validates every resolved address and only hands the connector
199+
* previously-checked public ones — closing the rebinding window between check
200+
* and connect. This is the authoritative SSRF gate for production fetches (no
201+
* separate pre-resolve).
202+
*
203+
* `resolve` is injectable so unit tests can cover it offline.
204+
*
205+
* @param hostname - The host being connected to.
206+
* @param options - The connector's lookup options; `all` selects the reply shape.
207+
* @param callback - Answered in whichever shape `options.all` asked for.
208+
* @param resolve - The DNS resolver to use; defaults to `dns.lookup`.
209+
*/
210+
export function guardedLookup(
211+
hostname: string,
212+
options: { all?: boolean } & Record<string, unknown>,
213+
callback: LookupReply,
214+
resolve: typeof dnsLookupCallback = dnsLookupCallback,
215+
): void {
216+
// Always query in all-address mode so every candidate is validated, even when
217+
// the caller only asked for one -- the connector must not be able to downgrade
218+
// us to a single unchecked answer.
219+
//
220+
// The REPLY, though, has to match the shape the caller asked for. Node's
221+
// `net.Socket` enables `autoSelectFamily` by default (Node 20+), which makes
222+
// it pass `all: true` and then read `addresses[0].address` off the result.
223+
// Answering such a call with the 3-argument string form makes it index into a
224+
// string and fail with `ERR_INVALID_IP_ADDRESS: undefined`, which took every
225+
// dev-server raster/tile proxy fetch down with a 502.
226+
const wantsAll = options?.all === true;
227+
const fail = (message: string): void => {
228+
callback(Object.assign(new Error(message), { code: "ENOTFOUND" }), "", 4);
229+
};
230+
resolve(hostname, { ...options, all: true, verbatim: true }, (err, addresses) => {
231+
if (err) {
232+
callback(err as NodeJS.ErrnoException, "", 4);
233+
return;
234+
}
235+
const list = addresses as unknown as LookupAddress[];
236+
if (!Array.isArray(list) || list.length === 0) {
237+
fail(`DNS lookup returned no addresses for ${hostname}`);
238+
return;
239+
}
240+
for (const entry of list) {
241+
if (isPrivateHost(entry.address)) {
242+
fail(`Blocked private/reserved address: ${hostname}${entry.address}`);
243+
return;
244+
}
245+
}
246+
if (wantsAll) {
247+
// Every entry was validated above, so handing back the whole list keeps
248+
// the connection pinned to checked addresses.
249+
callback(null, list);
250+
return;
251+
}
252+
const chosen = list[0];
253+
callback(null, chosen.address, chosen.family);
254+
});
255+
}
256+
257+
/** undici Agent that connects only through {@link guardedLookup}. */
188258
const guardedDispatcher = new Agent({
189259
connect: {
190-
lookup(hostname, options, callback) {
191-
// Force all-address mode after spreading connector options so the
192-
// caller cannot downgrade us to the single-address callback form.
193-
dnsLookupCallback(hostname, { ...options, all: true, verbatim: true }, (err, addresses) => {
194-
if (err) {
195-
callback(err as NodeJS.ErrnoException, "", 4);
196-
return;
197-
}
198-
const list = addresses as Array<{ address: string; family: number }>;
199-
if (!Array.isArray(list) || list.length === 0) {
200-
callback(
201-
Object.assign(new Error(`DNS lookup returned no addresses for ${hostname}`), {
202-
code: "ENOTFOUND",
203-
}),
204-
"",
205-
4,
206-
);
207-
return;
208-
}
209-
for (const entry of list) {
210-
if (isPrivateHost(entry.address)) {
211-
callback(
212-
Object.assign(
213-
new Error(`Blocked private/reserved address: ${hostname}${entry.address}`),
214-
{ code: "ENOTFOUND" },
215-
),
216-
"",
217-
4,
218-
);
219-
return;
220-
}
221-
}
222-
const chosen = list[0];
223-
callback(null, chosen.address, chosen.family);
224-
});
225-
},
260+
// undici types the reply as the single-address form only; `net` also accepts
261+
// (and with `all: true` requires) the array form that guardedLookup sends.
262+
lookup: guardedLookup as unknown as LookupFunction,
226263
},
227264
});
228265

tests/edge-proxy-redirect.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
assertPublicHttpUrl,
1616
assertResolvedPublicHost,
1717
fetchWithGuard,
18+
guardedLookup,
1819
PROXY_MAX_BODY_BYTES,
1920
PROXY_MAX_REDIRECT_HOPS,
2021
readBodyWithLimit,
@@ -461,3 +462,69 @@ describe("Vite proxy guard — fetchWithGuard redirect policy", () => {
461462
assert.equal(called, false);
462463
});
463464
});
465+
466+
describe("guarded connector DNS lookup", () => {
467+
// `dns.lookup(host, { all: true }, cb)` stand-in.
468+
const resolving = (addresses: Array<{ address: string; family: number }>) =>
469+
((hostname: string, options: unknown, cb: (err: unknown, addresses: unknown) => void) => {
470+
cb(null, addresses);
471+
}) as never;
472+
473+
it("answers the array form when the connector asked for all addresses", () => {
474+
// Node's `net` enables autoSelectFamily by default, so it passes
475+
// `all: true` and then reads `addresses[0].address` off the reply. Replying
476+
// with the single-address string form makes it index into a string and
477+
// throw `ERR_INVALID_IP_ADDRESS: undefined`, which 502s every proxied
478+
// raster fetch.
479+
const addresses = [
480+
{ address: "93.184.216.34", family: 4 },
481+
{ address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 },
482+
];
483+
let reply: unknown[] | undefined;
484+
guardedLookup(
485+
"example.com",
486+
{ all: true },
487+
(err, address, family) => {
488+
assert.equal(err, null);
489+
reply = [address, family];
490+
},
491+
resolving(addresses),
492+
);
493+
494+
assert.deepEqual(reply?.[0], addresses, "hands back every validated address");
495+
});
496+
497+
it("answers the single-address form when the connector did not ask for all", () => {
498+
let reply: unknown[] | undefined;
499+
guardedLookup(
500+
"example.com",
501+
{},
502+
(err, address, family) => {
503+
assert.equal(err, null);
504+
reply = [address, family];
505+
},
506+
resolving([{ address: "93.184.216.34", family: 4 }]),
507+
);
508+
509+
assert.deepEqual(reply, ["93.184.216.34", 4]);
510+
});
511+
512+
it("refuses a host that resolves to a private address in either reply shape", () => {
513+
for (const options of [{ all: true }, {}]) {
514+
let error: Error | null = null;
515+
guardedLookup(
516+
"rebind.example",
517+
options,
518+
(err) => {
519+
error = err as Error;
520+
},
521+
resolving([
522+
{ address: "93.184.216.34", family: 4 },
523+
{ address: "169.254.169.254", family: 4 },
524+
]),
525+
);
526+
527+
assert.match(String(error), /169\.254\.169\.254/);
528+
}
529+
});
530+
});

0 commit comments

Comments
 (0)