tighten permission checks and input validation across the api
This commit is contained in:
47 files changed
+721
-225
No files matched your search
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<TagI
|
||||
* resp-200: {tags:array<{name:string!, size:integer, lastUpdated:string, digest:string}>!, 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');
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
provider.config as unknown as Record<string, unknown>
|
||||
) as unknown as SecretProviderConfig;
|
||||
const incoming = body.config as Record<string, unknown>;
|
||||
const stored = provider.config as unknown as Record<string, unknown>;
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string | null | undefined> {
|
||||
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<typeof unmapHawserDisplayComposeOptionsToStaging>[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 });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<string>!, errors:array<string>, 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user