Skip to content

Commit a551f7c

Browse files
Ayush7614pre-commit-ci[bot]giswqs
authored
fix(workers): harden Vite proxy and github-raw redirect fetches (#1633)
* fix(workers): harden Vite proxy and github-raw redirect fetches Block private/metadata targets (and redirect pivots) on the Vite binary proxies, and fetch github-raw through the allowlisted manual redirect helper. * style: auto-format (ruff + oxfmt) [pre-commit.ci] * fix(workers): close Vite proxy DNS-rebinding and body-cap gaps Resolve and pin DNS via an undici lookup that rejects private addresses, cover the full fe80::/10 range, bound each hop with a timeout, and enforce the body limit while streaming instead of after arrayBuffer. * style: auto-format (ruff + oxfmt) [pre-commit.ci] * fix(workers): declare undici and tighten Vite proxy guard Add undici as a desktop dependency so Docker production builds resolve the DNS-pinning agent, fail closed on bad IPv4 literals, stop leaking guard errors to clients, and cover DNS-resolution branches in tests. * style: auto-format (ruff + oxfmt) [pre-commit.ci] * fix(workers): keep DNS checks on Vite proxy test fetch path fetchImpl no longer skips assertResolvedPublicHost, so injecting a custom fetch cannot bypass the DNS-rebinding guard. * style: auto-format (ruff + oxfmt) [pre-commit.ci] * Address review feedback - Move undici from dependencies to devDependencies in apps/geolibre-desktop/package.json (with a refreshed lockfile). It is reachable only from vite.config.ts and tests/edge-proxy-redirect.ts, both dev-server paths, so it should not ship as a runtime dependency. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: giswqs <giswqs@gmail.com>
1 parent 055f315 commit a551f7c

7 files changed

Lines changed: 669 additions & 44 deletions

File tree

apps/geolibre-desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
"postcss": "^8.5.23",
111111
"tailwindcss": "^4.3.0",
112112
"typescript": "^7.0.2",
113+
"undici": "^7.29.0",
113114
"vite": "^8.1.5",
114115
"vite-plugin-pwa": "^1.3.0"
115116
}
Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
/**
2+
* SSRF guard for the Vite dev-server `__geolibre_*_proxy` binary proxies.
3+
*
4+
* Validates that a target URL is a public HTTP(S) address (not loopback,
5+
* private RFC-1918, link-local, metadata, or IPv6 ULA/loopback), resolves
6+
* DNS names and checks every returned address before connecting (with a
7+
* custom undici lookup that pins the connection to a validated address),
8+
* and follows redirects manually while re-validating each hop.
9+
*
10+
* Exported so `tests/` can import and exercise the guard without pulling in
11+
* the full vite.config.
12+
*/
13+
14+
import { lookup as dnsLookupCallback } from "node:dns";
15+
import { lookup as dnsLookup } from "node:dns/promises";
16+
import type { IncomingMessage, ServerResponse } from "node:http";
17+
import { Agent, fetch as undiciFetch } from "undici";
18+
19+
export const PROXY_MAX_REDIRECT_HOPS = 5;
20+
export const PROXY_MAX_BODY_BYTES = 50 * 1024 * 1024; // 50 MB
21+
export const PROXY_FETCH_TIMEOUT_MS = 30_000;
22+
23+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
24+
25+
/**
26+
* Returns an error message if `urlString` is not a safe public HTTP(S) URL,
27+
* or `null` when it is acceptable. This only inspects the literal hostname;
28+
* call {@link assertResolvedPublicHost} before connecting so DNS names that
29+
* resolve to private addresses are also refused.
30+
*/
31+
export function validatePublicUrl(urlString: string): string | null {
32+
let parsed: URL;
33+
try {
34+
parsed = new URL(urlString);
35+
} catch {
36+
return "Malformed URL";
37+
}
38+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
39+
return "Only http/https URLs are allowed";
40+
}
41+
if (parsed.username || parsed.password) {
42+
return "URLs with credentials are not allowed";
43+
}
44+
// Non-default ports are remapped/ignored differently across runtimes; keep
45+
// the allowlist on the default http/https ports only.
46+
if (parsed.port !== "") {
47+
return `Blocked non-default port: ${parsed.port}`;
48+
}
49+
const hostname = parsed.hostname;
50+
const bare = stripIpv6Brackets(hostname);
51+
52+
if (isPrivateHost(bare)) {
53+
return `Blocked private/reserved address: ${hostname}`;
54+
}
55+
return null;
56+
}
57+
58+
/**
59+
* Throws if `urlString` is not a safe, publicly-routable HTTP(S) URL.
60+
*/
61+
export function assertPublicHttpUrl(urlString: string): void {
62+
const err = validatePublicUrl(urlString);
63+
if (err) throw new Error(err);
64+
}
65+
66+
function stripIpv6Brackets(host: string): string {
67+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
68+
}
69+
70+
function isIpv4Literal(host: string): boolean {
71+
const parts = host.split(".");
72+
return parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p));
73+
}
74+
75+
function isIpv6Literal(host: string): boolean {
76+
return host.includes(":");
77+
}
78+
79+
/** True when `host` is a literal IPv4/IPv6 address (not a DNS name). */
80+
export function isIpLiteral(host: string): boolean {
81+
const bare = stripIpv6Brackets(host);
82+
return isIpv4Literal(bare) || isIpv6Literal(bare);
83+
}
84+
85+
export function isPrivateHost(host: string): boolean {
86+
const bare = stripIpv6Brackets(host);
87+
if (bare === "localhost" || bare.endsWith(".localhost")) return true;
88+
89+
if (isIpv4Literal(bare)) {
90+
const octets = bare.split(".").map(Number);
91+
// Fail closed: unclassifiable / out-of-range literals are treated as blocked.
92+
if (octets.some((o) => o > 255)) return true;
93+
return isPrivateIPv4(octets);
94+
}
95+
96+
if (isIpv6Literal(bare)) {
97+
return isPrivateIPv6(bare);
98+
}
99+
100+
if (bare === "metadata.google.internal") return true;
101+
102+
return false;
103+
}
104+
105+
function isPrivateIPv4(octets: number[]): boolean {
106+
const [a, b] = octets;
107+
if (a === 127) return true; // 127.0.0.0/8 loopback
108+
if (a === 10) return true; // 10.0.0.0/8
109+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
110+
if (a === 192 && b === 168) return true; // 192.168.0.0/16
111+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local / cloud metadata
112+
if (a === 0) return true; // 0.0.0.0/8 "this" network
113+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
114+
if (a === 192 && b === 0 && octets[2] === 0) return true; // 192.0.0.0/24 IETF protocol
115+
if (a === 192 && b === 0 && octets[2] === 2) return true; // 192.0.2.0/24 TEST-NET-1
116+
if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmarking
117+
if (a === 198 && b === 51 && octets[2] === 100) return true; // 198.51.100.0/24 documentation
118+
if (a === 203 && b === 0 && octets[2] === 113) return true; // 203.0.113.0/24 documentation
119+
if (a >= 224) return true; // 224.0.0.0+ multicast + reserved
120+
return false;
121+
}
122+
123+
function isPrivateIPv6(addr: string): boolean {
124+
const lower = addr.toLowerCase();
125+
126+
const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(lower);
127+
if (mappedDotted) {
128+
return isPrivateIPv4(mappedDotted[1].split(".").map(Number));
129+
}
130+
131+
const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(lower);
132+
if (mappedHex) {
133+
const hi = parseInt(mappedHex[1], 16);
134+
const lo = parseInt(mappedHex[2], 16);
135+
return isPrivateIPv4([(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff]);
136+
}
137+
138+
if (lower === "::1") return true; // loopback
139+
if (lower === "::") return true; // unspecified
140+
141+
// Link-local is fe80::/10 (first hextet 0xfe80–0xfebf), not merely "fe80:".
142+
const firstHextet = parseInt(lower.split(":")[0] || "", 16);
143+
if (Number.isFinite(firstHextet) && (firstHextet & 0xffc0) === 0xfe80) return true;
144+
145+
// ULA fc00::/7
146+
if (Number.isFinite(firstHextet) && (firstHextet & 0xfe00) === 0xfc00) return true;
147+
148+
return false;
149+
}
150+
151+
/**
152+
* Resolve `hostname` (when it is a DNS name) and refuse if any returned
153+
* address is private/reserved. IP literals are checked synchronously.
154+
*
155+
* `lookup` is injectable so unit tests can cover the DNS branch offline.
156+
*/
157+
export async function assertResolvedPublicHost(
158+
hostname: string,
159+
lookup: typeof dnsLookup = dnsLookup,
160+
): Promise<void> {
161+
const bare = stripIpv6Brackets(hostname);
162+
if (isIpLiteral(bare)) {
163+
if (isPrivateHost(bare)) {
164+
throw new Error(`Blocked private/reserved address: ${hostname}`);
165+
}
166+
return;
167+
}
168+
if (isPrivateHost(bare)) {
169+
throw new Error(`Blocked private/reserved address: ${hostname}`);
170+
}
171+
const results = await lookup(bare, { all: true, verbatim: true });
172+
if (results.length === 0) {
173+
throw new Error(`DNS lookup returned no addresses for ${hostname}`);
174+
}
175+
for (const { address } of results) {
176+
if (isPrivateHost(address)) {
177+
throw new Error(`Blocked private/reserved address: ${hostname}${address}`);
178+
}
179+
}
180+
}
181+
182+
/**
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+
*/
188+
const guardedDispatcher = new Agent({
189+
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+
},
226+
},
227+
});
228+
229+
function mergeAbortSignals(timeoutMs: number, caller?: AbortSignal | null): AbortSignal {
230+
const timeout = AbortSignal.timeout(timeoutMs);
231+
if (!caller) return timeout;
232+
const any = (AbortSignal as unknown as { any?: (signals: AbortSignal[]) => AbortSignal }).any;
233+
if (typeof any === "function") return any([timeout, caller]);
234+
return timeout;
235+
}
236+
237+
/**
238+
* Fetch `targetUrl` with manual redirect following, a per-hop timeout, and
239+
* (by default) a dispatcher that pins connects to validated public addresses.
240+
*
241+
* DNS SSRF checks happen inside `guardedDispatcher.lookup` for production
242+
* fetches. Inject `fetchImpl` only for offline unit tests — that path still
243+
* runs {@link assertResolvedPublicHost} so a custom fetch cannot skip the
244+
* DNS-rebinding check.
245+
*/
246+
export async function fetchWithGuard(
247+
targetUrl: string,
248+
init: RequestInit = {},
249+
options: {
250+
timeoutMs?: number;
251+
/** Test-only fetch substitute. Still resolves+validates the hostname. */
252+
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
253+
/** Test-only DNS override used with `fetchImpl`. */
254+
lookup?: typeof dnsLookup;
255+
} = {},
256+
): Promise<Response> {
257+
assertPublicHttpUrl(targetUrl);
258+
const timeoutMs = options.timeoutMs ?? PROXY_FETCH_TIMEOUT_MS;
259+
const fetchImpl = options.fetchImpl;
260+
261+
let current = targetUrl;
262+
for (let hop = 0; hop <= PROXY_MAX_REDIRECT_HOPS; hop++) {
263+
const { signal: callerSignal, ...rest } = init;
264+
const signal = mergeAbortSignals(timeoutMs, callerSignal ?? null);
265+
let response: Response;
266+
if (fetchImpl) {
267+
// No undici dispatcher on this path — resolve+validate before fetching.
268+
await assertResolvedPublicHost(new URL(current).hostname, options.lookup);
269+
response = await fetchImpl(current, { ...rest, signal, redirect: "manual" });
270+
} else {
271+
response = (await undiciFetch(current, {
272+
...rest,
273+
signal,
274+
redirect: "manual",
275+
dispatcher: guardedDispatcher,
276+
})) as unknown as Response;
277+
}
278+
if (!REDIRECT_STATUSES.has(response.status)) {
279+
return response;
280+
}
281+
const location = response.headers.get("location");
282+
if (!location) return response;
283+
const next = new URL(location, current).toString();
284+
assertPublicHttpUrl(next);
285+
current = next;
286+
}
287+
throw new Error("Too many proxy redirects");
288+
}
289+
290+
/**
291+
* Read an upstream body while enforcing {@link PROXY_MAX_BODY_BYTES}. Checks
292+
* Content-Length early when present and aborts mid-stream if the running
293+
* total exceeds the cap.
294+
*/
295+
export async function readBodyWithLimit(
296+
response: Response,
297+
maxBytes: number = PROXY_MAX_BODY_BYTES,
298+
): Promise<Buffer> {
299+
const declared = Number(response.headers.get("content-length"));
300+
if (Number.isFinite(declared) && declared > maxBytes) {
301+
throw new Error("Upstream response exceeds size limit");
302+
}
303+
304+
if (!response.body) {
305+
return Buffer.alloc(0);
306+
}
307+
308+
const reader = response.body.getReader();
309+
const chunks: Uint8Array[] = [];
310+
let total = 0;
311+
while (true) {
312+
const { done, value } = await reader.read();
313+
if (done) break;
314+
if (!value) continue;
315+
total += value.byteLength;
316+
if (total > maxBytes) {
317+
await reader.cancel().catch(() => undefined);
318+
throw new Error("Upstream response exceeds size limit");
319+
}
320+
chunks.push(value);
321+
}
322+
return Buffer.concat(chunks.map((c) => Buffer.from(c)));
323+
}
324+
325+
/**
326+
* Hardened version of the Vite dev-server binary proxy handler. Validates the
327+
* target URL against SSRF rules (including DNS resolution), follows redirects
328+
* manually, and caps the response body size while streaming.
329+
*/
330+
export async function proxyBinaryRequestGuarded(
331+
req: IncomingMessage,
332+
res: ServerResponse,
333+
proxyPath: string,
334+
): Promise<void> {
335+
const requestUrl = new URL(req.url ?? "", `http://localhost${proxyPath}`);
336+
const target = requestUrl.searchParams.get("url");
337+
if (!target || !/^https?:\/\//i.test(target)) {
338+
res.statusCode = 400;
339+
res.setHeader("content-type", "text/plain");
340+
res.end("Missing or invalid target URL");
341+
return;
342+
}
343+
344+
const urlErr = validatePublicUrl(target);
345+
if (urlErr) {
346+
res.statusCode = 502;
347+
res.setHeader("content-type", "text/plain");
348+
res.end(urlErr);
349+
return;
350+
}
351+
352+
const headers = new Headers();
353+
const range = req.headers.range;
354+
if (range) headers.set("range", range);
355+
356+
let response: Response;
357+
try {
358+
response = await fetchWithGuard(target, { headers });
359+
} catch (err) {
360+
// Do not echo err.message — resolved private IPs / undici connect details
361+
// would turn this proxy into an internal-network disclosure oracle.
362+
console.warn("[vite-proxy-guard] upstream fetch blocked or failed:", err);
363+
res.statusCode = 502;
364+
res.setHeader("content-type", "text/plain");
365+
res.end("Upstream fetch failed");
366+
return;
367+
}
368+
369+
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
370+
let body: Buffer;
371+
try {
372+
body = await readBodyWithLimit(response);
373+
} catch (err) {
374+
console.warn("[vite-proxy-guard] upstream body rejected:", err);
375+
res.statusCode = 502;
376+
res.setHeader("content-type", "text/plain");
377+
res.end("Upstream response exceeds size limit");
378+
return;
379+
}
380+
381+
res.statusCode = response.status;
382+
res.setHeader("access-control-allow-origin", "*");
383+
res.setHeader("cache-control", "public, max-age=3600");
384+
res.setHeader("content-type", contentType);
385+
for (const header of ["accept-ranges", "content-range"]) {
386+
const value = response.headers.get(header);
387+
if (value) res.setHeader(header, value);
388+
}
389+
res.setHeader("content-length", String(body.byteLength));
390+
res.end(body);
391+
}

0 commit comments

Comments
 (0)