feat : 6 astra improve it
CI / Verify and audit (push) Successful in 2m33s
CI / Build, scan and deploy immutable images (push) Failing after 1m29s

This commit is contained in:
2026-09-22 18:28:18 +07:00 Unverified
parent 88afa8e947
commit 87e6bcd96f
56 changed files with 1351 additions and 511 deletions
+27
View File
@@ -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" }] : []),
];
}
+65
View File
@@ -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" })));
});
});
+69
View File
@@ -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; }
}
+45
View File
@@ -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);
}