feat : 6 astra improve it
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
export function securityHeaders(production: boolean) {
|
||||
const ads = "https://*.googlesyndication.com https://*.doubleclick.net https://*.googleadservices.com https://*.googletagservices.com https://www.google.com";
|
||||
const policy = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'unsafe-inline' ${production ? "" : "'unsafe-eval'"} https://challenges.cloudflare.com ${ads}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
`img-src 'self' data: blob: https://buzz-cdn.astrxl.dev ${ads}`,
|
||||
"font-src 'self' data:",
|
||||
`connect-src 'self' https://buzz-cdn.astrxl.dev https://challenges.cloudflare.com ${ads}${production ? "" : " ws: wss:"}`,
|
||||
`frame-src https://challenges.cloudflare.com https://www.youtube-nocookie.com ${ads}`,
|
||||
"media-src 'self' blob:",
|
||||
"worker-src 'self' blob:",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
...(production ? ["upgrade-insecure-requests"] : []),
|
||||
].join("; ");
|
||||
return [
|
||||
{ key: "Content-Security-Policy", value: policy },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()" },
|
||||
...(production ? [{ key: "Strict-Transport-Security", value: "max-age=31536000" }] : []),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { boundedBody, errorResponse, HttpError, readJson, requireSameOrigin, withUploadSlot } from "./http";
|
||||
import { trustedClientAddress } from "./rate-limit";
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe("request boundaries", () => {
|
||||
it("compares origin to configuration, not attacker-controlled Host", () => {
|
||||
vi.stubEnv("BETTER_AUTH_URL", "https://guide.example.test");
|
||||
expect(() => requireSameOrigin(new Request("https://evil.test", { headers: { origin: "https://evil.test", host: "evil.test" } }))).toThrow("cross-origin");
|
||||
expect(() => requireSameOrigin(new Request("http://internal", { headers: { origin: "https://guide.example.test" } }))).not.toThrow();
|
||||
expect(() => requireSameOrigin(new Request("http://internal"))).toThrow("cross-origin");
|
||||
});
|
||||
|
||||
it("rejects actual streamed bytes even with a forged Content-Length", async () => {
|
||||
const body = new ReadableStream<Uint8Array>({ start(controller) {
|
||||
controller.enqueue(new Uint8Array(8));
|
||||
controller.enqueue(new Uint8Array(8));
|
||||
controller.close();
|
||||
} });
|
||||
const request = new Request("https://test.invalid", {
|
||||
method: "POST", body, headers: { "content-length": "1" }, duplex: "half",
|
||||
} as RequestInit & { duplex: string });
|
||||
await expect(boundedBody(request, 10).arrayBuffer()).rejects.toMatchObject({ status: 413 });
|
||||
});
|
||||
|
||||
it("rejects invalid JSON and unsupported content types", async () => {
|
||||
await expect(readJson(new Request("https://test.invalid", { method: "POST", body: "{}" }))).rejects.toMatchObject({ status: 415 });
|
||||
await expect(readJson(new Request("https://test.invalid", { method: "POST", headers: { "content-type": "application/json" }, body: "{" }))).rejects.toMatchObject({ status: 400 });
|
||||
const oversized = new Request("https://test.invalid", {
|
||||
method: "POST", headers: { "content-type": "application/json", "content-length": "1" },
|
||||
body: JSON.stringify({ value: "a".repeat(32) }),
|
||||
});
|
||||
await expect(readJson(oversized, 16)).rejects.toMatchObject({ status: 413 });
|
||||
});
|
||||
|
||||
it("bounds concurrent upload work and releases slots after failure", async () => {
|
||||
let finish!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => { finish = resolve; });
|
||||
const first = withUploadSlot(() => blocked);
|
||||
const second = withUploadSlot(() => blocked);
|
||||
await expect(withUploadSlot(async () => null)).rejects.toMatchObject({ status: 429 });
|
||||
finish();
|
||||
await Promise.all([first, second]);
|
||||
await expect(withUploadSlot(async () => { throw new Error("decode failed"); })).rejects.toThrow();
|
||||
await expect(withUploadSlot(async () => "ok")).resolves.toBe("ok");
|
||||
});
|
||||
|
||||
it("returns retry timing and hides internal errors", async () => {
|
||||
const response = errorResponse(new HttpError(429, "too-many-requests", 10));
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.headers.get("retry-after")).toBe("10");
|
||||
expect(await errorResponse(new Error("secret password")).text()).not.toContain("password");
|
||||
});
|
||||
|
||||
it("ignores spoofed forwarding headers unless explicitly configured", () => {
|
||||
vi.stubEnv("TRUSTED_CLIENT_IP_HEADER", "");
|
||||
expect(trustedClientAddress(new Headers({ "x-forwarded-for": "1.2.3.4" }))).toBe("unknown");
|
||||
vi.stubEnv("TRUSTED_CLIENT_IP_HEADER", "x-real-ip");
|
||||
expect(trustedClientAddress(new Headers({ "x-real-ip": "1.2.3.4, 5.6.7.8" }))).toBe("unknown");
|
||||
expect(trustedClientAddress(new Headers({ "x-real-ip": "::ffff:192.0.2.1" }))).toBe("192.0.2.1");
|
||||
expect(trustedClientAddress(new Headers({ "x-real-ip": "2001:db8:1:2::1234" })))
|
||||
.toBe(trustedClientAddress(new Headers({ "x-real-ip": "2001:0db8:0001:0002:0:0:0:1235" })));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
import { getRedisClient } from "@/lib/redis/client";
|
||||
import { HttpError } from "./http";
|
||||
|
||||
const consumeScript = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
||||
local ttl = redis.call('PTTL', KEYS[1])
|
||||
if ttl < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]); ttl = tonumber(ARGV[1]) end
|
||||
return {count, ttl}
|
||||
`;
|
||||
|
||||
export function trustedClientAddress(headers: Headers): string {
|
||||
// Only enable this after the origin is restricted to the sanitizing proxy.
|
||||
const header = process.env.TRUSTED_CLIENT_IP_HEADER;
|
||||
const address = header ? headers.get(header)?.trim() : undefined;
|
||||
if (!address || address.includes("%") || !isIP(address)) return "unknown";
|
||||
if (isIP(address) === 4) return address;
|
||||
// URL normalizes alternate IPv6 spellings. Collapse residential /64s.
|
||||
const normalized = new URL(`http://[${address}]/`).hostname.slice(1, -1);
|
||||
if (normalized.startsWith("::ffff:")) {
|
||||
const groups = normalized.slice(7).split(":").map((part) => parseInt(part, 16));
|
||||
return `${groups[0] >> 8}.${groups[0] & 255}.${groups[1] >> 8}.${groups[1] & 255}`;
|
||||
}
|
||||
const [left, right = ""] = normalized.split("::");
|
||||
const first = left ? left.split(":") : [];
|
||||
const last = right ? right.split(":") : [];
|
||||
const groups = [...first, ...Array(8 - first.length - last.length).fill("0"), ...last];
|
||||
return `${groups.slice(0, 4).join(":")}/64`;
|
||||
}
|
||||
|
||||
export async function consumeRateLimit(key: string, rule: { window: number; max: number }) {
|
||||
const digest = createHash("sha256").update(key).digest("hex");
|
||||
const redis = await getRedisClient();
|
||||
const result = await redis.eval(consumeScript, 1,
|
||||
`${process.env.REDIS_SECURITY_PREFIX || "buzz:security"}:rate:${digest}`, rule.window * 1000) as [number, number];
|
||||
return { allowed: result[0] <= rule.max, retryAfter: result[0] <= rule.max ? null : Math.max(1, Math.ceil(result[1] / 1000)) };
|
||||
}
|
||||
|
||||
export async function limitRequest(scope: string, identity: string, max: number, window = 60) {
|
||||
const result = await consumeRateLimit(`${scope}:${identity}`, { window, max });
|
||||
if (!result.allowed) throw new HttpError(429, "too-many-requests", result.retryAfter ?? window);
|
||||
}
|
||||
Reference in New Issue
Block a user