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 { existsSync } from "node:fs";
import { isIP } from "node:net";
import PQueue from "p-queue"; import PQueue from "p-queue";
import { type Browser, chromium, type Page } from "playwright"; 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 MAX_PAGES = Math.max(1, parseInt(process.env.BROWSER_MAX_PAGES || "3", 10));
const CRASH_WINDOW_MS = 60_000; const CRASH_WINDOW_MS = 60_000;
@@ -59,50 +57,68 @@ function recordCrash(): void {
backoffUntil = now + delay; backoffUntil = now + delay;
} }
async function isBlockedUrl(url: string): Promise<boolean> { function isBrowserLocalUrl(url: string): boolean {
try { const parsed = new URL(url);
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 ( if (
parsed.protocol === "data:" || key === "connection" ||
parsed.protocol === "blob:" || key === "content-encoding" ||
parsed.protocol === "about:" key === "content-length" ||
key === "transfer-encoding"
) { ) {
return false; return;
} }
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { headers[key] = value;
return true; });
} return headers;
const hostname = parsed.hostname.replace(/^\[|]$/g, "");
if (isIP(hostname)) {
return isPrivateIp(hostname);
}
try {
const result = await lookup(hostname, { all: true });
const entries = Array.isArray(result) ? result : [result];
return entries.some((e) => isPrivateIp(e.address));
} catch {
return true;
}
} catch {
return true;
}
} }
async function installNetworkGuard(page: Page): Promise<void> { async function installNetworkGuard(page: Page): Promise<void> {
await page.route("**/*", async (route) => { await page.route("**/*", async (route) => {
const url = route.request().url(); const url = route.request().url();
if (await isBlockedUrl(url)) { let parsed: URL;
try {
parsed = new URL(url);
} catch {
await route.abort("blockedbyclient").catch(() => {}); await route.abort("blockedbyclient").catch(() => {});
return; 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 { try {
const response = await route.fetch(); const response = await safeFetch(url, {
const responseUrl = response.url(); method,
if (responseUrl !== url && (await isBlockedUrl(responseUrl))) { maxBytes: MAX_URL_FETCH_SIZE,
await route.abort("blockedbyclient").catch(() => {}); headers: {
return; Accept: route.request().headers().accept ?? "*/*",
} },
await route.fulfill({ response }); });
const body = method === "HEAD" ? undefined : Buffer.from(await response.arrayBuffer());
await route.fulfill({
status: response.status,
headers: responseHeadersForBrowser(response),
body,
});
} catch { } catch {
await route.abort("failed").catch(() => {}); await route.abort("failed").catch(() => {});
} }
+2 -2
View File
@@ -28,8 +28,8 @@ const envSchema = z
SNAPOTTER_LICENSE_KEY: z.string().default(""), SNAPOTTER_LICENSE_KEY: z.string().default(""),
FILE_MAX_AGE_HOURS: z.coerce.number().default(72), FILE_MAX_AGE_HOURS: z.coerce.number().default(72),
CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(60), CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(60),
MAX_UPLOAD_SIZE_MB: z.coerce.number().default(0), MAX_UPLOAD_SIZE_MB: z.coerce.number().default(100),
MAX_BATCH_SIZE: z.coerce.number().default(0), MAX_BATCH_SIZE: z.coerce.number().default(100),
CONCURRENT_JOBS: z.coerce.number().default(0), CONCURRENT_JOBS: z.coerce.number().default(0),
MAX_MEGAPIXELS: z.coerce.number().default(0), MAX_MEGAPIXELS: z.coerce.number().default(0),
RATE_LIMIT_PER_MIN: z.coerce.number().default(300), RATE_LIMIT_PER_MIN: z.coerce.number().default(300),
+28 -3
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
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 { isDisabledRole } from "../permissions.js";
import { auditLog, sanitizeAuditInput } from "./audit.js"; import { auditLog, sanitizeAuditInput } from "./audit.js";
// ── Types ───────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────
@@ -24,7 +25,7 @@ export interface ExternalAuthParams {
export interface ExternalAuthResult { export interface ExternalAuthResult {
user: { id: string; username: string; role: string; team: string } | null; user: { id: string; username: string; role: string; team: string } | null;
action: "matched" | "linked" | "created" | "denied"; action: "matched" | "linked" | "created" | "denied";
deniedReason?: "user_not_authorized" | "user_limit_reached"; deniedReason?: "user_not_authorized" | "user_limit_reached" | "user_disabled";
} }
// ── Username helpers ────────────────────────────────────────────── // ── Username helpers ──────────────────────────────────────────────
@@ -94,10 +95,18 @@ export async function resolveExternalUser(params: ExternalAuthParams): Promise<E
const [existingByExtId] = await db const [existingByExtId] = await db
.select() .select()
.from(schema.users) .from(schema.users)
.where(eq(schema.users.externalId, externalId)) .where(and(eq(schema.users.externalId, externalId), eq(schema.users.authProvider, provider)))
.limit(1); .limit(1);
if (existingByExtId) { 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 // Update email if changed
if (email && email !== existingByExtId.email) { if (email && email !== existingByExtId.email) {
await db await db
@@ -125,6 +134,14 @@ export async function resolveExternalUser(params: ExternalAuthParams): Promise<E
.limit(1); .limit(1);
if (existingByEmail) { 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 await db
.update(schema.users) .update(schema.users)
.set({ .set({
@@ -154,6 +171,14 @@ export async function resolveExternalUser(params: ExternalAuthParams): Promise<E
// 3. Auto-create // 3. Auto-create
if (autoCreate) { 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 // Check user limit
if (env.MAX_USERS > 0) { if (env.MAX_USERS > 0) {
const [countResult] = await db.select({ count: sql<number>`COUNT(*)` }).from(schema.users); 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 MAX_URLS_PER_REQUEST = 50;
export const URL_FETCH_CONCURRENCY = 4; 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. * 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 * 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 }); 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; let currentUrl = url;
for (let i = 0; i <= MAX_REDIRECTS; i++) { for (let i = 0; i <= MAX_REDIRECTS; i++) {
const { resolvedIp } = await validateFetchUrl(currentUrl); 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 } = { const fetchOptions: RequestInit & { agent?: http.Agent | https.Agent } = {
signal, signal: safeOptions.signal,
redirect: "manual", redirect: "manual",
method: safeOptions.method ?? "GET",
headers: { headers: {
"User-Agent": "SnapOtter/2.0 (file-fetch)", "User-Agent": "SnapOtter/2.0 (file-fetch)",
Host: parsed.host, Host: parsed.host,
...safeOptions.headers,
}, },
body: safeOptions.body as BodyInit | null | undefined,
}; };
// Node.js undici-based fetch does not support the `agent` option directly. // 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, currentUrl,
{ {
agent, agent,
signal: signal ?? undefined, signal: safeOptions.signal ?? undefined,
headers: { headers: {
"User-Agent": "SnapOtter/2.0 (file-fetch)", "User-Agent": "SnapOtter/2.0 (file-fetch)",
...safeOptions.headers,
}, },
method: "GET", method: safeOptions.method ?? "GET",
}, },
(incomingMessage) => { (incomingMessage) => {
const chunks: Buffer[] = []; 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", () => { incomingMessage.on("end", () => {
if (settled) return;
settled = true;
const body = Buffer.concat(chunks); const body = Buffer.concat(chunks);
const headers = new Headers(); const headers = new Headers();
for (const [key, value] of Object.entries(incomingMessage.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); req.on("error", reject);
if (safeOptions.body) req.write(safeOptions.body);
req.end(); req.end();
}); });
} else { } else {
@@ -241,7 +307,7 @@ export async function safeFetch(url: string, signal?: AbortSignal): Promise<Resp
continue; continue;
} }
return res; return withResponseSizeLimit(res, safeOptions.maxBytes);
} }
throw new Error("Too many redirects"); throw new Error("Too many redirects");
} }
+4 -1
View File
@@ -1,3 +1,5 @@
import { safeFetch } from "./ssrf.js";
interface DeliveryOptions { interface DeliveryOptions {
maxRetries?: number; maxRetries?: number;
initialDelayMs?: number; initialDelayMs?: number;
@@ -36,11 +38,12 @@ export async function deliverWebhook(
} }
try { try {
const response = await fetch(url, { const response = await safeFetch(url, {
method: "POST", method: "POST",
headers, headers,
body: payload, body: payload,
signal: AbortSignal.timeout(timeoutMs), signal: AbortSignal.timeout(timeoutMs),
maxBytes: 1024 * 1024,
}); });
if (response.ok) { 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[]> { export async function getPermissions(role: Role | string): Promise<Permission[]> {
if (typeof role !== "string" || isDisabledRole(role)) return [];
if (role in ROLE_PERMISSIONS) { if (role in ROLE_PERMISSIONS) {
return ROLE_PERMISSIONS[role as Role]; return ROLE_PERMISSIONS[role as Role];
} }
@@ -69,6 +70,37 @@ export async function hasEffectivePermission(
return true; 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( export function requirePermission(
permission: Permission, permission: Permission,
): (request: FastifyRequest, reply: FastifyReply) => Promise<AuthUser | null> { ): (request: FastifyRequest, reply: FastifyReply) => Promise<AuthUser | null> {
@@ -87,6 +119,8 @@ export function requirePermission(
} }
export async function hasToolAccess(role: string, toolId: string): Promise<boolean> { export async function hasToolAccess(role: string, toolId: string): Promise<boolean> {
if (isDisabledRole(role)) return false;
// Built-in roles have no tool restrictions // Built-in roles have no tool restrictions
if (role in ROLE_PERMISSIONS) return true; 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)) .where(eq(schema.roles.name, role))
.limit(1); .limit(1);
// Role not found or no toolPermissions configured -- allow all // Unknown custom roles do not get implicit access. Known custom roles with
if (!roleRow?.toolPermissions) return true; // no per-tool restriction keep the historical "all tools" behavior.
if (!roleRow) return false;
if (!roleRow.toolPermissions) return true;
const tp = roleRow.toolPermissions; const tp = roleRow.toolPermissions;
@@ -123,11 +159,34 @@ export async function hasToolAccess(role: string, toolId: string): Promise<boole
return true; // Unknown mode = allow return true; // Unknown mode = allow
} catch { } catch {
// DB not yet available during early startup return false;
return true;
} }
} }
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( export async function requireOwnershipOrPermission(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply,
+72 -37
View File
@@ -9,7 +9,12 @@ import { sharedRedis } from "../jobs/connection.js";
import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js"; import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js";
import { authAttempts } from "../lib/metrics.js"; import { authAttempts } from "../lib/metrics.js";
import { getSettingNumber, getSettingString } from "../lib/settings-helpers.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); const scryptAsync = promisify(scrypt);
@@ -343,6 +348,15 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const audit = auditFromRequest(request); 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) { if (!user?.passwordHash) {
authAttempts.inc({ method: "password", result: "failure" }); authAttempts.inc({ method: "password", result: "failure" });
await audit("LOGIN_FAILED", { await audit("LOGIN_FAILED", {
@@ -362,6 +376,15 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return reply.status(401).send({ error: "Invalid credentials" }); 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 ────────────────────────────────────────── // ── MFA challenge ──────────────────────────────────────────
if (user.totpEnabled) { if (user.totpEnabled) {
const mfaToken = randomUUID(); 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 }); 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({ return reply.status(200).send({
requiresMfa: true, requiresMfa: true,
mfaToken, mfaToken,
mfaRequired, mfaRequired: mfaRequiredByPolicy,
message: "MFA verification required", 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 // Create session
const token = createSessionToken(); const token = createSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); 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)); 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 & { const cookieReply = reply as FastifyReply & {
setCookie?: (name: string, value: string, opts: Record<string, unknown>) => 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, teamName: teamRow?.name ?? user.team,
}, },
expiresAt: expiresAt.toISOString(), 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" }); 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({ return reply.send({
user: { user: {
id: user.id, id: user.id,
@@ -723,6 +743,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
code: "ESCALATION_DENIED", 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 // Resolve team -- frontend sends team name (e.g. "Default"), not ID
const requestedTeam = body.team; const requestedTeam = body.team;
@@ -831,19 +857,6 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
updatedAt: new Date(), 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) { if (body.role) {
const validBuiltinRoles = ["admin", "editor", "user"]; const validBuiltinRoles = ["admin", "editor", "user"];
const [customRoleRow] = validBuiltinRoles.includes(body.role) 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)); : await db.select().from(schema.roles).where(eq(schema.roles.name, body.role));
const isValid = validBuiltinRoles.includes(body.role) || customRoleRow; const isValid = validBuiltinRoles.includes(body.role) || customRoleRow;
if (isValid) { 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 // Prevent removing your own admin role
if (id === admin.id && body.role !== "admin") { if (id === admin.id && body.role !== "admin") {
return reply.status(400).send({ return reply.status(400).send({
@@ -1121,7 +1150,7 @@ export async function authMiddleware(app: FastifyInstance): Promise<void> {
.select() .select()
.from(schema.users) .from(schema.users)
.where(eq(schema.users.id, key.userId)); .where(eq(schema.users.id, key.userId));
if (apiUser) { if (apiUser && !isDisabledRole(apiUser.role)) {
authAttempts.inc({ method: "apikey", result: "success" }); authAttempts.inc({ method: "apikey", result: "success" });
const keyPermissions = key.permissions ?? undefined; const keyPermissions = key.permissions ?? undefined;
(request as FastifyRequest & { user?: AuthUser }).user = { (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" }); 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 ─────────────────────────────────── // ── Idle timeout enforcement ───────────────────────────────────
const idleTimeoutMinutes = await getSettingNumber("sessionIdleTimeoutMinutes"); const idleTimeoutMinutes = await getSettingNumber("sessionIdleTimeoutMinutes");
if (idleTimeoutMinutes > 0) { if (idleTimeoutMinutes > 0) {
+2 -2
View File
@@ -9,7 +9,7 @@ import { sharedRedis } from "../jobs/connection.js";
import { auditFromRequest } from "../lib/audit.js"; import { auditFromRequest } from "../lib/audit.js";
import { decrypt, encrypt } from "../lib/encryption.js"; import { decrypt, encrypt } from "../lib/encryption.js";
import { getSettingString } from "../lib/settings-helpers.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"; import { createSessionToken, getAuthUser, requireAuth } from "./auth.js";
// ── Constants ───────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────
@@ -271,7 +271,7 @@ export async function registerMfa(app: FastifyInstance): Promise<void> {
// Load user // Load user
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, userId)); 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({ return reply.status(401).send({
error: "User not found or MFA not configured", error: "User not found or MFA not configured",
code: "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; 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 // 5. Create session
const token = createSessionToken(); const token = createSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); 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; 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) // Create session (same pattern as OIDC)
const token = createSessionToken(); const token = createSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); 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 { env } from "../config.js";
import { db, schema } from "../db/index.js"; import { db, schema } from "../db/index.js";
import { auditFromRequest } from "../lib/audit.js"; import { auditFromRequest } from "../lib/audit.js";
import { getPermissions, hasEffectivePermission } from "../permissions.js"; import {
import { computeKeyPrefix, hashPassword, requireAuth } from "../plugins/auth.js"; 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 // 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 // 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(), 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> { export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/api-keys — Generate a new API key // POST /api/v1/api-keys — Generate a new API key
app.post( app.post(
"/api/v1/api-keys", "/api/v1/api-keys",
{ config: { rateLimit: API_KEYS_RATE_LIMIT } }, { config: { rateLimit: API_KEYS_RATE_LIMIT } },
async (request: FastifyRequest, reply: FastifyReply) => { async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply); const user = await requireApiKeyManagement(request, reply);
if (!user) return; if (!user) return;
const parsed = createApiKeySchema.safeParse(request.body ?? {}); const parsed = createApiKeySchema.safeParse(request.body ?? {});
@@ -45,18 +86,12 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
const body = parsed.data; const body = parsed.data;
const name = body.name?.trim() || "Default API Key"; const name = body.name?.trim() || "Default API Key";
let scopedPermissions: string[] | null = null; const scoped = await deriveApiKeyPermissionsForCreate(user, body.permissions);
if (body.permissions && body.permissions.length > 0) { if (scoped.invalid.length > 0) {
const userPerms = await getPermissions(user.role); return reply.status(400).send({
const permSet = new Set<string>(userPerms); error: `Cannot scope key with permissions you don't have: ${scoped.invalid.join(", ")}`,
const invalid = body.permissions.filter((p) => !permSet.has(p)); code: "VALIDATION_ERROR",
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;
} }
let expiresAt: Date | null = null; let expiresAt: Date | null = null;
@@ -88,7 +123,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
keyHash, keyHash,
keyPrefix, keyPrefix,
name, name,
permissions: scopedPermissions, permissions: scoped.permissions,
expiresAt, expiresAt,
}); });
} catch { } catch {
@@ -106,7 +141,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
id, id,
key: rawKey, key: rawKey,
name, name,
permissions: scopedPermissions, permissions: scoped.permissions,
expiresAt: expiresAt?.toISOString() ?? null, expiresAt: expiresAt?.toISOString() ?? null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}); });
@@ -118,7 +153,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
"/api/v1/api-keys", "/api/v1/api-keys",
{ config: { rateLimit: API_KEYS_RATE_LIMIT } }, { config: { rateLimit: API_KEYS_RATE_LIMIT } },
async (request: FastifyRequest, reply: FastifyReply) => { async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply); const user = await requireApiKeyManagement(request, reply);
if (!user) return; if (!user) return;
const selectFields = { const selectFields = {
@@ -156,7 +191,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
"/api/v1/api-keys/:id", "/api/v1/api-keys/:id",
{ config: { rateLimit: API_KEYS_RATE_LIMIT } }, { config: { rateLimit: API_KEYS_RATE_LIMIT } },
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply); const user = await requireApiKeyManagement(request, reply);
if (!user) return; if (!user) return;
const { id } = request.params; 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 { resolveToolPool } from "../lib/pool.js";
import { InputValidationError } from "../modality/contract.js"; import { InputValidationError } from "../modality/contract.js";
import { inputHandlerFor } from "../modality/input-handler.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 { updateJobProgress } from "./progress.js";
import { getToolConfig } from "./tool-factory.js"; import { getToolConfig } from "./tool-factory.js";
@@ -67,6 +67,8 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
if (!tool || toolSection(tool) !== section) { if (!tool || toolSection(tool) !== section) {
return reply.status(404).send({ error: "Not found", code: "NOT_FOUND" }); 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. // Batch processing (especially with AI) can take tens of minutes.
// Disable the Node.js HTTP socket timeout so the connection is not // 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 ──────────────────────── // ── Create job ID and initial progress ────────────────────────
const parentId = clientJobId || randomUUID(); const parentId = clientJobId || randomUUID();
const userId = getAuthUser(request)?.id ?? null; const userId = authUser.id;
const pool: Pool = resolveToolPool(toolId); const pool: Pool = resolveToolPool(toolId);
// Insert the parent row BEFORE updateJobProgress, because the // 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 { db, schema } from "../../db/index.js";
import { auditFromRequest } from "../../lib/audit.js"; import { auditFromRequest } from "../../lib/audit.js";
import { encrypt } from "../../lib/encryption.js"; import { encrypt } from "../../lib/encryption.js";
import { validateFetchUrl } from "../../lib/ssrf.js";
import { deliverWebhook } from "../../lib/webhook-delivery.js"; import { deliverWebhook } from "../../lib/webhook-delivery.js";
import { requirePermission } from "../../permissions.js"; import { requirePermission } from "../../permissions.js";
@@ -82,6 +83,19 @@ async function checkFeatureGate(reply: FastifyReply): Promise<boolean> {
return true; 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> { export async function registerWebhookRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/enterprise/webhooks -- list all destinations // GET /api/v1/enterprise/webhooks -- list all destinations
app.get("/api/v1/enterprise/webhooks", async (request: FastifyRequest, reply: FastifyReply) => { 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 }; const dest = { ...parsed.data };
if (!(await validateWebhookDestinationUrl(dest.url, reply))) return;
// Encrypt the auth header before storage // Encrypt the auth header before storage
if (dest.authHeader && env.DATA_ENCRYPTION_KEY) { if (dest.authHeader && env.DATA_ENCRYPTION_KEY) {
@@ -167,6 +182,7 @@ export async function registerWebhookRoutes(app: FastifyInstance): Promise<void>
} }
const dest = { ...parsed.data }; const dest = { ...parsed.data };
if (!(await validateWebhookDestinationUrl(dest.url, reply))) return;
// Encrypt the auth header before storage // Encrypt the auth header before storage
if (dest.authHeader && env.DATA_ENCRYPTION_KEY) { if (dest.authHeader && env.DATA_ENCRYPTION_KEY) {
+4 -1
View File
@@ -185,7 +185,10 @@ async function fetchSingleUrl(
let response: Response; let response: Response;
try { try {
response = await safeFetch(url, controller.signal); response = await safeFetch(url, {
signal: controller.signal,
maxBytes: MAX_URL_FETCH_SIZE,
});
} finally { } finally {
clearTimeout(timeout); 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 { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js";
import { InputValidationError } from "../modality/contract.js"; import { InputValidationError } from "../modality/contract.js";
import { inputHandlerFor } from "../modality/input-handler.js"; import { inputHandlerFor } from "../modality/input-handler.js";
import { hasEffectivePermission } from "../permissions.js"; import { hasEffectivePermission, hasEffectiveToolAccess } from "../permissions.js";
import { getAuthUser, requireAuth } from "../plugins/auth.js"; import { requireAuth } from "../plugins/auth.js";
import { updateJobProgress, updateSingleFileProgress } from "./progress.js"; import { updateJobProgress, updateSingleFileProgress } from "./progress.js";
import { getRegisteredToolIds, getToolConfig } from "./tool-factory.js"; import { getRegisteredToolIds, getToolConfig } from "./tool-factory.js";
@@ -222,6 +222,9 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
"/api/v1/pipeline/execute", "/api/v1/pipeline/execute",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => { async (request: FastifyRequest, reply: FastifyReply) => {
const authUser = requireAuth(request, reply);
if (!authUser) return;
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "file"; let filename = "file";
let pipelineRaw: string | null = null; 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`, 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 // Guard: check if the tool's AI feature bundle is installed
const missingBundleId = getFirstMissingBundleForTool(resolvedToolId); const missingBundleId = getFirstMissingBundleForTool(resolvedToolId);
@@ -428,7 +436,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
// ── Enqueue as a BullMQ flow ──────────────────────────────── // ── Enqueue as a BullMQ flow ────────────────────────────────
const jobId = randomUUID(); const jobId = randomUUID();
const userId = getAuthUser(request)?.id ?? null; const userId = authUser.id;
const originalSize = fileBuffer.length; const originalSize = fileBuffer.length;
// Upload decoded file to object storage // 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`, 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(); const id = randomUUID();
@@ -691,6 +704,9 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
config: { rateLimit: { max: 20, timeWindow: "1 minute" } }, config: { rateLimit: { max: 20, timeWindow: "1 minute" } },
}, },
async (request: FastifyRequest, reply: FastifyReply) => { async (request: FastifyRequest, reply: FastifyReply) => {
const authUser = requireAuth(request, reply);
if (!authUser) return;
// ── Parse multipart ────────────────────────────────────────────── // ── Parse multipart ──────────────────────────────────────────────
interface ParsedFile { interface ParsedFile {
buffer: Buffer; buffer: Buffer;
@@ -780,6 +796,11 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
error: `Step ${i + 1}: Tool "${step.toolId}" not found`, 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); const missingBundleId = getFirstMissingBundleForTool(resolvedToolId);
if (missingBundleId) { if (missingBundleId) {
@@ -832,7 +853,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
// ── Prepare files and build flow ───────────────────────────────── // ── Prepare files and build flow ─────────────────────────────────
const parentId = clientJobId || randomUUID(); const parentId = clientJobId || randomUUID();
const userId = getAuthUser(request)?.id ?? null; const userId = authUser.id;
// Insert batch-finalize row BEFORE updateJobProgress to avoid // Insert batch-finalize row BEFORE updateJobProgress to avoid
// a duplicate-key race with the progress persist layer. // 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 { z } from "zod";
import { db, schema } from "../db/index.js"; import { db, schema } from "../db/index.js";
import { auditFromRequest } from "../lib/audit.js"; import { auditFromRequest } from "../lib/audit.js";
import { requirePermission } from "../permissions.js"; import { permissionsNotHeldBy, requirePermission } from "../permissions.js";
const ALL_PERMISSIONS: Permission[] = [ const ALL_PERMISSIONS: Permission[] = [
"tools:use", "tools:use",
@@ -96,7 +96,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/roles — Create custom role // POST /api/v1/roles — Create custom role
app.post("/api/v1/roles", async (request: FastifyRequest, reply: FastifyReply) => { 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; if (!user) return;
const parsed = createRoleSchema.safeParse(request.body); const parsed = createRoleSchema.safeParse(request.body);
@@ -114,6 +114,13 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
.status(400) .status(400)
.send({ error: `Invalid permissions: ${invalid.join(", ")}`, code: "VALIDATION_ERROR" }); .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)); const [existing] = await db.select().from(schema.roles).where(eq(schema.roles.name, name));
if (existing) { if (existing) {
@@ -151,7 +158,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
app.put( app.put(
"/api/v1/roles/:id", "/api/v1/roles/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { 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; if (!user) return;
const { id } = request.params; const { id } = request.params;
@@ -193,6 +200,13 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
code: "VALIDATION_ERROR", 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; updates.permissions = body.permissions;
} }
if (body.toolPermissions !== undefined) { if (body.toolPermissions !== undefined) {
@@ -218,7 +232,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
app.delete( app.delete(
"/api/v1/roles/:id", "/api/v1/roles/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { 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; if (!user) return;
const { id } = request.params; 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 { auditFromRequest } from "../lib/audit.js";
import { decrypt, encrypt, isEncrypted } from "../lib/encryption.js"; import { decrypt, encrypt, isEncrypted } from "../lib/encryption.js";
import { requirePermission } from "../permissions.js"; import { requirePermission } from "../permissions.js";
import { requireAuth } from "../plugins/auth.js";
const settingsBodySchema = z.record(z.string().min(1), z.unknown()); const settingsBodySchema = z.record(z.string().min(1), z.unknown());
@@ -25,15 +24,34 @@ const SENSITIVE_KEYS = new Set([
"instance_id", "instance_id",
"oidc_client_secret", "oidc_client_secret",
"saml_idp_certificate", "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", "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"]); const READONLY_KEYS = new Set(["cookie_secret", "instance_id"]);
async function encryptIfSensitive(key: string, value: string): Promise<string> { 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); return encrypt(value, env.DATA_ENCRYPTION_KEY);
} }
@@ -55,7 +73,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
"/api/v1/settings", "/api/v1/settings",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => { async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply); const user = await requirePermission("settings:read")(request, reply);
if (!user) return; if (!user) return;
const isAdmin = user.role === "admin"; const isAdmin = user.role === "admin";
@@ -175,7 +193,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
"/api/v1/settings/:key", "/api/v1/settings/:key",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { key: string } }>, reply: FastifyReply) => { async (request: FastifyRequest<{ Params: { key: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply); const user = await requirePermission("settings:read")(request, reply);
if (!user) return; if (!user) return;
const { key } = request.params; 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 { InputValidationError } from "../modality/contract.js";
import { inputHandlerFor } from "../modality/input-handler.js"; import { inputHandlerFor } from "../modality/input-handler.js";
import { MediaInputHandler, type MediaInputKind } from "../modality/media-input.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"; import { updateSingleFileProgress } from "./progress.js";
/** Context passed to tool process functions for cooperative cancellation, scratch storage, and progress. */ /** 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), : apiToolPath(config.toolId),
{ config: { rateLimit: toolRateLimit } }, { config: { rateLimit: toolRateLimit } },
async (request: FastifyRequest, reply: FastifyReply) => { async (request: FastifyRequest, reply: FastifyReply) => {
// Check per-tool access before processing uploads const authUser = await requireToolAccess(request, reply, config.toolId);
const authUser = getAuthUser(request); if (!authUser) return;
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 jobId = randomUUID(); const jobId = randomUUID();
const maxInputs = config.maxInputs ?? 1; 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 // Check per-user concurrent job limit before enqueuing
const userId = getAuthUser(request)?.id ?? null; const userId = authUser.id;
const maxConcurrent = await getSettingNumber("maxConcurrentJobsPerUser", 0); const maxConcurrent = await getSettingNumber("maxConcurrentJobsPerUser", 0);
if (maxConcurrent > 0 && userId) { if (maxConcurrent > 0 && userId) {
const activeJobs = await db const activeJobs = await db
@@ -570,10 +564,9 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
.then(({ isToolAuditEnabled, auditFromRequest }) => .then(({ isToolAuditEnabled, auditFromRequest }) =>
isToolAuditEnabled().then((enabled) => { isToolAuditEnabled().then((enabled) => {
if (!enabled) return; if (!enabled) return;
const user = getAuthUser(request);
return auditFromRequest(request)("TOOL_EXECUTED", { return auditFromRequest(request)("TOOL_EXECUTED", {
userId: user?.id, userId: authUser.id,
username: user?.username, username: authUser.username,
toolId: config.toolId, toolId: config.toolId,
inputFileCount: received.length, inputFileCount: received.length,
totalInputSize: received.reduce((sum, r) => sum + r.size, 0), 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 SELECT DISTINCT id, stored_name, size, user_id FROM chain
`); `);
const chainRows = cteResult.rows; 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) // 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 deleteStoredFile(row.stored_name);
await deleteThumbnail(row.stored_name); await deleteThumbnail(row.stored_name);
} }
// Batch DB delete // Batch DB delete
const chainIds = chainRows.map((r) => r.id); const chainIds = deletableChainRows.map((r) => r.id);
if (chainIds.length > 0) { if (chainIds.length > 0) {
await db.delete(schema.userFiles).where(inArray(schema.userFiles.id, chainIds)); await db.delete(schema.userFiles).where(inArray(schema.userFiles.id, chainIds));
} }
// Decrement storageUsed per user (group by userId for files:all scenarios) // Decrement storageUsed per user (group by userId for files:all scenarios)
const perUserSizes = new Map<string, number>(); const perUserSizes = new Map<string, number>();
for (const row of chainRows) { for (const row of deletableChainRows) {
if (row.user_id && row.size) { if (row.user_id && row.size) {
perUserSizes.set(row.user_id, (perUserSizes.get(row.user_id) ?? 0) + 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", { await auditFromRequest(request)("FILE_DELETED", {
userId: user.id, userId: user.id,
count: chainRows.length, count: deletableChainRows.length,
ids, 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 * toolId the tool that produced this result
*/ */
app.post("/api/v1/files/save-result", async (request: FastifyRequest, reply: FastifyReply) => { app.post("/api/v1/files/save-result", async (request: FastifyRequest, reply: FastifyReply) => {
const user = getAuthUser(request); const user = requireAuth(request, reply);
const userId = user?.id ?? null; if (!user) return;
const userId = user.id;
// Enforce per-user storage quota before saving results // Enforce per-user storage quota before saving results
try { try {
@@ -741,6 +745,13 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
if (!parent) { if (!parent) {
return reply.status(404).send({ error: "Parent file not found" }); 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; 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}' -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 ## 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 | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `MAX_UPLOAD_SIZE_MB` | `0` (unlimited) | Maximum file size per upload in megabytes. 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` | `0` (unlimited) | Maximum number of files in a single batch request. 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. | | `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_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. | | `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 - DATABASE_URL=postgres://snapotter:snapotter@postgres:5432/snapotter
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
# --- Limits (0 = unlimited) --- # --- Limits (set 0 for unlimited) ---
# - MAX_UPLOAD_SIZE_MB=0 # Per-file upload limit in MB # - MAX_UPLOAD_SIZE_MB=100 # Per-file upload limit in MB
# - MAX_BATCH_SIZE=0 # Max files per batch request # - MAX_BATCH_SIZE=100 # Max files per batch request
# - RATE_LIMIT_PER_MIN=0 # API rate limit (0 = disabled, 100 = recommended for public) # - RATE_LIMIT_PER_MIN=0 # API rate limit (0 = disabled, 100 = recommended for public)
# - MAX_USERS=0 # Max user accounts # - 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 | | `AUTH_ENABLED` | `true` | Enable/disable login requirement |
| `DEFAULT_USERNAME` | `admin` | Initial admin username | | `DEFAULT_USERNAME` | `admin` | Initial admin username |
| `DEFAULT_PASSWORD` | `admin` | Initial admin password (forced change on first login) | | `DEFAULT_PASSWORD` | `admin` | Initial admin password (forced change on first login) |
| `MAX_UPLOAD_SIZE_MB` | `0` (unlimited) | Per-file upload limit | | `MAX_UPLOAD_SIZE_MB` | `100` | Per-file upload limit |
| `MAX_BATCH_SIZE` | `0` (unlimited) | Max files per batch request | | `MAX_BATCH_SIZE` | `100` | Max files per batch request |
| `RATE_LIMIT_PER_MIN` | `0` (disabled) | API requests per minute per IP | | `RATE_LIMIT_PER_MIN` | `0` (disabled) | API requests per minute per IP |
| `MAX_USERS` | `0` (unlimited) | Maximum user accounts | | `MAX_USERS` | `0` (unlimited) | Maximum user accounts |
| `TRUST_PROXY` | `true` | Trust X-Forwarded-For headers from reverse proxy | | `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 | | `DEFAULT_PASSWORD` | `admin` | Default admin password |
| `SKIP_MUST_CHANGE_PASSWORD` | `false` | Skip forced password change (CI/dev only) | | `SKIP_MUST_CHANGE_PASSWORD` | `false` | Skip forced password change (CI/dev only) |
| `RATE_LIMIT_PER_MIN` | `0` | API rate limit per minute (0 = disabled) | | `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) |
+1 -1
View File
@@ -16,7 +16,7 @@ export {
type PdfCompressionPreset, type PdfCompressionPreset,
} from "./ghostscript.js"; } from "./ghostscript.js";
export { type ConvertOptions, convertDocument, parseConvertTarget } from "./libreoffice.js"; export { type ConvertOptions, convertDocument, parseConvertTarget } from "./libreoffice.js";
export { type PandocOptions, pandocAvailable, runPandoc } from "./pandoc.js"; export { buildPandocArgs, type PandocOptions, pandocAvailable, runPandoc } from "./pandoc.js";
export { export {
assertValidRange, assertValidRange,
qpdfDecrypt, qpdfDecrypt,
+16 -7
View File
@@ -45,6 +45,21 @@ export interface PandocOptions {
extraArgs?: string[]; extraArgs?: string[];
} }
export function buildPandocArgs(
inPath: string,
outPath: string,
opts: PandocOptions = {},
): string[] {
const args = ["--sandbox", inPath, "-o", outPath];
if (opts.selfContained) {
args.push(...selfContainedArgs());
}
if (opts.extraArgs) {
args.push(...opts.extraArgs);
}
return args;
}
/** Runs pandoc in -> out; rejects with the stderr tail on failure. */ /** Runs pandoc in -> out; rejects with the stderr tail on failure. */
export function runPandoc( export function runPandoc(
inPath: string, inPath: string,
@@ -52,13 +67,7 @@ export function runPandoc(
opts: PandocOptions = {}, opts: PandocOptions = {},
): Promise<void> { ): Promise<void> {
const timeoutMs = opts.timeoutMs ?? 120_000; const timeoutMs = opts.timeoutMs ?? 120_000;
const args = [inPath, "-o", outPath]; const args = buildPandocArgs(inPath, outPath, opts);
if (opts.selfContained) {
args.push(...selfContainedArgs());
}
if (opts.extraArgs) {
args.push(...opts.extraArgs);
}
return new Promise<void>((resolvePromise, reject) => { return new Promise<void>((resolvePromise, reject) => {
const child = spawn(pandocBin(), args, { stdio: ["ignore", "pipe", "pipe"] }); const child = spawn(pandocBin(), args, { stdio: ["ignore", "pipe", "pipe"] });
let err = ""; let err = "";
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../apps/api/src/config.js", () => ({
env: {
API_KEYS_RATE_LIMIT_PER_MIN: 30,
SESSION_DURATION_HOURS: 168,
DEFAULT_PASSWORD: "Adminpass1",
SKIP_MUST_CHANGE_PASSWORD: true,
AUTH_ENABLED: true,
EXTERNAL_URL: "",
},
}));
vi.mock("../../../apps/api/src/db/index.js", () => ({
db: {},
schema: {},
}));
vi.mock("../../../apps/api/src/lib/audit.js", () => ({
auditFromRequest: () => vi.fn(),
sanitizeAuditInput: (value: string) => value,
}));
vi.mock("../../../apps/api/src/jobs/connection.js", () => ({
sharedRedis: () => ({
setex: vi.fn(),
get: vi.fn(),
del: vi.fn(),
}),
}));
vi.mock("../../../apps/api/src/lib/metrics.js", () => ({
authAttempts: { inc: vi.fn() },
}));
vi.mock("../../../apps/api/src/lib/settings-helpers.js", () => ({
getSettingNumber: vi.fn().mockResolvedValue(0),
getSettingString: vi.fn().mockResolvedValue("optional"),
}));
import { deriveApiKeyPermissionsForCreate } from "../../../apps/api/src/routes/api-keys.js";
describe("deriveApiKeyPermissionsForCreate", () => {
it("defaults a scoped caller's new key to the caller's effective scope", async () => {
const scoped = await deriveApiKeyPermissionsForCreate({
id: "u1",
username: "admin",
role: "admin",
apiKeyPermissions: ["tools:use", "apikeys:own"],
});
expect(scoped).toEqual({
permissions: ["tools:use", "apikeys:own"],
invalid: [],
});
});
it("rejects requested permissions outside the caller's API key scope", async () => {
const scoped = await deriveApiKeyPermissionsForCreate(
{
id: "u1",
username: "admin",
role: "admin",
apiKeyPermissions: ["tools:use", "apikeys:own"],
},
["tools:use", "users:manage"],
);
expect(scoped).toEqual({
permissions: ["tools:use", "users:manage"],
invalid: ["users:manage"],
});
});
it("keeps unscoped session-created keys unscoped when no scope is requested", async () => {
const scoped = await deriveApiKeyPermissionsForCreate({
id: "u1",
username: "admin",
role: "admin",
});
expect(scoped).toEqual({ permissions: null, invalid: [] });
});
});
+49 -2
View File
@@ -5,6 +5,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const dbMock = vi.hoisted(() => { const dbMock = vi.hoisted(() => {
let idx = 0; let idx = 0;
let results: unknown[][] = []; let results: unknown[][] = [];
const queryResult = () => {
const promise = Promise.resolve(results[idx++] ?? []);
return Object.assign(promise, {
limit: () => promise,
});
};
return { return {
reset(r: unknown[][]) { reset(r: unknown[][]) {
idx = 0; idx = 0;
@@ -13,6 +19,7 @@ const dbMock = vi.hoisted(() => {
nextResult() { nextResult() {
return results[idx++] ?? []; return results[idx++] ?? [];
}, },
queryResult,
}; };
}); });
@@ -20,12 +27,20 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({
db: { db: {
select: () => ({ select: () => ({
from: () => ({ from: () => ({
where: () => Promise.resolve(dbMock.nextResult()), where: () => dbMock.queryResult(),
}), }),
}), }),
insert: () => ({
values: () => Promise.resolve({ rowCount: 1 }),
}),
}, },
schema: { schema: {
users: { username: "username" }, users: {
username: "username",
externalId: "external_id",
authProvider: "auth_provider",
email: "email",
},
}, },
})); }));
@@ -192,3 +207,35 @@ describe("findUniqueUsername", () => {
expect(result).toBe("user_5"); expect(result).toBe("user_5");
}); });
}); });
describe("resolveExternalUser", () => {
beforeEach(() => {
dbMock.reset([]);
});
it("denies SSO auto-create when the configured default role is disabled", async () => {
const mod = await import("../../../apps/api/src/lib/external-auth-resolver.js");
const logger = {
info: vi.fn(),
warn: vi.fn(),
};
const result = await mod.resolveExternalUser({
provider: "oidc",
externalId: "subject-1",
username: "subject",
autoCreate: true,
autoLink: false,
defaultRole: "disabled",
logger: logger as never,
ip: "127.0.0.1",
requestId: "req-1",
});
expect(result).toEqual({
user: null,
action: "denied",
deniedReason: "user_disabled",
});
expect(logger.warn).toHaveBeenCalledWith("oidc auto-create blocked: default role is disabled");
});
});
+59 -1
View File
@@ -21,7 +21,14 @@ vi.mock("../../../apps/api/src/plugins/auth.js", () => ({
getAuthUser: () => null, getAuthUser: () => null,
})); }));
import { getPermissions, hasPermission } from "../../../apps/api/src/permissions.js"; import {
getEffectivePermissions,
getPermissions,
hasEffectiveToolAccess,
hasPermission,
isDisabledRole,
permissionsNotHeldBy,
} from "../../../apps/api/src/permissions.js";
describe("permissions", () => { describe("permissions", () => {
describe("getPermissions", () => { describe("getPermissions", () => {
@@ -91,4 +98,55 @@ describe("permissions", () => {
expect(await hasPermission("user", "settings:write")).toBe(false); expect(await hasPermission("user", "settings:write")).toBe(false);
}); });
}); });
describe("effective scoped permissions", () => {
it("intersects role permissions with API key scope", async () => {
const perms = await getEffectivePermissions({
id: "u1",
username: "admin",
role: "admin",
apiKeyPermissions: ["tools:use", "apikeys:own"],
});
expect(perms).toEqual(["tools:use", "apikeys:own"]);
});
it("reports requested permissions outside the caller effective scope", async () => {
const invalid = await permissionsNotHeldBy(
{
id: "u1",
username: "admin",
role: "admin",
apiKeyPermissions: ["tools:use", "apikeys:own"],
},
["tools:use", "users:manage"],
);
expect(invalid).toEqual(["users:manage"]);
});
it("denies tool execution when API key scope lacks tools:use", async () => {
await expect(
hasEffectiveToolAccess(
{
id: "u1",
username: "admin",
role: "admin",
apiKeyPermissions: ["apikeys:own"],
},
"resize",
),
).resolves.toBe(false);
});
});
describe("disabled roles", () => {
it("identifies disabled SCIM roles", () => {
expect(isDisabledRole("disabled")).toBe(true);
expect(isDisabledRole("disabled:user")).toBe(true);
expect(isDisabledRole("user")).toBe(false);
});
it("returns no permissions for disabled roles", async () => {
await expect(getPermissions("disabled:admin")).resolves.toEqual([]);
});
});
}); });
+12 -3
View File
@@ -22,7 +22,10 @@ type Permission =
| "teams:manage" | "teams:manage"
| "features:manage" | "features:manage"
| "system:health" | "system:health"
| "audit:read"; | "audit:read"
| "compliance:manage"
| "webhooks:manage"
| "security:manage";
const ALL_PERMISSIONS: Permission[] = [ const ALL_PERMISSIONS: Permission[] = [
"tools:use", "tools:use",
@@ -39,6 +42,9 @@ const ALL_PERMISSIONS: Permission[] = [
"features:manage", "features:manage",
"system:health", "system:health",
"audit:read", "audit:read",
"compliance:manage",
"webhooks:manage",
"security:manage",
]; ];
const ROLE_NAME_PATTERN = /^[a-z0-9_-]+$/; const ROLE_NAME_PATTERN = /^[a-z0-9_-]+$/;
@@ -263,8 +269,8 @@ describe("roles route logic", () => {
}); });
describe("ALL_PERMISSIONS constant", () => { describe("ALL_PERMISSIONS constant", () => {
it("contains exactly 14 permissions", () => { it("contains exactly 17 permissions", () => {
expect(ALL_PERMISSIONS).toHaveLength(14); expect(ALL_PERMISSIONS).toHaveLength(17);
}); });
it("contains all expected permissions", () => { it("contains all expected permissions", () => {
@@ -280,6 +286,9 @@ describe("roles route logic", () => {
expect(ALL_PERMISSIONS).toContain("features:manage"); expect(ALL_PERMISSIONS).toContain("features:manage");
expect(ALL_PERMISSIONS).toContain("system:health"); expect(ALL_PERMISSIONS).toContain("system:health");
expect(ALL_PERMISSIONS).toContain("audit:read"); expect(ALL_PERMISSIONS).toContain("audit:read");
expect(ALL_PERMISSIONS).toContain("compliance:manage");
expect(ALL_PERMISSIONS).toContain("webhooks:manage");
expect(ALL_PERMISSIONS).toContain("security:manage");
}); });
}); });
+20 -2
View File
@@ -3,13 +3,13 @@ import { MAX_REDIRECTS, safeFetch, validateFetchUrl } from "../../../apps/api/sr
describe("validateFetchUrl", () => { describe("validateFetchUrl", () => {
it("allows valid public HTTP URL", async () => { it("allows valid public HTTP URL", async () => {
const result = await validateFetchUrl("https://images.unsplash.com/photo.jpg"); const result = await validateFetchUrl("https://93.184.216.34/photo.jpg");
expect(result).toHaveProperty("resolvedIp"); expect(result).toHaveProperty("resolvedIp");
expect(typeof result.resolvedIp).toBe("string"); expect(typeof result.resolvedIp).toBe("string");
}); });
it("allows valid public HTTP URL without TLS", async () => { it("allows valid public HTTP URL without TLS", async () => {
const result = await validateFetchUrl("http://example.com/image.png"); const result = await validateFetchUrl("http://93.184.216.34/image.png");
expect(result).toHaveProperty("resolvedIp"); expect(result).toHaveProperty("resolvedIp");
}); });
@@ -269,4 +269,22 @@ describe("safeFetch", () => {
"Redirect without Location header", "Redirect without Location header",
); );
}); });
it("enforces maxBytes while reading HTTP responses", async () => {
mockFetch.mockResolvedValueOnce(
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(3));
controller.enqueue(new Uint8Array(3));
controller.close();
},
}),
{ status: 200 },
),
);
const res = await safeFetch("http://93.184.216.34/image.jpg", { maxBytes: 4 });
await expect(res.arrayBuffer()).rejects.toThrow("Response exceeds maximum size");
});
}); });
+1 -1
View File
@@ -63,7 +63,7 @@ vi.mock("../../../apps/api/src/routes/progress.js", () => ({
})); }));
vi.mock("../../../apps/api/src/plugins/auth.js", () => ({ vi.mock("../../../apps/api/src/plugins/auth.js", () => ({
getAuthUser: vi.fn(() => null), getAuthUser: vi.fn(() => ({ id: "user-1", username: "test", role: "admin" })),
})); }));
vi.mock("../../../apps/api/src/lib/analytics.js", () => ({ vi.mock("../../../apps/api/src/lib/analytics.js", () => ({
+18
View File
@@ -932,6 +932,24 @@ describe("loadEnv", () => {
expect(env.CONCURRENT_JOBS).toBe(0); expect(env.CONCURRENT_JOBS).toBe(0);
}); });
it("defaults upload and batch limits to bounded values", async () => {
delete process.env.MAX_UPLOAD_SIZE_MB;
delete process.env.MAX_BATCH_SIZE;
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
const env = loadEnv();
expect(env.MAX_UPLOAD_SIZE_MB).toBe(100);
expect(env.MAX_BATCH_SIZE).toBe(100);
});
it("still accepts explicit 0 for unlimited upload and batch limits", async () => {
process.env.MAX_UPLOAD_SIZE_MB = "0";
process.env.MAX_BATCH_SIZE = "0";
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
const env = loadEnv();
expect(env.MAX_UPLOAD_SIZE_MB).toBe(0);
expect(env.MAX_BATCH_SIZE).toBe(0);
});
it("coerces negative numbers for numeric fields", async () => { it("coerces negative numbers for numeric fields", async () => {
process.env.PORT = "-1"; process.env.PORT = "-1";
const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
+9
View File
@@ -1,9 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const safeFetchMock = vi.hoisted(() => vi.fn());
vi.mock("../../../apps/api/src/lib/ssrf.js", () => ({
safeFetch: safeFetchMock,
}));
describe("webhook delivery", () => { describe("webhook delivery", () => {
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.resetModules(); vi.resetModules();
safeFetchMock.mockReset();
safeFetchMock.mockImplementation((url, options) => fetch(url, options));
}); });
afterEach(() => { afterEach(() => {
@@ -21,6 +29,7 @@ describe("webhook delivery", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.attempts).toBe(1); expect(result.attempts).toBe(1);
expect(safeFetchMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, opts] = fetchMock.mock.calls[0]; const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("https://siem.example.com/input"); expect(url).toBe("https://siem.example.com/input");
+22 -1
View File
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { pandocAvailable, runPandoc } from "@snapotter/doc-engine"; import { buildPandocArgs, pandocAvailable, runPandoc } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
describe("pandocAvailable", () => { describe("pandocAvailable", () => {
@@ -11,6 +11,27 @@ describe("pandocAvailable", () => {
}); });
}); });
describe("buildPandocArgs", () => {
it("runs conversions inside the pandoc sandbox", () => {
expect(buildPandocArgs("input.md", "out.docx")).toEqual([
"--sandbox",
"input.md",
"-o",
"out.docx",
]);
});
it("keeps extra args after the sandboxed input/output args", () => {
expect(buildPandocArgs("input.md", "out.html", { extraArgs: ["--standalone"] })).toEqual([
"--sandbox",
"input.md",
"-o",
"out.html",
"--standalone",
]);
});
});
describe.skipIf(!pandocAvailable())("runPandoc (requires pandoc)", () => { describe.skipIf(!pandocAvailable())("runPandoc (requires pandoc)", () => {
let tmpDir: string; let tmpDir: string;