mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix: gbfs - refuse upstream redirects and enforce cap while streaming. extract network step into fetch so test can stand in. Pass redirect 'manual,' reject and 3xx w/ a 502. Log target host serverside. Replace read then measure cap w/ readResponseTextCapped. Cancels stream if over 5mb. Closes #30 and #31
This commit is contained in:
1 parent
25838e056a
commit
db35b4978b
4 files changed
+246
-32
No files matched your search
+99
-31
@@ -1320,6 +1320,8 @@ function radioBrowserProxy() {
|
||||
// ---------------------------------------------------------------------------
|
||||
/** Upstream fetch timeout for GBFS requests (ms). */
|
||||
const GBFS_PROXY_TIMEOUT_MS = 12000;
|
||||
/** Hard cap on a GBFS upstream body (bytes), enforced while the body streams. */
|
||||
export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
/** Allowlisted GBFS hostnames; wildcard *.publicbikesystem.net also accepted. */
|
||||
const GBFS_ALLOWED_HOSTS = new Set([
|
||||
'gbfs.lyft.com',
|
||||
@@ -3335,13 +3337,90 @@ function gbfsCacheControl(pathname) {
|
||||
return 'no-store';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hostname a redirect Location header points at, resolved against the request
|
||||
* URL so a relative Location still names a host. Empty when absent or unparsable.
|
||||
*
|
||||
* @param {string|null} location
|
||||
* @param {string} requestUrl
|
||||
* @returns {string}
|
||||
*/
|
||||
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,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (upstream.status >= 300 && upstream.status < 400) {
|
||||
try { await upstream.body?.cancel(); } 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);
|
||||
return {
|
||||
status: upstream.status,
|
||||
contentType: upstream.headers.get('content-type') || 'application/json',
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin: GBFS bike-share proxy with host allowlisting and size limits.
|
||||
*
|
||||
* Accepts GET /api/gbfs/<encoded-upstream-URL> and proxies the request
|
||||
* to the upstream GBFS provider. Validates hostname against an allowlist,
|
||||
* restricts to station_information/station_status paths, enforces HTTPS,
|
||||
* and caps response body at 5 MB.
|
||||
* refuses upstream redirects, and caps the response body at 5 MB while it
|
||||
* streams.
|
||||
*
|
||||
* @returns {import('vite').Plugin}
|
||||
*/
|
||||
@@ -3401,44 +3480,33 @@ 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);
|
||||
upstream = await fetchGbfsUpstream(upstreamUrl.toString());
|
||||
} catch (error) {
|
||||
if (error?.code === 'GBFS_REDIRECT') {
|
||||
// The target host stays server-side; the client only learns that a redirect happened.
|
||||
console.error(
|
||||
`[GBFS Proxy] ${upstreamUrl.hostname} redirected to ${error.redirectHost || 'an unparsable Location'}; redirects are not followed`,
|
||||
);
|
||||
res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: 'GBFS upstream redirected; redirects are not followed' }));
|
||||
return;
|
||||
}
|
||||
if (error?.code === 'RESPONSE_TOO_LARGE') {
|
||||
res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: 'GBFS upstream response too large' }));
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 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';
|
||||
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, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
|
||||
Reference in new issue
Block a user