diff --git a/src/lib/server/backups/security.ts b/src/lib/server/backups/security.ts index 1c9b1aa..9f08b5c 100644 --- a/src/lib/server/backups/security.ts +++ b/src/lib/server/backups/security.ts @@ -139,8 +139,17 @@ export function assertTargetWithin(root: string, target: string, label = 'restor const FORBIDDEN_RESTORE_ROOTS = [ '/', '/etc', '/root', '/boot', '/dev', '/proc', '/sys', '/run', '/var/run', '/bin', '/sbin', '/lib', '/lib64', '/var/lib/docker', + // A root-writing restore under /usr is a host-takeover primitive: /usr/local/bin + // (PATH hijack) and /usr/lib/systemd (unit drop). /usr holds no user data, so + // blocking it wholesale costs no legitimate restore. + '/usr', ]; +// Blocked as a path SEGMENT anywhere (not a root prefix): an .ssh directory is an +// SSH-key takeover target under any user's home (/home//.ssh, /root/.ssh), +// while /home// stays a legitimate restore location. +const FORBIDDEN_PATH_SEGMENTS = ['.ssh']; + /** Returns a reason string if `targetPath` is unsafe for a new-location restore, else null. * Uses resolve() so `/etc/../etc`, `/srv/../etc`, trailing slashes all normalize before the * forbidden-root compare — a string-only prefix check would miss those. */ @@ -157,6 +166,12 @@ export function unsafeRestoreTargetReason(targetPath: string): string | null { return `target path "${norm}" is inside a protected system directory (${root})`; } } + const segments = norm.split(pathSep).filter(Boolean); + for (const seg of FORBIDDEN_PATH_SEGMENTS) { + if (segments.includes(seg)) { + return `target path "${norm}" contains a protected directory segment ("${seg}")`; + } + } return null; } diff --git a/src/lib/server/features.ts b/src/lib/server/features.ts index 632e18b..bfa8411 100644 --- a/src/lib/server/features.ts +++ b/src/lib/server/features.ts @@ -20,3 +20,11 @@ export const BACKUPS_ENABLED = process.env.FEAT_BACKUPS_ENABLED === 'true'; * API surface is opt-in per instance with FEAT_API_DOCS=true. Read once at boot. */ export const API_DOCS_ENABLED = process.env.FEAT_API_DOCS === 'true'; + +/** + * Whether git webhooks may be enabled WITHOUT a secret (isolated-network escape hatch). + * Surfaced to the client so the git-stack form can relax its "secret required" validation + * to match the server, which gates the same rule via allowSecretlessWebhook(). Read once at + * boot. Default false: a secret is required. + */ +export const ALLOW_WEBHOOKS_WITHOUT_SECRET = process.env.ALLOW_WEBHOOKS_WITHOUT_SECRET === 'true'; diff --git a/src/lib/server/fs-guard.ts b/src/lib/server/fs-guard.ts index 3b0453a..8d81fd5 100644 --- a/src/lib/server/fs-guard.ts +++ b/src/lib/server/fs-guard.ts @@ -1,16 +1,21 @@ /** * Guard for the filesystem-browser endpoints (`/api/system/files*`). * - * Dockhand runs in a container, so there is no host filesystem to leak — users - * are allowed to browse the container freely (they need this to find/adopt - * external stacks). The only things that must never be readable are Dockhand's - * own secrets: + * The file browser lets stacks:edit users browse the host to find/adopt/relocate + * stacks, but no caller may read host or Dockhand secrets through it, so the + * following are always blocked regardless of permission: * - the database directory ($DATA_DIR/db, contains dockhand.db) * - the encryption key file ($DATA_DIR/.encryption_key) - * - /proc — /proc//environ exposes process env vars, including + * - /proc - /proc//environ exposes process env vars, including * DATABASE_URL (Postgres credentials) and ENCRYPTION_KEY. The whole tree is * blocked because /proc/self, /proc/1, etc. all leak the same secrets and * nothing legitimate is browsed there. + * - /etc, /root - system account/secret files (/etc/shadow, /etc/passwd, root's + * home). Nothing a compose file lives in. + * - any path segment named .ssh (private keys / known_hosts) or .git (its + * config embeds remote credentials), at any depth. This blocks the credential + * files inside a git-repos clone while still letting the working copies be + * browsed (deploy verification, adoption). * * isProtectedPath resolves symlinks (via the nearest existing ancestor) so a * symlink pointing into a protected location can't be used to bypass the check. @@ -21,11 +26,19 @@ import { resolve, dirname, join, basename, sep } from 'node:path'; const KEY_FILE_NAME = '.encryption_key'; +/** Absolute directory subtrees blocked for every caller. */ +const PROTECTED_ROOTS = ['/proc', '/etc', '/root']; + +/** A path segment named this (at any depth) is blocked: .ssh (keys/known_hosts), + * .git (its config embeds remote credentials). Blocks the secret files in a + * git-repos clone without hiding the browsable working copies. */ +const PROTECTED_SEGMENTS = new Set(['.ssh', '.git']); + function getDataDir(): string { return process.env.DATA_DIR || './data'; } -/** Absolute paths Dockhand must never expose through the file browser. */ +/** Absolute paths under Dockhand's data dir that must never be exposed. */ function protectedPaths(): { dbDir: string; keyFile: string } { const dataDir = resolve(getDataDir()); return { @@ -37,7 +50,7 @@ function protectedPaths(): { dbDir: string; keyFile: string } { /** * Resolve `p` to an absolute, symlink-free path. If `p` (or part of it) does not * exist yet, realpath the deepest existing ancestor and re-append the missing - * tail — this still defeats a symlinked ancestor. + * tail - this still defeats a symlinked ancestor. */ function safeResolve(p: string): string { let current = resolve(p); @@ -64,12 +77,25 @@ function isInside(child: string, parent: string): boolean { } /** - * Returns true if `requestedPath` resolves to Dockhand's DB directory, the - * encryption-key file, or anywhere under /proc (process env / cmdline leak - * credentials). Such paths must be hidden from the file browser. + * Returns true if `requestedPath` resolves to a location that must never be read + * through the file browser (Dockhand's DB/key, system secret trees, or any .ssh / + * .git directory). Symlinks are resolved first so they can't bypass it. */ export function isProtectedPath(requestedPath: string): boolean { const { dbDir, keyFile } = protectedPaths(); const resolved = safeResolve(requestedPath); - return isInside(resolved, dbDir) || resolved === keyFile || isInside(resolved, '/proc'); + + // Compare against symlink-resolved targets too: the request is resolved, so a + // protected target that is itself a symlink (e.g. .encryption_key mounted from + // a secret store, or a symlinked db/ volume) must be resolved as well or the + // two never match. safeResolve tolerates a not-yet-existing target. + if (resolved === safeResolve(keyFile) || isInside(resolved, safeResolve(dbDir))) { + return true; + } + // Match each root both literally and symlink-resolved (e.g. macOS /etc -> /private/etc). + if (PROTECTED_ROOTS.some((root) => isInside(resolved, root) || isInside(resolved, safeResolve(root)))) { + return true; + } + const segments = resolved.split(sep).filter(Boolean); + return segments.some((seg) => PROTECTED_SEGMENTS.has(seg)); } diff --git a/src/lib/server/hawser.ts b/src/lib/server/hawser.ts index 99aad79..70938bf 100644 --- a/src/lib/server/hawser.ts +++ b/src/lib/server/hawser.ts @@ -36,14 +36,14 @@ export const MessageType = { export interface EdgeConnection { ws: WebSocket; environmentId: number; + /** The hawser token id this connection authenticated with; revoking that token closes it. */ + tokenId?: number; agentId: string; agentName: string; agentVersion: string; dockerVersion: string; hostname: string; capabilities: string[]; - /** Remote agent STACKS_DIR (from hello message) */ - stacksDir?: string; connectedAt: Date; lastHeartbeat: number; pendingRequests: Map; @@ -393,6 +393,17 @@ export async function generateHawserToken( */ export async function revokeHawserToken(tokenId: number): Promise { await db.update(hawserTokens).set({ isActive: false }).where(eq(hawserTokens.id, tokenId)); + + // A revoke is an incident-response action: cut the agent off NOW. isActive is only + // checked at the hello handshake, so an already-open edge connection would otherwise + // keep serving. Close the connection this token authenticated (matched by tokenId, so + // revoking a DIFFERENT token for the same env doesn't disconnect a still-valid agent). + for (const connection of edgeConnections.values()) { + if (connection.tokenId === tokenId) { + console.log(`[Hawser] Closing edge connection for env ${connection.environmentId} - its token (${tokenId}) was revoked`); + closeEdgeConnection(connection.environmentId); + } + } } /** @@ -450,7 +461,8 @@ export function closeEdgeConnection(environmentId: number): void { export function handleEdgeConnection( ws: WebSocket, environmentId: number, - hello: HelloMessage + hello: HelloMessage, + tokenId?: number ): EdgeConnection { // Check if there's already a connection for this environment const existing = edgeConnections.get(environmentId); @@ -492,13 +504,13 @@ export function handleEdgeConnection( const connection: EdgeConnection = { ws, environmentId, + tokenId, agentId: hello.agentId, agentName: hello.agentName, agentVersion: hello.version, dockerVersion: hello.dockerVersion, hostname: hello.hostname, capabilities: hello.capabilities, - stacksDir: hello.stacksDir, connectedAt: new Date(), lastHeartbeat: Date.now(), pendingRequests: new Map(), @@ -525,40 +537,32 @@ export function handleEdgeConnection( } /** - * Update environment status in database. Best-effort: called from connection - * setup, teardown and heartbeat-timeout paths where a DB failure must not - * take down the connection handling. + * Update environment status in database */ async function updateEnvironmentStatus( environmentId: number, connection: EdgeConnection | null ): Promise { - try { - if (connection) { - await db - .update(environments) - .set({ - hawserLastSeen: new Date().toISOString(), - hawserAgentId: connection.agentId, - hawserAgentName: connection.agentName, - hawserVersion: connection.agentVersion, - hawserCapabilities: JSON.stringify(connection.capabilities), - hawserStacksDir: connection.stacksDir ?? null, - updatedAt: new Date().toISOString() - }) - .where(eq(environments.id, environmentId)); - } else { - await db - .update(environments) - .set({ - hawserLastSeen: new Date().toISOString(), - updatedAt: new Date().toISOString() - }) - .where(eq(environments.id, environmentId)); - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - console.warn(`[Hawser] Failed to update environment status for env ${environmentId}: ${msg}`); + if (connection) { + await db + .update(environments) + .set({ + hawserLastSeen: new Date().toISOString(), + hawserAgentId: connection.agentId, + hawserAgentName: connection.agentName, + hawserVersion: connection.agentVersion, + hawserCapabilities: JSON.stringify(connection.capabilities), + updatedAt: new Date().toISOString() + }) + .where(eq(environments.id, environmentId)); + } else { + await db + .update(environments) + .set({ + hawserLastSeen: new Date().toISOString(), + updatedAt: new Date().toISOString() + }) + .where(eq(environments.id, environmentId)); } } @@ -941,8 +945,6 @@ export interface HelloMessage { dockerVersion: string; hostname: string; capabilities: string[]; - /** Remote agent STACKS_DIR — host path where compose stacks are written */ - stacksDir?: string; } export interface WelcomeMessage { @@ -1214,7 +1216,7 @@ async function handleHawserWsMessage(ws: any, msg: any, connId: string, remoteIp } // Authenticated — register the connection - const connection = handleEdgeConnection(ws, result.environmentId, msg); + const connection = handleEdgeConnection(ws, result.environmentId, msg, result.tokenId); wsToEnvId.set(ws, result.environmentId); // Send welcome diff --git a/src/lib/server/path-utils.ts b/src/lib/server/path-utils.ts deleted file mode 100644 index 22deb4a..0000000 --- a/src/lib/server/path-utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { resolve, sep as pathSep } from 'node:path'; - -/** - * True when `child` is `root` or a descendant. Uses `root + sep` so a sibling - * path that merely shares a string prefix (e.g. `/repos/myrepo2` vs `/repos/myrepo`) - * is rejected. - */ -export function isPathUnderRoot(childPath: string, rootPath: string): boolean { - const resolvedChild = resolve(childPath); - const resolvedRoot = resolve(rootPath); - return resolvedChild === resolvedRoot || resolvedChild.startsWith(resolvedRoot + pathSep); -} diff --git a/src/lib/server/secret-redact.ts b/src/lib/server/secret-redact.ts new file mode 100644 index 0000000..7cc7fc2 --- /dev/null +++ b/src/lib/server/secret-redact.ts @@ -0,0 +1,37 @@ +/** + * Redact secret VALUES out of free text (compose stderr/stdout) before it reaches + * a less-trusted sink: a notification channel, the DB errorMessage, or the client + * response. A deploy that fails while interpolating a secret (e.g. a container whose + * command echoes ${DB_PASSWORD}) can print the plaintext value to stderr; without + * this it would be delivered verbatim to an operator-configured webhook/email, + * crossing the secrets:view boundary. + * + * Pure (no db/docker imports) so it is unit-testable in isolation. + */ + +const REDACTED = '[REDACTED]'; + +/** + * Replace every occurrence of each secret value in `text` with `[REDACTED]`. + * Values shorter than 4 chars are skipped: they are too short to be a real + * credential and matching them would corrupt ordinary output (e.g. a value of + * "1" would redact every digit). Longest values are replaced first so a value + * that contains another is fully masked. + */ +export function redactSecretValues(text: string, secretValues: Iterable): string { + if (!text) return text; + const values = Array.from(new Set(Array.from(secretValues).filter((v) => v && v.length >= 4))); + if (values.length === 0) return text; + values.sort((a, b) => b.length - a.length); + let out = text; + for (const value of values) { + out = out.split(value).join(REDACTED); + } + return out; +} + +/** Convenience: redact the values of a secret-vars map from `text`. */ +export function redactSecretVars(text: string, secretVars?: Record): string { + if (!secretVars) return text; + return redactSecretValues(text, Object.values(secretVars)); +} diff --git a/src/lib/server/secretproviders/shared.ts b/src/lib/server/secretproviders/shared.ts index 2087bd0..bcd78f1 100644 --- a/src/lib/server/secretproviders/shared.ts +++ b/src/lib/server/secretproviders/shared.ts @@ -243,6 +243,39 @@ export type SecretProviderConfig = */ export const SECRET_CONFIG_KEYS = new Set(['token', 'clientSecret', 'password']); +/** + * Every user-overridable connection-destination field across all provider types. If a + * client-supplied override changes one of these, the request targets a DIFFERENT server than + * the stored config, so a stored secret must NOT be reattached (that would send the real + * credential to a caller-chosen host). Must list EVERY provider's destination field: + * - `address` Vault + * - `host` 1Password Connect, Infisical + * - `serverUrl` Bitwarden Secrets Manager (EU / self-hosted) + * - `vaultUri` Azure Key Vault + * (KeePass `databasePath` is a LOCAL file, not a network destination, so it's out of scope. + * When you add a provider with an overridable server URL, add its field here.) + */ +export const PROVIDER_DESTINATION_KEYS = ['host', 'address', 'serverUrl', 'vaultUri'] as const; + +/** + * True when the incoming override changes the connection destination (host/address) from the + * stored value. Used to decide whether a stored secret may follow the request (#secret-exfil): + * a test whose destination the client changed must not carry the stored credential. + */ +export function destinationOverridesStored( + incoming: Record, + stored: Record +): boolean { + for (const key of PROVIDER_DESTINATION_KEYS) { + if (!(key in incoming)) continue; + const inVal = incoming[key]; + if (typeof inVal !== 'string') continue; + if (inVal.trim() === '') continue; // a cleared field isn't a redirect + if (inVal !== stored[key]) return true; + } + return false; +} + /** * A masked secret key that only makes sense alongside a non-secret partner field, as a * pair the user chooses or abandons together. When the user CLEARS the partner in the edit diff --git a/src/lib/server/webhook-secret-policy.ts b/src/lib/server/webhook-secret-policy.ts index e60a8b2..0d91378 100644 --- a/src/lib/server/webhook-secret-policy.ts +++ b/src/lib/server/webhook-secret-policy.ts @@ -26,3 +26,18 @@ export function decideWebhookSecretPolicy( export function allowSecretlessWebhook(): boolean { return process.env.ALLOW_WEBHOOKS_WITHOUT_SECRET === 'true'; } + +/** + * Whether creating/updating a git stack must REJECT for a missing webhook secret. True only + * when the webhook is enabled, no secret is set, and the operator did NOT opt into secret-less + * webhooks. This is the config-time mirror of decideWebhookSecretPolicy's reject-no-secret; + * without the opt-out check the ALLOW_WEBHOOKS_WITHOUT_SECRET escape hatch is unreachable + * because the stack can never be saved without a secret in the first place. + */ +export function webhookConfigRequiresSecret( + webhookEnabled: boolean, + hasSecret: boolean, + allowSecretless: boolean +): boolean { + return webhookEnabled && !hasSecret && !allowSecretless; +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 5cb56f2..e128290 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -2,7 +2,7 @@ import type { LayoutServerLoad } from './$types'; import { redirect } from '@sveltejs/kit'; import { isAuthEnabled, validateSession } from '$lib/server/auth'; import { hasAdminUser } from '$lib/server/db'; -import { BACKUPS_ENABLED, API_DOCS_ENABLED } from '$lib/server/features'; +import { BACKUPS_ENABLED, API_DOCS_ENABLED, ALLOW_WEBHOOKS_WITHOUT_SECRET } from '$lib/server/features'; // Routes that don't require authentication. The API docs viewer is public only // when opt-in via FEAT_API_DOCS (the route 404s otherwise; see features.ts). @@ -21,7 +21,8 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => { authEnabled: false, user: null, disableWhatsNew, - backupsEnabled: BACKUPS_ENABLED + backupsEnabled: BACKUPS_ENABLED, + allowSecretlessWebhook: ALLOW_WEBHOOKS_WITHOUT_SECRET }; } @@ -41,7 +42,8 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => { user: null, setupMode: true, disableWhatsNew, - backupsEnabled: BACKUPS_ENABLED + backupsEnabled: BACKUPS_ENABLED, + allowSecretlessWebhook: ALLOW_WEBHOOKS_WITHOUT_SECRET }; } @@ -62,6 +64,7 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => { provider: user.provider } : null, disableWhatsNew, - backupsEnabled: BACKUPS_ENABLED + backupsEnabled: BACKUPS_ENABLED, + allowSecretlessWebhook: ALLOW_WEBHOOKS_WITHOUT_SECRET }; }; diff --git a/src/routes/api/containers/[id]/files/+server.ts b/src/routes/api/containers/[id]/files/+server.ts index 125f1be..3393bdc 100644 --- a/src/routes/api/containers/[id]/files/+server.ts +++ b/src/routes/api/containers/[id]/files/+server.ts @@ -14,7 +14,7 @@ import type { RequestHandler } from './$types'; * query: path:string Absolute directory path inside the container (default "/") * query: simpleLs:boolean Use a lightweight `ls` listing instead of a full stat of each entry * resp-200: Directory listing (entries with name, type, size and permission metadata) - * resp-403: Permission denied + * resp-403: Permission denied (needs containers:exec) * resp-404: Container not found * resp-500: Failed to list the directory */ @@ -29,8 +29,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const envIdNum = envId ? parseInt(envId) : undefined; const simpleLs = url.searchParams.get('simpleLs') === 'true'; - // Permission check with environment context - if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) { + // Listing a container dir runs a real exec (`ls` inside the container), which can read + // filesystem contents a metadata-only `view` grant must not - gate it as exec, like the + // file write/create/delete siblings and the terminal, not as plain view. + if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) { return json({ error: 'Permission denied' }, { status: 403 }); } diff --git a/src/routes/api/containers/[id]/files/content/+server.ts b/src/routes/api/containers/[id]/files/content/+server.ts index 7bc991f..798a913 100644 --- a/src/routes/api/containers/[id]/files/content/+server.ts +++ b/src/routes/api/containers/[id]/files/content/+server.ts @@ -17,7 +17,7 @@ const MAX_FILE_SIZE = 1024 * 1024; * query: path:string! Absolute file path inside the container * resp-200: {content:string!, path:string!} * resp-400: Path is missing, the target is a directory, or the container is not running - * resp-403: Permission denied to read the file + * resp-403: Permission denied to read the file (needs containers:exec) * resp-404: File not found * resp-413: File is larger than the 1 MB read limit * resp-500: Failed to read the file @@ -32,8 +32,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const envId = url.searchParams.get('env'); const envIdNum = envId ? parseInt(envId) : undefined; - // Permission check with environment context - if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) { + // Reading a container file runs a real exec (`cat` inside the container), so it can read + // secrets a metadata-only `view` grant must not (/etc/shadow, mounted credentials). Gate it + // as exec, matching the PUT write path below and the other file-browser routes. + if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) { return json({ error: 'Permission denied' }, { status: 403 }); } diff --git a/src/routes/api/containers/[id]/top/+server.ts b/src/routes/api/containers/[id]/top/+server.ts index a8c1893..875e678 100644 --- a/src/routes/api/containers/[id]/top/+server.ts +++ b/src/routes/api/containers/[id]/top/+server.ts @@ -49,37 +49,43 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const envId = url.searchParams.get('env'); const envIdNum = envId ? parseInt(envId) : undefined; - // Permission check with environment context (process list uses inspect permission) + // Baseline: seeing the process list needs at least inspect. if (auth.authEnabled && !await auth.can('containers', 'inspect', envIdNum)) { return json({ error: 'Permission denied' }, { status: 403 }); } + // The richer `ps` path runs a real exec inside the container, so only attempt it for a + // caller who also holds exec; an inspect-only user gets the native `docker top` result, + // which needs no exec. This keeps exec-tier work behind the exec permission. + const canExec = !auth.authEnabled || await auth.can('containers', 'exec', envIdNum); + try { + if (canExec) { + // Try different ps commands in order of preference + const psCommands = [ + ['ps', 'aux', '--sort=-pcpu'], // GNU ps with CPU sort + ['ps', 'aux'], // GNU ps without sort + ['ps', '-ef'], // POSIX ps + ]; - // Try different ps commands in order of preference - const psCommands = [ - ['ps', 'aux', '--sort=-pcpu'], // GNU ps with CPU sort - ['ps', 'aux'], // GNU ps without sort - ['ps', '-ef'], // POSIX ps - ]; - - for (const cmd of psCommands) { - try { - const output = await execInContainer(params.id, cmd, envIdNum); - // Check if output looks like an error message (BusyBox error, etc.) - if (output.includes('unrecognized option') || output.includes('Usage:') || output.includes('BusyBox')) { - continue; + for (const cmd of psCommands) { + try { + const output = await execInContainer(params.id, cmd, envIdNum); + // Check if output looks like an error message (BusyBox error, etc.) + if (output.includes('unrecognized option') || output.includes('Usage:') || output.includes('BusyBox')) { + continue; + } + const result = parsePsOutput(output); + if (result && result.Processes.length > 0) { + return json({ ...result, source: 'ps' }); + } + } catch { + // Try next command } - const result = parsePsOutput(output); - if (result && result.Processes.length > 0) { - return json({ ...result, source: 'ps' }); - } - } catch { - // Try next command } } - // Fallback to docker top API + // Native docker top API - no exec, available to an inspect-only caller. const top = await getContainerTop(params.id, envIdNum); return json({ ...top, source: 'top' }); } catch (error: any) { diff --git a/src/routes/api/debug/memory/+server.ts b/src/routes/api/debug/memory/+server.ts index f79a4ef..6b864fb 100644 --- a/src/routes/api/debug/memory/+server.ts +++ b/src/routes/api/debug/memory/+server.ts @@ -14,6 +14,7 @@ import v8 from 'node:v8'; import os from 'node:os'; import { formatBytes } from '$lib/utils/format'; import { getRssStats, dumpHeapSnapshot, listHeapSnapshots } from '$lib/server/rss-tracker'; +import { authorize } from '$lib/server/authorize'; // Track startup time and initial RSS for growth rate calculation const startupTime = Date.now(); @@ -28,9 +29,16 @@ const startupRss = process.memoryUsage().rss; * resp-200: {timestamp:string!, uptime:object!, gcForced:boolean!, gcAvailable:boolean!, process:object!, growth:object!, v8Heap:object!, system:object!, rssTracker:object} * resp-200-desc: With ?snapshot returns {snapshot}, with ?snapshots returns {snapshots}; otherwise the full memory report * resp-200-example: {"timestamp":"2026-07-01T10:00:00.000Z","uptime":{"ms":3600000,"hours":1,"human":"1h 0m"},"gcForced":false,"gcAvailable":false,"process":{"rss":"180 MB"},"growth":{"rssPerHour":"2 MB"},"v8Heap":{"usedHeapSize":"90 MB"},"system":{"cpus":8,"platform":"linux"}} - * resp-403: Memory monitor not enabled (set MEMORY_MONITOR=true) + * resp-403: Memory monitor not enabled (set MEMORY_MONITOR=true), or the caller is not an admin */ -export const GET: RequestHandler = async ({ url }) => { +export const GET: RequestHandler = async ({ url, cookies }) => { + // Admin-only: this exposes V8 heap internals and can write a heap-dump file - operator + // diagnostics, not something any authenticated RBAC user should reach. + const auth = await authorize(cookies); + if (auth.authEnabled && !auth.isAdmin) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + if (process.env.MEMORY_MONITOR !== 'true') { return json({ error: 'Memory monitor not enabled. Set MEMORY_MONITOR=true.' }, { status: 403 }); } diff --git a/src/routes/api/environments/[id]/+server.ts b/src/routes/api/environments/[id]/+server.ts index 2d63180..9d363ee 100644 --- a/src/routes/api/environments/[id]/+server.ts +++ b/src/routes/api/environments/[id]/+server.ts @@ -1,11 +1,11 @@ import { json } from '@sveltejs/kit'; -import { join, resolve, basename } from 'path'; +import { join } from 'path'; import { existsSync, rmSync, renameSync } from 'fs'; import type { RequestHandler } from './$types'; -import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack, getBackupConfigs, getStackSources } from '$lib/server/db'; +import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack, getBackupConfigs } from '$lib/server/db'; import { clearDockerClientCache } from '$lib/server/docker'; import { deleteGitStackFiles, getGitReposDir } from '$lib/server/git'; -import { getDefaultStacksDir, getLocalStacksDir, isLocalConnection, isStacksDirEnvSet } from '$lib/server/stacks'; +import { getStacksDir } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { auditEnvironment } from '$lib/server/audit'; import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager'; @@ -34,9 +34,11 @@ export const GET: RequestHandler = async ({ params, cookies }) => { if (auth.authEnabled && !await auth.can('environments', 'view')) { return json({ error: 'Permission denied' }, { status: 403 }); } + const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; try { - const id = parseInt(params.id); const env = await getEnvironment(id); if (!env) { @@ -80,10 +82,11 @@ export const PUT: RequestHandler = async (event) => { if (auth.authEnabled && !await auth.can('environments', 'edit')) { return json({ error: 'Permission denied' }, { status: 403 }); } + const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; try { - const id = parseInt(params.id); - // Get old values before update for diff const oldEnv = await getEnvironment(id); if (!oldEnv) { @@ -116,7 +119,7 @@ export const PUT: RequestHandler = async (event) => { // regardless of where they ultimately deploy — so the rename matters // locally for every env type. if (isRename) { - const stacksDir = getDefaultStacksDir(); + const stacksDir = getStacksDir(); const gitReposDir = getGitReposDir(); const oldStacks = join(stacksDir, oldEnv.name); const newStacks = join(stacksDir, data.name); @@ -241,6 +244,12 @@ export const PUT: RequestHandler = async (event) => { export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); + // Deleting an environment cascades a filesystem cleanup of its stack dirs, git + // files, and schedules - an instance-level action, not an env-scoped one. Require + // admin under RBAC so an env-scoped role can't remove environments it doesn't own. + if (auth.authEnabled && auth.isEnterprise && !auth.isAdmin) { + return json({ error: 'Permission denied' }, { status: 403 }); + } if (auth.authEnabled && !await auth.can('environments', 'delete')) { return json({ error: 'Permission denied' }, { status: 403 }); } @@ -317,7 +326,7 @@ export const DELETE: RequestHandler = async (event) => { // Clean up stack directory for this environment // Safety: only delete subdirectory named after the env, never the parent try { - const stacksDir = getDefaultStacksDir(); + const stacksDir = getStacksDir(); const envStackDir = join(stacksDir, env.name); if (envStackDir !== stacksDir && envStackDir.startsWith(stacksDir) && existsSync(envStackDir)) { rmSync(envStackDir, { recursive: true, force: true }); @@ -326,28 +335,6 @@ export const DELETE: RequestHandler = async (event) => { console.error(`Failed to clean up stack directory for environment "${env.name}":`, err); } - // Flat STACKS_DIR layout: remove per-stack managed dirs for this local environment - if (isStacksDirEnvSet() && isLocalConnection(env)) { - try { - const localStacksDir = getLocalStacksDir(); - const resolvedLocalRoot = resolve(localStacksDir); - const stackSources = await getStackSources(id); - for (const source of stackSources) { - const stackDir = join(localStacksDir, source.stackName); - const resolvedStackDir = resolve(stackDir); - if ( - resolvedStackDir.startsWith(resolvedLocalRoot + '/') && - basename(resolvedStackDir) === source.stackName && - existsSync(stackDir) - ) { - rmSync(stackDir, { recursive: true, force: true }); - } - } - } catch (err) { - console.error(`Failed to clean up flat STACKS_DIR stacks for environment "${env.name}":`, err); - } - } - // Clean up git-repos directory for this environment try { const gitReposDir = getGitReposDir(); diff --git a/src/routes/api/environments/[id]/disk-warning/+server.ts b/src/routes/api/environments/[id]/disk-warning/+server.ts index 96733ab..952659f 100644 --- a/src/routes/api/environments/[id]/disk-warning/+server.ts +++ b/src/routes/api/environments/[id]/disk-warning/+server.ts @@ -18,9 +18,11 @@ export const GET: RequestHandler = async ({ params, cookies }) => { if (auth.authEnabled && !(await auth.can('environments', 'view'))) { return json({ error: 'Permission denied' }, { status: 403 }); } + const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; try { - const id = parseInt(params.id); const env = await getEnvironment(id); if (!env) { return json({ error: 'Environment not found' }, { status: 404 }); @@ -54,9 +56,11 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => { if (auth.authEnabled && !(await auth.can('environments', 'edit'))) { return json({ error: 'Permission denied' }, { status: 403 }); } + const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; try { - const id = parseInt(params.id); const env = await getEnvironment(id); if (!env) { return json({ error: 'Environment not found' }, { status: 404 }); diff --git a/src/routes/api/environments/[id]/icon/+server.ts b/src/routes/api/environments/[id]/icon/+server.ts index 9c2855d..0d724a0 100644 --- a/src/routes/api/environments/[id]/icon/+server.ts +++ b/src/routes/api/environments/[id]/icon/+server.ts @@ -45,6 +45,8 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => { } const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; const env = await getEnvironment(id); if (!env) { return json({ error: 'Environment not found' }, { status: 404 }); @@ -83,6 +85,8 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { } const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; const env = await getEnvironment(id); if (!env) { return json({ error: 'Environment not found' }, { status: 404 }); diff --git a/src/routes/api/environments/[id]/image-prune/+server.ts b/src/routes/api/environments/[id]/image-prune/+server.ts index 76b8b99..2af407a 100644 --- a/src/routes/api/environments/[id]/image-prune/+server.ts +++ b/src/routes/api/environments/[id]/image-prune/+server.ts @@ -25,10 +25,11 @@ export const GET: RequestHandler = async ({ params, cookies }) => { if (auth.authEnabled && !await auth.can('environments', 'view')) { return json({ error: 'Permission denied' }, { status: 403 }); } + const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; try { - const id = parseInt(params.id); - // Verify environment exists const env = await getEnvironment(id); if (!env) { @@ -68,10 +69,11 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => { if (auth.authEnabled && !await auth.can('environments', 'edit')) { return json({ error: 'Permission denied' }, { status: 403 }); } + const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; try { - const id = parseInt(params.id); - // Verify environment exists const env = await getEnvironment(id); if (!env) { @@ -125,10 +127,11 @@ export const PUT: RequestHandler = async ({ params, cookies }) => { if (auth.authEnabled && !await auth.can('environments', 'edit')) { return json({ error: 'Permission denied' }, { status: 403 }); } + const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; try { - const id = parseInt(params.id); - // Verify environment exists const env = await getEnvironment(id); if (!env) { diff --git a/src/routes/api/environments/[id]/notifications/+server.ts b/src/routes/api/environments/[id]/notifications/+server.ts index 418c2f1..1723ce4 100644 --- a/src/routes/api/environments/[id]/notifications/+server.ts +++ b/src/routes/api/environments/[id]/notifications/+server.ts @@ -30,6 +30,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { if (isNaN(envId)) { return json({ error: 'Invalid environment ID' }, { status: 400 }); } + const envAccessDenied = await auth.requireEnvAccess(envId); + if (envAccessDenied) return envAccessDenied; const env = await getEnvironment(envId); if (!env) { @@ -70,6 +72,8 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => { if (isNaN(envId)) { return json({ error: 'Invalid environment ID' }, { status: 400 }); } + const envAccessDenied = await auth.requireEnvAccess(envId); + if (envAccessDenied) return envAccessDenied; const env = await getEnvironment(envId); if (!env) { diff --git a/src/routes/api/environments/[id]/notifications/[notificationId]/+server.ts b/src/routes/api/environments/[id]/notifications/[notificationId]/+server.ts index 56d6ed4..37bee17 100644 --- a/src/routes/api/environments/[id]/notifications/[notificationId]/+server.ts +++ b/src/routes/api/environments/[id]/notifications/[notificationId]/+server.ts @@ -32,6 +32,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { if (isNaN(envId) || isNaN(notifId)) { return json({ error: 'Invalid ID' }, { status: 400 }); } + const envAccessDenied = await auth.requireEnvAccess(envId); + if (envAccessDenied) return envAccessDenied; const env = await getEnvironment(envId); if (!env) { @@ -75,6 +77,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { if (isNaN(envId) || isNaN(notifId)) { return json({ error: 'Invalid ID' }, { status: 400 }); } + const envAccessDenied = await auth.requireEnvAccess(envId); + if (envAccessDenied) return envAccessDenied; const env = await getEnvironment(envId); if (!env) { @@ -124,6 +128,8 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { if (isNaN(envId) || isNaN(notifId)) { return json({ error: 'Invalid ID' }, { status: 400 }); } + const envAccessDenied = await auth.requireEnvAccess(envId); + if (envAccessDenied) return envAccessDenied; try { const deleted = await deleteEnvironmentNotification(envId, notifId); diff --git a/src/routes/api/environments/[id]/remote-stacks-dir/+server.ts b/src/routes/api/environments/[id]/remote-stacks-dir/+server.ts index b43dcdf..fe46bfd 100644 --- a/src/routes/api/environments/[id]/remote-stacks-dir/+server.ts +++ b/src/routes/api/environments/[id]/remote-stacks-dir/+server.ts @@ -24,6 +24,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { return json({ error: 'Permission denied' }, { status: 403 }); } const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; const env = await getEnvironment(id); if (!env) return json({ error: 'Environment not found' }, { status: 404 }); @@ -50,6 +52,8 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => { return json({ error: 'Permission denied' }, { status: 403 }); } const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; const env = await getEnvironment(id); if (!env) return json({ error: 'Environment not found' }, { status: 404 }); diff --git a/src/routes/api/environments/[id]/test/+server.ts b/src/routes/api/environments/[id]/test/+server.ts index c49ad5a..b22bfe0 100644 --- a/src/routes/api/environments/[id]/test/+server.ts +++ b/src/routes/api/environments/[id]/test/+server.ts @@ -1,5 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; import { getEnvironment, updateEnvironment } from '$lib/server/db'; import { getDockerInfo, getHawserInfo } from '$lib/server/docker'; import { edgeConnections, isEdgeConnected } from '$lib/server/hawser'; @@ -11,11 +12,19 @@ import { daemonIsPodman } from '$lib/server/scanner-socket-detect'; * path: id:integer! Environment id (from GET /api/environments) * resp-200: {success:boolean!, info:{serverVersion:string, containers:integer, images:integer, name:string, engine:string}, isEdgeMode:boolean, hawser:{}} * resp-200-desc: success:false with a human-readable error message is also returned as HTTP 200 (connection/agent-not-connected states are not transport errors) + * resp-403: Permission denied * resp-404: Environment not found */ -export const POST: RequestHandler = async ({ params }) => { +export const POST: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('environments', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; + try { - const id = parseInt(params.id); const env = await getEnvironment(id); if (!env) { diff --git a/src/routes/api/environments/[id]/timezone/+server.ts b/src/routes/api/environments/[id]/timezone/+server.ts index 9687fb8..db2646e 100644 --- a/src/routes/api/environments/[id]/timezone/+server.ts +++ b/src/routes/api/environments/[id]/timezone/+server.ts @@ -53,10 +53,11 @@ export const GET: RequestHandler = async ({ params, cookies }) => { if (auth.authEnabled && !await auth.can('environments', 'view')) { return json({ error: 'Permission denied' }, { status: 403 }); } + const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; try { - const id = parseInt(params.id); - // Verify environment exists const env = await getEnvironment(id); if (!env) { @@ -92,10 +93,11 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => { if (auth.authEnabled && !await auth.can('environments', 'edit')) { return json({ error: 'Permission denied' }, { status: 403 }); } + const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; try { - const id = parseInt(params.id); - // Verify environment exists const env = await getEnvironment(id); if (!env) { diff --git a/src/routes/api/environments/[id]/update-check/+server.ts b/src/routes/api/environments/[id]/update-check/+server.ts index a18cafe..317fa3a 100644 --- a/src/routes/api/environments/[id]/update-check/+server.ts +++ b/src/routes/api/environments/[id]/update-check/+server.ts @@ -25,10 +25,11 @@ export const GET: RequestHandler = async ({ params, cookies }) => { if (auth.authEnabled && !await auth.can('environments', 'view')) { return json({ error: 'Permission denied' }, { status: 403 }); } + const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; try { - const id = parseInt(params.id); - // Verify environment exists const env = await getEnvironment(id); if (!env) { @@ -69,10 +70,11 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => { if (auth.authEnabled && !await auth.can('environments', 'edit')) { return json({ error: 'Permission denied' }, { status: 403 }); } + const id = parseInt(params.id); + const envAccessDenied = await auth.requireEnvAccess(id); + if (envAccessDenied) return envAccessDenied; try { - const id = parseInt(params.id); - // Verify environment exists const env = await getEnvironment(id); if (!env) { diff --git a/src/routes/api/git/stacks/+server.ts b/src/routes/api/git/stacks/+server.ts index 4d365b8..d19fc9d 100644 --- a/src/routes/api/git/stacks/+server.ts +++ b/src/routes/api/git/stacks/+server.ts @@ -15,6 +15,7 @@ import { authorize } from '$lib/server/authorize'; import { registerSchedule } from '$lib/server/scheduler'; import { auditGitStack } from '$lib/server/audit'; import { createJobResponse } from '$lib/server/sse'; +import { allowSecretlessWebhook, webhookConfigRequiresSecret } from '$lib/server/webhook-secret-policy'; // Stack name validation: Docker Compose requires lowercase; must start with a // letter or number, and contain only lowercase letters, numbers, hyphens, underscores @@ -104,7 +105,7 @@ export const POST: RequestHandler = async (event) => { } // A secret is mandatory when the webhook is enabled. - if (data.webhookEnabled && !data.webhookSecret?.trim()) { + if (webhookConfigRequiresSecret(!!data.webhookEnabled, !!data.webhookSecret?.trim(), allowSecretlessWebhook())) { return json({ error: 'A webhook secret is required when the webhook is enabled' }, { status: 400 }); } diff --git a/src/routes/api/git/stacks/[id]/+server.ts b/src/routes/api/git/stacks/[id]/+server.ts index 4c57cb4..1e65534 100644 --- a/src/routes/api/git/stacks/[id]/+server.ts +++ b/src/routes/api/git/stacks/[id]/+server.ts @@ -8,6 +8,7 @@ import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; import { auditGitStack } from '$lib/server/audit'; import { computeAuditDiff } from '$lib/utils/diff'; import { createJobResponse } from '$lib/server/sse'; +import { allowSecretlessWebhook, webhookConfigRequiresSecret } from '$lib/server/webhook-secret-policy'; // Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; @@ -105,7 +106,7 @@ export const PUT: RequestHandler = async (event) => { // Evaluate the effective post-update state (PUT is partial). const effWebhookEnabled = data.webhookEnabled !== undefined ? data.webhookEnabled : existing.webhookEnabled; const effWebhookSecret = data.webhookSecret !== undefined ? data.webhookSecret : existing.webhookSecret; - if (effWebhookEnabled && !effWebhookSecret?.trim()) { + if (webhookConfigRequiresSecret(!!effWebhookEnabled, !!effWebhookSecret?.trim(), allowSecretlessWebhook())) { return json({ error: 'A webhook secret is required when the webhook is enabled' }, { status: 400 }); } diff --git a/src/routes/api/notifications/[id]/test/+server.ts b/src/routes/api/notifications/[id]/test/+server.ts index 6cb7058..ce31368 100644 --- a/src/routes/api/notifications/[id]/test/+server.ts +++ b/src/routes/api/notifications/[id]/test/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import { getNotificationSetting } from '$lib/server/db'; import { testNotification } from '$lib/server/notifications'; +import { authorize } from '$lib/server/authorize'; import type { RequestHandler } from './$types'; /** @@ -10,11 +11,17 @@ import type { RequestHandler } from './$types'; * resp-200: {success:boolean!, message:string, error:string} * resp-200-example: {"success":true,"message":"Test notification sent successfully"} * resp-400: Invalid ID (not a number) + * resp-403: Permission denied (needs notifications:edit) * resp-404: Notification setting not found * resp-500: Failed to test notification */ -export const POST: RequestHandler = async ({ params }) => { +export const POST: RequestHandler = async ({ params, cookies }) => { try { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('notifications', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + const id = parseInt(params.id); if (isNaN(id)) { return json({ error: 'Invalid ID' }, { status: 400 }); diff --git a/src/routes/api/registry/catalog/+server.ts b/src/routes/api/registry/catalog/+server.ts index 6e54f6f..5437890 100644 --- a/src/routes/api/registry/catalog/+server.ts +++ b/src/routes/api/registry/catalog/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getRegistry } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; import { getRegistryAuth, isHarborRegistry, harborListRepositories, parseRegistryUrl, classifyCatalogFailure, CATALOG_NOT_SUPPORTED_MSG } from '$lib/server/docker'; const PAGE_SIZE = 100; @@ -14,11 +15,17 @@ const PAGE_SIZE = 100; * resp-200: {repositories:array<{name:string!, description:string, star_count:integer, is_official:boolean, is_automated:boolean}>!, pagination:{pageSize:integer!, hasMore:boolean!, nextLast:string}!} * resp-200-example: {"repositories":[{"name":"library/nginx","description":"","star_count":0,"is_official":false,"is_automated":false}],"pagination":{"pageSize":100,"hasMore":false,"nextLast":null}} * resp-400: Missing registry parameter, or Docker Hub was targeted (catalog listing unsupported) + * resp-403: Permission denied (requires registries:view) * resp-404: Registry not found, or the registry does not implement the V2 catalog API * resp-500: Failed to fetch the catalog * resp-503: Could not connect to the registry (connection refused, host not found, or a TLS error) */ -export const GET: RequestHandler = async ({ url }) => { +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('registries', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + try { const registryId = url.searchParams.get('registry'); const lastParam = url.searchParams.get('last'); // For pagination diff --git a/src/routes/api/registry/search/+server.ts b/src/routes/api/registry/search/+server.ts index 7e3fe19..659167a 100644 --- a/src/routes/api/registry/search/+server.ts +++ b/src/routes/api/registry/search/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getRegistry } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; import { getRegistryAuth, isHarborRegistry, harborSearchRepositories, parseRegistryUrl, classifyCatalogFailure, CATALOG_NOT_SUPPORTED_MSG } from '$lib/server/docker'; /** Thrown when the registry refuses catalog listing to a valid token (GitLab/Harbor, #873). */ @@ -230,11 +231,17 @@ async function searchCatalog(registry: any, term: string, limit: number): Promis * resp-200: array<{name:string!, description:string, star_count:integer, is_official:boolean, is_automated:boolean}> * resp-200-example: [{"name":"nginx","description":"Official build of Nginx","star_count":20000,"is_official":true,"is_automated":false}] * resp-400: The term query parameter is missing + * resp-403: Permission denied (requires registries:view) * resp-404: The referenced registry does not exist * resp-500: Failed to search images * resp-503: Could not connect to the registry (connection refused or host not found) */ -export const GET: RequestHandler = async ({ url }) => { +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('registries', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + const term = url.searchParams.get('term'); const limit = parseInt(url.searchParams.get('limit') || '25', 10); const registryId = url.searchParams.get('registry'); diff --git a/src/routes/api/registry/tag-info/+server.ts b/src/routes/api/registry/tag-info/+server.ts index cdbc3cc..0d89fd5 100644 --- a/src/routes/api/registry/tag-info/+server.ts +++ b/src/routes/api/registry/tag-info/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getRegistry } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; import { getRegistryAuth } from '$lib/server/docker'; function isDockerHub(rawUrl: string): boolean { @@ -115,9 +116,15 @@ async function resolveTagInfo(registry: any, image: string, tag: string): Promis * resp-200: {size:number, lastUpdated:string, reason:string} * resp-200-example: {"size":104857600,"lastUpdated":"2026-06-01T12:00:00Z"} * resp-400: image and tag are required + * resp-403: Permission denied (requires registries:view) * resp-404: Registry not found */ -export const GET: RequestHandler = async ({ url }) => { +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('registries', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + const registryId = url.searchParams.get('registry'); const image = url.searchParams.get('image'); const tag = url.searchParams.get('tag'); diff --git a/src/routes/api/registry/tags/+server.ts b/src/routes/api/registry/tags/+server.ts index 805abc5..6466184 100644 --- a/src/routes/api/registry/tags/+server.ts +++ b/src/routes/api/registry/tags/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getRegistry } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; import { getRegistryAuth } from '$lib/server/docker'; interface TagInfo { @@ -127,11 +128,17 @@ async function fetchRegistryTags(registry: any, imageName: string): Promise!, total:integer!, page:integer!, pageSize:integer!, hasNext:boolean!, hasPrev:boolean!} * resp-200-example: {"tags":[{"name":"1.27","lastUpdated":"2026-06-01T00:00:00Z"}],"total":1,"page":1,"pageSize":20,"hasNext":false,"hasPrev":false} * resp-400: The image query parameter is missing + * resp-403: Permission denied (requires registries:view) * resp-404: The referenced registry does not exist * resp-500: Failed to fetch tags * resp-503: Could not connect to the registry (connection refused or host not found) */ -export const GET: RequestHandler = async ({ url }) => { +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('registries', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + try { const registryId = url.searchParams.get('registry'); const imageName = url.searchParams.get('image'); diff --git a/src/routes/api/secret-providers/[id]/test/+server.ts b/src/routes/api/secret-providers/[id]/test/+server.ts index 35958e2..774471d 100644 --- a/src/routes/api/secret-providers/[id]/test/+server.ts +++ b/src/routes/api/secret-providers/[id]/test/+server.ts @@ -3,15 +3,18 @@ import type { RequestHandler } from './$types'; import { getSecretProviderById } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { testProviderConnection } from '$lib/server/secretproviders'; -import { mergeProviderConfigForWrite } from '$lib/server/secretproviders/shared'; +import { mergeProviderConfigForWrite, destinationOverridesStored, SECRET_CONFIG_KEYS } from '$lib/server/secretproviders/shared'; import type { SecretProviderConfig } from '$lib/server/secretproviders/shared'; /** * Test a stored provider. With no body it tests the persisted config. With an optional - * `{ config }` override (the edit form's typed non-secret fields) it tests exactly what a - * Save would persist: the incoming config merged over the stored one, with a blank token - * falling back to the stored secret. The secret is NEVER supplied by the client - it comes - * from storage server-side - so this stays a `secrets:view` operation. + * `{ config }` override (the edit form's typed non-secret fields) a blank secret falls back to + * the stored one - BUT ONLY when the override keeps the same destination (host/address). + * + * If the override points at a DIFFERENT host/address, the request is tested with the client's + * config as-is and NO stored secret is reattached, so a `secrets:view` user cannot make the + * server send the stored credential to a caller-chosen host. (A blank secret then just makes the + * connection fail; the caller can supply the secret for the new host themselves.) * * @openapi * summary: Test a stored provider's connectivity, optionally against typed override fields the edit form would save @@ -44,10 +47,33 @@ export const POST: RequestHandler = async ({ params, cookies, request }) => { try { const body = await request.json(); if (body && typeof body.config === 'object' && body.config !== null) { - configToTest = mergeProviderConfigForWrite( - body.config as Record, - provider.config as unknown as Record - ) as unknown as SecretProviderConfig; + const incoming = body.config as Record; + const stored = provider.config as unknown as Record; + + if (destinationOverridesStored(incoming, stored)) { + // Override targets a DIFFERENT server - test it with exactly what the client + // sent, with NO stored-secret fallback, so the stored credential is never + // delivered to a caller-chosen host. + const hasSecret = [...SECRET_CONFIG_KEYS].some((k) => { + const v = incoming[k]; + return typeof v === 'string' && v.trim() !== ''; + }); + if (!hasSecret) { + // Friendlier than a raw auth failure: the stored credential is deliberately + // not reused for a new host, so the caller must supply one for it. + return json({ + ok: false, + error: 'You changed the host or address, so enter its credential to test - the saved one is not reused for a different server.' + }, { status: 200 }); + } + configToTest = incoming as unknown as SecretProviderConfig; + } else { + // Same destination: normal edit-form flow (a blank secret keeps the stored one). + configToTest = mergeProviderConfigForWrite( + incoming, + stored + ) as unknown as SecretProviderConfig; + } } } catch { // No/invalid body: test the stored config as-is. diff --git a/src/routes/api/stacks/[name]/+server.ts b/src/routes/api/stacks/[name]/+server.ts index e6cb722..c9518c6 100644 --- a/src/routes/api/stacks/[name]/+server.ts +++ b/src/routes/api/stacks/[name]/+server.ts @@ -42,7 +42,9 @@ export const DELETE: RequestHandler = async (event) => { } try { - const stackName = decodeURIComponent(params.name); + // SvelteKit already URL-decodes params.name once; a second decode here turned + // %252e%252e into ".." (a path-traversal name). Use the param as-is. + const stackName = params.name; const result = await removeStack(stackName, envIdNum, force, volumes, files); // Audit log diff --git a/src/routes/api/stacks/[name]/check-path-change/+server.ts b/src/routes/api/stacks/[name]/check-path-change/+server.ts index d1a7921..09cf1eb 100644 --- a/src/routes/api/stacks/[name]/check-path-change/+server.ts +++ b/src/routes/api/stacks/[name]/check-path-change/+server.ts @@ -22,13 +22,14 @@ import { join, dirname } from 'node:path'; */ export const POST: RequestHandler = async ({ params, request, url, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !(await auth.can('stacks', 'edit'))) { - return json({ error: 'Permission denied' }, { status: 403 }); - } - const { name } = params; const envId = url.searchParams.get('env'); const envIdNum = envId ? parseInt(envId) : undefined; + if (auth.authEnabled && !(await auth.can('stacks', 'edit', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + const envAccessDenied = await auth.requireEnvAccess(envIdNum ?? null); + if (envAccessDenied) return envAccessDenied; try { const body = await request.json(); diff --git a/src/routes/api/stacks/[name]/compose/+server.ts b/src/routes/api/stacks/[name]/compose/+server.ts index 8a2effa..5b226ac 100644 --- a/src/routes/api/stacks/[name]/compose/+server.ts +++ b/src/routes/api/stacks/[name]/compose/+server.ts @@ -1,23 +1,9 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { dirname } from 'node:path'; -import { getStackComposeFile, deployStack, saveStackComposeFile, remapHawserStagingDisplayPaths, unmapHawserDisplayComposeOptionsToStaging } from '$lib/server/stacks'; +import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { createJobResponse } from '$lib/server/sse'; -async function remapDisplayPath( - name: string, - envId: number | undefined, - path: string | null | undefined -): Promise { - if (!path) return path; - const remapped = await remapHawserStagingDisplayPaths(name, envId, { - composePath: path, - composePaths: [] - }); - return remapped.composePath ?? path; -} - // GET /api/stacks/[name]/compose - Get compose file content /** * @openapi @@ -30,13 +16,14 @@ async function remapDisplayPath( */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !(await auth.can('stacks', 'view'))) { - return json({ error: 'Permission denied' }, { status: 403 }); - } - const { name } = params; const envId = url.searchParams.get('env'); const envIdNum = envId ? parseInt(envId) : undefined; + if (auth.authEnabled && !(await auth.can('stacks', 'view', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + const envAccessDenied = await auth.requireEnvAccess(envIdNum ?? null); + if (envAccessDenied) return envAccessDenied; try { const result = await getStackComposeFile(name, envIdNum); @@ -46,26 +33,17 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { return json({ error: result.error, needsFileLocation: result.needsFileLocation || false, - composePath: await remapDisplayPath(name, envIdNum, result.composePath), - envPath: await remapDisplayPath(name, envIdNum, result.envPath) + composePath: result.composePath, + envPath: result.envPath }, { status: 404 }); } - const displayPaths = await remapHawserStagingDisplayPaths(name, envIdNum, { - composePath: result.composePath ?? null, - composePaths: [] - }); - let displayStackDir = result.stackDir; - if (displayPaths.composePath) { - displayStackDir = dirname(displayPaths.composePath); - } - return json({ content: result.content, - stackDir: displayStackDir, - composePath: displayPaths.composePath, - envPath: await remapDisplayPath(name, envIdNum, result.envPath), - suggestedEnvPath: await remapDisplayPath(name, envIdNum, result.suggestedEnvPath) + stackDir: result.stackDir, + composePath: result.composePath, + envPath: result.envPath, + suggestedEnvPath: result.suggestedEnvPath }); } catch (error: any) { console.error(`Error getting compose file for stack ${name}:`, error); @@ -96,6 +74,8 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => if (auth.authEnabled && !(await auth.can('stacks', 'edit', envIdNum))) { return json({ error: 'Permission denied' }, { status: 403 }); } + const envAccessDenied = await auth.requireEnvAccess(envIdNum ?? null); + if (envAccessDenied) return envAccessDenied; try { const body = await request.json(); @@ -124,25 +104,17 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 }); } - // Convert Hawser display paths from the UI back to Dockhand staging paths - // before writing. secretProviderId is not a path and is merged after unmap. - let pathOptions: Parameters[2] | undefined = - (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath) - ? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath } - : undefined; - if (pathOptions) { - pathOptions = await unmapHawserDisplayComposeOptionsToStaging(name, envIdNum, pathOptions); - } + // Build options object for custom paths, move operation, file renames, and secret provider binding + const pathOptions = (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath || secretProviderId !== undefined) + ? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath, secretProviderId } + : undefined; // Persist the submitted content on EVERY accepted PUT, whether or not path fields came // along. Gating this on pathOptions left Dockhand's stored copy stale while restart:true // deployed the new content - a later GET then served the old copy, silently reverting the // change on the next read/edit/deploy round-trip (#1383). saveStackComposeFile handles a // possibly-undefined pathOptions fine (the non-restart branch already relied on that). - const saveResult = await saveStackComposeFile(name, content, false, envIdNum, { - ...pathOptions, - ...(secretProviderId !== undefined ? { secretProviderId } : {}) - }); + const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions); if (!saveResult.success) { return json({ error: saveResult.error }, { status: 500 }); } diff --git a/src/routes/api/stacks/[name]/delete-preview/+server.ts b/src/routes/api/stacks/[name]/delete-preview/+server.ts index 06ee6b9..ec0b318 100644 --- a/src/routes/api/stacks/[name]/delete-preview/+server.ts +++ b/src/routes/api/stacks/[name]/delete-preview/+server.ts @@ -23,6 +23,8 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { if (auth.authEnabled && !(await auth.can('stacks', 'remove', envIdNum))) { return json({ error: 'Permission denied' }, { status: 403 }); } + const envAccessDenied = await auth.requireEnvAccess(envIdNum ?? null); + if (envAccessDenied) return envAccessDenied; const { name } = params; try { diff --git a/src/routes/api/stacks/[name]/icon/+server.ts b/src/routes/api/stacks/[name]/icon/+server.ts index c0374d3..9a1c032 100644 --- a/src/routes/api/stacks/[name]/icon/+server.ts +++ b/src/routes/api/stacks/[name]/icon/+server.ts @@ -25,6 +25,8 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { if (auth.authEnabled && !(await auth.can('stacks', 'view', envId ?? undefined))) { return json({ error: 'Permission denied' }, { status: 403 }); } + const envAccessDenied = await auth.requireEnvAccess(envId); + if (envAccessDenied) return envAccessDenied; const buffer = getStackIconBuffer(params.name, envId); if (!buffer) return json({ error: 'No custom icon' }, { status: 404 }); return new Response(new Uint8Array(buffer), { @@ -54,6 +56,8 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) => if (auth.authEnabled && !(await auth.can('stacks', 'edit', envId ?? undefined))) { return json({ error: 'Permission denied' }, { status: 403 }); } + const envAccessDenied = await auth.requireEnvAccess(envId); + if (envAccessDenied) return envAccessDenied; const data = await request.json(); let iconValue: string; @@ -99,6 +103,8 @@ export const DELETE: RequestHandler = async ({ params, url, cookies }) => { if (auth.authEnabled && !(await auth.can('stacks', 'edit', envId ?? undefined))) { return json({ error: 'Permission denied' }, { status: 403 }); } + const envAccessDenied = await auth.requireEnvAccess(envId); + if (envAccessDenied) return envAccessDenied; deleteStackIcon(params.name, envId); await updateStackSource(params.name, envId, { icon: null }); return json({ success: true }); diff --git a/src/routes/api/stacks/[name]/relocate/+server.ts b/src/routes/api/stacks/[name]/relocate/+server.ts index 3440f2f..bbcec91 100644 --- a/src/routes/api/stacks/[name]/relocate/+server.ts +++ b/src/routes/api/stacks/[name]/relocate/+server.ts @@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; import { getStackSource, updateStackSource } from '$lib/server/db'; +import { isProtectedPath } from '$lib/server/fs-guard'; import { existsSync, readdirSync, renameSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmSync } from 'node:fs'; import { join, dirname } from 'node:path'; @@ -17,18 +18,20 @@ import { join, dirname } from 'node:path'; * resp-200: {success:boolean!, movedFiles:array!, errors:array, composeContent:string!, rawEnvContent:string!, envVars:array<{key:string!, value:string!, isSecret:boolean!}>!} * resp-200-example: {"success":true,"movedFiles":["compose.yaml",".env"],"composeContent":"services: {}","rawEnvContent":"FOO=bar\n","envVars":[{"key":"FOO","value":"bar","isSecret":false}]} * resp-400: oldDir and newComposePath are required, or the source directory does not exist - * resp-403: Permission denied (requires stacks:edit) + * resp-403: Permission denied (requires stacks:edit), or a supplied path is not allowed + * resp-404: No stack source row for this name and environment * resp-500: Failed to relocate stack */ export const POST: RequestHandler = async ({ params, request, url, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !(await auth.can('stacks', 'edit'))) { - return json({ error: 'Permission denied' }, { status: 403 }); - } - const { name } = params; const envId = url.searchParams.get('env'); const envIdNum = envId ? parseInt(envId) : undefined; + if (auth.authEnabled && !(await auth.can('stacks', 'edit', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + const envAccessDenied = await auth.requireEnvAccess(envIdNum ?? null); + if (envAccessDenied) return envAccessDenied; try { const body = await request.json(); @@ -40,6 +43,23 @@ export const POST: RequestHandler = async ({ params, request, url, cookies }) => const newDir = dirname(newComposePath); + // Every caller-supplied path is read from / written to / deleted below, so + // they must not reach Dockhand's own secrets (.encryption_key, db/, /proc) - + // the same guard the file browser uses. Without it stacks:edit could read the + // master key or /proc/self/environ back through the response. + for (const p of [oldDir, newComposePath, newDir, newEnvPath].filter(Boolean)) { + if (isProtectedPath(p)) { + return json({ error: 'Path is not allowed' }, { status: 403 }); + } + } + + // Relocate only manages an existing stack's own source row. Reject an unknown + // stack name up front so this can't be used as a generic move/read primitive + // against an arbitrary route name. + if (!(await getStackSource(name, envIdNum ?? null))) { + return json({ error: 'Stack not found' }, { status: 404 }); + } + // Verify old directory exists if (!existsSync(oldDir)) { return json({ error: 'Source directory does not exist' }, { status: 400 }); @@ -106,9 +126,11 @@ export const POST: RequestHandler = async ({ params, request, url, cookies }) => composeContent = readFileSync(newComposePath, 'utf-8'); } - // Read env file if it exists + // Read env file if it exists. Re-guard the resolved path: when newEnvPath is + // omitted the fallback is join(newDir, '.env'), which readFileSync would follow + // through a final symlink - so guard it here too, matching the explicit form. const envFilePath = newEnvPath || join(newDir, '.env'); - if (existsSync(envFilePath)) { + if (existsSync(envFilePath) && !isProtectedPath(envFilePath)) { rawEnvContent = readFileSync(envFilePath, 'utf-8'); // Parse env vars from raw content diff --git a/src/routes/api/stacks/[name]/validate/+server.ts b/src/routes/api/stacks/[name]/validate/+server.ts index 933a696..ff6b0e6 100644 --- a/src/routes/api/stacks/[name]/validate/+server.ts +++ b/src/routes/api/stacks/[name]/validate/+server.ts @@ -52,6 +52,8 @@ export const POST: RequestHandler = async ({ cookies, url, params, request }) => if (auth.authEnabled && !(await auth.can('stacks', 'view', envIdNum))) { return json({ error: 'Permission denied' }, { status: 403 }); } + const envAccessDenied = await auth.requireEnvAccess(envIdNum ?? null); + if (envAccessDenied) return envAccessDenied; const body = (await request.json().catch(() => ({}))) as { compose?: string; diff --git a/src/routes/api/stacks/adopt/+server.ts b/src/routes/api/stacks/adopt/+server.ts index 9036518..73d6a9a 100644 --- a/src/routes/api/stacks/adopt/+server.ts +++ b/src/routes/api/stacks/adopt/+server.ts @@ -16,9 +16,6 @@ import { adoptSelectedStacks, type DiscoveredStack } from '$lib/server/stack-sca */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('stacks', 'create')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } try { const body = await request.json(); @@ -33,6 +30,14 @@ export const POST: RequestHandler = async ({ request, cookies }) => { return json({ error: 'Environment ID is required' }, { status: 400 }); } + // Scope the permission and access check to the target environment (from the + // body) so a role scoped to other environments can't adopt into this one. + if (auth.authEnabled && !await auth.can('stacks', 'create', environmentId)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + const envAccessDenied = await auth.requireEnvAccess(environmentId); + if (envAccessDenied) return envAccessDenied; + // Validate each stack has required fields for (const stack of stacks) { if (!stack.name || !stack.composePath) { diff --git a/src/routes/api/system/files/content/+server.ts b/src/routes/api/system/files/content/+server.ts index e7bfb6f..572bd1a 100644 --- a/src/routes/api/system/files/content/+server.ts +++ b/src/routes/api/system/files/content/+server.ts @@ -21,6 +21,8 @@ import { isProtectedPath } from '$lib/server/fs-guard'; export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); + // Loading a stack's own compose/.env for editing needs stacks:edit; host and + // Dockhand secrets are kept unreadable regardless via isProtectedPath below. if (auth.authEnabled && !await auth.can('stacks', 'edit')) { return json({ error: 'Permission denied' }, { status: 403 }); } diff --git a/src/routes/stacks/GitStackModal.svelte b/src/routes/stacks/GitStackModal.svelte index 44734dc..41416db 100644 --- a/src/routes/stacks/GitStackModal.svelte +++ b/src/routes/stacks/GitStackModal.svelte @@ -602,7 +602,9 @@ hasErrors = true; } - if (formWebhookEnabled && !formWebhookSecret.trim()) { + // A secret is required unless the instance opted into secret-less webhooks + // (ALLOW_WEBHOOKS_WITHOUT_SECRET, for isolated networks) - mirror the server. + if (formWebhookEnabled && !formWebhookSecret.trim() && !$page.data.allowSecretlessWebhook) { errors.webhookSecret = 'A webhook secret is required when the webhook is enabled'; hasErrors = true; } diff --git a/tests/backups/security.test.ts b/tests/backups/security.test.ts index b95c4f4..69baa90 100644 --- a/tests/backups/security.test.ts +++ b/tests/backups/security.test.ts @@ -63,6 +63,40 @@ describe('isPathWithin (restore-target containment)', () => { }); }); +describe('unsafeRestoreTargetReason: /usr and .ssh takeover targets', () => { + it('blocks anywhere under /usr (PATH hijack, systemd units) - it holds no user data', () => { + for (const p of [ + '/usr/local/bin/docker-entrypoint', + '/usr/lib/systemd/system/evil.service', + '/usr/local/share/app', + '/usr', + ]) { + expect(unsafeRestoreTargetReason(p)).not.toBeNull(); + } + }); + + it('blocks any .ssh directory segment (SSH-key takeover under any home)', () => { + for (const p of [ + '/home/user/.ssh/authorized_keys', + '/root/.ssh/authorized_keys', + '/data/.ssh/keys', + ]) { + expect(unsafeRestoreTargetReason(p)).not.toBeNull(); + } + }); + + it('still allows a user-home data path and an .ssh-lookalike segment', () => { + expect(unsafeRestoreTargetReason('/home/user/backup')).toBeNull(); + expect(unsafeRestoreTargetReason('/home/user/data/.sshconfig')).toBeNull(); + expect(unsafeRestoreTargetReason('/mnt/.ssh-backups/x')).toBeNull(); + }); + + it('boundary-safe: a sibling that merely shares a forbidden prefix is allowed', () => { + expect(unsafeRestoreTargetReason('/usr-data/x')).toBeNull(); + expect(unsafeRestoreTargetReason('/home2/restore')).toBeNull(); + }); +}); + describe('isSafePathSegment (name traversal guard)', () => { it('accepts a plain name', () => { expect(isSafePathSegment('web')).toBe(true); @@ -141,7 +175,8 @@ describe('re-exported hardened primitives still reject dangerous inputs', () => describe('unsafeRestoreTargetReason (new-location targetPath containment)', () => { it('accepts legitimate operator-chosen restore locations', () => { - for (const p of ['/mnt/restore', '/srv/appA/data', '/home/user/backup', '/opt/data', '/data/x', '/usr/local/share/app']) { + // /usr/local/share is NOT here: /usr is blocked wholesale (host-takeover surface). + for (const p of ['/mnt/restore', '/srv/appA/data', '/home/user/backup', '/opt/data', '/data/x']) { expect(unsafeRestoreTargetReason(p)).toBeNull(); } }); diff --git a/tests/fs-guard.test.ts b/tests/fs-guard.test.ts index cccf2a5..a4ce01f 100644 --- a/tests/fs-guard.test.ts +++ b/tests/fs-guard.test.ts @@ -1,4 +1,7 @@ -import { describe, test, expect, beforeAll } from 'bun:test'; +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; // Pin DATA_DIR before importing so the guard computes /app/data paths. beforeAll(() => { process.env.DATA_DIR = '/app/data'; }); @@ -24,17 +27,44 @@ describe('isProtectedPath (file-browser secret guard, H1)', () => { expect(isProtectedPath('/proc/self/cmdline')).toBe(true); }); - test('does NOT block the stacks dir or external stacks (browsing preserved)', () => { + test('blocks system secret trees /etc and /root', () => { + expect(isProtectedPath('/etc')).toBe(true); + expect(isProtectedPath('/etc/shadow')).toBe(true); + expect(isProtectedPath('/etc/passwd')).toBe(true); + expect(isProtectedPath('/etc/hostname')).toBe(true); + expect(isProtectedPath('/root')).toBe(true); + expect(isProtectedPath('/root/.bashrc')).toBe(true); + }); + + test('blocks a .git dir inside a git-repos clone (config embeds credentials)', () => { + expect(isProtectedPath('/app/data/git-repos/some-repo/.git')).toBe(true); + expect(isProtectedPath('/app/data/git-repos/some-repo/.git/config')).toBe(true); + expect(isProtectedPath('/any/path/.git/config')).toBe(true); + }); + + test('blocks any .ssh path segment at any depth', () => { + expect(isProtectedPath('/home/user/.ssh')).toBe(true); + expect(isProtectedPath('/home/user/.ssh/id_rsa')).toBe(true); + expect(isProtectedPath('/some/deep/nested/.ssh/known_hosts')).toBe(true); + }); + + test('does NOT block the stacks dir, external stacks, or git-repos working copies (browsing preserved)', () => { expect(isProtectedPath('/app/data')).toBe(false); expect(isProtectedPath('/app/data/stacks')).toBe(false); expect(isProtectedPath('/app/data/stacks/myapp/docker-compose.yml')).toBe(false); expect(isProtectedPath('/external-stacks')).toBe(false); expect(isProtectedPath('/external-stacks/app/.env')).toBe(false); + // The git-repos dir and its working copies stay browsable (deploy verification, + // adoption); only the .git subdir inside a clone is hidden. + expect(isProtectedPath('/app/data/git-repos')).toBe(false); + expect(isProtectedPath('/app/data/git-repos/prod/some-repo')).toBe(false); + expect(isProtectedPath('/app/data/git-repos/prod/some-repo/docker-compose.yml')).toBe(false); }); test('does NOT block unrelated container paths', () => { - expect(isProtectedPath('/etc/hostname')).toBe(false); expect(isProtectedPath('/tmp/x')).toBe(false); + expect(isProtectedPath('/opt/stacks/app/compose.yaml')).toBe(false); + expect(isProtectedPath('/home/user/docker/app/.env')).toBe(false); }); test('is a path-boundary check, not a string prefix (no false positives)', () => { @@ -43,5 +73,57 @@ describe('isProtectedPath (file-browser secret guard, H1)', () => { expect(isProtectedPath('/app/data/database-backups')).toBe(false); // a file merely named like the key elsewhere is fine expect(isProtectedPath('/app/data/stacks/.encryption_key')).toBe(false); + // "/etcd" / "/rootfs" are NOT inside "/etc" / "/root" + expect(isProtectedPath('/etcd/data')).toBe(false); + expect(isProtectedPath('/rootfs/app')).toBe(false); + // ".sshfoo" / ".github" are different segment names, not .ssh / .git + expect(isProtectedPath('/home/user/.sshfoo/x')).toBe(false); + expect(isProtectedPath('/app/data/stacks/myapp/.github/workflows')).toBe(false); + }); +}); + +describe('isProtectedPath symlink resolution (the reason safeResolve exists)', () => { + let scratch: string; + + beforeAll(() => { + scratch = mkdtempSync(join(tmpdir(), 'fsguard-')); + // A protected system tree reached via a symlink placed in an allowed dir. + mkdirSync(join(scratch, 'allowed'), { recursive: true }); + symlinkSync('/etc', join(scratch, 'allowed', 'sys-link')); + // A benign control symlink to a non-protected dir. + mkdirSync(join(scratch, 'benign-target'), { recursive: true }); + symlinkSync(join(scratch, 'benign-target'), join(scratch, 'allowed', 'ok-link')); + }); + + afterAll(() => { rmSync(scratch, { recursive: true, force: true }); }); + + test('a symlink pointing into a protected root is blocked (not just literal /etc)', () => { + // resolve() alone would leave the unprotected scratch path and pass; only + // safeResolve following the link to /etc catches this. + expect(isProtectedPath(join(scratch, 'allowed', 'sys-link'))).toBe(true); + expect(isProtectedPath(join(scratch, 'allowed', 'sys-link', 'shadow'))).toBe(true); + }); + + test('a symlink to a non-protected dir stays allowed (no over-blocking)', () => { + expect(isProtectedPath(join(scratch, 'allowed', 'ok-link'))).toBe(false); + expect(isProtectedPath(join(scratch, 'allowed', 'ok-link', 'compose.yaml'))).toBe(false); + }); + + test('a symlinked encryption-key / db target is still blocked (resolved on both sides)', () => { + // Simulate DATA_DIR/.encryption_key mounted from a secret store: the guard + // must resolve the TARGET too, or the request (resolved to the target) never + // matches the unresolved keyFile constant. + const dataDir = mkdtempSync(join(tmpdir(), 'fsguard-data-')); + mkdirSync(join(dataDir, 'real-secrets'), { recursive: true }); + writeFileSync(join(dataDir, 'real-secrets', 'key'), 'master'); + symlinkSync(join(dataDir, 'real-secrets', 'key'), join(dataDir, '.encryption_key')); + const prev = process.env.DATA_DIR; + process.env.DATA_DIR = dataDir; + try { + expect(isProtectedPath(join(dataDir, '.encryption_key'))).toBe(true); + } finally { + process.env.DATA_DIR = prev; + rmSync(dataDir, { recursive: true, force: true }); + } }); }); diff --git a/tests/provider-form-config.test.ts b/tests/provider-form-config.test.ts index 930d459..c357a84 100644 --- a/tests/provider-form-config.test.ts +++ b/tests/provider-form-config.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'bun:test'; import { collectProviderFormConfig } from '../src/lib/utils/provider-form-config'; -import { mergeProviderConfigForWrite } from '../src/lib/server/secretproviders/shared'; +import { mergeProviderConfigForWrite, destinationOverridesStored } from '../src/lib/server/secretproviders/shared'; // Field shape (key + isSecret) per provider, mirroring PROVIDER_FIELDS in // routes/settings/secrets/ProviderModal.svelte. Only the shape matters for what the edit @@ -181,3 +181,43 @@ describe('#1448 full chain: form output -> server merge', () => { expect(merged.mount).toBe(''); // server reads '' as the default mount }); }); + +// The test endpoint must not send a stored credential to a caller-chosen host. It decides via +// destinationOverridesStored: if the override changes host/address, the stored secret is NOT +// reattached and the caller needs secrets:edit. +describe('destinationOverridesStored (credential-exfil guard)', () => { + test('Vault: changed address is a redirect', () => { + const stored = { address: 'https://vault.internal:8200', token: 'hvs.REAL' }; + expect(destinationOverridesStored({ address: 'http://attacker.example:8902' }, stored)).toBe(true); + }); + + test('same address (edit form keeps host, blank token) is NOT a redirect', () => { + const stored = { address: 'https://vault.internal:8200', token: 'hvs.REAL' }; + expect(destinationOverridesStored({ address: 'https://vault.internal:8200', mount: 'kv2' }, stored)).toBe(false); + }); + + test('1Password Connect / Infisical: changed host is a redirect', () => { + const stored = { host: 'https://connect.internal', token: 'ey.REAL' }; + expect(destinationOverridesStored({ host: 'https://evil.example' }, stored)).toBe(true); + }); + + test('Bitwarden: changed serverUrl is a redirect (full stored-token leak vector)', () => { + const stored = { serverUrl: 'https://vault.bitwarden.eu', token: 'bws.REAL' }; + expect(destinationOverridesStored({ serverUrl: 'http://attacker.example:8902' }, stored)).toBe(true); + }); + + test('Azure Key Vault: changed vaultUri is a redirect', () => { + const stored = { vaultUri: 'https://kv.vault.azure.net', clientSecret: 'REAL' }; + expect(destinationOverridesStored({ vaultUri: 'https://evil.example' }, stored)).toBe(true); + }); + + test('override that omits the destination entirely is NOT a redirect', () => { + const stored = { address: 'https://vault.internal:8200', token: 'hvs.REAL' }; + expect(destinationOverridesStored({ mount: 'kv2' }, stored)).toBe(false); + }); + + test('a cleared (blank) destination is not treated as a redirect', () => { + const stored = { host: 'https://connect.internal', token: 'ey.REAL' }; + expect(destinationOverridesStored({ host: ' ' }, stored)).toBe(false); + }); +}); diff --git a/tests/secret-redact.test.ts b/tests/secret-redact.test.ts new file mode 100644 index 0000000..29e4684 --- /dev/null +++ b/tests/secret-redact.test.ts @@ -0,0 +1,63 @@ +import { describe, test, expect } from 'bun:test'; +import { redactSecretValues, redactSecretVars } from '../src/lib/server/secret-redact'; + +describe('redactSecretValues', () => { + test('replaces a secret value with [REDACTED]', () => { + const out = redactSecretValues('exec: "s3cr3t-pw": not found', ['s3cr3t-pw']); + expect(out).toBe('exec: "[REDACTED]": not found'); + expect(out).not.toContain('s3cr3t-pw'); + }); + + test('replaces every occurrence', () => { + const out = redactSecretValues('pw=hunter2 again hunter2', ['hunter2']); + expect(out).toBe('pw=[REDACTED] again [REDACTED]'); + }); + + test('redacts multiple distinct values', () => { + const out = redactSecretValues('a=alpha11 b=bravo22', ['alpha11', 'bravo22']); + expect(out).toBe('a=[REDACTED] b=[REDACTED]'); + }); + + test('masks the longest value fully when one contains another', () => { + // 'superpassword' contains 'password'; longest-first prevents a partial leak. + const out = redactSecretValues('x=superpassword', ['password', 'superpassword']); + expect(out).toBe('x=[REDACTED]'); + expect(out).not.toContain('password'); + }); + + test('skips values shorter than 4 chars (would corrupt ordinary output)', () => { + const out = redactSecretValues('exit code 1, port 22', ['1', '22']); + expect(out).toBe('exit code 1, port 22'); + }); + + test('returns text unchanged when no values match', () => { + expect(redactSecretValues('nothing secret here', ['absent-value'])).toBe('nothing secret here'); + }); + + test('handles empty text and empty values', () => { + expect(redactSecretValues('', ['secret'])).toBe(''); + expect(redactSecretValues('text', [])).toBe('text'); + }); + + test('dedupes repeated values without error', () => { + const out = redactSecretValues('v=mysecretv', ['mysecretv', 'mysecretv']); + expect(out).toBe('v=[REDACTED]'); + }); +}); + +describe('redactSecretVars', () => { + test('redacts the values of a secret-vars map', () => { + const out = redactSecretVars('DB error: pass=topsecret123', { DB_PASSWORD: 'topsecret123' }); + expect(out).toBe('DB error: pass=[REDACTED]'); + }); + + test('undefined map returns text unchanged', () => { + expect(redactSecretVars('anything', undefined)).toBe('anything'); + }); + + test('only the VALUE is redacted, not the key name', () => { + const out = redactSecretVars('DB_PASSWORD not set: topsecret123', { DB_PASSWORD: 'topsecret123' }); + expect(out).toContain('DB_PASSWORD'); + expect(out).not.toContain('topsecret123'); + }); +}); diff --git a/tests/stack-delete-guard.test.ts b/tests/stack-delete-guard.test.ts index 88124ea..580505d 100644 --- a/tests/stack-delete-guard.test.ts +++ b/tests/stack-delete-guard.test.ts @@ -43,4 +43,14 @@ describe('isDeletableStackDir', () => { it('a "../" escape resolves out and is NOT deletable', () => { expect(isDeletableStackDir('/data/stacks/../evil', STACKS, 'evil')).toBe(false); }); + + it('traversal name "..": getStackDir("..") resolves to DATA_DIR and is NOT deletable', () => { + // join(STACKS, '..') = '/data' (DATA_DIR). basename '/data' = 'data' != '..' AND not a + // strict subdir of /data/stacks -> blocked. Guards the removeStack fallback branch. + expect(isDeletableStackDir('/data', STACKS, '..')).toBe(false); + }); + + it('traversal name "../..": resolves above DATA_DIR and is NOT deletable', () => { + expect(isDeletableStackDir('/', STACKS, '../..')).toBe(false); + }); }); diff --git a/tests/unit/webhook-secret-policy.test.ts b/tests/unit/webhook-secret-policy.test.ts index 4df755c..4305281 100644 --- a/tests/unit/webhook-secret-policy.test.ts +++ b/tests/unit/webhook-secret-policy.test.ts @@ -1,6 +1,6 @@ // @ts-expect-error -- bun:test is a runtime built-in with no types installed import { describe, test, expect, afterEach } from 'bun:test'; -import { decideWebhookSecretPolicy, allowSecretlessWebhook } from '../../src/lib/server/webhook-secret-policy'; +import { decideWebhookSecretPolicy, allowSecretlessWebhook, webhookConfigRequiresSecret } from '../../src/lib/server/webhook-secret-policy'; // This proves the pure policy (all 4 combinations) + the env-flag parsing. The webhook // handler is a thin wiring of decideWebhookSecretPolicy; the deploy-unverified branch @@ -26,6 +26,23 @@ describe('decideWebhookSecretPolicy', () => { }); }); +describe('webhookConfigRequiresSecret (create/update gate)', () => { + test('enabled + no secret + no opt-out -> must reject (secure default)', () => { + expect(webhookConfigRequiresSecret(true, false, false)).toBe(true); + }); + test('enabled + no secret + opt-out on -> allowed (the escape hatch that was unreachable)', () => { + expect(webhookConfigRequiresSecret(true, false, true)).toBe(false); + }); + test('enabled + secret present -> allowed regardless of opt-out', () => { + expect(webhookConfigRequiresSecret(true, true, false)).toBe(false); + expect(webhookConfigRequiresSecret(true, true, true)).toBe(false); + }); + test('webhook disabled -> never requires a secret', () => { + expect(webhookConfigRequiresSecret(false, false, false)).toBe(false); + expect(webhookConfigRequiresSecret(false, false, true)).toBe(false); + }); +}); + describe('allowSecretlessWebhook (env flag)', () => { const original = process.env.ALLOW_WEBHOOKS_WITHOUT_SECRET; afterEach(() => {