+12








fcd7c979df
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
158 lines
6.2 KiB
TypeScript
158 lines
6.2 KiB
TypeScript
import { Buffer } from "node:buffer";
|
|
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
|
import type { Response } from "undici";
|
|
import { CustomWidgetDomainError } from "./errors";
|
|
|
|
export const MAX_RESPONSE_BODY_BYTES = 1024 * 1024;
|
|
export const MAX_RESPONSE_JSON_DEPTH = 32;
|
|
export const MAX_RESPONSE_JSON_NODES = 50_000;
|
|
|
|
export function assertJsonBudget(value: unknown): void {
|
|
let nodes = 0;
|
|
const visit = (current: unknown, depth: number): void => {
|
|
if (++nodes > MAX_RESPONSE_JSON_NODES)
|
|
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too large" });
|
|
if (depth > MAX_RESPONSE_JSON_DEPTH)
|
|
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too deeply nested" });
|
|
if (Array.isArray(current)) current.forEach((entry) => visit(entry, depth + 1));
|
|
else if (current !== null && typeof current === "object")
|
|
Object.values(current).forEach((entry) => visit(entry, depth + 1));
|
|
};
|
|
visit(value, 0);
|
|
}
|
|
|
|
async function readLimitedBody(response: Response): Promise<string> {
|
|
const length = Number(response.headers.get("content-length"));
|
|
if (Number.isFinite(length) && length > MAX_RESPONSE_BODY_BYTES) throw tooLarge();
|
|
if (!response.body) return "";
|
|
const reader = response.body.getReader();
|
|
const chunks: Uint8Array[] = [];
|
|
let size = 0;
|
|
while (true) {
|
|
const chunk = await reader.read();
|
|
if (chunk.done) break;
|
|
size += chunk.value.byteLength;
|
|
if (size > MAX_RESPONSE_BODY_BYTES) {
|
|
await reader.cancel();
|
|
throw tooLarge();
|
|
}
|
|
chunks.push(chunk.value);
|
|
}
|
|
const bytes = new Uint8Array(size);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
bytes.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
}
|
|
|
|
/** Bound every decoding stage as well as the on-wire response body. */
|
|
export function decodeResponseBody(body: ArrayBuffer, contentEncoding: string | null): ArrayBuffer | Buffer {
|
|
if (!contentEncoding || body.byteLength === 0) return body;
|
|
const encodings = contentEncoding
|
|
.toLowerCase()
|
|
.split(",")
|
|
.map((value) => value.trim());
|
|
if (encodings.length > 3)
|
|
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Too many upstream content encodings" });
|
|
let decoded: ArrayBuffer | Buffer = body;
|
|
for (const encoding of encodings.toReversed()) {
|
|
try {
|
|
const options = { maxOutputLength: MAX_RESPONSE_BODY_BYTES };
|
|
switch (encoding) {
|
|
case "identity":
|
|
break;
|
|
case "gzip":
|
|
decoded = gunzipSync(decoded, options);
|
|
break;
|
|
case "deflate":
|
|
decoded = inflateSync(decoded, options);
|
|
break;
|
|
case "br":
|
|
decoded = brotliDecompressSync(decoded, options);
|
|
break;
|
|
default:
|
|
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Unsupported upstream content encoding" });
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof CustomWidgetDomainError) throw error;
|
|
if (error instanceof Error && "code" in error && error.code === "ERR_BUFFER_TOO_LARGE") throw tooLarge();
|
|
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Invalid compressed upstream response" });
|
|
}
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
export async function parseResponseBody(response: Response, textFallback = false): Promise<unknown> {
|
|
const text = await readLimitedBody(response);
|
|
if (!text) return null;
|
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
try {
|
|
if (contentType.includes("ndjson")) {
|
|
const json = text
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.map((line) => JSON.parse(line) as unknown);
|
|
assertJsonBudget(json);
|
|
return json;
|
|
}
|
|
const json = JSON.parse(text) as unknown;
|
|
assertJsonBudget(json);
|
|
return json;
|
|
} catch (error) {
|
|
if (error instanceof CustomWidgetDomainError) throw error;
|
|
if (!textFallback && contentType.includes("json")) {
|
|
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
|
|
}
|
|
return text;
|
|
}
|
|
}
|
|
|
|
const tooLarge = () =>
|
|
new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
|
|
|
|
export function redactResponseSecrets(data: unknown, secrets: Array<{ kind: string; value: string }>): unknown {
|
|
if (secrets.length === 0) return data;
|
|
const values = secrets.filter(({ value }) => value.length > 0);
|
|
const username = secrets.find(({ kind }) => kind === "username")?.value;
|
|
const password = secrets.find(({ kind }) => kind === "password")?.value;
|
|
if (username && password) values.push({ kind: "basic", value: `${username}:${password}` });
|
|
const sensitive = new Set<string>();
|
|
const embedded = new Set<string>();
|
|
for (const { kind, value } of values) {
|
|
for (const encoded of [
|
|
value,
|
|
JSON.stringify(value).slice(1, -1),
|
|
encodeURIComponent(value),
|
|
Buffer.from(value).toString("base64"),
|
|
Buffer.from(value).toString("base64").replace(/=+$/u, ""),
|
|
Buffer.from(value).toString("base64url"),
|
|
]) {
|
|
sensitive.add(encoded);
|
|
// Usernames and short credentials can be ordinary words or characters in response data.
|
|
if (kind === "authenticationQuery" || (kind !== "username" && value.length >= 12)) embedded.add(encoded);
|
|
}
|
|
}
|
|
const embeddedValues = [...embedded].toSorted((a, b) => b.length - a.length);
|
|
const redactEmbedded = (value: string) => {
|
|
for (const secret of embeddedValues) value = value.replaceAll(secret, "[REDACTED]");
|
|
return value;
|
|
};
|
|
const visit = (value: unknown): unknown => {
|
|
if (typeof value === "string") {
|
|
if (sensitive.has(value)) return "[REDACTED]";
|
|
return redactEmbedded(value);
|
|
}
|
|
if ((typeof value === "number" || typeof value === "boolean" || value === null) && sensitive.has(String(value)))
|
|
return "[REDACTED]";
|
|
if (Array.isArray(value)) return value.map(visit);
|
|
if (value !== null && typeof value === "object") {
|
|
return Object.fromEntries(Object.entries(value).map(([key, child]) => [redactEmbedded(key), visit(child)]));
|
|
}
|
|
return value;
|
|
};
|
|
return visit(data);
|
|
}
|