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 = [
|
||||
'/', '/etc', '/root', '/boot', '/dev', '/proc', '/sys', '/run',
|
||||
'/var/run', '/bin', '/sbin', '/lib', '/lib64', '/var/lib/docker',
|
||||
// A root-writing restore under /usr is a host-takeover primitive: /usr/local/bin
|
||||
// (PATH hijack) and /usr/lib/systemd (unit drop). /usr holds no user data, so
|
||||
// blocking it wholesale costs no legitimate restore.
|
||||
'/usr',
|
||||
];
|
||||
|
||||
// Blocked as a path SEGMENT anywhere (not a root prefix): an .ssh directory is an
|
||||
// SSH-key takeover target under any user's home (/home/<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.
|
||||
* Uses resolve() so `/etc/../etc`, `/srv/../etc`, trailing slashes all normalize before the
|
||||
* forbidden-root compare — a string-only prefix check would miss those. */
|
||||
@@ -157,6 +166,12 @@ export function unsafeRestoreTargetReason(targetPath: string): string | null {
|
||||
return `target path "${norm}" is inside a protected system directory (${root})`;
|
||||
}
|
||||
}
|
||||
const segments = norm.split(pathSep).filter(Boolean);
|
||||
for (const seg of FORBIDDEN_PATH_SEGMENTS) {
|
||||
if (segments.includes(seg)) {
|
||||
return `target path "${norm}" contains a protected directory segment ("${seg}")`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,3 +20,11 @@ export const BACKUPS_ENABLED = process.env.FEAT_BACKUPS_ENABLED === 'true';
|
||||
* API surface is opt-in per instance with FEAT_API_DOCS=true. Read once at boot.
|
||||
*/
|
||||
export const API_DOCS_ENABLED = process.env.FEAT_API_DOCS === 'true';
|
||||
|
||||
/**
|
||||
* Whether git webhooks may be enabled WITHOUT a secret (isolated-network escape hatch).
|
||||
* Surfaced to the client so the git-stack form can relax its "secret required" validation
|
||||
* to match the server, which gates the same rule via allowSecretlessWebhook(). Read once at
|
||||
* boot. Default false: a secret is required.
|
||||
*/
|
||||
export const ALLOW_WEBHOOKS_WITHOUT_SECRET = process.env.ALLOW_WEBHOOKS_WITHOUT_SECRET === 'true';
|
||||
+37
-11
@@ -1,16 +1,21 @@
|
||||
/**
|
||||
* Guard for the filesystem-browser endpoints (`/api/system/files*`).
|
||||
*
|
||||
* Dockhand runs in a container, so there is no host filesystem to leak — users
|
||||
* are allowed to browse the container freely (they need this to find/adopt
|
||||
* external stacks). The only things that must never be readable are Dockhand's
|
||||
* own secrets:
|
||||
* The file browser lets stacks:edit users browse the host to find/adopt/relocate
|
||||
* stacks, but no caller may read host or Dockhand secrets through it, so the
|
||||
* following are always blocked regardless of permission:
|
||||
* - the database directory ($DATA_DIR/db, contains dockhand.db)
|
||||
* - the encryption key file ($DATA_DIR/.encryption_key)
|
||||
* - /proc — /proc/<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
|
||||
* blocked because /proc/self, /proc/1, etc. all leak the same secrets and
|
||||
* nothing legitimate is browsed there.
|
||||
* - /etc, /root - system account/secret files (/etc/shadow, /etc/passwd, root's
|
||||
* home). Nothing a compose file lives in.
|
||||
* - any path segment named .ssh (private keys / known_hosts) or .git (its
|
||||
* config embeds remote credentials), at any depth. This blocks the credential
|
||||
* files inside a git-repos clone while still letting the working copies be
|
||||
* browsed (deploy verification, adoption).
|
||||
*
|
||||
* isProtectedPath resolves symlinks (via the nearest existing ancestor) so a
|
||||
* symlink pointing into a protected location can't be used to bypass the check.
|
||||
@@ -21,11 +26,19 @@ import { resolve, dirname, join, basename, sep } from 'node:path';
|
||||
|
||||
const KEY_FILE_NAME = '.encryption_key';
|
||||
|
||||
/** Absolute directory subtrees blocked for every caller. */
|
||||
const PROTECTED_ROOTS = ['/proc', '/etc', '/root'];
|
||||
|
||||
/** A path segment named this (at any depth) is blocked: .ssh (keys/known_hosts),
|
||||
* .git (its config embeds remote credentials). Blocks the secret files in a
|
||||
* git-repos clone without hiding the browsable working copies. */
|
||||
const PROTECTED_SEGMENTS = new Set(['.ssh', '.git']);
|
||||
|
||||
function getDataDir(): string {
|
||||
return process.env.DATA_DIR || './data';
|
||||
}
|
||||
|
||||
/** Absolute paths Dockhand must never expose through the file browser. */
|
||||
/** Absolute paths under Dockhand's data dir that must never be exposed. */
|
||||
function protectedPaths(): { dbDir: string; keyFile: string } {
|
||||
const dataDir = resolve(getDataDir());
|
||||
return {
|
||||
@@ -37,7 +50,7 @@ function protectedPaths(): { dbDir: string; keyFile: string } {
|
||||
/**
|
||||
* Resolve `p` to an absolute, symlink-free path. If `p` (or part of it) does not
|
||||
* exist yet, realpath the deepest existing ancestor and re-append the missing
|
||||
* tail — this still defeats a symlinked ancestor.
|
||||
* tail - this still defeats a symlinked ancestor.
|
||||
*/
|
||||
function safeResolve(p: string): string {
|
||||
let current = resolve(p);
|
||||
@@ -64,12 +77,25 @@ function isInside(child: string, parent: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `requestedPath` resolves to Dockhand's DB directory, the
|
||||
* encryption-key file, or anywhere under /proc (process env / cmdline leak
|
||||
* credentials). Such paths must be hidden from the file browser.
|
||||
* Returns true if `requestedPath` resolves to a location that must never be read
|
||||
* through the file browser (Dockhand's DB/key, system secret trees, or any .ssh /
|
||||
* .git directory). Symlinks are resolved first so they can't bypass it.
|
||||
*/
|
||||
export function isProtectedPath(requestedPath: string): boolean {
|
||||
const { dbDir, keyFile } = protectedPaths();
|
||||
const resolved = safeResolve(requestedPath);
|
||||
return isInside(resolved, dbDir) || resolved === keyFile || isInside(resolved, '/proc');
|
||||
|
||||
// Compare against symlink-resolved targets too: the request is resolved, so a
|
||||
// protected target that is itself a symlink (e.g. .encryption_key mounted from
|
||||
// a secret store, or a symlinked db/ volume) must be resolved as well or the
|
||||
// two never match. safeResolve tolerates a not-yet-existing target.
|
||||
if (resolved === safeResolve(keyFile) || isInside(resolved, safeResolve(dbDir))) {
|
||||
return true;
|
||||
}
|
||||
// Match each root both literally and symlink-resolved (e.g. macOS /etc -> /private/etc).
|
||||
if (PROTECTED_ROOTS.some((root) => isInside(resolved, root) || isInside(resolved, safeResolve(root)))) {
|
||||
return true;
|
||||
}
|
||||
const segments = resolved.split(sep).filter(Boolean);
|
||||
return segments.some((seg) => PROTECTED_SEGMENTS.has(seg));
|
||||
}
|
||||
+38
-36
@@ -36,14 +36,14 @@ export const MessageType = {
|
||||
export interface EdgeConnection {
|
||||
ws: WebSocket;
|
||||
environmentId: number;
|
||||
/** The hawser token id this connection authenticated with; revoking that token closes it. */
|
||||
tokenId?: number;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
agentVersion: string;
|
||||
dockerVersion: string;
|
||||
hostname: string;
|
||||
capabilities: string[];
|
||||
/** Remote agent STACKS_DIR (from hello message) */
|
||||
stacksDir?: string;
|
||||
connectedAt: Date;
|
||||
lastHeartbeat: number;
|
||||
pendingRequests: Map<string, PendingRequest>;
|
||||
@@ -393,6 +393,17 @@ export async function generateHawserToken(
|
||||
*/
|
||||
export async function revokeHawserToken(tokenId: number): Promise<void> {
|
||||
await db.update(hawserTokens).set({ isActive: false }).where(eq(hawserTokens.id, tokenId));
|
||||
|
||||
// A revoke is an incident-response action: cut the agent off NOW. isActive is only
|
||||
// checked at the hello handshake, so an already-open edge connection would otherwise
|
||||
// keep serving. Close the connection this token authenticated (matched by tokenId, so
|
||||
// revoking a DIFFERENT token for the same env doesn't disconnect a still-valid agent).
|
||||
for (const connection of edgeConnections.values()) {
|
||||
if (connection.tokenId === tokenId) {
|
||||
console.log(`[Hawser] Closing edge connection for env ${connection.environmentId} - its token (${tokenId}) was revoked`);
|
||||
closeEdgeConnection(connection.environmentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,7 +461,8 @@ export function closeEdgeConnection(environmentId: number): void {
|
||||
export function handleEdgeConnection(
|
||||
ws: WebSocket,
|
||||
environmentId: number,
|
||||
hello: HelloMessage
|
||||
hello: HelloMessage,
|
||||
tokenId?: number
|
||||
): EdgeConnection {
|
||||
// Check if there's already a connection for this environment
|
||||
const existing = edgeConnections.get(environmentId);
|
||||
@@ -492,13 +504,13 @@ export function handleEdgeConnection(
|
||||
const connection: EdgeConnection = {
|
||||
ws,
|
||||
environmentId,
|
||||
tokenId,
|
||||
agentId: hello.agentId,
|
||||
agentName: hello.agentName,
|
||||
agentVersion: hello.version,
|
||||
dockerVersion: hello.dockerVersion,
|
||||
hostname: hello.hostname,
|
||||
capabilities: hello.capabilities,
|
||||
stacksDir: hello.stacksDir,
|
||||
connectedAt: new Date(),
|
||||
lastHeartbeat: Date.now(),
|
||||
pendingRequests: new Map(),
|
||||
@@ -525,40 +537,32 @@ export function handleEdgeConnection(
|
||||
}
|
||||
|
||||
/**
|
||||
* Update environment status in database. Best-effort: called from connection
|
||||
* setup, teardown and heartbeat-timeout paths where a DB failure must not
|
||||
* take down the connection handling.
|
||||
* Update environment status in database
|
||||
*/
|
||||
async function updateEnvironmentStatus(
|
||||
environmentId: number,
|
||||
connection: EdgeConnection | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (connection) {
|
||||
await db
|
||||
.update(environments)
|
||||
.set({
|
||||
hawserLastSeen: new Date().toISOString(),
|
||||
hawserAgentId: connection.agentId,
|
||||
hawserAgentName: connection.agentName,
|
||||
hawserVersion: connection.agentVersion,
|
||||
hawserCapabilities: JSON.stringify(connection.capabilities),
|
||||
hawserStacksDir: connection.stacksDir ?? null,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(eq(environments.id, environmentId));
|
||||
} else {
|
||||
await db
|
||||
.update(environments)
|
||||
.set({
|
||||
hawserLastSeen: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(eq(environments.id, environmentId));
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[Hawser] Failed to update environment status for env ${environmentId}: ${msg}`);
|
||||
if (connection) {
|
||||
await db
|
||||
.update(environments)
|
||||
.set({
|
||||
hawserLastSeen: new Date().toISOString(),
|
||||
hawserAgentId: connection.agentId,
|
||||
hawserAgentName: connection.agentName,
|
||||
hawserVersion: connection.agentVersion,
|
||||
hawserCapabilities: JSON.stringify(connection.capabilities),
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(eq(environments.id, environmentId));
|
||||
} else {
|
||||
await db
|
||||
.update(environments)
|
||||
.set({
|
||||
hawserLastSeen: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(eq(environments.id, environmentId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -941,8 +945,6 @@ export interface HelloMessage {
|
||||
dockerVersion: string;
|
||||
hostname: string;
|
||||
capabilities: string[];
|
||||
/** Remote agent STACKS_DIR — host path where compose stacks are written */
|
||||
stacksDir?: string;
|
||||
}
|
||||
|
||||
export interface WelcomeMessage {
|
||||
@@ -1214,7 +1216,7 @@ async function handleHawserWsMessage(ws: any, msg: any, connId: string, remoteIp
|
||||
}
|
||||
|
||||
// Authenticated — register the connection
|
||||
const connection = handleEdgeConnection(ws, result.environmentId, msg);
|
||||
const connection = handleEdgeConnection(ws, result.environmentId, msg, result.tokenId);
|
||||
wsToEnvId.set(ws, result.environmentId);
|
||||
|
||||
// Send welcome
|
||||
|
||||
@@ -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']);
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 {
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user