-
-
Notifications
You must be signed in to change notification settings - Fork 628
Expand file tree
/
Copy pathvite-proxy-guard.ts
More file actions
428 lines (388 loc) · 15.2 KB
/
Copy pathvite-proxy-guard.ts
File metadata and controls
428 lines (388 loc) · 15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
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
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
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
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
/**
* SSRF guard for the Vite dev-server `__geolibre_*_proxy` binary proxies.
*
* Validates that a target URL is a public HTTP(S) address (not loopback,
* private RFC-1918, link-local, metadata, or IPv6 ULA/loopback), resolves
* DNS names and checks every returned address before connecting (with a
* custom undici lookup that pins the connection to a validated address),
* and follows redirects manually while re-validating each hop.
*
* Exported so `tests/` can import and exercise the guard without pulling in
* the full vite.config.
*/
import { lookup as dnsLookupCallback } from "node:dns";
import { lookup as dnsLookup } from "node:dns/promises";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { LookupFunction } from "node:net";
import { Agent, fetch as undiciFetch } from "undici";
export const PROXY_MAX_REDIRECT_HOPS = 5;
export const PROXY_MAX_BODY_BYTES = 50 * 1024 * 1024; // 50 MB
export const PROXY_FETCH_TIMEOUT_MS = 30_000;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
/**
* Returns an error message if `urlString` is not a safe public HTTP(S) URL,
* or `null` when it is acceptable. This only inspects the literal hostname;
* call {@link assertResolvedPublicHost} before connecting so DNS names that
* resolve to private addresses are also refused.
*/
export function validatePublicUrl(urlString: string): string | null {
let parsed: URL;
try {
parsed = new URL(urlString);
} catch {
return "Malformed URL";
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return "Only http/https URLs are allowed";
}
if (parsed.username || parsed.password) {
return "URLs with credentials are not allowed";
}
// Non-default ports are remapped/ignored differently across runtimes; keep
// the allowlist on the default http/https ports only.
if (parsed.port !== "") {
return `Blocked non-default port: ${parsed.port}`;
}
const hostname = parsed.hostname;
const bare = stripIpv6Brackets(hostname);
if (isPrivateHost(bare)) {
return `Blocked private/reserved address: ${hostname}`;
}
return null;
}
/**
* Throws if `urlString` is not a safe, publicly-routable HTTP(S) URL.
*/
export function assertPublicHttpUrl(urlString: string): void {
const err = validatePublicUrl(urlString);
if (err) throw new Error(err);
}
function stripIpv6Brackets(host: string): string {
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
}
function isIpv4Literal(host: string): boolean {
const parts = host.split(".");
return parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p));
}
function isIpv6Literal(host: string): boolean {
return host.includes(":");
}
/** True when `host` is a literal IPv4/IPv6 address (not a DNS name). */
export function isIpLiteral(host: string): boolean {
const bare = stripIpv6Brackets(host);
return isIpv4Literal(bare) || isIpv6Literal(bare);
}
export function isPrivateHost(host: string): boolean {
const bare = stripIpv6Brackets(host);
if (bare === "localhost" || bare.endsWith(".localhost")) return true;
if (isIpv4Literal(bare)) {
const octets = bare.split(".").map(Number);
// Fail closed: unclassifiable / out-of-range literals are treated as blocked.
if (octets.some((o) => o > 255)) return true;
return isPrivateIPv4(octets);
}
if (isIpv6Literal(bare)) {
return isPrivateIPv6(bare);
}
if (bare === "metadata.google.internal") return true;
return false;
}
function isPrivateIPv4(octets: number[]): boolean {
const [a, b] = octets;
if (a === 127) return true; // 127.0.0.0/8 loopback
if (a === 10) return true; // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a === 192 && b === 168) return true; // 192.168.0.0/16
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local / cloud metadata
if (a === 0) return true; // 0.0.0.0/8 "this" network
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
if (a === 192 && b === 0 && octets[2] === 0) return true; // 192.0.0.0/24 IETF protocol
if (a === 192 && b === 0 && octets[2] === 2) return true; // 192.0.2.0/24 TEST-NET-1
if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmarking
if (a === 198 && b === 51 && octets[2] === 100) return true; // 198.51.100.0/24 documentation
if (a === 203 && b === 0 && octets[2] === 113) return true; // 203.0.113.0/24 documentation
if (a >= 224) return true; // 224.0.0.0+ multicast + reserved
return false;
}
function isPrivateIPv6(addr: string): boolean {
const lower = addr.toLowerCase();
const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(lower);
if (mappedDotted) {
return isPrivateIPv4(mappedDotted[1].split(".").map(Number));
}
const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(lower);
if (mappedHex) {
const hi = parseInt(mappedHex[1], 16);
const lo = parseInt(mappedHex[2], 16);
return isPrivateIPv4([(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff]);
}
if (lower === "::1") return true; // loopback
if (lower === "::") return true; // unspecified
// Link-local is fe80::/10 (first hextet 0xfe80–0xfebf), not merely "fe80:".
const firstHextet = parseInt(lower.split(":")[0] || "", 16);
if (Number.isFinite(firstHextet) && (firstHextet & 0xffc0) === 0xfe80) return true;
// ULA fc00::/7
if (Number.isFinite(firstHextet) && (firstHextet & 0xfe00) === 0xfc00) return true;
return false;
}
/**
* Resolve `hostname` (when it is a DNS name) and refuse if any returned
* address is private/reserved. IP literals are checked synchronously.
*
* `lookup` is injectable so unit tests can cover the DNS branch offline.
*/
export async function assertResolvedPublicHost(
hostname: string,
lookup: typeof dnsLookup = dnsLookup,
): Promise<void> {
const bare = stripIpv6Brackets(hostname);
if (isIpLiteral(bare)) {
if (isPrivateHost(bare)) {
throw new Error(`Blocked private/reserved address: ${hostname}`);
}
return;
}
if (isPrivateHost(bare)) {
throw new Error(`Blocked private/reserved address: ${hostname}`);
}
const results = await lookup(bare, { all: true, verbatim: true });
if (results.length === 0) {
throw new Error(`DNS lookup returned no addresses for ${hostname}`);
}
for (const { address } of results) {
if (isPrivateHost(address)) {
throw new Error(`Blocked private/reserved address: ${hostname} → ${address}`);
}
}
}
/** One resolved address, as `dns.lookup(..., { all: true })` returns them. */
export type LookupAddress = { address: string; family: number };
/**
* The reply a `net`/undici connector lookup accepts: the single-address form
* `(err, address, family)`, or — when the caller asked for `all: true` — the
* array form `(err, addresses)`.
*/
export type LookupReply = (
err: NodeJS.ErrnoException | null,
address: string | LookupAddress[],
family?: number,
) => void;
/**
* DNS lookup that validates every resolved address and only hands the connector
* previously-checked public ones — closing the rebinding window between check
* and connect. This is the authoritative SSRF gate for production fetches (no
* separate pre-resolve).
*
* `resolve` is injectable so unit tests can cover it offline.
*
* @param hostname - The host being connected to.
* @param options - The connector's lookup options; `all` selects the reply shape.
* @param callback - Answered in whichever shape `options.all` asked for.
* @param resolve - The DNS resolver to use; defaults to `dns.lookup`.
*/
export function guardedLookup(
hostname: string,
options: { all?: boolean } & Record<string, unknown>,
callback: LookupReply,
resolve: typeof dnsLookupCallback = dnsLookupCallback,
): void {
// Always query in all-address mode so every candidate is validated, even when
// the caller only asked for one -- the connector must not be able to downgrade
// us to a single unchecked answer.
//
// The REPLY, though, has to match the shape the caller asked for. Node's
// `net.Socket` enables `autoSelectFamily` by default (Node 20+), which makes
// it pass `all: true` and then read `addresses[0].address` off the result.
// Answering such a call with the 3-argument string form makes it index into a
// string and fail with `ERR_INVALID_IP_ADDRESS: undefined`, which took every
// dev-server raster/tile proxy fetch down with a 502.
const wantsAll = options?.all === true;
const fail = (message: string): void => {
callback(Object.assign(new Error(message), { code: "ENOTFOUND" }), "", 4);
};
resolve(hostname, { ...options, all: true, verbatim: true }, (err, addresses) => {
if (err) {
callback(err as NodeJS.ErrnoException, "", 4);
return;
}
const list = addresses as unknown as LookupAddress[];
if (!Array.isArray(list) || list.length === 0) {
fail(`DNS lookup returned no addresses for ${hostname}`);
return;
}
for (const entry of list) {
if (isPrivateHost(entry.address)) {
fail(`Blocked private/reserved address: ${hostname} → ${entry.address}`);
return;
}
}
if (wantsAll) {
// Every entry was validated above, so handing back the whole list keeps
// the connection pinned to checked addresses.
callback(null, list);
return;
}
const chosen = list[0];
callback(null, chosen.address, chosen.family);
});
}
/** undici Agent that connects only through {@link guardedLookup}. */
const guardedDispatcher = new Agent({
connect: {
// undici types the reply as the single-address form only; `net` also accepts
// (and with `all: true` requires) the array form that guardedLookup sends.
lookup: guardedLookup as unknown as LookupFunction,
},
});
function mergeAbortSignals(timeoutMs: number, caller?: AbortSignal | null): AbortSignal {
const timeout = AbortSignal.timeout(timeoutMs);
if (!caller) return timeout;
const any = (AbortSignal as unknown as { any?: (signals: AbortSignal[]) => AbortSignal }).any;
if (typeof any === "function") return any([timeout, caller]);
return timeout;
}
/**
* Fetch `targetUrl` with manual redirect following, a per-hop timeout, and
* (by default) a dispatcher that pins connects to validated public addresses.
*
* DNS SSRF checks happen inside `guardedDispatcher.lookup` for production
* fetches. Inject `fetchImpl` only for offline unit tests — that path still
* runs {@link assertResolvedPublicHost} so a custom fetch cannot skip the
* DNS-rebinding check.
*/
export async function fetchWithGuard(
targetUrl: string,
init: RequestInit = {},
options: {
timeoutMs?: number;
/** Test-only fetch substitute. Still resolves+validates the hostname. */
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
/** Test-only DNS override used with `fetchImpl`. */
lookup?: typeof dnsLookup;
} = {},
): Promise<Response> {
assertPublicHttpUrl(targetUrl);
const timeoutMs = options.timeoutMs ?? PROXY_FETCH_TIMEOUT_MS;
const fetchImpl = options.fetchImpl;
let current = targetUrl;
for (let hop = 0; hop <= PROXY_MAX_REDIRECT_HOPS; hop++) {
const { signal: callerSignal, ...rest } = init;
const signal = mergeAbortSignals(timeoutMs, callerSignal ?? null);
let response: Response;
if (fetchImpl) {
// No undici dispatcher on this path — resolve+validate before fetching.
await assertResolvedPublicHost(new URL(current).hostname, options.lookup);
response = await fetchImpl(current, { ...rest, signal, redirect: "manual" });
} else {
response = (await undiciFetch(current, {
...rest,
signal,
redirect: "manual",
dispatcher: guardedDispatcher,
})) as unknown as Response;
}
if (!REDIRECT_STATUSES.has(response.status)) {
return response;
}
const location = response.headers.get("location");
if (!location) return response;
const next = new URL(location, current).toString();
assertPublicHttpUrl(next);
current = next;
}
throw new Error("Too many proxy redirects");
}
/**
* Read an upstream body while enforcing {@link PROXY_MAX_BODY_BYTES}. Checks
* Content-Length early when present and aborts mid-stream if the running
* total exceeds the cap.
*/
export async function readBodyWithLimit(
response: Response,
maxBytes: number = PROXY_MAX_BODY_BYTES,
): Promise<Buffer> {
const declared = Number(response.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBytes) {
throw new Error("Upstream response exceeds size limit");
}
if (!response.body) {
return Buffer.alloc(0);
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel().catch(() => undefined);
throw new Error("Upstream response exceeds size limit");
}
chunks.push(value);
}
return Buffer.concat(chunks.map((c) => Buffer.from(c)));
}
/**
* Hardened version of the Vite dev-server binary proxy handler. Validates the
* target URL against SSRF rules (including DNS resolution), follows redirects
* manually, and caps the response body size while streaming.
*/
export async function proxyBinaryRequestGuarded(
req: IncomingMessage,
res: ServerResponse,
proxyPath: string,
): Promise<void> {
const requestUrl = new URL(req.url ?? "", `http://localhost${proxyPath}`);
const target = requestUrl.searchParams.get("url");
if (!target || !/^https?:\/\//i.test(target)) {
res.statusCode = 400;
res.setHeader("content-type", "text/plain");
res.end("Missing or invalid target URL");
return;
}
const urlErr = validatePublicUrl(target);
if (urlErr) {
res.statusCode = 502;
res.setHeader("content-type", "text/plain");
res.end(urlErr);
return;
}
const headers = new Headers();
const range = req.headers.range;
if (range) headers.set("range", range);
let response: Response;
try {
response = await fetchWithGuard(target, { headers });
} catch (err) {
// Do not echo err.message — resolved private IPs / undici connect details
// would turn this proxy into an internal-network disclosure oracle.
console.warn("[vite-proxy-guard] upstream fetch blocked or failed:", err);
res.statusCode = 502;
res.setHeader("content-type", "text/plain");
res.end("Upstream fetch failed");
return;
}
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
let body: Buffer;
try {
body = await readBodyWithLimit(response);
} catch (err) {
console.warn("[vite-proxy-guard] upstream body rejected:", err);
res.statusCode = 502;
res.setHeader("content-type", "text/plain");
res.end("Upstream response exceeds size limit");
return;
}
res.statusCode = response.status;
res.setHeader("access-control-allow-origin", "*");
res.setHeader("cache-control", "public, max-age=3600");
res.setHeader("content-type", contentType);
for (const header of ["accept-ranges", "content-range"]) {
const value = response.headers.get(header);
if (value) res.setHeader(header, value);
}
res.setHeader("content-length", String(body.byteLength));
res.end(body);
}