mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +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
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user