tighten permission checks and input validation across the api
This commit is contained in:
47 files changed
+721
-225
No files matched your search
@@ -139,8 +139,17 @@ export function assertTargetWithin(root: string, target: string, label = 'restor
|
|||||||
const FORBIDDEN_RESTORE_ROOTS = [
|
const FORBIDDEN_RESTORE_ROOTS = [
|
||||||
'/', '/etc', '/root', '/boot', '/dev', '/proc', '/sys', '/run',
|
'/', '/etc', '/root', '/boot', '/dev', '/proc', '/sys', '/run',
|
||||||
'/var/run', '/bin', '/sbin', '/lib', '/lib64', '/var/lib/docker',
|
'/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/<user>/.ssh, /root/.ssh),
|
||||||
|
// while /home/<user>/<data> 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.
|
/** 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
|
* Uses resolve() so `/etc/../etc`, `/srv/../etc`, trailing slashes all normalize before the
|
||||||
* forbidden-root compare — a string-only prefix check would miss those. */
|
* 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})`;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* 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';
|
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';
|
||||||
+37
-11
@@ -1,16 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Guard for the filesystem-browser endpoints (`/api/system/files*`).
|
* Guard for the filesystem-browser endpoints (`/api/system/files*`).
|
||||||
*
|
*
|
||||||
* Dockhand runs in a container, so there is no host filesystem to leak — users
|
* The file browser lets stacks:edit users browse the host to find/adopt/relocate
|
||||||
* are allowed to browse the container freely (they need this to find/adopt
|
* stacks, but no caller may read host or Dockhand secrets through it, so the
|
||||||
* external stacks). The only things that must never be readable are Dockhand's
|
* following are always blocked regardless of permission:
|
||||||
* own secrets:
|
|
||||||
* - the database directory ($DATA_DIR/db, contains dockhand.db)
|
* - the database directory ($DATA_DIR/db, contains dockhand.db)
|
||||||
* - the encryption key file ($DATA_DIR/.encryption_key)
|
* - the encryption key file ($DATA_DIR/.encryption_key)
|
||||||
* - /proc — /proc/<pid>/environ exposes process env vars, including
|
* - /proc - /proc/<pid>/environ exposes process env vars, including
|
||||||
* DATABASE_URL (Postgres credentials) and ENCRYPTION_KEY. The whole tree is
|
* DATABASE_URL (Postgres credentials) and ENCRYPTION_KEY. The whole tree is
|
||||||
* blocked because /proc/self, /proc/1, etc. all leak the same secrets and
|
* blocked because /proc/self, /proc/1, etc. all leak the same secrets and
|
||||||
* nothing legitimate is browsed there.
|
* 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
|
* isProtectedPath resolves symlinks (via the nearest existing ancestor) so a
|
||||||
* symlink pointing into a protected location can't be used to bypass the check.
|
* 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';
|
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 {
|
function getDataDir(): string {
|
||||||
return process.env.DATA_DIR || './data';
|
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 } {
|
function protectedPaths(): { dbDir: string; keyFile: string } {
|
||||||
const dataDir = resolve(getDataDir());
|
const dataDir = resolve(getDataDir());
|
||||||
return {
|
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
|
* 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
|
* 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 {
|
function safeResolve(p: string): string {
|
||||||
let current = resolve(p);
|
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
|
* Returns true if `requestedPath` resolves to a location that must never be read
|
||||||
* encryption-key file, or anywhere under /proc (process env / cmdline leak
|
* through the file browser (Dockhand's DB/key, system secret trees, or any .ssh /
|
||||||
* credentials). Such paths must be hidden from the file browser.
|
* .git directory). Symlinks are resolved first so they can't bypass it.
|
||||||
*/
|
*/
|
||||||
export function isProtectedPath(requestedPath: string): boolean {
|
export function isProtectedPath(requestedPath: string): boolean {
|
||||||
const { dbDir, keyFile } = protectedPaths();
|
const { dbDir, keyFile } = protectedPaths();
|
||||||
const resolved = safeResolve(requestedPath);
|
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));
|
||||||
}
|
}
|
||||||
+38
-36
@@ -36,14 +36,14 @@ export const MessageType = {
|
|||||||
export interface EdgeConnection {
|
export interface EdgeConnection {
|
||||||
ws: WebSocket;
|
ws: WebSocket;
|
||||||
environmentId: number;
|
environmentId: number;
|
||||||
|
/** The hawser token id this connection authenticated with; revoking that token closes it. */
|
||||||
|
tokenId?: number;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
agentName: string;
|
agentName: string;
|
||||||
agentVersion: string;
|
agentVersion: string;
|
||||||
dockerVersion: string;
|
dockerVersion: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
/** Remote agent STACKS_DIR (from hello message) */
|
|
||||||
stacksDir?: string;
|
|
||||||
connectedAt: Date;
|
connectedAt: Date;
|
||||||
lastHeartbeat: number;
|
lastHeartbeat: number;
|
||||||
pendingRequests: Map<string, PendingRequest>;
|
pendingRequests: Map<string, PendingRequest>;
|
||||||
@@ -393,6 +393,17 @@ export async function generateHawserToken(
|
|||||||
*/
|
*/
|
||||||
export async function revokeHawserToken(tokenId: number): Promise<void> {
|
export async function revokeHawserToken(tokenId: number): Promise<void> {
|
||||||
await db.update(hawserTokens).set({ isActive: false }).where(eq(hawserTokens.id, tokenId));
|
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(
|
export function handleEdgeConnection(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
environmentId: number,
|
environmentId: number,
|
||||||
hello: HelloMessage
|
hello: HelloMessage,
|
||||||
|
tokenId?: number
|
||||||
): EdgeConnection {
|
): EdgeConnection {
|
||||||
// Check if there's already a connection for this environment
|
// Check if there's already a connection for this environment
|
||||||
const existing = edgeConnections.get(environmentId);
|
const existing = edgeConnections.get(environmentId);
|
||||||
@@ -492,13 +504,13 @@ export function handleEdgeConnection(
|
|||||||
const connection: EdgeConnection = {
|
const connection: EdgeConnection = {
|
||||||
ws,
|
ws,
|
||||||
environmentId,
|
environmentId,
|
||||||
|
tokenId,
|
||||||
agentId: hello.agentId,
|
agentId: hello.agentId,
|
||||||
agentName: hello.agentName,
|
agentName: hello.agentName,
|
||||||
agentVersion: hello.version,
|
agentVersion: hello.version,
|
||||||
dockerVersion: hello.dockerVersion,
|
dockerVersion: hello.dockerVersion,
|
||||||
hostname: hello.hostname,
|
hostname: hello.hostname,
|
||||||
capabilities: hello.capabilities,
|
capabilities: hello.capabilities,
|
||||||
stacksDir: hello.stacksDir,
|
|
||||||
connectedAt: new Date(),
|
connectedAt: new Date(),
|
||||||
lastHeartbeat: Date.now(),
|
lastHeartbeat: Date.now(),
|
||||||
pendingRequests: new Map(),
|
pendingRequests: new Map(),
|
||||||
@@ -525,40 +537,32 @@ export function handleEdgeConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update environment status in database. Best-effort: called from connection
|
* Update environment status in database
|
||||||
* setup, teardown and heartbeat-timeout paths where a DB failure must not
|
|
||||||
* take down the connection handling.
|
|
||||||
*/
|
*/
|
||||||
async function updateEnvironmentStatus(
|
async function updateEnvironmentStatus(
|
||||||
environmentId: number,
|
environmentId: number,
|
||||||
connection: EdgeConnection | null
|
connection: EdgeConnection | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
if (connection) {
|
||||||
if (connection) {
|
await db
|
||||||
await db
|
.update(environments)
|
||||||
.update(environments)
|
.set({
|
||||||
.set({
|
hawserLastSeen: new Date().toISOString(),
|
||||||
hawserLastSeen: new Date().toISOString(),
|
hawserAgentId: connection.agentId,
|
||||||
hawserAgentId: connection.agentId,
|
hawserAgentName: connection.agentName,
|
||||||
hawserAgentName: connection.agentName,
|
hawserVersion: connection.agentVersion,
|
||||||
hawserVersion: connection.agentVersion,
|
hawserCapabilities: JSON.stringify(connection.capabilities),
|
||||||
hawserCapabilities: JSON.stringify(connection.capabilities),
|
updatedAt: new Date().toISOString()
|
||||||
hawserStacksDir: connection.stacksDir ?? null,
|
})
|
||||||
updatedAt: new Date().toISOString()
|
.where(eq(environments.id, environmentId));
|
||||||
})
|
} else {
|
||||||
.where(eq(environments.id, environmentId));
|
await db
|
||||||
} else {
|
.update(environments)
|
||||||
await db
|
.set({
|
||||||
.update(environments)
|
hawserLastSeen: new Date().toISOString(),
|
||||||
.set({
|
updatedAt: new Date().toISOString()
|
||||||
hawserLastSeen: new Date().toISOString(),
|
})
|
||||||
updatedAt: new Date().toISOString()
|
.where(eq(environments.id, environmentId));
|
||||||
})
|
|
||||||
.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}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,8 +945,6 @@ export interface HelloMessage {
|
|||||||
dockerVersion: string;
|
dockerVersion: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
/** Remote agent STACKS_DIR — host path where compose stacks are written */
|
|
||||||
stacksDir?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WelcomeMessage {
|
export interface WelcomeMessage {
|
||||||
@@ -1214,7 +1216,7 @@ async function handleHawserWsMessage(ws: any, msg: any, connId: string, remoteIp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Authenticated — register the connection
|
// 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);
|
wsToEnvId.set(ws, result.environmentId);
|
||||||
|
|
||||||
// Send welcome
|
// Send welcome
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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>): 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, string>): string {
|
||||||
|
if (!secretVars) return text;
|
||||||
|
return redactSecretValues(text, Object.values(secretVars));
|
||||||
|
}
|
||||||
@@ -243,6 +243,39 @@ export type SecretProviderConfig =
|
|||||||
*/
|
*/
|
||||||
export const SECRET_CONFIG_KEYS = new Set(['token', 'clientSecret', 'password']);
|
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<string, unknown>,
|
||||||
|
stored: Record<string, unknown>
|
||||||
|
): 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
|
* 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
|
* pair the user chooses or abandons together. When the user CLEARS the partner in the edit
|
||||||
|
|||||||
@@ -26,3 +26,18 @@ export function decideWebhookSecretPolicy(
|
|||||||
export function allowSecretlessWebhook(): boolean {
|
export function allowSecretlessWebhook(): boolean {
|
||||||
return process.env.ALLOW_WEBHOOKS_WITHOUT_SECRET === 'true';
|
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;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import type { LayoutServerLoad } from './$types';
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { isAuthEnabled, validateSession } from '$lib/server/auth';
|
import { isAuthEnabled, validateSession } from '$lib/server/auth';
|
||||||
import { hasAdminUser } from '$lib/server/db';
|
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
|
// 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).
|
// 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,
|
authEnabled: false,
|
||||||
user: null,
|
user: null,
|
||||||
disableWhatsNew,
|
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,
|
user: null,
|
||||||
setupMode: true,
|
setupMode: true,
|
||||||
disableWhatsNew,
|
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
|
provider: user.provider
|
||||||
} : null,
|
} : null,
|
||||||
disableWhatsNew,
|
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: 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
|
* 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-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-404: Container not found
|
||||||
* resp-500: Failed to list the directory
|
* 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 envIdNum = envId ? parseInt(envId) : undefined;
|
||||||
const simpleLs = url.searchParams.get('simpleLs') === 'true';
|
const simpleLs = url.searchParams.get('simpleLs') === 'true';
|
||||||
|
|
||||||
// Permission check with environment context
|
// Listing a container dir runs a real exec (`ls` inside the container), which can read
|
||||||
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
// 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 });
|
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
|
* query: path:string! Absolute file path inside the container
|
||||||
* resp-200: {content:string!, path:string!}
|
* resp-200: {content:string!, path:string!}
|
||||||
* resp-400: Path is missing, the target is a directory, or the container is not running
|
* 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-404: File not found
|
||||||
* resp-413: File is larger than the 1 MB read limit
|
* resp-413: File is larger than the 1 MB read limit
|
||||||
* resp-500: Failed to read the file
|
* 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 envId = url.searchParams.get('env');
|
||||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||||
|
|
||||||
// Permission check with environment context
|
// Reading a container file runs a real exec (`cat` inside the container), so it can read
|
||||||
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
// 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 });
|
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 envId = url.searchParams.get('env');
|
||||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
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)) {
|
if (auth.authEnabled && !await auth.can('containers', 'inspect', envIdNum)) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
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 {
|
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
|
for (const cmd of psCommands) {
|
||||||
const psCommands = [
|
try {
|
||||||
['ps', 'aux', '--sort=-pcpu'], // GNU ps with CPU sort
|
const output = await execInContainer(params.id, cmd, envIdNum);
|
||||||
['ps', 'aux'], // GNU ps without sort
|
// Check if output looks like an error message (BusyBox error, etc.)
|
||||||
['ps', '-ef'], // POSIX ps
|
if (output.includes('unrecognized option') || output.includes('Usage:') || output.includes('BusyBox')) {
|
||||||
];
|
continue;
|
||||||
|
}
|
||||||
for (const cmd of psCommands) {
|
const result = parsePsOutput(output);
|
||||||
try {
|
if (result && result.Processes.length > 0) {
|
||||||
const output = await execInContainer(params.id, cmd, envIdNum);
|
return json({ ...result, source: 'ps' });
|
||||||
// Check if output looks like an error message (BusyBox error, etc.)
|
}
|
||||||
if (output.includes('unrecognized option') || output.includes('Usage:') || output.includes('BusyBox')) {
|
} catch {
|
||||||
continue;
|
// 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);
|
const top = await getContainerTop(params.id, envIdNum);
|
||||||
return json({ ...top, source: 'top' });
|
return json({ ...top, source: 'top' });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import v8 from 'node:v8';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
import { getRssStats, dumpHeapSnapshot, listHeapSnapshots } from '$lib/server/rss-tracker';
|
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
|
// Track startup time and initial RSS for growth rate calculation
|
||||||
const startupTime = Date.now();
|
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: {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-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-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') {
|
if (process.env.MEMORY_MONITOR !== 'true') {
|
||||||
return json({ error: 'Memory monitor not enabled. Set MEMORY_MONITOR=true.' }, { status: 403 });
|
return json({ error: 'Memory monitor not enabled. Set MEMORY_MONITOR=true.' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { join, resolve, basename } from 'path';
|
import { join } from 'path';
|
||||||
import { existsSync, rmSync, renameSync } from 'fs';
|
import { existsSync, rmSync, renameSync } from 'fs';
|
||||||
import type { RequestHandler } from './$types';
|
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 { clearDockerClientCache } from '$lib/server/docker';
|
||||||
import { deleteGitStackFiles, getGitReposDir } from '$lib/server/git';
|
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 { authorize } from '$lib/server/authorize';
|
||||||
import { auditEnvironment } from '$lib/server/audit';
|
import { auditEnvironment } from '$lib/server/audit';
|
||||||
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
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')) {
|
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = parseInt(params.id);
|
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
|
|
||||||
if (!env) {
|
if (!env) {
|
||||||
@@ -80,10 +82,11 @@ export const PUT: RequestHandler = async (event) => {
|
|||||||
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = parseInt(params.id);
|
|
||||||
|
|
||||||
// Get old values before update for diff
|
// Get old values before update for diff
|
||||||
const oldEnv = await getEnvironment(id);
|
const oldEnv = await getEnvironment(id);
|
||||||
if (!oldEnv) {
|
if (!oldEnv) {
|
||||||
@@ -116,7 +119,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||||||
// regardless of where they ultimately deploy — so the rename matters
|
// regardless of where they ultimately deploy — so the rename matters
|
||||||
// locally for every env type.
|
// locally for every env type.
|
||||||
if (isRename) {
|
if (isRename) {
|
||||||
const stacksDir = getDefaultStacksDir();
|
const stacksDir = getStacksDir();
|
||||||
const gitReposDir = getGitReposDir();
|
const gitReposDir = getGitReposDir();
|
||||||
const oldStacks = join(stacksDir, oldEnv.name);
|
const oldStacks = join(stacksDir, oldEnv.name);
|
||||||
const newStacks = join(stacksDir, data.name);
|
const newStacks = join(stacksDir, data.name);
|
||||||
@@ -241,6 +244,12 @@ export const PUT: RequestHandler = async (event) => {
|
|||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
const { params, cookies } = event;
|
const { params, cookies } = event;
|
||||||
const auth = await authorize(cookies);
|
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')) {
|
if (auth.authEnabled && !await auth.can('environments', 'delete')) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
@@ -317,7 +326,7 @@ export const DELETE: RequestHandler = async (event) => {
|
|||||||
// Clean up stack directory for this environment
|
// Clean up stack directory for this environment
|
||||||
// Safety: only delete subdirectory named after the env, never the parent
|
// Safety: only delete subdirectory named after the env, never the parent
|
||||||
try {
|
try {
|
||||||
const stacksDir = getDefaultStacksDir();
|
const stacksDir = getStacksDir();
|
||||||
const envStackDir = join(stacksDir, env.name);
|
const envStackDir = join(stacksDir, env.name);
|
||||||
if (envStackDir !== stacksDir && envStackDir.startsWith(stacksDir) && existsSync(envStackDir)) {
|
if (envStackDir !== stacksDir && envStackDir.startsWith(stacksDir) && existsSync(envStackDir)) {
|
||||||
rmSync(envStackDir, { recursive: true, force: true });
|
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);
|
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
|
// Clean up git-repos directory for this environment
|
||||||
try {
|
try {
|
||||||
const gitReposDir = getGitReposDir();
|
const gitReposDir = getGitReposDir();
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
|||||||
if (auth.authEnabled && !(await auth.can('environments', 'view'))) {
|
if (auth.authEnabled && !(await auth.can('environments', 'view'))) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = parseInt(params.id);
|
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
return json({ error: 'Environment not found' }, { status: 404 });
|
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'))) {
|
if (auth.authEnabled && !(await auth.can('environments', 'edit'))) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = parseInt(params.id);
|
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
return json({ error: 'Environment not found' }, { status: 404 });
|
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 id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
return json({ error: 'Environment not found' }, { status: 404 });
|
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 id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
return json({ error: 'Environment not found' }, { status: 404 });
|
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')) {
|
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = parseInt(params.id);
|
|
||||||
|
|
||||||
// Verify environment exists
|
// Verify environment exists
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
@@ -68,10 +69,11 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
|||||||
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = parseInt(params.id);
|
|
||||||
|
|
||||||
// Verify environment exists
|
// Verify environment exists
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
@@ -125,10 +127,11 @@ export const PUT: RequestHandler = async ({ params, cookies }) => {
|
|||||||
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = parseInt(params.id);
|
|
||||||
|
|
||||||
// Verify environment exists
|
// Verify environment exists
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
|||||||
if (isNaN(envId)) {
|
if (isNaN(envId)) {
|
||||||
return json({ error: 'Invalid environment ID' }, { status: 400 });
|
return json({ error: 'Invalid environment ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(envId);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
const env = await getEnvironment(envId);
|
const env = await getEnvironment(envId);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
@@ -70,6 +72,8 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
|||||||
if (isNaN(envId)) {
|
if (isNaN(envId)) {
|
||||||
return json({ error: 'Invalid environment ID' }, { status: 400 });
|
return json({ error: 'Invalid environment ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(envId);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
const env = await getEnvironment(envId);
|
const env = await getEnvironment(envId);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
|||||||
if (isNaN(envId) || isNaN(notifId)) {
|
if (isNaN(envId) || isNaN(notifId)) {
|
||||||
return json({ error: 'Invalid ID' }, { status: 400 });
|
return json({ error: 'Invalid ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(envId);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
const env = await getEnvironment(envId);
|
const env = await getEnvironment(envId);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
@@ -75,6 +77,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
|
|||||||
if (isNaN(envId) || isNaN(notifId)) {
|
if (isNaN(envId) || isNaN(notifId)) {
|
||||||
return json({ error: 'Invalid ID' }, { status: 400 });
|
return json({ error: 'Invalid ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(envId);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
const env = await getEnvironment(envId);
|
const env = await getEnvironment(envId);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
@@ -124,6 +128,8 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
|||||||
if (isNaN(envId) || isNaN(notifId)) {
|
if (isNaN(envId) || isNaN(notifId)) {
|
||||||
return json({ error: 'Invalid ID' }, { status: 400 });
|
return json({ error: 'Invalid ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(envId);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deleted = await deleteEnvironmentNotification(envId, notifId);
|
const deleted = await deleteEnvironmentNotification(envId, notifId);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
|||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
const id = parseInt(params.id);
|
const id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
if (!env) return json({ error: 'Environment not found' }, { status: 404 });
|
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 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
const id = parseInt(params.id);
|
const id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
if (!env) return json({ error: 'Environment not found' }, { status: 404 });
|
if (!env) return json({ error: 'Environment not found' }, { status: 404 });
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { getEnvironment, updateEnvironment } from '$lib/server/db';
|
import { getEnvironment, updateEnvironment } from '$lib/server/db';
|
||||||
import { getDockerInfo, getHawserInfo } from '$lib/server/docker';
|
import { getDockerInfo, getHawserInfo } from '$lib/server/docker';
|
||||||
import { edgeConnections, isEdgeConnected } from '$lib/server/hawser';
|
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)
|
* 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: {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-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
|
* 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 {
|
try {
|
||||||
const id = parseInt(params.id);
|
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
|
|
||||||
if (!env) {
|
if (!env) {
|
||||||
|
|||||||
@@ -53,10 +53,11 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
|||||||
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = parseInt(params.id);
|
|
||||||
|
|
||||||
// Verify environment exists
|
// Verify environment exists
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
@@ -92,10 +93,11 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
|||||||
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = parseInt(params.id);
|
|
||||||
|
|
||||||
// Verify environment exists
|
// Verify environment exists
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
|||||||
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = parseInt(params.id);
|
|
||||||
|
|
||||||
// Verify environment exists
|
// Verify environment exists
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
@@ -69,10 +70,11 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
|||||||
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(id);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = parseInt(params.id);
|
|
||||||
|
|
||||||
// Verify environment exists
|
// Verify environment exists
|
||||||
const env = await getEnvironment(id);
|
const env = await getEnvironment(id);
|
||||||
if (!env) {
|
if (!env) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { authorize } from '$lib/server/authorize';
|
|||||||
import { registerSchedule } from '$lib/server/scheduler';
|
import { registerSchedule } from '$lib/server/scheduler';
|
||||||
import { auditGitStack } from '$lib/server/audit';
|
import { auditGitStack } from '$lib/server/audit';
|
||||||
import { createJobResponse } from '$lib/server/sse';
|
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
|
// Stack name validation: Docker Compose requires lowercase; must start with a
|
||||||
// letter or number, and contain only lowercase letters, numbers, hyphens, underscores
|
// 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.
|
// 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 });
|
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 { auditGitStack } from '$lib/server/audit';
|
||||||
import { computeAuditDiff } from '$lib/utils/diff';
|
import { computeAuditDiff } from '$lib/utils/diff';
|
||||||
import { createJobResponse } from '$lib/server/sse';
|
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
|
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
|
||||||
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
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).
|
// Evaluate the effective post-update state (PUT is partial).
|
||||||
const effWebhookEnabled = data.webhookEnabled !== undefined ? data.webhookEnabled : existing.webhookEnabled;
|
const effWebhookEnabled = data.webhookEnabled !== undefined ? data.webhookEnabled : existing.webhookEnabled;
|
||||||
const effWebhookSecret = data.webhookSecret !== undefined ? data.webhookSecret : existing.webhookSecret;
|
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 });
|
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 { json } from '@sveltejs/kit';
|
||||||
import { getNotificationSetting } from '$lib/server/db';
|
import { getNotificationSetting } from '$lib/server/db';
|
||||||
import { testNotification } from '$lib/server/notifications';
|
import { testNotification } from '$lib/server/notifications';
|
||||||
|
import { authorize } from '$lib/server/authorize';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,11 +11,17 @@ import type { RequestHandler } from './$types';
|
|||||||
* resp-200: {success:boolean!, message:string, error:string}
|
* resp-200: {success:boolean!, message:string, error:string}
|
||||||
* resp-200-example: {"success":true,"message":"Test notification sent successfully"}
|
* resp-200-example: {"success":true,"message":"Test notification sent successfully"}
|
||||||
* resp-400: Invalid ID (not a number)
|
* resp-400: Invalid ID (not a number)
|
||||||
|
* resp-403: Permission denied (needs notifications:edit)
|
||||||
* resp-404: Notification setting not found
|
* resp-404: Notification setting not found
|
||||||
* resp-500: Failed to test notification
|
* resp-500: Failed to test notification
|
||||||
*/
|
*/
|
||||||
export const POST: RequestHandler = async ({ params }) => {
|
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||||
try {
|
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);
|
const id = parseInt(params.id);
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
return json({ error: 'Invalid ID' }, { status: 400 });
|
return json({ error: 'Invalid ID' }, { status: 400 });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getRegistry } from '$lib/server/db';
|
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';
|
import { getRegistryAuth, isHarborRegistry, harborListRepositories, parseRegistryUrl, classifyCatalogFailure, CATALOG_NOT_SUPPORTED_MSG } from '$lib/server/docker';
|
||||||
|
|
||||||
const PAGE_SIZE = 100;
|
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: {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-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-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-404: Registry not found, or the registry does not implement the V2 catalog API
|
||||||
* resp-500: Failed to fetch the catalog
|
* resp-500: Failed to fetch the catalog
|
||||||
* resp-503: Could not connect to the registry (connection refused, host not found, or a TLS error)
|
* 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 {
|
try {
|
||||||
const registryId = url.searchParams.get('registry');
|
const registryId = url.searchParams.get('registry');
|
||||||
const lastParam = url.searchParams.get('last'); // For pagination
|
const lastParam = url.searchParams.get('last'); // For pagination
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getRegistry } from '$lib/server/db';
|
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';
|
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). */
|
/** 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: 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-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-400: The term query parameter is missing
|
||||||
|
* resp-403: Permission denied (requires registries:view)
|
||||||
* resp-404: The referenced registry does not exist
|
* resp-404: The referenced registry does not exist
|
||||||
* resp-500: Failed to search images
|
* resp-500: Failed to search images
|
||||||
* resp-503: Could not connect to the registry (connection refused or host not found)
|
* 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 term = url.searchParams.get('term');
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '25', 10);
|
const limit = parseInt(url.searchParams.get('limit') || '25', 10);
|
||||||
const registryId = url.searchParams.get('registry');
|
const registryId = url.searchParams.get('registry');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getRegistry } from '$lib/server/db';
|
import { getRegistry } from '$lib/server/db';
|
||||||
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { getRegistryAuth } from '$lib/server/docker';
|
import { getRegistryAuth } from '$lib/server/docker';
|
||||||
|
|
||||||
function isDockerHub(rawUrl: string): boolean {
|
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: {size:number, lastUpdated:string, reason:string}
|
||||||
* resp-200-example: {"size":104857600,"lastUpdated":"2026-06-01T12:00:00Z"}
|
* resp-200-example: {"size":104857600,"lastUpdated":"2026-06-01T12:00:00Z"}
|
||||||
* resp-400: image and tag are required
|
* resp-400: image and tag are required
|
||||||
|
* resp-403: Permission denied (requires registries:view)
|
||||||
* resp-404: Registry not found
|
* 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 registryId = url.searchParams.get('registry');
|
||||||
const image = url.searchParams.get('image');
|
const image = url.searchParams.get('image');
|
||||||
const tag = url.searchParams.get('tag');
|
const tag = url.searchParams.get('tag');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getRegistry } from '$lib/server/db';
|
import { getRegistry } from '$lib/server/db';
|
||||||
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { getRegistryAuth } from '$lib/server/docker';
|
import { getRegistryAuth } from '$lib/server/docker';
|
||||||
|
|
||||||
interface TagInfo {
|
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: {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-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-400: The image query parameter is missing
|
||||||
|
* resp-403: Permission denied (requires registries:view)
|
||||||
* resp-404: The referenced registry does not exist
|
* resp-404: The referenced registry does not exist
|
||||||
* resp-500: Failed to fetch tags
|
* resp-500: Failed to fetch tags
|
||||||
* resp-503: Could not connect to the registry (connection refused or host not found)
|
* 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 {
|
try {
|
||||||
const registryId = url.searchParams.get('registry');
|
const registryId = url.searchParams.get('registry');
|
||||||
const imageName = url.searchParams.get('image');
|
const imageName = url.searchParams.get('image');
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ import type { RequestHandler } from './$types';
|
|||||||
import { getSecretProviderById } from '$lib/server/db';
|
import { getSecretProviderById } from '$lib/server/db';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { testProviderConnection } from '$lib/server/secretproviders';
|
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';
|
import type { SecretProviderConfig } from '$lib/server/secretproviders/shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test a stored provider. With no body it tests the persisted config. With an optional
|
* 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
|
* `{ config }` override (the edit form's typed non-secret fields) a blank secret falls back to
|
||||||
* Save would persist: the incoming config merged over the stored one, with a blank token
|
* the stored one - BUT ONLY when the override keeps the same destination (host/address).
|
||||||
* 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.
|
* 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
|
* @openapi
|
||||||
* summary: Test a stored provider's connectivity, optionally against typed override fields the edit form would save
|
* 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 {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
if (body && typeof body.config === 'object' && body.config !== null) {
|
if (body && typeof body.config === 'object' && body.config !== null) {
|
||||||
configToTest = mergeProviderConfigForWrite(
|
const incoming = body.config as Record<string, unknown>;
|
||||||
body.config as Record<string, unknown>,
|
const stored = provider.config as unknown as Record<string, unknown>;
|
||||||
provider.config as unknown as Record<string, unknown>
|
|
||||||
) as unknown as SecretProviderConfig;
|
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 {
|
} catch {
|
||||||
// No/invalid body: test the stored config as-is.
|
// No/invalid body: test the stored config as-is.
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ export const DELETE: RequestHandler = async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
const result = await removeStack(stackName, envIdNum, force, volumes, files);
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ import { join, dirname } from 'node:path';
|
|||||||
*/
|
*/
|
||||||
export const POST: RequestHandler = async ({ params, request, url, cookies }) => {
|
export const POST: RequestHandler = async ({ params, request, url, cookies }) => {
|
||||||
const auth = await authorize(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 { name } = params;
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
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 {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|||||||
@@ -1,23 +1,9 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { dirname } from 'node:path';
|
import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/server/stacks';
|
||||||
import { getStackComposeFile, deployStack, saveStackComposeFile, remapHawserStagingDisplayPaths, unmapHawserDisplayComposeOptionsToStaging } from '$lib/server/stacks';
|
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { createJobResponse } from '$lib/server/sse';
|
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
|
// GET /api/stacks/[name]/compose - Get compose file content
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -30,13 +16,14 @@ async function remapDisplayPath(
|
|||||||
*/
|
*/
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
const auth = await authorize(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 { name } = params;
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
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 {
|
try {
|
||||||
const result = await getStackComposeFile(name, envIdNum);
|
const result = await getStackComposeFile(name, envIdNum);
|
||||||
@@ -46,26 +33,17 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|||||||
return json({
|
return json({
|
||||||
error: result.error,
|
error: result.error,
|
||||||
needsFileLocation: result.needsFileLocation || false,
|
needsFileLocation: result.needsFileLocation || false,
|
||||||
composePath: await remapDisplayPath(name, envIdNum, result.composePath),
|
composePath: result.composePath,
|
||||||
envPath: await remapDisplayPath(name, envIdNum, result.envPath)
|
envPath: result.envPath
|
||||||
}, { status: 404 });
|
}, { 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({
|
return json({
|
||||||
content: result.content,
|
content: result.content,
|
||||||
stackDir: displayStackDir,
|
stackDir: result.stackDir,
|
||||||
composePath: displayPaths.composePath,
|
composePath: result.composePath,
|
||||||
envPath: await remapDisplayPath(name, envIdNum, result.envPath),
|
envPath: result.envPath,
|
||||||
suggestedEnvPath: await remapDisplayPath(name, envIdNum, result.suggestedEnvPath)
|
suggestedEnvPath: result.suggestedEnvPath
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`Error getting compose file for stack ${name}:`, error);
|
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))) {
|
if (auth.authEnabled && !(await auth.can('stacks', 'edit', envIdNum))) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(envIdNum ?? null);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
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 });
|
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
|
// Build options object for custom paths, move operation, file renames, and secret provider binding
|
||||||
// before writing. secretProviderId is not a path and is merged after unmap.
|
const pathOptions = (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath || secretProviderId !== undefined)
|
||||||
let pathOptions: Parameters<typeof unmapHawserDisplayComposeOptionsToStaging>[2] | undefined =
|
? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath, secretProviderId }
|
||||||
(composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath)
|
: undefined;
|
||||||
? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath }
|
|
||||||
: undefined;
|
|
||||||
if (pathOptions) {
|
|
||||||
pathOptions = await unmapHawserDisplayComposeOptionsToStaging(name, envIdNum, pathOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist the submitted content on EVERY accepted PUT, whether or not path fields came
|
// 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
|
// 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
|
// 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
|
// 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).
|
// possibly-undefined pathOptions fine (the non-restart branch already relied on that).
|
||||||
const saveResult = await saveStackComposeFile(name, content, false, envIdNum, {
|
const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
|
||||||
...pathOptions,
|
|
||||||
...(secretProviderId !== undefined ? { secretProviderId } : {})
|
|
||||||
});
|
|
||||||
if (!saveResult.success) {
|
if (!saveResult.success) {
|
||||||
return json({ error: saveResult.error }, { status: 500 });
|
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))) {
|
if (auth.authEnabled && !(await auth.can('stacks', 'remove', envIdNum))) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(envIdNum ?? null);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
|
|
||||||
const { name } = params;
|
const { name } = params;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|||||||
if (auth.authEnabled && !(await auth.can('stacks', 'view', envId ?? undefined))) {
|
if (auth.authEnabled && !(await auth.can('stacks', 'view', envId ?? undefined))) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(envId);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
const buffer = getStackIconBuffer(params.name, envId);
|
const buffer = getStackIconBuffer(params.name, envId);
|
||||||
if (!buffer) return json({ error: 'No custom icon' }, { status: 404 });
|
if (!buffer) return json({ error: 'No custom icon' }, { status: 404 });
|
||||||
return new Response(new Uint8Array(buffer), {
|
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))) {
|
if (auth.authEnabled && !(await auth.can('stacks', 'edit', envId ?? undefined))) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(envId);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
const data = await request.json();
|
const data = await request.json();
|
||||||
|
|
||||||
let iconValue: string;
|
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))) {
|
if (auth.authEnabled && !(await auth.can('stacks', 'edit', envId ?? undefined))) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
const envAccessDenied = await auth.requireEnvAccess(envId);
|
||||||
|
if (envAccessDenied) return envAccessDenied;
|
||||||
deleteStackIcon(params.name, envId);
|
deleteStackIcon(params.name, envId);
|
||||||
await updateStackSource(params.name, envId, { icon: null });
|
await updateStackSource(params.name, envId, { icon: null });
|
||||||
return json({ success: true });
|
return json({ success: true });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { getStackSource, updateStackSource } from '$lib/server/db';
|
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 { existsSync, readdirSync, renameSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmSync } from 'node:fs';
|
||||||
import { join, dirname } from 'node:path';
|
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: {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-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-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
|
* resp-500: Failed to relocate stack
|
||||||
*/
|
*/
|
||||||
export const POST: RequestHandler = async ({ params, request, url, cookies }) => {
|
export const POST: RequestHandler = async ({ params, request, url, cookies }) => {
|
||||||
const auth = await authorize(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 { name } = params;
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
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 {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@@ -40,6 +43,23 @@ export const POST: RequestHandler = async ({ params, request, url, cookies }) =>
|
|||||||
|
|
||||||
const newDir = dirname(newComposePath);
|
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
|
// Verify old directory exists
|
||||||
if (!existsSync(oldDir)) {
|
if (!existsSync(oldDir)) {
|
||||||
return json({ error: 'Source directory does not exist' }, { status: 400 });
|
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');
|
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');
|
const envFilePath = newEnvPath || join(newDir, '.env');
|
||||||
if (existsSync(envFilePath)) {
|
if (existsSync(envFilePath) && !isProtectedPath(envFilePath)) {
|
||||||
rawEnvContent = readFileSync(envFilePath, 'utf-8');
|
rawEnvContent = readFileSync(envFilePath, 'utf-8');
|
||||||
|
|
||||||
// Parse env vars from raw content
|
// 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))) {
|
if (auth.authEnabled && !(await auth.can('stacks', 'view', envIdNum))) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
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 {
|
const body = (await request.json().catch(() => ({}))) as {
|
||||||
compose?: string;
|
compose?: string;
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ import { adoptSelectedStacks, type DiscoveredStack } from '$lib/server/stack-sca
|
|||||||
*/
|
*/
|
||||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
if (auth.authEnabled && !await auth.can('stacks', 'create')) {
|
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
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 });
|
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
|
// Validate each stack has required fields
|
||||||
for (const stack of stacks) {
|
for (const stack of stacks) {
|
||||||
if (!stack.name || !stack.composePath) {
|
if (!stack.name || !stack.composePath) {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { isProtectedPath } from '$lib/server/fs-guard';
|
|||||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||||
const auth = await authorize(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')) {
|
if (auth.authEnabled && !await auth.can('stacks', 'edit')) {
|
||||||
return json({ error: 'Permission denied' }, { status: 403 });
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -602,7 +602,9 @@
|
|||||||
hasErrors = true;
|
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';
|
errors.webhookSecret = 'A webhook secret is required when the webhook is enabled';
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)', () => {
|
describe('isSafePathSegment (name traversal guard)', () => {
|
||||||
it('accepts a plain name', () => {
|
it('accepts a plain name', () => {
|
||||||
expect(isSafePathSegment('web')).toBe(true);
|
expect(isSafePathSegment('web')).toBe(true);
|
||||||
@@ -141,7 +175,8 @@ describe('re-exported hardened primitives still reject dangerous inputs', () =>
|
|||||||
|
|
||||||
describe('unsafeRestoreTargetReason (new-location targetPath containment)', () => {
|
describe('unsafeRestoreTargetReason (new-location targetPath containment)', () => {
|
||||||
it('accepts legitimate operator-chosen restore locations', () => {
|
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();
|
expect(unsafeRestoreTargetReason(p)).toBeNull();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+85
-3
@@ -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.
|
// Pin DATA_DIR before importing so the guard computes /app/data paths.
|
||||||
beforeAll(() => { process.env.DATA_DIR = '/app/data'; });
|
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);
|
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')).toBe(false);
|
||||||
expect(isProtectedPath('/app/data/stacks')).toBe(false);
|
expect(isProtectedPath('/app/data/stacks')).toBe(false);
|
||||||
expect(isProtectedPath('/app/data/stacks/myapp/docker-compose.yml')).toBe(false);
|
expect(isProtectedPath('/app/data/stacks/myapp/docker-compose.yml')).toBe(false);
|
||||||
expect(isProtectedPath('/external-stacks')).toBe(false);
|
expect(isProtectedPath('/external-stacks')).toBe(false);
|
||||||
expect(isProtectedPath('/external-stacks/app/.env')).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', () => {
|
test('does NOT block unrelated container paths', () => {
|
||||||
expect(isProtectedPath('/etc/hostname')).toBe(false);
|
|
||||||
expect(isProtectedPath('/tmp/x')).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)', () => {
|
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);
|
expect(isProtectedPath('/app/data/database-backups')).toBe(false);
|
||||||
// a file merely named like the key elsewhere is fine
|
// a file merely named like the key elsewhere is fine
|
||||||
expect(isProtectedPath('/app/data/stacks/.encryption_key')).toBe(false);
|
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 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, test, expect } from 'bun:test';
|
import { describe, test, expect } from 'bun:test';
|
||||||
import { collectProviderFormConfig } from '../src/lib/utils/provider-form-config';
|
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
|
// Field shape (key + isSecret) per provider, mirroring PROVIDER_FIELDS in
|
||||||
// routes/settings/secrets/ProviderModal.svelte. Only the shape matters for what the edit
|
// 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
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,4 +43,14 @@ describe('isDeletableStackDir', () => {
|
|||||||
it('a "../" escape resolves out and is NOT deletable', () => {
|
it('a "../" escape resolves out and is NOT deletable', () => {
|
||||||
expect(isDeletableStackDir('/data/stacks/../evil', STACKS, 'evil')).toBe(false);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
||||||
import { describe, test, expect, afterEach } from 'bun:test';
|
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
|
// 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
|
// 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)', () => {
|
describe('allowSecretlessWebhook (env flag)', () => {
|
||||||
const original = process.env.ALLOW_WEBHOOKS_WITHOUT_SECRET;
|
const original = process.env.ALLOW_WEBHOOKS_WITHOUT_SECRET;
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
Reference in new issue
Block a user