fix(security): harden API against pentest findings

- Default TRUST_PROXY=false to prevent XFF rate limit bypass (PT-01)
- Return 400 instead of 500 on malformed JSON input (PT-03)
- Default MAX_PIPELINE_STEPS=20 to prevent DoS (PT-04)
- Validate clientJobId length (max 128) across all routes (PT-06)
- Add security headers to all reply.hijack() streaming responses (PT-07)
- Sanitize usernames in audit log to prevent stored XSS (PT-08)
- Block TRACE method with 405 response (PT-10)
- Add 429 RateLimited response to OpenAPI spec (PT-12)
- Default MAX_SVG_SIZE_MB=50 to limit SVGZ decompression (PT-13)
- Pin Dockerfile base images by digest
- Sanitize OIDC IdP error and sub claim in audit log
- Sync Docker compose/Dockerfile defaults with env.ts
This commit is contained in:
SnapOtter
2026-06-07 21:54:27 +08:00
parent 19f40f58c9
commit ace41168bc
20 changed files with 122 additions and 24 deletions
+11 -2
View File
@@ -137,8 +137,10 @@ app.addContentTypeParser("application/json", { parseAs: "string" }, (_request, b
try {
const str = typeof body === "string" ? body : (body as Buffer).toString();
done(null, str.length > 0 ? JSON.parse(str) : {});
} catch (err) {
done(err as Error, undefined);
} catch {
const parseErr = new Error("Malformed JSON in request body") as Error & { statusCode: number };
parseErr.statusCode = 400;
done(parseErr, undefined);
}
});
@@ -188,6 +190,13 @@ await app.register(rateLimit, {
allowList: (request) => !request.url.startsWith("/api/"),
});
// Block TRACE method (returns 401 instead of 405 without this)
app.addHook("onRequest", async (request, reply) => {
if (request.method === "TRACE") {
return reply.status(405).send({ error: "Method not allowed" });
}
});
// Multipart upload support
await registerUpload(app);
+6
View File
@@ -2,6 +2,12 @@ import { randomUUID } from "node:crypto";
import type { FastifyBaseLogger } from "fastify";
import { db, schema } from "../db/index.js";
const MAX_AUDIT_INPUT_LENGTH = 200;
export function sanitizeAuditInput(raw: string): string {
return raw.replace(/[<>&"']/g, "").slice(0, MAX_AUDIT_INPUT_LENGTH) || "(empty)";
}
type AuditEvent =
| "LOGIN_SUCCESS"
| "LOGIN_FAILED"
+12
View File
@@ -25,3 +25,15 @@ export function buildCsp(isDocs: boolean): string {
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https://tile.openstreetmap.org; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`;
}
export function getSecurityHeaders(): Record<string, string> {
return {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "0",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Content-Security-Policy": buildCsp(false),
};
}
+3 -3
View File
@@ -44,9 +44,9 @@ const envSchema = z
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
MAX_WORKER_THREADS: z.coerce.number().default(0),
PROCESSING_TIMEOUT_S: z.coerce.number().default(0),
MAX_PIPELINE_STEPS: z.coerce.number().default(0),
MAX_PIPELINE_STEPS: z.coerce.number().default(20),
MAX_CANVAS_PIXELS: z.coerce.number().default(0),
MAX_SVG_SIZE_MB: z.coerce.number().default(0),
MAX_SVG_SIZE_MB: z.coerce.number().default(50),
MAX_SPLIT_GRID: z.coerce.number().default(100),
MAX_STORAGE_PER_USER_MB: z.coerce.number().default(5000),
MAX_WORKSPACE_SIZE_GB: z.coerce.number().default(10),
@@ -55,7 +55,7 @@ const envSchema = z
LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(30),
TRUST_PROXY: z
.enum(["true", "false"])
.default("true")
.default("false")
.transform((v) => v === "true"),
OIDC_ENABLED: z
.enum(["true", "false"])
+25
View File
@@ -59,6 +59,31 @@ components:
scheme: bearer
description: Session token from login or API key (prefixed with si_)
responses:
RateLimited:
description: Too many requests. The client has exceeded the rate limit.
headers:
Retry-After:
schema:
type: integer
description: Seconds until the rate limit window resets
X-RateLimit-Limit:
schema:
type: integer
description: Maximum requests per time window
X-RateLimit-Remaining:
schema:
type: integer
description: Remaining requests in current window
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Rate limit exceeded
schemas:
Error:
type: object
+9 -3
View File
@@ -5,7 +5,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import { auditLog, sanitizeAuditInput } from "../lib/audit.js";
import { getPermissions, requirePermission } from "../permissions.js";
const scryptAsync = promisify(scrypt);
@@ -221,13 +221,19 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
.get();
if (!user || !user.passwordHash) {
auditLog(request.log, "LOGIN_FAILED", { username: body.username, reason: "unknown_user" });
auditLog(request.log, "LOGIN_FAILED", {
username: sanitizeAuditInput(body.username),
reason: "unknown_user",
});
return reply.status(401).send({ error: "Invalid credentials" });
}
const valid = await verifyPassword(body.password, user.passwordHash);
if (!valid) {
auditLog(request.log, "LOGIN_FAILED", { username: body.username, reason: "bad_password" });
auditLog(request.log, "LOGIN_FAILED", {
username: sanitizeAuditInput(body.username),
reason: "bad_password",
});
return reply.status(401).send({ error: "Invalid credentials" });
}
+8 -3
View File
@@ -5,7 +5,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import * as oidc from "openid-client";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import { auditLog, sanitizeAuditInput } from "../lib/audit.js";
import { createSessionToken } from "./auth.js";
// ── Types ─────────────────────────────────────────────────────────
@@ -229,7 +229,9 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
{ error: query.error, description: query.error_description },
"OIDC IdP returned error",
);
auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: query.error });
auditLog(request.log, "OIDC_LOGIN_FAILED", {
reason: sanitizeAuditInput(String(query.error)),
});
return redirectToLogin(reply, "oidc_auth_failed");
}
@@ -370,7 +372,10 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
// 4d. No user found and no auto-create
if (!userId) {
request.log.warn({ sub, email }, "OIDC user not authorized");
auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "user_not_authorized", sub });
auditLog(request.log, "OIDC_LOGIN_FAILED", {
reason: "user_not_authorized",
sub: sanitizeAuditInput(String(sub)),
});
return redirectToLogin(reply, "oidc_user_not_authorized");
}
+6 -1
View File
@@ -16,6 +16,7 @@ import PQueue from "p-queue";
import sharp from "sharp";
import { env } from "../config.js";
import { autoOrient } from "../lib/auto-orient.js";
import { getSecurityHeaders } from "../lib/csp.js";
import { resolveConcurrency } from "../lib/env.js";
import { formatZodErrors } from "../lib/errors.js";
import { isToolInstalled } from "../lib/feature-status.js";
@@ -84,7 +85,10 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (typeof raw === "string" && raw.length > 0 && raw.length <= 128) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -269,6 +273,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
"Transfer-Encoding": "chunked",
"X-Job-Id": jobId,
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
...getSecurityHeaders(),
});
const archive = archiver("zip", { zlib: { level: 5 } });
+10 -2
View File
@@ -19,6 +19,7 @@ import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { trackEvent } from "../lib/analytics.js";
import { autoOrient } from "../lib/auto-orient.js";
import { getSecurityHeaders } from "../lib/csp.js";
import { resolveConcurrency } from "../lib/env.js";
import { formatZodErrors } from "../lib/errors.js";
import { isToolInstalled } from "../lib/feature-status.js";
@@ -91,7 +92,10 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
} else if (part.fieldname === "pipeline") {
pipelineRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (typeof raw === "string" && raw.length > 0 && raw.length <= 128) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -476,7 +480,10 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
} else if (part.fieldname === "pipeline") {
pipelineRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (typeof raw === "string" && raw.length > 0 && raw.length <= 128) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -732,6 +739,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
"Transfer-Encoding": "chunked",
"X-Job-Id": jobId,
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
...getSecurityHeaders(),
});
const archive = archiver("zip", { zlib: { level: 5 } });
+2
View File
@@ -11,6 +11,7 @@
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../db/index.js";
import { getSecurityHeaders } from "../lib/csp.js";
export interface JobProgress {
jobId: string;
@@ -230,6 +231,7 @@ export async function registerProgressRoutes(app: FastifyInstance): Promise<void
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
...getSecurityHeaders(),
});
// Helper to send an SSE message
+4 -1
View File
@@ -147,7 +147,10 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
fileId = part.value as string;
}
if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (typeof raw === "string" && raw.length > 0 && raw.length <= 128) {
clientJobId = raw;
}
}
}
}
+2
View File
@@ -3,6 +3,7 @@ import { extname } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { getSecurityHeaders } from "../../lib/csp.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
@@ -72,6 +73,7 @@ export function registerBulkRename(app: FastifyInstance) {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="renamed-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
...getSecurityHeaders(),
});
const archive = archiver("zip", { zlib: { level: 5 } });
+2
View File
@@ -4,6 +4,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { getSecurityHeaders } from "../../lib/csp.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
@@ -146,6 +147,7 @@ export function registerFavicon(app: FastifyInstance) {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="favicons-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
...getSecurityHeaders(),
});
const archive = archiver("zip", { zlib: { level: 5 } });
+4 -1
View File
@@ -57,7 +57,10 @@ export function registerOcr(app: FastifyInstance) {
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (typeof raw === "string" && raw.length > 0 && raw.length <= 128) {
clientJobId = raw;
}
}
}
} catch (err) {
+4 -1
View File
@@ -157,7 +157,10 @@ export function registerPassportPhoto(app: FastifyInstance) {
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (typeof raw === "string" && raw.length > 0 && raw.length <= 128) {
clientJobId = raw;
}
}
}
} catch (err) {
+2
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { getSecurityHeaders } from "../../lib/csp.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
@@ -162,6 +163,7 @@ export function registerSplit(app: FastifyInstance) {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="split-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
...getSecurityHeaders(),
});
const archive = archiver("zip", { zlib: { level: 5 } });
+6 -1
View File
@@ -7,6 +7,7 @@ import PQueue from "p-queue";
import sharp from "sharp";
import { z } from "zod";
import { env } from "../../config.js";
import { getSecurityHeaders } from "../../lib/csp.js";
import { resolveConcurrency } from "../../lib/env.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
@@ -129,7 +130,10 @@ export function registerSvgToRaster(app: FastifyInstance) {
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (typeof raw === "string" && raw.length > 0 && raw.length <= 128) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -321,6 +325,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
"Transfer-Encoding": "chunked",
"X-Job-Id": jobId,
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
...getSecurityHeaders(),
});
const archive = archiver("zip", { zlib: { level: 5 } });