docker-bastion/scripts/bastion-broker

107 lines
4.5 KiB
Plaintext
Raw Normal View History

#!/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 "<empty request>" "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 "<multiline request>" "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 <<RULES
$(collect_rules)
RULES
[ "$matched" -eq 1 ] || refuse "$REQ" "command not permitted"
# --- 4) Execute -----------------------------------------------------------
log "ALLOW: $REQ"
PREFIX=""
[ -f "$PREFIX_FILE" ] && PREFIX="$(cat "$PREFIX_FILE")"
set -f # no pathname expansion when we word-split below
# Intentional, unquoted word-splitting of the trusted prefix + validated
# request. set -f above means * ? [ are NOT globbed; IFS does the splitting.
# shellcheck disable=SC2086
set -- $PREFIX $REQ
[ "$#" -ge 1 ] || refuse "$REQ" "command not permitted"
exec "$@"