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