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 {
|
try {
|
||||||
const str = typeof body === "string" ? body : (body as Buffer).toString();
|
const str = typeof body === "string" ? body : (body as Buffer).toString();
|
||||||
done(null, str.length > 0 ? JSON.parse(str) : {});
|
done(null, str.length > 0 ? JSON.parse(str) : {});
|
||||||
} catch (err) {
|
} catch {
|
||||||
done(err as Error, undefined);
|
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/"),
|
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
|
// Multipart upload support
|
||||||
await registerUpload(app);
|
await registerUpload(app);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { randomUUID } from "node:crypto";
|
|||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { db, schema } from "../db/index.js";
|
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 =
|
type AuditEvent =
|
||||||
| "LOGIN_SUCCESS"
|
| "LOGIN_SUCCESS"
|
||||||
| "LOGIN_FAILED"
|
| "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'`;
|
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"),
|
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
|
||||||
MAX_WORKER_THREADS: z.coerce.number().default(0),
|
MAX_WORKER_THREADS: z.coerce.number().default(0),
|
||||||
PROCESSING_TIMEOUT_S: 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_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_SPLIT_GRID: z.coerce.number().default(100),
|
||||||
MAX_STORAGE_PER_USER_MB: z.coerce.number().default(5000),
|
MAX_STORAGE_PER_USER_MB: z.coerce.number().default(5000),
|
||||||
MAX_WORKSPACE_SIZE_GB: z.coerce.number().default(10),
|
MAX_WORKSPACE_SIZE_GB: z.coerce.number().default(10),
|
||||||
@@ -55,7 +55,7 @@ const envSchema = z
|
|||||||
LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(30),
|
LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(30),
|
||||||
TRUST_PROXY: z
|
TRUST_PROXY: z
|
||||||
.enum(["true", "false"])
|
.enum(["true", "false"])
|
||||||
.default("true")
|
.default("false")
|
||||||
.transform((v) => v === "true"),
|
.transform((v) => v === "true"),
|
||||||
OIDC_ENABLED: z
|
OIDC_ENABLED: z
|
||||||
.enum(["true", "false"])
|
.enum(["true", "false"])
|
||||||
|
|||||||
@@ -59,6 +59,31 @@ components:
|
|||||||
scheme: bearer
|
scheme: bearer
|
||||||
description: Session token from login or API key (prefixed with si_)
|
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:
|
schemas:
|
||||||
Error:
|
Error:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
import { db, schema } from "../db/index.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";
|
import { getPermissions, requirePermission } from "../permissions.js";
|
||||||
|
|
||||||
const scryptAsync = promisify(scrypt);
|
const scryptAsync = promisify(scrypt);
|
||||||
@@ -221,13 +221,19 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!user || !user.passwordHash) {
|
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" });
|
return reply.status(401).send({ error: "Invalid credentials" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await verifyPassword(body.password, user.passwordHash);
|
const valid = await verifyPassword(body.password, user.passwordHash);
|
||||||
if (!valid) {
|
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" });
|
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 * as oidc from "openid-client";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
import { db, schema } from "../db/index.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";
|
import { createSessionToken } from "./auth.js";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────
|
||||||
@@ -229,7 +229,9 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
{ error: query.error, description: query.error_description },
|
{ error: query.error, description: query.error_description },
|
||||||
"OIDC IdP returned error",
|
"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");
|
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
|
// 4d. No user found and no auto-create
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
request.log.warn({ sub, email }, "OIDC user not authorized");
|
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");
|
return redirectToLogin(reply, "oidc_user_not_authorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import PQueue from "p-queue";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
import { autoOrient } from "../lib/auto-orient.js";
|
import { autoOrient } from "../lib/auto-orient.js";
|
||||||
|
import { getSecurityHeaders } from "../lib/csp.js";
|
||||||
import { resolveConcurrency } from "../lib/env.js";
|
import { resolveConcurrency } from "../lib/env.js";
|
||||||
import { formatZodErrors } from "../lib/errors.js";
|
import { formatZodErrors } from "../lib/errors.js";
|
||||||
import { isToolInstalled } from "../lib/feature-status.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") {
|
} else if (part.fieldname === "settings") {
|
||||||
settingsRaw = part.value as string;
|
settingsRaw = part.value as string;
|
||||||
} else if (part.fieldname === "clientJobId") {
|
} 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) {
|
} catch (err) {
|
||||||
@@ -269,6 +273,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
"X-Job-Id": jobId,
|
"X-Job-Id": jobId,
|
||||||
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
|
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
|
||||||
|
...getSecurityHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { env } from "../config.js";
|
|||||||
import { db, schema } from "../db/index.js";
|
import { db, schema } from "../db/index.js";
|
||||||
import { trackEvent } from "../lib/analytics.js";
|
import { trackEvent } from "../lib/analytics.js";
|
||||||
import { autoOrient } from "../lib/auto-orient.js";
|
import { autoOrient } from "../lib/auto-orient.js";
|
||||||
|
import { getSecurityHeaders } from "../lib/csp.js";
|
||||||
import { resolveConcurrency } from "../lib/env.js";
|
import { resolveConcurrency } from "../lib/env.js";
|
||||||
import { formatZodErrors } from "../lib/errors.js";
|
import { formatZodErrors } from "../lib/errors.js";
|
||||||
import { isToolInstalled } from "../lib/feature-status.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") {
|
} else if (part.fieldname === "pipeline") {
|
||||||
pipelineRaw = part.value as string;
|
pipelineRaw = part.value as string;
|
||||||
} else if (part.fieldname === "clientJobId") {
|
} 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) {
|
} catch (err) {
|
||||||
@@ -476,7 +480,10 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
} else if (part.fieldname === "pipeline") {
|
} else if (part.fieldname === "pipeline") {
|
||||||
pipelineRaw = part.value as string;
|
pipelineRaw = part.value as string;
|
||||||
} else if (part.fieldname === "clientJobId") {
|
} 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) {
|
} catch (err) {
|
||||||
@@ -732,6 +739,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
"X-Job-Id": jobId,
|
"X-Job-Id": jobId,
|
||||||
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
|
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
|
||||||
|
...getSecurityHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { db, schema } from "../db/index.js";
|
import { db, schema } from "../db/index.js";
|
||||||
|
import { getSecurityHeaders } from "../lib/csp.js";
|
||||||
|
|
||||||
export interface JobProgress {
|
export interface JobProgress {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
@@ -230,6 +231,7 @@ export async function registerProgressRoutes(app: FastifyInstance): Promise<void
|
|||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
Connection: "keep-alive",
|
Connection: "keep-alive",
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
|
...getSecurityHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to send an SSE message
|
// Helper to send an SSE message
|
||||||
|
|||||||
@@ -147,7 +147,10 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
fileId = part.value as string;
|
fileId = part.value as string;
|
||||||
}
|
}
|
||||||
if (part.fieldname === "clientJobId") {
|
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 archiver from "archiver";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { getSecurityHeaders } from "../../lib/csp.js";
|
||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ export function registerBulkRename(app: FastifyInstance) {
|
|||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
"Content-Disposition": `attachment; filename="renamed-${jobId.slice(0, 8)}.zip"`,
|
"Content-Disposition": `attachment; filename="renamed-${jobId.slice(0, 8)}.zip"`,
|
||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
|
...getSecurityHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
|
import { getSecurityHeaders } from "../../lib/csp.js";
|
||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
@@ -146,6 +147,7 @@ export function registerFavicon(app: FastifyInstance) {
|
|||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
"Content-Disposition": `attachment; filename="favicons-${jobId.slice(0, 8)}.zip"`,
|
"Content-Disposition": `attachment; filename="favicons-${jobId.slice(0, 8)}.zip"`,
|
||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
|
...getSecurityHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ export function registerOcr(app: FastifyInstance) {
|
|||||||
} else if (part.fieldname === "settings") {
|
} else if (part.fieldname === "settings") {
|
||||||
settingsRaw = part.value as string;
|
settingsRaw = part.value as string;
|
||||||
} else if (part.fieldname === "clientJobId") {
|
} 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) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -157,7 +157,10 @@ export function registerPassportPhoto(app: FastifyInstance) {
|
|||||||
fileBuffer = Buffer.concat(chunks);
|
fileBuffer = Buffer.concat(chunks);
|
||||||
filename = sanitizeFilename(part.filename ?? "image");
|
filename = sanitizeFilename(part.filename ?? "image");
|
||||||
} else if (part.fieldname === "clientJobId") {
|
} 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) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
|
import { getSecurityHeaders } from "../../lib/csp.js";
|
||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
@@ -162,6 +163,7 @@ export function registerSplit(app: FastifyInstance) {
|
|||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
"Content-Disposition": `attachment; filename="split-${jobId.slice(0, 8)}.zip"`,
|
"Content-Disposition": `attachment; filename="split-${jobId.slice(0, 8)}.zip"`,
|
||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
|
...getSecurityHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import PQueue from "p-queue";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { env } from "../../config.js";
|
import { env } from "../../config.js";
|
||||||
|
import { getSecurityHeaders } from "../../lib/csp.js";
|
||||||
import { resolveConcurrency } from "../../lib/env.js";
|
import { resolveConcurrency } from "../../lib/env.js";
|
||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
@@ -129,7 +130,10 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
|||||||
} else if (part.fieldname === "settings") {
|
} else if (part.fieldname === "settings") {
|
||||||
settingsRaw = part.value as string;
|
settingsRaw = part.value as string;
|
||||||
} else if (part.fieldname === "clientJobId") {
|
} 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) {
|
} catch (err) {
|
||||||
@@ -321,6 +325,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
|||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
"X-Job-Id": jobId,
|
"X-Job-Id": jobId,
|
||||||
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
|
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
|
||||||
|
...getSecurityHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||||
|
|||||||
+4
-4
@@ -113,8 +113,8 @@ RUN set -e; \
|
|||||||
# files with multiple auxiliary images (depth maps, HDR gain maps).
|
# files with multiple auxiliary images (depth maps, HDR gain maps).
|
||||||
# Build libheif >= 1.19 from source for the fix (GitHub #183).
|
# Build libheif >= 1.19 from source for the fix (GitHub #183).
|
||||||
# Base images match production to avoid shared-library ABI mismatches.
|
# Base images match production to avoid shared-library ABI mismatches.
|
||||||
FROM debian:bookworm AS libheif-base-arm64
|
FROM debian:bookworm@sha256:ed4fcc40bb1162b6d2d32e7bec15044d13963779abbe63f67f1cd62b06220519 AS libheif-base-arm64
|
||||||
FROM ubuntu:24.04 AS libheif-base-amd64
|
FROM ubuntu:24.04@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54 AS libheif-base-amd64
|
||||||
|
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
FROM libheif-base-${TARGETARCH} AS libheif-builder
|
FROM libheif-base-${TARGETARCH} AS libheif-builder
|
||||||
@@ -306,9 +306,9 @@ ENV PORT=1349 \
|
|||||||
MAX_USERS=0 \
|
MAX_USERS=0 \
|
||||||
MAX_WORKER_THREADS=0 \
|
MAX_WORKER_THREADS=0 \
|
||||||
PROCESSING_TIMEOUT_S=0 \
|
PROCESSING_TIMEOUT_S=0 \
|
||||||
MAX_PIPELINE_STEPS=0 \
|
MAX_PIPELINE_STEPS=20 \
|
||||||
MAX_CANVAS_PIXELS=0 \
|
MAX_CANVAS_PIXELS=0 \
|
||||||
MAX_SVG_SIZE_MB=0 \
|
MAX_SVG_SIZE_MB=50 \
|
||||||
MAX_SPLIT_GRID=100 \
|
MAX_SPLIT_GRID=100 \
|
||||||
MAX_PDF_PAGES=0 \
|
MAX_PDF_PAGES=0 \
|
||||||
SESSION_DURATION_HOURS=168 \
|
SESSION_DURATION_HOURS=168 \
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ services:
|
|||||||
- CONCURRENT_JOBS=${CONCURRENT_JOBS:-0}
|
- CONCURRENT_JOBS=${CONCURRENT_JOBS:-0}
|
||||||
- MAX_WORKER_THREADS=${MAX_WORKER_THREADS:-0}
|
- MAX_WORKER_THREADS=${MAX_WORKER_THREADS:-0}
|
||||||
- PROCESSING_TIMEOUT_S=${PROCESSING_TIMEOUT_S:-0}
|
- PROCESSING_TIMEOUT_S=${PROCESSING_TIMEOUT_S:-0}
|
||||||
- MAX_PIPELINE_STEPS=${MAX_PIPELINE_STEPS:-0}
|
- MAX_PIPELINE_STEPS=${MAX_PIPELINE_STEPS:-20}
|
||||||
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-1000}
|
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-1000}
|
||||||
- MAX_USERS=${MAX_USERS:-0}
|
- MAX_USERS=${MAX_USERS:-0}
|
||||||
- SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168}
|
- SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ services:
|
|||||||
- CONCURRENT_JOBS=${CONCURRENT_JOBS:-0}
|
- CONCURRENT_JOBS=${CONCURRENT_JOBS:-0}
|
||||||
- MAX_WORKER_THREADS=${MAX_WORKER_THREADS:-0}
|
- MAX_WORKER_THREADS=${MAX_WORKER_THREADS:-0}
|
||||||
- PROCESSING_TIMEOUT_S=${PROCESSING_TIMEOUT_S:-0}
|
- PROCESSING_TIMEOUT_S=${PROCESSING_TIMEOUT_S:-0}
|
||||||
- MAX_PIPELINE_STEPS=${MAX_PIPELINE_STEPS:-0}
|
- MAX_PIPELINE_STEPS=${MAX_PIPELINE_STEPS:-20}
|
||||||
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-1000}
|
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-1000}
|
||||||
- MAX_USERS=${MAX_USERS:-0}
|
- MAX_USERS=${MAX_USERS:-0}
|
||||||
- SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168}
|
- SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168}
|
||||||
|
|||||||
Reference in New Issue
Block a user