76 lines
2.7 KiB
TypeScript
76 lines
2.7 KiB
TypeScript
/**
|
|
* 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 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
|
|
* 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.
|
|
*
|
|
* isProtectedPath resolves symlinks (via the nearest existing ancestor) so a
|
|
* symlink pointing into a protected location can't be used to bypass the check.
|
|
*/
|
|
|
|
import { realpathSync } from 'node:fs';
|
|
import { resolve, dirname, join, basename, sep } from 'node:path';
|
|
|
|
const KEY_FILE_NAME = '.encryption_key';
|
|
|
|
function getDataDir(): string {
|
|
return process.env.DATA_DIR || './data';
|
|
}
|
|
|
|
/** Absolute paths Dockhand must never expose through the file browser. */
|
|
function protectedPaths(): { dbDir: string; keyFile: string } {
|
|
const dataDir = resolve(getDataDir());
|
|
return {
|
|
dbDir: join(dataDir, 'db'),
|
|
keyFile: join(dataDir, KEY_FILE_NAME)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
function safeResolve(p: string): string {
|
|
let current = resolve(p);
|
|
const tail: string[] = [];
|
|
// Walk up until we hit a path that exists, realpath it, then rejoin the tail.
|
|
while (true) {
|
|
try {
|
|
const real = realpathSync(current);
|
|
return tail.length ? join(real, ...tail.reverse()) : real;
|
|
} catch {
|
|
const parent = dirname(current);
|
|
if (parent === current) {
|
|
// Reached the root without an existing ancestor; return as-is.
|
|
return resolve(p);
|
|
}
|
|
tail.push(basename(current));
|
|
current = parent;
|
|
}
|
|
}
|
|
}
|
|
|
|
function isInside(child: string, parent: string): boolean {
|
|
return child === parent || child.startsWith(parent + sep);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export function isProtectedPath(requestedPath: string): boolean {
|
|
const { dbDir, keyFile } = protectedPaths();
|
|
const resolved = safeResolve(requestedPath);
|
|
return isInside(resolved, dbDir) || resolved === keyFile || isInside(resolved, '/proc');
|
|
}
|