chore: post-2.0 QA hygiene across api hardening and qa metadata

This commit is contained in:
SnapOtter
2026-06-20 10:50:20 +08:00
parent 19d9ed181a
commit 0acc8ca751
11 changed files with 332 additions and 57 deletions
+4 -1
View File
@@ -238,7 +238,9 @@ app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) =>
await app.register(cors, {
origin: env.CORS_ORIGIN
? env.CORS_ORIGIN.split(",").map((s) => s.trim())
: process.env.NODE_ENV !== "production",
: process.env.NODE_ENV === "production"
? false
: [/^http:\/\/localhost:\d+$/],
});
// Security headers -- applied in all environments. HSTS is ignored over plain
@@ -286,6 +288,7 @@ await app.register(rateLimit, {
// Block TRACE method (returns 401 instead of 405 without this)
app.addHook("onRequest", async (request, reply) => {
if (request.method === "TRACE") {
reply.header("Allow", "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD");
return reply.status(405).send({ error: "Method not allowed" });
}
});
+3 -1
View File
@@ -37,8 +37,10 @@ export async function isToolAuditEnabled(): Promise<boolean> {
}
}
const AUDIT_STRIP_RE = /[<>&"'\r\n\0\x85\u2028\u2029]/g;
export function sanitizeAuditInput(raw: string): string {
return raw.replace(/[<>&"']/g, "").slice(0, MAX_AUDIT_INPUT_LENGTH) || "(empty)";
return raw.replace(AUDIT_STRIP_RE, "").slice(0, MAX_AUDIT_INPUT_LENGTH) || "(empty)";
}
/**
+67 -2
View File
@@ -1,6 +1,9 @@
import { lookup } from "node:dns/promises";
import { existsSync } from "node:fs";
import { isIP } from "node:net";
import PQueue from "p-queue";
import { type Browser, chromium } from "playwright";
import { type Browser, chromium, type Page } from "playwright";
import { isPrivateIp } from "./ssrf.js";
const MAX_PAGES = Math.max(1, parseInt(process.env.BROWSER_MAX_PAGES || "3", 10));
const CRASH_WINDOW_MS = 60_000;
@@ -56,6 +59,59 @@ function recordCrash(): void {
backoffUntil = now + delay;
}
async function isBlockedUrl(url: string): Promise<boolean> {
try {
const parsed = new URL(url);
if (
parsed.protocol === "data:" ||
parsed.protocol === "blob:" ||
parsed.protocol === "about:"
) {
return false;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return true;
}
const hostname = parsed.hostname.replace(/^\[|]$/g, "");
if (isIP(hostname)) {
return isPrivateIp(hostname);
}
try {
const result = await lookup(hostname, { all: true });
const entries = Array.isArray(result) ? result : [result];
return entries.some((e) => isPrivateIp(e.address));
} catch {
return true;
}
} catch {
return true;
}
}
async function installNetworkGuard(page: Page): Promise<void> {
await page.route("**/*", async (route) => {
const url = route.request().url();
if (await isBlockedUrl(url)) {
await route.abort("blockedbyclient").catch(() => {});
return;
}
try {
const response = await route.fetch();
const responseUrl = response.url();
if (responseUrl !== url && (await isBlockedUrl(responseUrl))) {
await route.abort("blockedbyclient").catch(() => {});
return;
}
await route.fulfill({ response });
} catch {
await route.abort("failed").catch(() => {});
}
});
await page.routeWebSocket("**/*", (ws) => {
ws.close();
});
}
async function getBrowser(): Promise<Browser> {
if (browserFailed) {
throw new Error("Browser service permanently disabled after repeated crashes");
@@ -67,7 +123,12 @@ async function getBrowser(): Promise<Browser> {
return browser;
}
browser = await chromium.launch({
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
args: [
"--no-sandbox",
"--disable-gpu",
"--disable-dev-shm-usage",
"--disable-background-networking",
],
});
browser.on("disconnected", () => {
browser = null;
@@ -88,7 +149,9 @@ export async function capturePage(url: string, options: CaptureOptions): Promise
const page = await b.newPage({
viewport: { width: options.viewportWidth, height: options.viewportHeight },
isMobile: options.isMobile,
serviceWorkers: "block",
});
await installNetworkGuard(page);
try {
await page.goto(url, { waitUntil: "load", timeout: PAGE_LOAD_TIMEOUT });
@@ -138,7 +201,9 @@ export async function captureHtml(html: string, options: CaptureOptions): Promis
const page = await b.newPage({
viewport: { width: options.viewportWidth, height: options.viewportHeight },
isMobile: options.isMobile,
serviceWorkers: "block",
});
await installNetworkGuard(page);
try {
await page.setContent(html, { waitUntil: "load", timeout: PAGE_LOAD_TIMEOUT });
+1
View File
@@ -46,6 +46,7 @@ const envSchema = z
MAX_WORKER_THREADS: z.coerce.number().default(0),
PROCESSING_TIMEOUT_S: z.coerce.number().default(0),
MAX_PIPELINE_STEPS: z.coerce.number().default(20),
MAX_PIPELINE_STEP_PIXELS: z.coerce.number().default(67_108_864),
MAX_CANVAS_PIXELS: z.coerce.number().default(0),
MAX_SVG_SIZE_MB: z.coerce.number().default(50),
MAX_SPLIT_GRID: z.coerce.number().default(100),
+6 -3
View File
@@ -50,6 +50,10 @@ function isPrivateIPv6(ip: string): boolean {
return false;
}
export function isPrivateIp(ip: string): boolean {
return isPrivateIPv4(ip) || isPrivateIPv6(ip);
}
/**
* Resolve a hostname and validate all returned IPs are public.
* Returns the first valid resolved IP so callers can pin it for the actual
@@ -58,7 +62,7 @@ function isPrivateIPv6(ip: string): boolean {
async function resolveAndCheck(hostname: string): Promise<string> {
const bare = hostname.replace(/^\[|]$/g, "");
if (isIP(bare)) {
if (isPrivateIPv4(bare) || isPrivateIPv6(bare)) {
if (isPrivateIp(bare)) {
throw new Error("URL resolves to a private or reserved IP address");
}
return bare;
@@ -67,8 +71,7 @@ async function resolveAndCheck(hostname: string): Promise<string> {
const result = await lookup(hostname, { all: true });
const addresses = Array.isArray(result) ? result : [result];
for (const entry of addresses) {
const addr = entry.address;
if (isPrivateIPv4(addr) || isPrivateIPv6(addr)) {
if (isPrivateIp(entry.address)) {
throw new Error("URL resolves to a private or reserved IP address");
}
}
+7
View File
@@ -15,6 +15,8 @@ function decodeNumericEntities(input: string): string {
* Sanitize an SVG buffer to prevent XXE, SSRF, and script injection.
* Throws if the SVG exceeds the maximum allowed size.
*/
const MAX_SVG_ELEMENTS = 5_000;
export function sanitizeSvg(buffer: Buffer): Buffer {
const maxSvgSize = env.MAX_SVG_SIZE_MB > 0 ? env.MAX_SVG_SIZE_MB * 1024 * 1024 : Infinity;
if (buffer.length > maxSvgSize) {
@@ -25,6 +27,11 @@ export function sanitizeSvg(buffer: Buffer): Buffer {
// ── Pre-processing: strip CDATA sections and decode numeric entities ──
// CDATA sections can hide script content from regex-based checks.
svg = svg.replace(/<!\[CDATA\[[\s\S]*?\]\]>/gi, "");
const elementCount = (svg.match(/<[a-zA-Z][^>]*\/?>/g) || []).length;
if (elementCount > MAX_SVG_ELEMENTS) {
throw new Error(`SVG exceeds maximum element count of ${MAX_SVG_ELEMENTS}`);
}
// Decode numeric entities so obfuscated URIs (e.g. &#106;avascript:) are visible.
svg = decodeNumericEntities(svg);
+9
View File
@@ -5876,6 +5876,7 @@ paths:
type: array
items:
type: string
nullable: true
expiresAt:
type: string
format: date-time
@@ -5923,6 +5924,7 @@ paths:
type: array
items:
type: string
nullable: true
createdAt:
type: string
format: date-time
@@ -6739,19 +6741,26 @@ paths:
type: string
actorId:
type: string
nullable: true
actorUsername:
type: string
action:
type: string
targetType:
type: string
nullable: true
targetId:
type: string
nullable: true
details:
type: object
nullable: true
ipAddress:
type: string
nullable: true
requestId:
type: string
nullable: true
createdAt:
type: string
format: date-time
+13
View File
@@ -413,6 +413,19 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// MFA plugin not loaded
}
const cookieReply = reply as FastifyReply & {
setCookie?: (name: string, value: string, opts: Record<string, unknown>) => FastifyReply;
};
if (typeof cookieReply.setCookie === "function") {
cookieReply.setCookie("snapotter-session", token, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: env.EXTERNAL_URL.startsWith("https"),
maxAge: SESSION_DURATION_MS / 1000,
});
}
return reply.send({
token,
user: {
+22
View File
@@ -387,6 +387,17 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
});
}
if (env.MAX_PIPELINE_STEP_PIXELS > 0) {
const s = settingsResult.data as Record<string, unknown>;
const w = Number(s.width) || 0;
const h = Number(s.height) || 0;
if (w > 0 && h > 0 && w * h > env.MAX_PIPELINE_STEP_PIXELS) {
return reply.status(400).send({
error: `Step ${i + 1} (${step.toolId}): Output dimensions ${w}x${h} exceed per-step pixel limit`,
});
}
}
parsedSteps.push({
toolId: step.toolId,
resolvedToolId,
@@ -787,6 +798,17 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
});
}
if (env.MAX_PIPELINE_STEP_PIXELS > 0) {
const s = settingsResult.data as Record<string, unknown>;
const w = Number(s.width) || 0;
const h = Number(s.height) || 0;
if (w > 0 && h > 0 && w * h > env.MAX_PIPELINE_STEP_PIXELS) {
return reply.status(400).send({
error: `Step ${i + 1} (${step.toolId}): Output dimensions ${w}x${h} exceed per-step pixel limit`,
});
}
}
parsedSteps.push({
toolId: step.toolId,
resolvedToolId,
+16 -1
View File
@@ -28,6 +28,10 @@ const SENSITIVE_KEYS = new Set([
"siem_webhook_auth",
]);
const REDACTED_KEYS = new Set(["cookie_secret", "oidc_client_secret", "siem_webhook_auth"]);
const READONLY_KEYS = new Set(["cookie_secret", "instance_id"]);
async function encryptIfSensitive(key: string, value: string): Promise<string> {
if (!env.DATA_ENCRYPTION_KEY || !SENSITIVE_KEYS.has(key)) return value;
return encrypt(value, env.DATA_ENCRYPTION_KEY);
@@ -57,6 +61,10 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
const settings: Record<string, string> = {};
for (const row of rows) {
if (!isAdmin && SENSITIVE_KEYS.has(row.key)) continue;
if (REDACTED_KEYS.has(row.key)) {
settings[row.key] = "********";
continue;
}
settings[row.key] = await decryptIfNeeded(row.value);
}
@@ -92,6 +100,13 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
});
}
if (READONLY_KEYS.has(key)) {
return reply.status(400).send({
error: `Setting "${key}" cannot be modified via the API`,
code: "READONLY_SETTING",
});
}
entries.push({ key, strValue });
}
@@ -152,7 +167,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
return reply.send({
key: row.key,
value: await decryptIfNeeded(row.value),
value: REDACTED_KEYS.has(row.key) ? "********" : await decryptIfNeeded(row.value),
updatedAt: row.updatedAt.toISOString(),
});
},