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:
dlucas489 committed 2026-09-04 15:31:06 -07:00
1 parent 25838e056a
commit db35b4978b
4 files changed
+246 -32

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
GBFS_MAX_BODY_BYTES,
fetchGbfsUpstream,
} from '../../vite.config.js';
const STATION_URL = 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_status.json';
const STATION_BODY = '{"data":{"stations":[]}}';
/**
* A Response whose body streams `chunk` forever until the reader cancels it.
* A zero high-water mark keeps the stream from pulling eagerly at construction,
* so `pulls()` counts only the chunks a reader actually asked for.
*/
function endlessResponse(chunk, onCancel) {
let pulls = 0;
const stream = new ReadableStream({
pull(controller) {
pulls += 1;
controller.enqueue(chunk);
},
cancel: onCancel,
}, { highWaterMark: 0 });
return { response: new Response(stream, { status: 200 }), pulls: () => pulls };
}
test('GBFS upstream fetch passes a direct 200 through unchanged', async () => {
let observedOptions = null;
const result = await fetchGbfsUpstream(STATION_URL, {
fetchImpl: async (_url, options) => {
observedOptions = options;
return new Response(STATION_BODY, {
status: 200,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
},
});
assert.equal(observedOptions.redirect, 'manual', 'fetch must not follow redirects on its own');
assert.ok(observedOptions.signal instanceof AbortSignal);
assert.deepEqual(result, {
status: 200,
contentType: 'application/json; charset=utf-8',
body: STATION_BODY,
});
});
test('GBFS upstream fetch forwards a non-redirect error status for the middleware to relay', async () => {
const result = await fetchGbfsUpstream(STATION_URL, {
fetchImpl: async () => new Response(null, { status: 404 }),
});
assert.equal(result.status, 404);
assert.equal(result.contentType, 'application/json', 'a missing upstream content type defaults to JSON');
assert.equal(result.body, '');
});
test('GBFS upstream fetch rejects every redirect status and never fetches the target', async () => {
for (const status of [301, 302, 303, 307, 308]) {
let calls = 0;
await assert.rejects(
fetchGbfsUpstream(STATION_URL, {
fetchImpl: async () => {
calls += 1;
return new Response('moved', {
status,
headers: { Location: 'https://attacker.example/station_status.json' },
});
},
}),
(error) => error?.code === 'GBFS_REDIRECT' && error.redirectHost === 'attacker.example',
`status ${status} must be refused`,
);
assert.equal(calls, 1, `status ${status}: the redirect target must not be requested`);
}
});
test('GBFS upstream fetch names the host of a relative redirect', async () => {
await assert.rejects(
fetchGbfsUpstream(STATION_URL, {
fetchImpl: async () => new Response(null, { status: 302, headers: { Location: '/v2/station_status.json' } }),
}),
(error) => error?.code === 'GBFS_REDIRECT' && error.redirectHost === 'gbfs.lyft.com',
);
});
test('GBFS upstream fetch rejects an oversized Content-Length before reading the body', async () => {
const { response, pulls } = endlessResponse(new Uint8Array(1024));
response.headers.set('Content-Length', String(GBFS_MAX_BODY_BYTES + 1));
await assert.rejects(
fetchGbfsUpstream(STATION_URL, { fetchImpl: async () => response }),
(error) => error?.code === 'RESPONSE_TOO_LARGE',
);
assert.equal(pulls(), 0, 'the body must not be read when the declared length already exceeds the cap');
});
test('GBFS upstream fetch cancels a length-less body the moment it passes the cap', async () => {
let cancelled = false;
const chunk = new Uint8Array(1024);
const { response, pulls } = endlessResponse(chunk, () => { cancelled = true; });
await assert.rejects(
fetchGbfsUpstream(STATION_URL, { fetchImpl: async () => response, maxBytes: 4 * chunk.byteLength }),
(error) => error?.code === 'RESPONSE_TOO_LARGE',
);
assert.equal(cancelled, true, 'the upstream stream must be cancelled, not drained');
assert.ok(pulls() <= 8, `only a few chunks may be read past the cap, saw ${pulls()}`);
});
test('GBFS upstream fetch measures the cap in bytes and accepts a body exactly at it', async () => {
const body = 'é'.repeat(64);
const result = await fetchGbfsUpstream(STATION_URL, {
fetchImpl: async () => new Response(body, { status: 200 }),
maxBytes: Buffer.byteLength(body),
});
assert.equal(result.body, body);
assert.equal(GBFS_MAX_BODY_BYTES, 5 * 1024 * 1024);
});
test('GBFS upstream fetch aborts a stalled connection with the timeout signal', async () => {
const startedAt = Date.now();
await assert.rejects(
fetchGbfsUpstream(STATION_URL, {
timeoutMs: 20,
fetchImpl: (_url, options) => new Promise((_resolve, reject) => {
options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
}),
}),
(error) => error?.name === 'AbortError',
);
assert.ok(Date.now() - startedAt < 500, 'the timeout must settle promptly');
});