fix(security): harden auth and outbound fetches

This commit is contained in:
SnapOtter
2026-06-29 17:54:12 +08:00
parent 6f85b3d12a
commit c6319cf8a9
34 changed files with 799 additions and 173 deletions
+51 -35
View File
@@ -1,9 +1,7 @@
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, type Page } from "playwright";
import { isPrivateIp } from "./ssrf.js";
import { MAX_URL_FETCH_SIZE, safeFetch } from "./ssrf.js";
const MAX_PAGES = Math.max(1, parseInt(process.env.BROWSER_MAX_PAGES || "3", 10));
const CRASH_WINDOW_MS = 60_000;
@@ -59,50 +57,68 @@ function recordCrash(): void {
backoffUntil = now + delay;
}
async function isBlockedUrl(url: string): Promise<boolean> {
try {
const parsed = new URL(url);
function isBrowserLocalUrl(url: string): boolean {
const parsed = new URL(url);
return parsed.protocol === "data:" || parsed.protocol === "blob:" || parsed.protocol === "about:";
}
function responseHeadersForBrowser(response: Response): Record<string, string> {
const headers: Record<string, string> = {};
response.headers.forEach((value, key) => {
if (
parsed.protocol === "data:" ||
parsed.protocol === "blob:" ||
parsed.protocol === "about:"
key === "connection" ||
key === "content-encoding" ||
key === "content-length" ||
key === "transfer-encoding"
) {
return false;
return;
}
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;
}
headers[key] = value;
});
return headers;
}
async function installNetworkGuard(page: Page): Promise<void> {
await page.route("**/*", async (route) => {
const url = route.request().url();
if (await isBlockedUrl(url)) {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
await route.abort("blockedbyclient").catch(() => {});
return;
}
if (isBrowserLocalUrl(url)) {
await route.continue().catch(() => {});
return;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
await route.abort("blockedbyclient").catch(() => {});
return;
}
const method = route.request().method();
if (method !== "GET" && method !== "HEAD") {
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 });
const response = await safeFetch(url, {
method,
maxBytes: MAX_URL_FETCH_SIZE,
headers: {
Accept: route.request().headers().accept ?? "*/*",
},
});
const body = method === "HEAD" ? undefined : Buffer.from(await response.arrayBuffer());
await route.fulfill({
status: response.status,
headers: responseHeadersForBrowser(response),
body,
});
} catch {
await route.abort("failed").catch(() => {});
}
+2 -2
View File
@@ -28,8 +28,8 @@ const envSchema = z
SNAPOTTER_LICENSE_KEY: z.string().default(""),
FILE_MAX_AGE_HOURS: z.coerce.number().default(72),
CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(60),
MAX_UPLOAD_SIZE_MB: z.coerce.number().default(0),
MAX_BATCH_SIZE: z.coerce.number().default(0),
MAX_UPLOAD_SIZE_MB: z.coerce.number().default(100),
MAX_BATCH_SIZE: z.coerce.number().default(100),
CONCURRENT_JOBS: z.coerce.number().default(0),
MAX_MEGAPIXELS: z.coerce.number().default(0),
RATE_LIMIT_PER_MIN: z.coerce.number().default(300),
+28 -3
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import type { FastifyBaseLogger } from "fastify";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { isDisabledRole } from "../permissions.js";
import { auditLog, sanitizeAuditInput } from "./audit.js";
// ── Types ─────────────────────────────────────────────────────────
@@ -24,7 +25,7 @@ export interface ExternalAuthParams {
export interface ExternalAuthResult {
user: { id: string; username: string; role: string; team: string } | null;
action: "matched" | "linked" | "created" | "denied";
deniedReason?: "user_not_authorized" | "user_limit_reached";
deniedReason?: "user_not_authorized" | "user_limit_reached" | "user_disabled";
}
// ── Username helpers ──────────────────────────────────────────────
@@ -94,10 +95,18 @@ export async function resolveExternalUser(params: ExternalAuthParams): Promise<E
const [existingByExtId] = await db
.select()
.from(schema.users)
.where(eq(schema.users.externalId, externalId))
.where(and(eq(schema.users.externalId, externalId), eq(schema.users.authProvider, provider)))
.limit(1);
if (existingByExtId) {
if (isDisabledRole(existingByExtId.role)) {
await audit(`${providerUpper}_LOGIN_FAILED`, {
reason: "user_disabled",
userId: existingByExtId.id,
});
return { user: null, action: "denied", deniedReason: "user_disabled" };
}
// Update email if changed
if (email && email !== existingByExtId.email) {
await db
@@ -125,6 +134,14 @@ export async function resolveExternalUser(params: ExternalAuthParams): Promise<E
.limit(1);
if (existingByEmail) {
if (isDisabledRole(existingByEmail.role)) {
await audit(`${providerUpper}_LOGIN_FAILED`, {
reason: "user_disabled",
userId: existingByEmail.id,
});
return { user: null, action: "denied", deniedReason: "user_disabled" };
}
await db
.update(schema.users)
.set({
@@ -154,6 +171,14 @@ export async function resolveExternalUser(params: ExternalAuthParams): Promise<E
// 3. Auto-create
if (autoCreate) {
if (isDisabledRole(defaultRole)) {
logger.warn(`${provider} auto-create blocked: default role is disabled`);
await audit(`${providerUpper}_LOGIN_FAILED`, {
reason: "user_disabled",
});
return { user: null, action: "denied", deniedReason: "user_disabled" };
}
// Check user limit
if (env.MAX_USERS > 0) {
const [countResult] = await db.select({ count: sql<number>`COUNT(*)` }).from(schema.users);
+73 -7
View File
@@ -124,6 +124,14 @@ export const MAX_URL_FETCH_SIZE = 50 * 1024 * 1024;
export const MAX_URLS_PER_REQUEST = 50;
export const URL_FETCH_CONCURRENCY = 4;
export interface SafeFetchOptions {
signal?: AbortSignal;
maxBytes?: number;
method?: string;
headers?: Record<string, string>;
body?: BodyInit | Buffer | string;
}
/**
* Create an HTTP(S) agent that pins DNS resolution to a specific IP address.
* This prevents DNS rebinding attacks where a hostname resolves to a different
@@ -158,7 +166,41 @@ function createPinnedAgent(resolvedIp: string, protocol: string): http.Agent | h
return new http.Agent({ lookup: pinnedLookup as never, maxSockets: 1 });
}
export async function safeFetch(url: string, signal?: AbortSignal): Promise<Response> {
function normalizeSafeFetchOptions(options?: AbortSignal | SafeFetchOptions): SafeFetchOptions {
if (!options) return {};
if ("aborted" in options && "addEventListener" in options) return { signal: options };
return options;
}
function withResponseSizeLimit(response: Response, maxBytes?: number): Response {
if (maxBytes === undefined || !response.body) return response;
let totalBytes = 0;
const limitedBody = response.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
totalBytes += chunk.byteLength;
if (totalBytes > maxBytes) {
controller.error(new Error(`Response exceeds maximum size of ${maxBytes} bytes`));
return;
}
controller.enqueue(chunk);
},
}),
);
return new Response(limitedBody, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
export async function safeFetch(
url: string,
options?: AbortSignal | SafeFetchOptions,
): Promise<Response> {
const safeOptions = normalizeSafeFetchOptions(options);
let currentUrl = url;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
const { resolvedIp } = await validateFetchUrl(currentUrl);
@@ -177,12 +219,15 @@ export async function safeFetch(url: string, signal?: AbortSignal): Promise<Resp
}
const fetchOptions: RequestInit & { agent?: http.Agent | https.Agent } = {
signal,
signal: safeOptions.signal,
redirect: "manual",
method: safeOptions.method ?? "GET",
headers: {
"User-Agent": "SnapOtter/2.0 (file-fetch)",
Host: parsed.host,
...safeOptions.headers,
},
body: safeOptions.body as BodyInit | null | undefined,
};
// Node.js undici-based fetch does not support the `agent` option directly.
@@ -196,16 +241,32 @@ export async function safeFetch(url: string, signal?: AbortSignal): Promise<Resp
currentUrl,
{
agent,
signal: signal ?? undefined,
signal: safeOptions.signal ?? undefined,
headers: {
"User-Agent": "SnapOtter/2.0 (file-fetch)",
...safeOptions.headers,
},
method: "GET",
method: safeOptions.method ?? "GET",
},
(incomingMessage) => {
const chunks: Buffer[] = [];
incomingMessage.on("data", (chunk: Buffer) => chunks.push(chunk));
let totalBytes = 0;
let settled = false;
incomingMessage.on("data", (chunk: Buffer) => {
if (settled) return;
totalBytes += chunk.length;
if (safeOptions.maxBytes !== undefined && totalBytes > safeOptions.maxBytes) {
settled = true;
req.destroy(
new Error(`Response exceeds maximum size of ${safeOptions.maxBytes} bytes`),
);
return;
}
chunks.push(chunk);
});
incomingMessage.on("end", () => {
if (settled) return;
settled = true;
const body = Buffer.concat(chunks);
const headers = new Headers();
for (const [key, value] of Object.entries(incomingMessage.headers)) {
@@ -222,10 +283,15 @@ export async function safeFetch(url: string, signal?: AbortSignal): Promise<Resp
}),
);
});
incomingMessage.on("error", reject);
incomingMessage.on("error", (err) => {
if (settled) return;
settled = true;
reject(err);
});
},
);
req.on("error", reject);
if (safeOptions.body) req.write(safeOptions.body);
req.end();
});
} else {
@@ -241,7 +307,7 @@ export async function safeFetch(url: string, signal?: AbortSignal): Promise<Resp
continue;
}
return res;
return withResponseSizeLimit(res, safeOptions.maxBytes);
}
throw new Error("Too many redirects");
}
+4 -1
View File
@@ -1,3 +1,5 @@
import { safeFetch } from "./ssrf.js";
interface DeliveryOptions {
maxRetries?: number;
initialDelayMs?: number;
@@ -36,11 +38,12 @@ export async function deliverWebhook(
}
try {
const response = await fetch(url, {
const response = await safeFetch(url, {
method: "POST",
headers,
body: payload,
signal: AbortSignal.timeout(timeoutMs),
maxBytes: 1024 * 1024,
});
if (response.ok) {
+63 -4
View File
@@ -37,6 +37,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
};
export async function getPermissions(role: Role | string): Promise<Permission[]> {
if (typeof role !== "string" || isDisabledRole(role)) return [];
if (role in ROLE_PERMISSIONS) {
return ROLE_PERMISSIONS[role as Role];
}
@@ -69,6 +70,37 @@ export async function hasEffectivePermission(
return true;
}
export function isDisabledRole(role: string | null | undefined): boolean {
return role === "disabled" || role?.startsWith("disabled:") === true;
}
export async function getEffectivePermissions(user: AuthUser): Promise<Permission[]> {
const rolePermissions = await getPermissions(user.role);
if (!user.apiKeyPermissions) return rolePermissions;
const scoped = new Set(user.apiKeyPermissions);
return rolePermissions.filter((permission) => scoped.has(permission));
}
export async function permissionsNotHeldBy(
user: AuthUser,
requestedPermissions: string[],
): Promise<string[]> {
const effectivePermissions = new Set(await getEffectivePermissions(user));
return requestedPermissions.filter(
(permission) => !effectivePermissions.has(permission as Permission),
);
}
export async function canAssignRole(actor: AuthUser, targetRole: string): Promise<boolean> {
if (isDisabledRole(targetRole)) return false;
const targetPermissions = await getPermissions(targetRole);
if (targetPermissions.length === 0) return false;
const actorPermissions = new Set(await getEffectivePermissions(actor));
return targetPermissions.every((permission) => actorPermissions.has(permission));
}
export function requirePermission(
permission: Permission,
): (request: FastifyRequest, reply: FastifyReply) => Promise<AuthUser | null> {
@@ -87,6 +119,8 @@ export function requirePermission(
}
export async function hasToolAccess(role: string, toolId: string): Promise<boolean> {
if (isDisabledRole(role)) return false;
// Built-in roles have no tool restrictions
if (role in ROLE_PERMISSIONS) return true;
@@ -97,8 +131,10 @@ export async function hasToolAccess(role: string, toolId: string): Promise<boole
.where(eq(schema.roles.name, role))
.limit(1);
// Role not found or no toolPermissions configured -- allow all
if (!roleRow?.toolPermissions) return true;
// Unknown custom roles do not get implicit access. Known custom roles with
// no per-tool restriction keep the historical "all tools" behavior.
if (!roleRow) return false;
if (!roleRow.toolPermissions) return true;
const tp = roleRow.toolPermissions;
@@ -123,11 +159,34 @@ export async function hasToolAccess(role: string, toolId: string): Promise<boole
return true; // Unknown mode = allow
} catch {
// DB not yet available during early startup
return true;
return false;
}
}
export async function hasEffectiveToolAccess(user: AuthUser, toolId: string): Promise<boolean> {
if (!(await hasEffectivePermission(user, "tools:use"))) return false;
return hasToolAccess(user.role, toolId);
}
export async function requireToolAccess(
request: FastifyRequest,
reply: FastifyReply,
toolId: string,
): Promise<AuthUser | null> {
const user = getAuthUser(request);
if (!user) {
reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" });
return null;
}
if (!(await hasEffectiveToolAccess(user, toolId))) {
reply.status(403).send({ error: "You don't have permission to use this tool" });
return null;
}
return user;
}
export async function requireOwnershipOrPermission(
request: FastifyRequest,
reply: FastifyReply,
+72 -37
View File
@@ -9,7 +9,12 @@ import { sharedRedis } from "../jobs/connection.js";
import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js";
import { authAttempts } from "../lib/metrics.js";
import { getSettingNumber, getSettingString } from "../lib/settings-helpers.js";
import { getPermissions, requirePermission } from "../permissions.js";
import {
canAssignRole,
getPermissions,
isDisabledRole,
requirePermission,
} from "../permissions.js";
const scryptAsync = promisify(scrypt);
@@ -343,6 +348,15 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const audit = auditFromRequest(request);
if (user && isDisabledRole(user.role)) {
authAttempts.inc({ method: "password", result: "failure" });
await audit("LOGIN_FAILED", {
username: sanitizeAuditInput(body.username),
reason: "disabled_user",
});
return reply.status(403).send({ error: "User is disabled", code: "USER_DISABLED" });
}
if (!user?.passwordHash) {
authAttempts.inc({ method: "password", result: "failure" });
await audit("LOGIN_FAILED", {
@@ -362,6 +376,15 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return reply.status(401).send({ error: "Invalid credentials" });
}
let mfaRequiredByPolicy = false;
try {
const { getMfaPolicy, isMfaRequiredForUser } = await import("./mfa.js");
const policy = await getMfaPolicy();
mfaRequiredByPolicy = isMfaRequiredForUser(policy, user.role);
} catch {
// MFA plugin not loaded
}
// ── MFA challenge ──────────────────────────────────────────
if (user.totpEnabled) {
const mfaToken = randomUUID();
@@ -370,24 +393,27 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
await audit("MFA_CHALLENGE_ISSUED", { userId: user.id, username: user.username });
// Determine if MFA policy requires enrollment for this user
let mfaRequired = false;
try {
const { getMfaPolicy, isMfaRequiredForUser } = await import("./mfa.js");
const policy = await getMfaPolicy();
mfaRequired = isMfaRequiredForUser(policy, user.role);
} catch {
// MFA plugin not loaded
}
return reply.status(200).send({
requiresMfa: true,
mfaToken,
mfaRequired,
mfaRequired: mfaRequiredByPolicy,
message: "MFA verification required",
});
}
if (mfaRequiredByPolicy) {
authAttempts.inc({ method: "password", result: "failure" });
await audit("LOGIN_FAILED", {
userId: user.id,
username: user.username,
reason: "mfa_enrollment_required",
});
return reply.status(403).send({
error: "MFA enrollment is required before login",
code: "MFA_ENROLLMENT_REQUIRED",
});
}
// Create session
const token = createSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
@@ -420,16 +446,6 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const [teamRow] = await db.select().from(schema.teams).where(eq(schema.teams.id, user.team));
// Check if MFA enrollment is required by policy but user hasn't enrolled yet
let mfaRequired = false;
try {
const { getMfaPolicy, isMfaRequiredForUser } = await import("./mfa.js");
const policy = await getMfaPolicy();
mfaRequired = isMfaRequiredForUser(policy, user.role) && !user.totpEnabled;
} catch {
// MFA plugin not loaded
}
const cookieReply = reply as FastifyReply & {
setCookie?: (name: string, value: string, opts: Record<string, unknown>) => FastifyReply;
};
@@ -454,7 +470,6 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
teamName: teamRow?.name ?? user.team,
},
expiresAt: expiresAt.toISOString(),
...(mfaRequired && { mfaRequired: true }),
});
},
);
@@ -537,6 +552,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return reply.status(401).send({ error: "User not found" });
}
if (isDisabledRole(user.role)) {
await db.delete(schema.sessions).where(eq(schema.sessions.id, token));
return reply.status(403).send({ error: "User is disabled", code: "USER_DISABLED" });
}
return reply.send({
user: {
id: user.id,
@@ -723,6 +743,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
code: "ESCALATION_DENIED",
});
}
if (!(await canAssignRole(admin, role))) {
return reply.status(403).send({
error: "Cannot create a user with permissions you don't have",
code: "ESCALATION_DENIED",
});
}
// Resolve team -- frontend sends team name (e.g. "Default"), not ID
const requestedTeam = body.team;
@@ -831,19 +857,6 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
updatedAt: new Date(),
};
// Escalation prevention
if (body.role) {
const roleHierarchy: Record<string, number> = { admin: 3, editor: 2, user: 1 };
const actorLevel = roleHierarchy[admin.role] ?? 0;
const targetLevel = roleHierarchy[body.role] ?? 0;
if (targetLevel > actorLevel) {
return reply.status(403).send({
error: "Cannot assign a role higher than your own",
code: "ESCALATION_DENIED",
});
}
}
if (body.role) {
const validBuiltinRoles = ["admin", "editor", "user"];
const [customRoleRow] = validBuiltinRoles.includes(body.role)
@@ -851,6 +864,22 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
: await db.select().from(schema.roles).where(eq(schema.roles.name, body.role));
const isValid = validBuiltinRoles.includes(body.role) || customRoleRow;
if (isValid) {
const roleHierarchy: Record<string, number> = { admin: 3, editor: 2, user: 1 };
const actorLevel = roleHierarchy[admin.role] ?? 0;
const targetLevel = roleHierarchy[body.role] ?? 0;
if (targetLevel > actorLevel) {
return reply.status(403).send({
error: "Cannot assign a role higher than your own",
code: "ESCALATION_DENIED",
});
}
if (!(await canAssignRole(admin, body.role))) {
return reply.status(403).send({
error: "Cannot assign a role with permissions you don't have",
code: "ESCALATION_DENIED",
});
}
// Prevent removing your own admin role
if (id === admin.id && body.role !== "admin") {
return reply.status(400).send({
@@ -1121,7 +1150,7 @@ export async function authMiddleware(app: FastifyInstance): Promise<void> {
.select()
.from(schema.users)
.where(eq(schema.users.id, key.userId));
if (apiUser) {
if (apiUser && !isDisabledRole(apiUser.role)) {
authAttempts.inc({ method: "apikey", result: "success" });
const keyPermissions = key.permissions ?? undefined;
(request as FastifyRequest & { user?: AuthUser }).user = {
@@ -1149,6 +1178,12 @@ export async function authMiddleware(app: FastifyInstance): Promise<void> {
return reply.status(401).send({ error: "User not found" });
}
if (isDisabledRole(user.role)) {
await db.delete(schema.sessions).where(eq(schema.sessions.id, token));
if (isPublic) return;
return reply.status(403).send({ error: "User is disabled", code: "USER_DISABLED" });
}
// ── Idle timeout enforcement ───────────────────────────────────
const idleTimeoutMinutes = await getSettingNumber("sessionIdleTimeoutMinutes");
if (idleTimeoutMinutes > 0) {
+2 -2
View File
@@ -9,7 +9,7 @@ import { sharedRedis } from "../jobs/connection.js";
import { auditFromRequest } from "../lib/audit.js";
import { decrypt, encrypt } from "../lib/encryption.js";
import { getSettingString } from "../lib/settings-helpers.js";
import { getPermissions } from "../permissions.js";
import { getPermissions, isDisabledRole } from "../permissions.js";
import { createSessionToken, getAuthUser, requireAuth } from "./auth.js";
// ── Constants ─────────────────────────────────────────────────────
@@ -271,7 +271,7 @@ export async function registerMfa(app: FastifyInstance): Promise<void> {
// Load user
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, userId));
if (!dbUser?.totpSecret) {
if (!dbUser?.totpSecret || isDisabledRole(dbUser.role)) {
return reply.status(401).send({
error: "User not found or MFA not configured",
code: "MFA_NOT_CONFIGURED",
+18
View File
@@ -274,6 +274,24 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
const resolvedUser = result.user;
let mfaRequired = false;
try {
const { getMfaPolicy, isMfaRequiredForUser } = await import("./mfa.js");
const policy = await getMfaPolicy();
mfaRequired = isMfaRequiredForUser(policy, resolvedUser.role);
} catch {
// MFA plugin not loaded
}
if (mfaRequired) {
authAttempts.inc({ method: "oidc", result: "failure" });
await audit("OIDC_LOGIN_FAILED", {
userId: resolvedUser.id,
username: resolvedUser.username,
reason: "mfa_required",
});
return redirectToLogin(reply, "mfa_required");
}
// 5. Create session
const token = createSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
+18
View File
@@ -164,6 +164,24 @@ export async function registerSaml(app: FastifyInstance): Promise<void> {
const resolvedUser = result.user;
let mfaRequired = false;
try {
const { getMfaPolicy, isMfaRequiredForUser } = await import("./mfa.js");
const policy = await getMfaPolicy();
mfaRequired = isMfaRequiredForUser(policy, resolvedUser.role);
} catch {
// MFA plugin not loaded
}
if (mfaRequired) {
authAttempts.inc({ method: "saml", result: "failure" });
await audit("SAML_LOGIN_FAILED", {
userId: resolvedUser.id,
username: resolvedUser.username,
reason: "mfa_required",
});
return redirectToLogin(reply, "mfa_required");
}
// Create session (same pattern as OIDC)
const token = createSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
+54 -19
View File
@@ -12,8 +12,12 @@ import { z } from "zod";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { auditFromRequest } from "../lib/audit.js";
import { getPermissions, hasEffectivePermission } from "../permissions.js";
import { computeKeyPrefix, hashPassword, requireAuth } from "../plugins/auth.js";
import {
getEffectivePermissions,
hasEffectivePermission,
permissionsNotHeldBy,
} from "../permissions.js";
import { type AuthUser, computeKeyPrefix, hashPassword, requireAuth } from "../plugins/auth.js";
// Per-route cap on the API-key management endpoints. Defaults to 30/min as an
// anti-abuse guard; raised via env in the e2e suite, where many api-keys specs
@@ -26,13 +30,50 @@ const createApiKeySchema = z.object({
expiresAt: z.string().optional(),
});
async function requireApiKeyManagement(
request: FastifyRequest,
reply: FastifyReply,
): Promise<AuthUser | null> {
const user = requireAuth(request, reply);
if (!user) return null;
if (
!(await hasEffectivePermission(user, "apikeys:own")) &&
!(await hasEffectivePermission(user, "apikeys:all"))
) {
reply.status(403).send({ error: "Insufficient permissions", code: "FORBIDDEN" });
return null;
}
return user;
}
export async function deriveApiKeyPermissionsForCreate(
user: AuthUser,
requestedPermissions?: string[],
): Promise<{ permissions: string[] | null; invalid: string[] }> {
if (requestedPermissions !== undefined) {
const invalid = await permissionsNotHeldBy(user, requestedPermissions);
return {
permissions: requestedPermissions.length > 0 ? requestedPermissions : [],
invalid,
};
}
if (user.apiKeyPermissions) {
return { permissions: await getEffectivePermissions(user), invalid: [] };
}
return { permissions: null, invalid: [] };
}
export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/api-keys — Generate a new API key
app.post(
"/api/v1/api-keys",
{ config: { rateLimit: API_KEYS_RATE_LIMIT } },
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
const user = await requireApiKeyManagement(request, reply);
if (!user) return;
const parsed = createApiKeySchema.safeParse(request.body ?? {});
@@ -45,18 +86,12 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
const body = parsed.data;
const name = body.name?.trim() || "Default API Key";
let scopedPermissions: string[] | null = null;
if (body.permissions && body.permissions.length > 0) {
const userPerms = await getPermissions(user.role);
const permSet = new Set<string>(userPerms);
const invalid = body.permissions.filter((p) => !permSet.has(p));
if (invalid.length > 0) {
return reply.status(400).send({
error: `Cannot scope key with permissions you don't have: ${invalid.join(", ")}`,
code: "VALIDATION_ERROR",
});
}
scopedPermissions = body.permissions;
const scoped = await deriveApiKeyPermissionsForCreate(user, body.permissions);
if (scoped.invalid.length > 0) {
return reply.status(400).send({
error: `Cannot scope key with permissions you don't have: ${scoped.invalid.join(", ")}`,
code: "VALIDATION_ERROR",
});
}
let expiresAt: Date | null = null;
@@ -88,7 +123,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
keyHash,
keyPrefix,
name,
permissions: scopedPermissions,
permissions: scoped.permissions,
expiresAt,
});
} catch {
@@ -106,7 +141,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
id,
key: rawKey,
name,
permissions: scopedPermissions,
permissions: scoped.permissions,
expiresAt: expiresAt?.toISOString() ?? null,
createdAt: new Date().toISOString(),
});
@@ -118,7 +153,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
"/api/v1/api-keys",
{ config: { rateLimit: API_KEYS_RATE_LIMIT } },
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
const user = await requireApiKeyManagement(request, reply);
if (!user) return;
const selectFields = {
@@ -156,7 +191,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
"/api/v1/api-keys/:id",
{ config: { rateLimit: API_KEYS_RATE_LIMIT } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
const user = await requireApiKeyManagement(request, reply);
if (!user) return;
const { id } = request.params;
+4 -2
View File
@@ -35,7 +35,7 @@ import { getObjectStream, putObject } from "../lib/object-storage.js";
import { resolveToolPool } from "../lib/pool.js";
import { InputValidationError } from "../modality/contract.js";
import { inputHandlerFor } from "../modality/input-handler.js";
import { getAuthUser } from "../plugins/auth.js";
import { requireToolAccess } from "../permissions.js";
import { updateJobProgress } from "./progress.js";
import { getToolConfig } from "./tool-factory.js";
@@ -67,6 +67,8 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
if (!tool || toolSection(tool) !== section) {
return reply.status(404).send({ error: "Not found", code: "NOT_FOUND" });
}
const authUser = await requireToolAccess(request, reply, toolId);
if (!authUser) return;
// Batch processing (especially with AI) can take tens of minutes.
// Disable the Node.js HTTP socket timeout so the connection is not
@@ -157,7 +159,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
// ── Create job ID and initial progress ────────────────────────
const parentId = clientJobId || randomUUID();
const userId = getAuthUser(request)?.id ?? null;
const userId = authUser.id;
const pool: Pool = resolveToolPool(toolId);
// Insert the parent row BEFORE updateJobProgress, because the
@@ -14,6 +14,7 @@ import { env } from "../../config.js";
import { db, schema } from "../../db/index.js";
import { auditFromRequest } from "../../lib/audit.js";
import { encrypt } from "../../lib/encryption.js";
import { validateFetchUrl } from "../../lib/ssrf.js";
import { deliverWebhook } from "../../lib/webhook-delivery.js";
import { requirePermission } from "../../permissions.js";
@@ -82,6 +83,19 @@ async function checkFeatureGate(reply: FastifyReply): Promise<boolean> {
return true;
}
async function validateWebhookDestinationUrl(url: string, reply: FastifyReply): Promise<boolean> {
try {
await validateFetchUrl(url);
return true;
} catch (err) {
reply.status(400).send({
error: "Webhook URL must resolve to a public HTTP(S) destination",
details: err instanceof Error ? err.message : String(err),
});
return false;
}
}
export async function registerWebhookRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/enterprise/webhooks -- list all destinations
app.get("/api/v1/enterprise/webhooks", async (request: FastifyRequest, reply: FastifyReply) => {
@@ -116,6 +130,7 @@ export async function registerWebhookRoutes(app: FastifyInstance): Promise<void>
}
const dest = { ...parsed.data };
if (!(await validateWebhookDestinationUrl(dest.url, reply))) return;
// Encrypt the auth header before storage
if (dest.authHeader && env.DATA_ENCRYPTION_KEY) {
@@ -167,6 +182,7 @@ export async function registerWebhookRoutes(app: FastifyInstance): Promise<void>
}
const dest = { ...parsed.data };
if (!(await validateWebhookDestinationUrl(dest.url, reply))) return;
// Encrypt the auth header before storage
if (dest.authHeader && env.DATA_ENCRYPTION_KEY) {
+4 -1
View File
@@ -185,7 +185,10 @@ async function fetchSingleUrl(
let response: Response;
try {
response = await safeFetch(url, controller.signal);
response = await safeFetch(url, {
signal: controller.signal,
maxBytes: MAX_URL_FETCH_SIZE,
});
} finally {
clearTimeout(timeout);
}
+25 -4
View File
@@ -35,8 +35,8 @@ import { resolveToolPool } from "../lib/pool.js";
import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js";
import { InputValidationError } from "../modality/contract.js";
import { inputHandlerFor } from "../modality/input-handler.js";
import { hasEffectivePermission } from "../permissions.js";
import { getAuthUser, requireAuth } from "../plugins/auth.js";
import { hasEffectivePermission, hasEffectiveToolAccess } from "../permissions.js";
import { requireAuth } from "../plugins/auth.js";
import { updateJobProgress, updateSingleFileProgress } from "./progress.js";
import { getRegisteredToolIds, getToolConfig } from "./tool-factory.js";
@@ -222,6 +222,9 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
"/api/v1/pipeline/execute",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const authUser = requireAuth(request, reply);
if (!authUser) return;
let fileBuffer: Buffer | null = null;
let filename = "file";
let pipelineRaw: string | null = null;
@@ -374,6 +377,11 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
error: `Step ${i + 1} (${step.toolId}): Tool not found or not available`,
});
}
if (!(await hasEffectiveToolAccess(authUser, resolvedToolId))) {
return reply.status(403).send({
error: `Step ${i + 1} (${step.toolId}): You don't have permission to use this tool`,
});
}
// Guard: check if the tool's AI feature bundle is installed
const missingBundleId = getFirstMissingBundleForTool(resolvedToolId);
@@ -428,7 +436,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
// ── Enqueue as a BullMQ flow ────────────────────────────────
const jobId = randomUUID();
const userId = getAuthUser(request)?.id ?? null;
const userId = authUser.id;
const originalSize = fileBuffer.length;
// Upload decoded file to object storage
@@ -573,6 +581,11 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
error: `Step ${i + 1}: Tool "${steps[i].toolId}" not found`,
});
}
if (!(await hasEffectiveToolAccess(user, steps[i].toolId))) {
return reply.status(403).send({
error: `Step ${i + 1}: You don't have permission to use this tool`,
});
}
}
const id = randomUUID();
@@ -691,6 +704,9 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
config: { rateLimit: { max: 20, timeWindow: "1 minute" } },
},
async (request: FastifyRequest, reply: FastifyReply) => {
const authUser = requireAuth(request, reply);
if (!authUser) return;
// ── Parse multipart ──────────────────────────────────────────────
interface ParsedFile {
buffer: Buffer;
@@ -780,6 +796,11 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
error: `Step ${i + 1}: Tool "${step.toolId}" not found`,
});
}
if (!(await hasEffectiveToolAccess(authUser, resolvedToolId))) {
return reply.status(403).send({
error: `Step ${i + 1} (${step.toolId}): You don't have permission to use this tool`,
});
}
const missingBundleId = getFirstMissingBundleForTool(resolvedToolId);
if (missingBundleId) {
@@ -832,7 +853,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
// ── Prepare files and build flow ─────────────────────────────────
const parentId = clientJobId || randomUUID();
const userId = getAuthUser(request)?.id ?? null;
const userId = authUser.id;
// Insert batch-finalize row BEFORE updateJobProgress to avoid
// a duplicate-key race with the progress persist layer.
+18 -4
View File
@@ -5,7 +5,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db, schema } from "../db/index.js";
import { auditFromRequest } from "../lib/audit.js";
import { requirePermission } from "../permissions.js";
import { permissionsNotHeldBy, requirePermission } from "../permissions.js";
const ALL_PERMISSIONS: Permission[] = [
"tools:use",
@@ -96,7 +96,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/roles — Create custom role
app.post("/api/v1/roles", async (request: FastifyRequest, reply: FastifyReply) => {
const user = await requirePermission("users:manage")(request, reply);
const user = await requirePermission("security:manage")(request, reply);
if (!user) return;
const parsed = createRoleSchema.safeParse(request.body);
@@ -114,6 +114,13 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
.status(400)
.send({ error: `Invalid permissions: ${invalid.join(", ")}`, code: "VALIDATION_ERROR" });
}
const broader = await permissionsNotHeldBy(user, permissions);
if (broader.length > 0) {
return reply.status(403).send({
error: `Cannot grant permissions you don't have: ${broader.join(", ")}`,
code: "ESCALATION_DENIED",
});
}
const [existing] = await db.select().from(schema.roles).where(eq(schema.roles.name, name));
if (existing) {
@@ -151,7 +158,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
app.put(
"/api/v1/roles/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = await requirePermission("users:manage")(request, reply);
const user = await requirePermission("security:manage")(request, reply);
if (!user) return;
const { id } = request.params;
@@ -193,6 +200,13 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
code: "VALIDATION_ERROR",
});
}
const broader = await permissionsNotHeldBy(user, body.permissions);
if (broader.length > 0) {
return reply.status(403).send({
error: `Cannot grant permissions you don't have: ${broader.join(", ")}`,
code: "ESCALATION_DENIED",
});
}
updates.permissions = body.permissions;
}
if (body.toolPermissions !== undefined) {
@@ -218,7 +232,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
app.delete(
"/api/v1/roles/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = await requirePermission("users:manage")(request, reply);
const user = await requirePermission("security:manage")(request, reply);
if (!user) return;
const { id } = request.params;
+23 -5
View File
@@ -14,7 +14,6 @@ import { db, schema } from "../db/index.js";
import { auditFromRequest } from "../lib/audit.js";
import { decrypt, encrypt, isEncrypted } from "../lib/encryption.js";
import { requirePermission } from "../permissions.js";
import { requireAuth } from "../plugins/auth.js";
const settingsBodySchema = z.record(z.string().min(1), z.unknown());
@@ -25,15 +24,34 @@ const SENSITIVE_KEYS = new Set([
"instance_id",
"oidc_client_secret",
"saml_idp_certificate",
"scim_token_hash",
"siem_config",
"siem_webhook_auth",
"webhook_destinations",
]);
const ENCRYPTED_KEYS = new Set([
"cookie_secret",
"oidc_client_secret",
"saml_idp_certificate",
"scim_token_hash",
"siem_webhook_auth",
]);
const REDACTED_KEYS = new Set(["cookie_secret", "oidc_client_secret", "siem_webhook_auth"]);
const REDACTED_KEYS = new Set([
"cookie_secret",
"oidc_client_secret",
"saml_idp_certificate",
"scim_token_hash",
"siem_config",
"siem_webhook_auth",
"webhook_destinations",
]);
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;
if (!env.DATA_ENCRYPTION_KEY || !ENCRYPTED_KEYS.has(key)) return value;
return encrypt(value, env.DATA_ENCRYPTION_KEY);
}
@@ -55,7 +73,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
"/api/v1/settings",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
const user = await requirePermission("settings:read")(request, reply);
if (!user) return;
const isAdmin = user.role === "admin";
@@ -175,7 +193,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
"/api/v1/settings/:key",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { key: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
const user = await requirePermission("settings:read")(request, reply);
if (!user) return;
const { key } = request.params;
+6 -13
View File
@@ -24,7 +24,7 @@ import { type ReceivedUpload, receiveUpload } from "../lib/upload-stream.js";
import { InputValidationError } from "../modality/contract.js";
import { inputHandlerFor } from "../modality/input-handler.js";
import { MediaInputHandler, type MediaInputKind } from "../modality/media-input.js";
import { getAuthUser } from "../plugins/auth.js";
import { requireToolAccess } from "../permissions.js";
import { updateSingleFileProgress } from "./progress.js";
/** Context passed to tool process functions for cooperative cancellation, scratch storage, and progress. */
@@ -236,14 +236,8 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
: apiToolPath(config.toolId),
{ config: { rateLimit: toolRateLimit } },
async (request: FastifyRequest, reply: FastifyReply) => {
// Check per-tool access before processing uploads
const authUser = getAuthUser(request);
if (authUser) {
const { hasToolAccess } = await import("../permissions.js");
if (!(await hasToolAccess(authUser.role, config.toolId))) {
return reply.status(403).send({ error: "You don't have permission to use this tool" });
}
}
const authUser = await requireToolAccess(request, reply, config.toolId);
if (!authUser) return;
const jobId = randomUUID();
const maxInputs = config.maxInputs ?? 1;
@@ -513,7 +507,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
}
// Check per-user concurrent job limit before enqueuing
const userId = getAuthUser(request)?.id ?? null;
const userId = authUser.id;
const maxConcurrent = await getSettingNumber("maxConcurrentJobsPerUser", 0);
if (maxConcurrent > 0 && userId) {
const activeJobs = await db
@@ -570,10 +564,9 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
.then(({ isToolAuditEnabled, auditFromRequest }) =>
isToolAuditEnabled().then((enabled) => {
if (!enabled) return;
const user = getAuthUser(request);
return auditFromRequest(request)("TOOL_EXECUTED", {
userId: user?.id,
username: user?.username,
userId: authUser.id,
username: authUser.username,
toolId: config.toolId,
inputFileCount: received.length,
totalInputSize: received.reduce((sum, r) => sum + r.size, 0),
+18 -7
View File
@@ -639,22 +639,25 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
SELECT DISTINCT id, stored_name, size, user_id FROM chain
`);
const chainRows = cteResult.rows;
const deletableChainRows = canDeleteAll
? chainRows
: chainRows.filter((row) => row.user_id === user.id);
// Filesystem deletes (must loop; cannot batch across the OS)
for (const row of chainRows) {
for (const row of deletableChainRows) {
await deleteStoredFile(row.stored_name);
await deleteThumbnail(row.stored_name);
}
// Batch DB delete
const chainIds = chainRows.map((r) => r.id);
const chainIds = deletableChainRows.map((r) => r.id);
if (chainIds.length > 0) {
await db.delete(schema.userFiles).where(inArray(schema.userFiles.id, chainIds));
}
// Decrement storageUsed per user (group by userId for files:all scenarios)
const perUserSizes = new Map<string, number>();
for (const row of chainRows) {
for (const row of deletableChainRows) {
if (row.user_id && row.size) {
perUserSizes.set(row.user_id, (perUserSizes.get(row.user_id) ?? 0) + row.size);
}
@@ -670,11 +673,11 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
await auditFromRequest(request)("FILE_DELETED", {
userId: user.id,
count: chainRows.length,
count: deletableChainRows.length,
ids,
});
return reply.send({ deleted: chainRows.length });
return reply.send({ deleted: deletableChainRows.length });
},
);
@@ -688,8 +691,9 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
* toolId the tool that produced this result
*/
app.post("/api/v1/files/save-result", async (request: FastifyRequest, reply: FastifyReply) => {
const user = getAuthUser(request);
const userId = user?.id ?? null;
const user = requireAuth(request, reply);
if (!user) return;
const userId = user.id;
// Enforce per-user storage quota before saving results
try {
@@ -741,6 +745,13 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
if (!parent) {
return reply.status(404).send({ error: "Parent file not found" });
}
if (
parent.userId &&
parent.userId !== user.id &&
!(await hasEffectivePermission(user, "files:all"))
) {
return reply.status(404).send({ error: "Parent file not found" });
}
const nextVersion = parent.version + 1;
+1 -1
View File
@@ -398,7 +398,7 @@ curl -X POST http://localhost:1349/api/v1/tools/image/compress/batch \
-F 'settings={"quality":80}'
```
Concurrency is controlled by `CONCURRENT_JOBS` (default: auto-detected from CPU cores). Set `MAX_BATCH_SIZE` to limit the number of files per batch (default: unlimited).
Concurrency is controlled by `CONCURRENT_JOBS` (default: auto-detected from CPU cores). `MAX_BATCH_SIZE` limits the number of files per batch (default: 100; set 0 for unlimited).
## Pipelines
+2 -2
View File
@@ -56,8 +56,8 @@ Telemetry note: embedded mode inherits the image's analytics default like any ot
| Variable | Default | Description |
|---|---|---|
| `MAX_UPLOAD_SIZE_MB` | `0` (unlimited) | Maximum file size per upload in megabytes. Set to 0 for unlimited. |
| `MAX_BATCH_SIZE` | `0` (unlimited) | Maximum number of files in a single batch request. Set to 0 for unlimited. |
| `MAX_UPLOAD_SIZE_MB` | `100` | Maximum file size per upload in megabytes. Set to 0 for unlimited. |
| `MAX_BATCH_SIZE` | `100` | Maximum number of files in a single batch request. Set to 0 for unlimited. |
| `CONCURRENT_JOBS` | `0` (auto) | Number of batch jobs that run in parallel. Set to 0 to auto-detect based on available CPU cores. |
| `MAX_MEGAPIXELS` | `0` (unlimited) | Maximum image resolution allowed in megapixels. Set to 0 for unlimited. |
| `MAX_WORKER_THREADS` | `0` (auto) | Maximum worker threads for image processing. Set to 0 to auto-detect based on available CPU cores. |
+5 -5
View File
@@ -31,9 +31,9 @@ services:
- DATABASE_URL=postgres://snapotter:snapotter@postgres:5432/snapotter
- REDIS_URL=redis://redis:6379
# --- Limits (0 = unlimited) ---
# - MAX_UPLOAD_SIZE_MB=0 # Per-file upload limit in MB
# - MAX_BATCH_SIZE=0 # Max files per batch request
# --- Limits (set 0 for unlimited) ---
# - MAX_UPLOAD_SIZE_MB=100 # Per-file upload limit in MB
# - MAX_BATCH_SIZE=100 # Max files per batch request
# - RATE_LIMIT_PER_MIN=0 # API rate limit (0 = disabled, 100 = recommended for public)
# - MAX_USERS=0 # Max user accounts
@@ -404,8 +404,8 @@ The startup error names the exact UID to use, so the quickest path is to start t
| `AUTH_ENABLED` | `true` | Enable/disable login requirement |
| `DEFAULT_USERNAME` | `admin` | Initial admin username |
| `DEFAULT_PASSWORD` | `admin` | Initial admin password (forced change on first login) |
| `MAX_UPLOAD_SIZE_MB` | `0` (unlimited) | Per-file upload limit |
| `MAX_BATCH_SIZE` | `0` (unlimited) | Max files per batch request |
| `MAX_UPLOAD_SIZE_MB` | `100` | Per-file upload limit |
| `MAX_BATCH_SIZE` | `100` | Max files per batch request |
| `RATE_LIMIT_PER_MIN` | `0` (disabled) | API requests per minute per IP |
| `MAX_USERS` | `0` (unlimited) | Maximum user accounts |
| `TRUST_PROXY` | `true` | Trust X-Forwarded-For headers from reverse proxy |
+1 -1
View File
@@ -228,4 +228,4 @@ See the [Configuration guide](/guide/configuration) for the full list. Key ones
| `DEFAULT_PASSWORD` | `admin` | Default admin password |
| `SKIP_MUST_CHANGE_PASSWORD` | `false` | Skip forced password change (CI/dev only) |
| `RATE_LIMIT_PER_MIN` | `0` | API rate limit per minute (0 = disabled) |
| `MAX_UPLOAD_SIZE_MB` | `0` | Maximum upload size in MB (0 = unlimited) |
| `MAX_UPLOAD_SIZE_MB` | `100` | Maximum upload size in MB (0 = unlimited) |