107 lines
4.5 KiB
Bash
107 lines
4.5 KiB
Bash
#!/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 "$@"
|