70 lines
2.7 KiB
TypeScript
70 lines
2.7 KiB
TypeScript
export class HttpError extends Error {
|
|
constructor(readonly status: number, message: string, readonly retryAfter?: number) {
|
|
super(message);
|
|
this.name = "HttpError";
|
|
}
|
|
}
|
|
|
|
export function securityLog(event: string, details: { actorId?: string; targetId?: string; status?: number } = {}) {
|
|
console.info(JSON.stringify({ type: "security", event, ...details, at: new Date().toISOString() }));
|
|
}
|
|
|
|
export function errorResponse(cause: unknown): Response {
|
|
const error = cause instanceof HttpError ? cause : new HttpError(503, "service-unavailable");
|
|
if (!(cause instanceof HttpError)) securityLog("dependency-unavailable", { status: 503 });
|
|
return Response.json({ error: error.message }, {
|
|
status: error.status,
|
|
headers: {
|
|
"Cache-Control": "no-store",
|
|
...(error.retryAfter === undefined ? {} : { "Retry-After": String(error.retryAfter) }),
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Mutations must come from the configured application, never the request Host. */
|
|
export function requireSameOrigin(request: Request) {
|
|
const configured = process.env.BETTER_AUTH_URL;
|
|
if (!configured) throw new HttpError(503, "origin-not-configured");
|
|
const origin = request.headers.get("origin");
|
|
if (origin !== new URL(configured).origin || request.headers.get("sec-fetch-site") === "cross-site") {
|
|
throw new HttpError(403, "cross-origin-request");
|
|
}
|
|
}
|
|
|
|
/** Count actual streamed bytes as well as checking the untrusted Content-Length. */
|
|
export function boundedBody(request: Request, maximum: number): Response {
|
|
const length = request.headers.get("content-length");
|
|
if (length && (!/^\d+$/u.test(length) || Number(length) > maximum)) {
|
|
throw new HttpError(413, "body-too-large");
|
|
}
|
|
let bytes = 0;
|
|
const stream = request.body?.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
|
|
transform(chunk, controller) {
|
|
bytes += chunk.byteLength;
|
|
if (bytes > maximum) throw new HttpError(413, "body-too-large");
|
|
controller.enqueue(chunk);
|
|
},
|
|
}));
|
|
return new Response(stream ?? null, { headers: request.headers });
|
|
}
|
|
|
|
export async function readJson(request: Request, maximum = 16 * 1024): Promise<unknown> {
|
|
if (request.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "application/json") {
|
|
throw new HttpError(415, "expected-json");
|
|
}
|
|
try {
|
|
return await boundedBody(request, maximum).json();
|
|
} catch (cause) {
|
|
if (cause instanceof HttpError) throw cause;
|
|
throw new HttpError(400, "invalid-json");
|
|
}
|
|
}
|
|
|
|
let uploads = 0;
|
|
/** Acquire before buffering multipart bodies or decoding images. */
|
|
export async function withUploadSlot<T>(task: () => Promise<T>): Promise<T> {
|
|
if (uploads >= 2) throw new HttpError(429, "uploads-busy", 5);
|
|
uploads += 1;
|
|
try { return await task(); } finally { uploads -= 1; }
|
|
}
|