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) |
|
||||
|
||||
@@ -16,7 +16,7 @@ export {
|
||||
type PdfCompressionPreset,
|
||||
} from "./ghostscript.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 {
|
||||
assertValidRange,
|
||||
qpdfDecrypt,
|
||||
|
||||
@@ -45,6 +45,21 @@ export interface PandocOptions {
|
||||
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. */
|
||||
export function runPandoc(
|
||||
inPath: string,
|
||||
@@ -52,13 +67,7 @@ export function runPandoc(
|
||||
opts: PandocOptions = {},
|
||||
): Promise<void> {
|
||||
const timeoutMs = opts.timeoutMs ?? 120_000;
|
||||
const args = [inPath, "-o", outPath];
|
||||
if (opts.selfContained) {
|
||||
args.push(...selfContainedArgs());
|
||||
}
|
||||
if (opts.extraArgs) {
|
||||
args.push(...opts.extraArgs);
|
||||
}
|
||||
const args = buildPandocArgs(inPath, outPath, opts);
|
||||
return new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(pandocBin(), args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let err = "";
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const dbMock = vi.hoisted(() => {
|
||||
let idx = 0;
|
||||
let results: unknown[][] = [];
|
||||
const queryResult = () => {
|
||||
const promise = Promise.resolve(results[idx++] ?? []);
|
||||
return Object.assign(promise, {
|
||||
limit: () => promise,
|
||||
});
|
||||
};
|
||||
return {
|
||||
reset(r: unknown[][]) {
|
||||
idx = 0;
|
||||
@@ -13,6 +19,7 @@ const dbMock = vi.hoisted(() => {
|
||||
nextResult() {
|
||||
return results[idx++] ?? [];
|
||||
},
|
||||
queryResult,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -20,12 +27,20 @@ vi.mock("../../../apps/api/src/db/index.js", () => ({
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => Promise.resolve(dbMock.nextResult()),
|
||||
where: () => dbMock.queryResult(),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: () => Promise.resolve({ rowCount: 1 }),
|
||||
}),
|
||||
},
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,14 @@ vi.mock("../../../apps/api/src/plugins/auth.js", () => ({
|
||||
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("getPermissions", () => {
|
||||
@@ -91,4 +98,55 @@ describe("permissions", () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,10 @@ type Permission =
|
||||
| "teams:manage"
|
||||
| "features:manage"
|
||||
| "system:health"
|
||||
| "audit:read";
|
||||
| "audit:read"
|
||||
| "compliance:manage"
|
||||
| "webhooks:manage"
|
||||
| "security:manage";
|
||||
|
||||
const ALL_PERMISSIONS: Permission[] = [
|
||||
"tools:use",
|
||||
@@ -39,6 +42,9 @@ const ALL_PERMISSIONS: Permission[] = [
|
||||
"features:manage",
|
||||
"system:health",
|
||||
"audit:read",
|
||||
"compliance:manage",
|
||||
"webhooks:manage",
|
||||
"security:manage",
|
||||
];
|
||||
|
||||
const ROLE_NAME_PATTERN = /^[a-z0-9_-]+$/;
|
||||
@@ -263,8 +269,8 @@ describe("roles route logic", () => {
|
||||
});
|
||||
|
||||
describe("ALL_PERMISSIONS constant", () => {
|
||||
it("contains exactly 14 permissions", () => {
|
||||
expect(ALL_PERMISSIONS).toHaveLength(14);
|
||||
it("contains exactly 17 permissions", () => {
|
||||
expect(ALL_PERMISSIONS).toHaveLength(17);
|
||||
});
|
||||
|
||||
it("contains all expected permissions", () => {
|
||||
@@ -280,6 +286,9 @@ describe("roles route logic", () => {
|
||||
expect(ALL_PERMISSIONS).toContain("features:manage");
|
||||
expect(ALL_PERMISSIONS).toContain("system:health");
|
||||
expect(ALL_PERMISSIONS).toContain("audit:read");
|
||||
expect(ALL_PERMISSIONS).toContain("compliance:manage");
|
||||
expect(ALL_PERMISSIONS).toContain("webhooks:manage");
|
||||
expect(ALL_PERMISSIONS).toContain("security:manage");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import { MAX_REDIRECTS, safeFetch, validateFetchUrl } from "../../../apps/api/sr
|
||||
|
||||
describe("validateFetchUrl", () => {
|
||||
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(typeof result.resolvedIp).toBe("string");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -269,4 +269,22 @@ describe("safeFetch", () => {
|
||||
"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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ vi.mock("../../../apps/api/src/routes/progress.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", () => ({
|
||||
|
||||
@@ -932,6 +932,24 @@ describe("loadEnv", () => {
|
||||
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 () => {
|
||||
process.env.PORT = "-1";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
safeFetchMock.mockReset();
|
||||
safeFetchMock.mockImplementation((url, options) => fetch(url, options));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -21,6 +29,7 @@ describe("webhook delivery", () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.attempts).toBe(1);
|
||||
expect(safeFetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://siem.example.com/input");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
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";
|
||||
|
||||
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)", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user