mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(security): harden auth and outbound fetches
This commit is contained in:
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user