mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
+11
-2
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
Reference in New Issue
Block a user