mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(gbfs): adapt redirect hardening to provider modules and bound body reads
This commit is contained in:
commit
4fae7fb278
6 files changed
+435
-77
No files matched your search
@@ -3,44 +3,49 @@
|
||||
* oversized Content-Length, then streams with a running cap so a chunked or
|
||||
* length-omitted response cannot blow past the limit. Throws { code:'RESPONSE_TOO_LARGE' }.
|
||||
*/
|
||||
export async function readResponseTextCapped(response, maxBytes) {
|
||||
export async function readResponseTextCapped(response, maxBytes, signal) {
|
||||
const tooLarge = () =>
|
||||
Object.assign(new Error('Upstream response too large'), {
|
||||
code: 'RESPONSE_TOO_LARGE',
|
||||
});
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
const err = new Error('Upstream response too large');
|
||||
err.code = 'RESPONSE_TOO_LARGE';
|
||||
throw err;
|
||||
void response.body?.cancel().catch(() => {});
|
||||
throw tooLarge();
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const reader = response.body?.getReader?.();
|
||||
if (!reader) {
|
||||
const text = await response.text();
|
||||
if (Buffer.byteLength(text) > maxBytes) {
|
||||
const err = new Error('Upstream response too large');
|
||||
err.code = 'RESPONSE_TOO_LARGE';
|
||||
throw err;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (Buffer.byteLength(text) > maxBytes) throw tooLarge();
|
||||
return text;
|
||||
}
|
||||
const cancel = () => {
|
||||
void reader.cancel().catch(() => {});
|
||||
};
|
||||
signal?.addEventListener('abort', cancel, { once: true });
|
||||
const decoder = new TextDecoder();
|
||||
let out = '';
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
const err = new Error('Upstream response too large');
|
||||
err.code = 'RESPONSE_TOO_LARGE';
|
||||
throw err;
|
||||
try {
|
||||
for (;;) {
|
||||
signal?.throwIfAborted();
|
||||
const { done, value } = await reader.read();
|
||||
signal?.throwIfAborted();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) throw tooLarge();
|
||||
out += decoder.decode(value, { stream: true });
|
||||
}
|
||||
out += decoder.decode(value, { stream: true });
|
||||
return out + decoder.decode();
|
||||
} catch (error) {
|
||||
cancel();
|
||||
throw error;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', cancel);
|
||||
reader.releaseLock();
|
||||
}
|
||||
out += decoder.decode();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parse a fetch() JSON response only after enforcing a hard byte cap. */
|
||||
|
||||
+116
-50
@@ -1,3 +1,4 @@
|
||||
import { readResponseTextCapped } from './common/http.js';
|
||||
import {
|
||||
isAllowedGbfsHost,
|
||||
isAllowedGbfsPath,
|
||||
@@ -10,6 +11,98 @@ import {
|
||||
/** Upstream fetch timeout for GBFS requests (ms). */
|
||||
const GBFS_PROXY_TIMEOUT_MS = 12000;
|
||||
|
||||
export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
function gbfsRedirectHost(location, requestUrl) {
|
||||
if (!location) return '';
|
||||
try {
|
||||
return new URL(String(location), requestUrl).hostname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one validated GBFS endpoint without following redirects and with the
|
||||
* body cap enforced while the response streams.
|
||||
*
|
||||
* The middleware validates the target before calling this; the network step
|
||||
* lives apart from it so a unit test can stand in for a hostile upstream — the
|
||||
* host allowlist keeps the real handler from being pointed at a local server.
|
||||
* `redirect: 'manual'` makes fetch() return a 3xx response instead of
|
||||
* following it, so an allowlisted operator that redirects cannot steer the
|
||||
* proxy to a destination the allowlist never saw; any 3xx is rejected with
|
||||
* { code:'GBFS_REDIRECT', redirectHost }. An oversized body surfaces as
|
||||
* { code:'RESPONSE_TOO_LARGE' } from readResponseTextCapped, which cancels the
|
||||
* upstream read the moment the running byte count passes the cap.
|
||||
*
|
||||
* @param {string} url - Validated https GBFS endpoint URL.
|
||||
* @param {object} [options]
|
||||
* @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation.
|
||||
* @param {number} [options.timeoutMs=GBFS_PROXY_TIMEOUT_MS] - Upstream abort timeout.
|
||||
* @param {number} [options.maxBytes=GBFS_MAX_BODY_BYTES] - Body byte cap.
|
||||
* @returns {Promise<{status:number,contentType:string,body:string}>}
|
||||
*/
|
||||
export async function fetchGbfsUpstream(
|
||||
url,
|
||||
{
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = GBFS_PROXY_TIMEOUT_MS,
|
||||
maxBytes = GBFS_MAX_BODY_BYTES,
|
||||
} = {},
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'gods-eye-view-gbfs-proxy/1.0',
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (upstream.status >= 300 && upstream.status < 400) {
|
||||
try {
|
||||
void upstream.body?.cancel().catch(() => {});
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
const err = new Error(
|
||||
'GBFS upstream redirected; redirects are not followed',
|
||||
);
|
||||
err.code = 'GBFS_REDIRECT';
|
||||
err.redirectHost = gbfsRedirectHost(
|
||||
upstream.headers.get('location'),
|
||||
url,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const body = await readResponseTextCapped(
|
||||
upstream,
|
||||
maxBytes,
|
||||
controller.signal,
|
||||
);
|
||||
return {
|
||||
status: upstream.status,
|
||||
contentType: upstream.headers.get('content-type') || 'application/json',
|
||||
body,
|
||||
};
|
||||
} catch (error) {
|
||||
controller.abort();
|
||||
if (upstream?.body && !upstream.body.locked) {
|
||||
void upstream.body.cancel().catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin: GBFS bike-share proxy with host allowlisting and size limits.
|
||||
*
|
||||
@@ -102,61 +195,14 @@ export function gbfsProxy() {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
GBFS_PROXY_TIMEOUT_MS,
|
||||
);
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await fetch(upstreamUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'gods-eye-view-gbfs-proxy/1.0',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Limit response size to prevent memory exhaustion from malicious upstream
|
||||
const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
const contentLength = Number(upstream.headers.get('content-length'));
|
||||
if (
|
||||
Number.isFinite(contentLength) &&
|
||||
contentLength > GBFS_MAX_BODY_BYTES
|
||||
) {
|
||||
res.writeHead(502, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({ error: 'GBFS upstream response too large' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const body = await upstream.text();
|
||||
if (Buffer.byteLength(body, 'utf8') > GBFS_MAX_BODY_BYTES) {
|
||||
res.writeHead(502, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({ error: 'GBFS upstream response too large' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const contentType =
|
||||
upstream.headers.get('content-type') || 'application/json';
|
||||
const upstream = await fetchGbfsUpstream(upstreamUrl.toString());
|
||||
res.writeHead(upstream.status, {
|
||||
'Content-Type': contentType,
|
||||
'Content-Type': upstream.contentType,
|
||||
'Cache-Control': gbfsCacheControl(upstreamUrl.pathname),
|
||||
'X-GBFS-Upstream': upstreamUrl.hostname,
|
||||
'X-GBFS-Cache': 'MISS',
|
||||
});
|
||||
res.end(body);
|
||||
res.end(upstream.body);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
res.writeHead(504, {
|
||||
@@ -166,6 +212,26 @@ export function gbfsProxy() {
|
||||
res.end(JSON.stringify({ error: 'GBFS upstream timeout' }));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
error?.code === 'GBFS_REDIRECT' ||
|
||||
error?.code === 'RESPONSE_TOO_LARGE'
|
||||
) {
|
||||
if (error.code === 'GBFS_REDIRECT')
|
||||
console.warn('[GBFS Proxy] Redirect refused', error.redirectHost);
|
||||
res.writeHead(502, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error:
|
||||
error.code === 'GBFS_REDIRECT'
|
||||
? 'GBFS upstream redirect refused'
|
||||
: 'GBFS upstream response too large',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error('[GBFS Proxy]', error?.message || String(error));
|
||||
res.writeHead(502, {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
Reference in new issue
Block a user