Files
gods-eye-view/server/providers/gbfs.js
T
Sameh Khamis 0ad75095a7 Validate provider response formats
CCTV frames and the Street View fallback accept only JPEG, PNG, GIF, WebP and
AVIF, parsed from the media type essence and sent under the canonical type;
any other type is cancelled and falls through to the next fallback as a
failed upstream does. Live media must match its feed kind (video/mp4,
video/webm, or multipart/x-mixed-replace with a boundary) before the camera
is reported healthy or any byte is relayed, and its redirects now stay on the
registered origin like frames. Every /api/cctv route uses the same-site gate.

The GBFS proxy relays only bodies that parse as JSON, re-serialized under a
fixed application/json type.
2026-10-21 15:07:20 -07:00

303 lines
10 KiB
JavaScript

import { readResponseTextCapped } from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import {
isAllowedGbfsHost,
isAllowedGbfsPath,
gbfsCacheControl,
gbfsUpstreamHeaders,
} from '../../src/data/gbfsSource.js';
// ---------------------------------------------------------------------------
// GBFS (General Bikeshare Feed Specification) proxy constants
// ---------------------------------------------------------------------------
/** Upstream fetch timeout for GBFS requests (ms). */
const GBFS_PROXY_TIMEOUT_MS = 12000;
/** Value of the Client-Identifier header sent with every upstream request. */
export const GBFS_CLIENT_IDENTIFIER = 'gods-eye-view';
export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB
/**
* Requests/minute/IP admitted by the proxy.
*
* The bikeshare layer's own demand is small and known: `src/layers/bikeshare/
* registry.js` catalogues 15 systems, station information is cached per city
* after its first fetch, and station status refreshes once per city every
* `STATUS_POLL_MS` (60 s) with in-flight requests deduplicated. Even with every
* catalogued system in camera range at once that is about 15 requests a minute,
* so 120 is eight times a maximal real session while still capping a caller
* using this proxy as an open relay against a third-party GBFS host.
*/
export const GBFS_RATE_PER_MIN = 120;
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',
// Urban Sharing (the Norwegian city-bike feeds) asks every client to
// name itself in this header; other operators ignore it.
'Client-Identifier': GBFS_CLIENT_IDENTIFIER,
...gbfsUpstreamHeaders(new URL(url).hostname),
},
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.
*
* 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.
*
* @returns {import('vite').Plugin}
*/
export function gbfsProxy({ ratePerMinute = GBFS_RATE_PER_MIN } = {}) {
const allow = makeRateLimiter({
windowMs: 60_000,
max: ratePerMinute,
globalMax: ratePerMinute * 3,
});
const installMiddleware = (server) => {
server.middlewares.use('/api/gbfs', async (req, res) => {
try {
if (req.method !== 'GET') {
res.writeHead(405, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
return;
}
// Frequency, checked before the target is even parsed: the allowlist
// already says WHERE this proxy may go, nothing said HOW OFTEN. A
// refused request performs no outbound fetch and starts no timer.
if (!allow(clientKey(req))) {
res.writeHead(429, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Retry-After': '10',
});
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
return;
}
const url = new URL(req.url || '/', 'http://localhost');
const encodedTarget = url.pathname.replace(/^\/+/, '');
if (!encodedTarget) {
res.writeHead(400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'Missing GBFS upstream target' }));
return;
}
let decodedTarget = '';
try {
decodedTarget = decodeURIComponent(encodedTarget);
} catch {
res.writeHead(400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'Invalid GBFS target encoding' }));
return;
}
let upstreamUrl = null;
try {
upstreamUrl = new URL(decodedTarget);
} catch {
res.writeHead(400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'Invalid GBFS upstream URL' }));
return;
}
if (upstreamUrl.protocol !== 'https:') {
res.writeHead(400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({ error: 'Only https GBFS targets are allowed' }),
);
return;
}
if (!isAllowedGbfsHost(upstreamUrl.hostname)) {
res.writeHead(403, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'GBFS host not allowed' }));
return;
}
if (!isAllowedGbfsPath(upstreamUrl.pathname, upstreamUrl.hostname)) {
res.writeHead(400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error:
'Only station_information/station_status endpoints are allowed',
}),
);
return;
}
const upstream = await fetchGbfsUpstream(upstreamUrl.toString());
// Only JSON is relayed, re-serialized under a fixed type: neither the
// upstream's text nor its declared type reaches the browser.
let payload;
try {
payload = JSON.parse(upstream.body);
} catch {
res.writeHead(502, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'GBFS upstream returned non-JSON' }));
return;
}
res.writeHead(upstream.status, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': gbfsCacheControl(upstreamUrl.pathname),
'X-GBFS-Upstream': upstreamUrl.hostname,
'X-GBFS-Cache': 'MISS',
});
res.end(JSON.stringify(payload));
} catch (error) {
if (error?.name === 'AbortError') {
res.writeHead(504, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
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',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'GBFS proxy error' }));
}
});
};
return {
name: 'gbfs-proxy',
configureServer: installMiddleware,
configurePreviewServer: installMiddleware,
};
}