#!/bin/sh # =========================================================================== # bastion-broker — command-allowlist gate for docker-bastion "broker mode". # # Invoked once per session with the client-requested command: # • SSH: the force-command wrapper passes "$SSH_ORIGINAL_COMMAND". # • HTTP: the CGI passes the X-Bastion-Command request header. # # The requested command is matched — anchored, WHOLE-LINE — against a set of # extended-regex (ERE) rules. On a match it is word-split (NO shell, so the # metacharacters ; | & ` $( ) < > are literal arguments, never operators) # and exec'd, optionally behind a trusted COMMAND_PREFIX. No match → refused. # # --------------------------------------------------------------------------- # Security model # --------------------------------------------------------------------------- # The regex rules are the ENTIRE authorization boundary for what a client may # run. Two properties keep that boundary tight: # # 1. Whole-line anchoring (grep -x). A rule must match the request from # first character to last; it can never match a substring. # 2. Shell-free execution. The validated request is split on whitespace and # exec'd directly — there is no `sh -c`. A sloppy rule therefore cannot # escalate to command injection; the worst case is that an allowed # program receives an odd-looking argument. # # Still: write rules with restrictive argument classes (e.g. [^[:space:]]+), # never a bare `.*`. Values that must reach the target program intact cannot # contain whitespace (word-splitting) — generate passwords/tokens from a # space-free alphabet (hex, base64url) on the caller side. # # Config is read from boot-written FILES, never the environment: sshd does # not propagate the daemon's env to a ForceCommand session, so anything the # broker needs at session time must live on disk. # =========================================================================== set -u export HOME=/home/agent export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ENV_RULES=/etc/bastion/allowed-commands.env # snapshot of $ALLOWED_COMMANDS (written at boot) LIVE_RULES=/etc/bastion/allowed-commands.list # optional live bind-mount (re-read every session) PREFIX_FILE=/etc/bastion/command-prefix # optional trusted prefix (written at boot) # $1 wins (SSH wrapper / HTTP CGI pass it explicitly); fall back to the env # var sshd sets so the broker also works as a bare ForceCommand target. REQ="${1:-${SSH_ORIGINAL_COMMAND:-}}" log() { printf '[broker] %s\n' "$1" >&2; } refuse() { # $1 = reason for the audit log (may include the request), # $2 = sanitized message shown to the client. log "DENY: $1" printf 'bastion: %s\n' "$2" >&2 exit 126 } # --- 1) Empty request ----------------------------------------------------- [ -n "$REQ" ] || refuse "" "no command supplied (broker mode expects a command)" # --- 2) Reject anything spanning more than one line ----------------------- # grep matches line-by-line; a multi-line request could slip a benign first # line past the allowlist while carrying a second, malicious line that the # shell-free exec would still pass along. Refuse outright. if [ "$(printf '%s' "$REQ" | tr -dc '\n\r' | wc -c)" -ne 0 ]; then refuse "" "command not permitted" fi # --- 3) Match against the rule set --------------------------------------- # Sources are concatenated; comments (#…) and blank lines are dropped and # each rule is trimmed of surrounding whitespace (so indented YAML block # lines work). grep -Eqx => ERE, whole-line (implicit ^…$ anchoring), quiet. collect_rules() { for src in "$ENV_RULES" "$LIVE_RULES"; do [ -f "$src" ] || continue awk '{ sub(/^[[:space:]]+/, ""); sub(/[[:space:]]+$/, "") } NF && $0 !~ /^#/' "$src" done } matched=0 while IFS= read -r pat; do [ -n "$pat" ] || continue if printf '%s' "$REQ" | grep -Eqx -- "$pat"; then matched=1 break fi done <