project-nomad/admin/app/services/custom_app_guard.ts

168 lines
6.8 KiB
TypeScript

import { dirname, normalize } from 'node:path'
import env from '#start/env'
/**
* Security guardrails for user-defined ("custom app") containers.
*
* project-nomad runs containers as host siblings via the mounted Docker socket (DooD), so a
* misconfigured bind mount or image is a real host-takeover vector. The posture here is
* "guardrails with warnings": hard-block the genuinely catastrophic, warn-but-allow the merely
* risky so a trusted admin keeps their power without an easy foot-gun.
*/
export interface GuardEvaluation {
/** Hard rejections — the install cannot proceed until these are fixed. */
blocked: string[]
/** Advisory warnings — overridable via the "install anyway" force flag. */
warnings: string[]
}
/** Absolute host directories that must never be bind-mounted into a custom container. */
const SYSTEM_BLOCK_PREFIXES = ['/etc', '/proc', '/sys', '/boot', '/dev', '/run', '/var/run']
/** Registries we ship curated apps from; anything else is allowed but warned on. */
const TRUSTED_REGISTRIES = ['docker.io', 'registry-1.docker.io', 'ghcr.io', 'lscr.io', 'quay.io']
/** Resolve the managed storage root (where bind mounts are expected to live). */
export function getStorageRoot(): string {
return normalize(env.get('NOMAD_STORAGE_PATH', '/opt/project-nomad/storage')).replace(/\/+$/, '')
}
/** Normalize an absolute path: collapse `..`/`.` segments and strip any trailing slash. */
function normalizeHostPath(p: string): string {
return normalize(p).replace(/\/+$/, '') || '/'
}
/** True when `child` equals `ancestor` or sits beneath it. */
function isWithin(child: string, ancestor: string): boolean {
return child === ancestor || child.startsWith(ancestor + '/')
}
/**
* Evaluate user-supplied bind mounts. Hard-blocks the Docker socket, core system directories,
* and any mount at or above project-nomad's own install tree (which would expose its code/data).
* Warns on any host path outside the managed storage root.
*/
export function evaluateBindMounts(
volumes: { host_path: string; container_path: string }[]
): GuardEvaluation {
const blocked: string[] = []
const warnings: string[] = []
const storageRoot = getStorageRoot()
// The install tree is the parent of the storage root (e.g. /opt/project-nomad). Mounting it —
// or any ancestor, up to and including `/` — would hand a container project-nomad's own files.
const installRoot = dirname(storageRoot)
for (const { host_path: hostPath, container_path: containerPath } of volumes) {
const host = normalizeHostPath(hostPath)
if (!hostPath.startsWith('/')) {
blocked.push(`Volume host path "${hostPath}" must be an absolute path.`)
continue
}
if (!containerPath.startsWith('/')) {
blocked.push(`Volume container path "${containerPath}" must be an absolute path.`)
continue
}
// A colon is Docker's bind delimiter (host:container:options). A path containing one would be
// re-split by Docker into a different mount than the one validated here — reject it outright so
// the checks below can't be bypassed by a parse-differential. (The validator blocks this too;
// this keeps the guard self-defending for any caller that skips validation.)
if (hostPath.includes(':') || containerPath.includes(':')) {
blocked.push(`Volume paths must not contain a colon (":"): "${hostPath}" → "${containerPath}".`)
continue
}
// The Docker socket is the most dangerous mount of all — full control of the host daemon.
if (host.endsWith('docker.sock') || /\/docker\.sock$/.test(host)) {
blocked.push(
`Mounting the Docker socket ("${hostPath}") is not allowed — it grants full host control.`
)
continue
}
// Core system directories.
if (host === '/' || SYSTEM_BLOCK_PREFIXES.some((p) => isWithin(host, p))) {
blocked.push(`Mounting system directory "${hostPath}" is not allowed.`)
continue
}
// At or above project-nomad's own install tree (covers `/`, `/opt`, `/opt/project-nomad`).
if (host === installRoot || isWithin(installRoot, host)) {
blocked.push(
`Mounting "${hostPath}" would expose project-nomad's own files and is not allowed.`
)
continue
}
// Anything outside the managed storage root is allowed but flagged.
if (!isWithin(host, storageRoot)) {
warnings.push(
`Volume "${hostPath}" is outside the managed storage root (${storageRoot}). Make sure you trust this image with access to that path.`
)
}
}
return { blocked, warnings }
}
/**
* Evaluate a Docker image reference. Hard-blocks malformed references; warns on moving tags
* (`:latest`/untagged) and images from registries outside the trusted set.
*/
export function evaluateImageReference(image: string): GuardEvaluation {
const blocked: string[] = []
const warnings: string[] = []
const ref = image.trim()
// Loose validity check: no whitespace/control chars, and a sane character set for an image ref.
if (!ref || /\s/.test(ref) || !/^[\w./:@-]+$/.test(ref)) {
blocked.push(`"${image}" is not a valid image reference.`)
return { blocked, warnings }
}
// Split off any digest, then any tag, to inspect the registry and tag.
const [nameAndTag] = ref.split('@')
const firstSegment = nameAndTag.split('/')[0]
const hasRegistryHost =
nameAndTag.includes('/') && (firstSegment.includes('.') || firstSegment.includes(':'))
const registry = hasRegistryHost ? firstSegment.split(':')[0] : 'docker.io'
if (!TRUSTED_REGISTRIES.includes(registry)) {
warnings.push(
`Image is from "${registry}", which is outside project-nomad's trusted registries. Only install images you trust.`
)
}
// Determine the tag (ignore a colon that's part of a registry host:port in the first segment).
const remainder = hasRegistryHost ? nameAndTag.slice(firstSegment.length + 1) : nameAndTag
const tag = remainder.includes(':') ? remainder.split(':').pop() : undefined
const hasDigest = ref.includes('@sha256:')
if (!hasDigest && (!tag || tag === 'latest')) {
warnings.push(
`Image "${image}" uses a moving tag (${tag ? ':latest' : 'no tag'}). Pin a specific version for reproducible installs.`
)
}
return { blocked, warnings }
}
/** Combine bind-mount and image evaluations into a single result. */
export function evaluateCustomApp(input: {
image?: string
volumes?: { host_path: string; container_path: string }[]
}): GuardEvaluation {
const bind = evaluateBindMounts(input.volumes ?? [])
const img = input.image ? evaluateImageReference(input.image) : { blocked: [], warnings: [] }
return {
blocked: [...bind.blocked, ...img.blocked],
warnings: [...bind.warnings, ...img.warnings],
}
}
/** Default resource caps applied to custom containers unless the user overrides them. */
export const DEFAULT_MEMORY_MB = 1024
export const DEFAULT_CPUS = 1