Files
TREK/server/src/utils/cappedFetch.ts
T
Maurice 38f3cb3101 fix(server): the second pass over this branch's own fixes
An adversarial review of what had already landed here turned up three that
were wrong, and they are the reason for this commit.

The CSP change removed 'unsafe-eval', which is not leftover: heic-to's
libheif build initialises embind through new Function(), and without it every
iPhone .heic upload fails in the browser. 'wasm-unsafe-eval' alone was tried
once already and was not enough (93b51a0b). Put back, with the reason written
down this time and the test pinning it rather than its absence.

The idempotency in-flight guard never fired. finalize() runs when the
handler's observable completes, but Nest writes the response - and therefore
stores the row - several microtasks later, so the waiting request always woke
up before the row existed and ran the handler a second time. It releases from
the point the row is written now; finalize is the backstop for handlers that
answer another way. The test drove the two in the opposite order from Nest,
which is why it passed either way.

And the trip-scoping check treated an id that resolves to nothing like one
belonging to someone else. accommodation_id has no foreign key, so shortening
a trip's dates leaves a booking pointing at a gap - which update() has always
healed by clearing the link, and which would have become a permanent 400.

The rest: a reply to a soft-deleted chat message is refused and the create
path blanks the quote like the list path does; the ws-token buckets are keyed
by account rather than by IP, where every user behind one NAT shared a
ceiling; the weather and vacay caps read through the stream, since a chunked
response has no content-length to check; the Google host gate takes country
domains again; abandoned response bodies are cancelled instead of left to GC;
the boot failure prints its errno; and a share link that is not yours to read
now says so instead of reporting that the journey has none.

TICKETJSON gets a real repair: the in-place GLOB fix only helps installs that
have not run it yet, so an appended migration puts back the note text the
case-insensitive LIKE cut off, for rows where what it stored is not valid
JSON. A genuine receipt always parses, so it cannot be touched.

Wiki: the audit log records the IP Express resolves after TRUST_PROXY, not
the leftmost X-Forwarded-For entry a caller can write. That is the right way
round, but it means the hop count has to be accurate - said plainly in both
places now.
2026-08-23 11:59:12 +02:00

115 lines
4.5 KiB
TypeScript

/**
* Response size caps for outbound fetches.
*
* Every outbound request has to bound what it buffers, and there are two
* things worth bounding: the length the provider declares (cheap, but a
* chunked response omits it) and the bytes that actually arrive. The
* declared-length check is the pattern transit.service.ts established; the
* streaming read is the one the plugin installer needed for codeload
* archives, lifted here so the other fetch sites can share it.
*/
/** Minimal shape of the bits of Response these helpers touch (test stubs are partial). */
interface CappedReader {
read(): Promise<{ done?: boolean; value?: Uint8Array }>;
cancel(): Promise<unknown>;
}
interface CappedResponse {
headers?: { get(name: string): string | null } | null;
body?: { getReader(): CappedReader; cancel?(): Promise<unknown> } | null;
arrayBuffer?(): Promise<ArrayBuffer>;
text?(): Promise<string>;
json?(): Promise<unknown>;
}
/** True when the provider declares a body larger than the budget. */
export function exceedsDeclaredLength(res: CappedResponse, maxBytes: number): boolean {
return Number(res.headers?.get('content-length') ?? 0) > maxBytes;
}
/**
* Let go of a body nobody is going to read. undici keeps the connection
* reserved until the stream ends, so a bail-out that just returns leaves the
* socket pinned until the garbage collector gets around to it — which is the
* one thing a size cap is supposed to prevent.
*/
export function discardBody(res: CappedResponse): void {
void res.body?.cancel?.().catch(() => {});
}
/**
* readCappedText plus the parse, for the providers that only ever answer with a
* JSON document. Answers `undefined` when the body is over the cap, was cut off
* at it, or does not parse: every caller already has a "the provider
* misbehaved" branch, and none of them can do anything with half a document.
*/
export async function readCappedJson<T>(res: CappedResponse, maxBytes: number): Promise<T | undefined> {
if (exceedsDeclaredLength(res, maxBytes)) {
discardBody(res);
return undefined;
}
// Neither a stream nor text() means there is nothing to read incrementally
// and nothing to cap — a null-body status, or a partial stub in a test.
if (!res.body?.getReader && !res.text && !res.arrayBuffer && res.json) {
return await res.json() as T;
}
const { text, truncated } = await readCappedText(res, maxBytes);
if (truncated) return undefined;
try {
return JSON.parse(text) as T;
} catch {
return undefined;
}
}
/**
* Read a body, stopping once maxBytes have arrived. Callers that must reject
* an oversized payload look at `truncated`; callers that only scan the head of
* the document (og: tags, a coordinate regex) can use the partial bytes and
* degrade to whatever they find. Responses without a stream fall back to
* arrayBuffer(), which is post-checked instead.
*/
export async function readCapped(res: CappedResponse, maxBytes: number): Promise<{ bytes: Buffer; truncated: boolean }> {
const reader = res.body?.getReader();
if (!reader) {
const raw = res.arrayBuffer ? Buffer.from(await res.arrayBuffer()) : Buffer.from(res.text ? await res.text() : '');
return raw.length > maxBytes
? { bytes: raw.subarray(0, maxBytes), truncated: true }
: { bytes: raw, truncated: false };
}
const chunks: Buffer[] = [];
let received = 0;
let truncated = false;
for (;;) {
const { done, value } = await reader.read();
if (done || !value) break;
chunks.push(Buffer.from(value));
received += value.length;
if (received > maxBytes) {
truncated = true;
await reader.cancel().catch(() => {});
break;
}
}
const bytes = Buffer.concat(chunks);
return { bytes: truncated ? bytes.subarray(0, maxBytes) : bytes, truncated };
}
/**
* readCapped for text bodies. Decodes as UTF-8, so a chopped trailing sequence
* comes back as a replacement character rather than as a throw. Without a
* stream it reads text() directly instead of going through readCapped, which
* prefers arrayBuffer().
*/
export async function readCappedText(res: CappedResponse, maxBytes: number): Promise<{ text: string; truncated: boolean }> {
if (!res.body?.getReader && res.text) {
const raw = Buffer.from(await res.text(), 'utf8');
return raw.length > maxBytes
? { text: new TextDecoder('utf-8').decode(raw.subarray(0, maxBytes)), truncated: true }
: { text: raw.toString('utf8'), truncated: false };
}
const { bytes, truncated } = await readCapped(res, maxBytes);
return { text: new TextDecoder('utf-8').decode(bytes), truncated };
}