diff --git a/Dockerfile b/Dockerfile index ff71b54..0bfa217 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,10 +74,11 @@ RUN addgroup -g ${SSH_GID} agent && \ COPY config/sshd_config /etc/ssh/sshd_config COPY scripts/start-container /usr/local/bin/start-container COPY scripts/bastion-list-keys /usr/local/bin/bastion-list-keys +COPY scripts/bastion-broker /usr/local/bin/bastion-broker # AuthorizedKeysCommand requires its script and *every* parent dir to be # root-owned and not group/world-writable. /usr/local/bin satisfies that # by default; chmod 0755 is the safe canonical mode for the script itself. -RUN chmod 0755 /usr/local/bin/start-container /usr/local/bin/bastion-list-keys && \ +RUN chmod 0755 /usr/local/bin/start-container /usr/local/bin/bastion-list-keys /usr/local/bin/bastion-broker && \ mkdir -p /etc/bastion /etc/bastion/users.d /etc/ssh/keys /var/empty && \ chmod 700 /etc/ssh/keys && \ chmod 755 /etc/bastion/users.d && \ @@ -85,12 +86,41 @@ RUN chmod 0755 /usr/local/bin/start-container /usr/local/bin/bastion-list-keys & # --------------------------------------------------------------------------- # Environment -# FORCE_COMMAND — REQUIRED. The single command run on every login. -# Shell metacharacters are supported. +# The bastion runs in one of two modes, decided at boot: +# +# • FORCE_COMMAND mode (default) — one fixed command per session; client +# input is ignored. Set FORCE_COMMAND. +# • Broker mode — the client SUPPLIES the command and it is validated +# against a regex allowlist before running. Set ALLOWED_COMMANDS (and/or +# mount /etc/bastion/allowed-commands.list). When the allowlist is set, +# FORCE_COMMAND is not required and is ignored. +# +# FORCE_COMMAND — FORCE_COMMAND mode: the single command run on +# every login. Shell metacharacters are supported. # Examples: # docker exec -it app bash # docker compose -f /workspace/compose.yml exec app bash # cd /workspace && ./deploy.sh +# ALLOWED_COMMANDS — Broker mode: newline-separated list of extended +# regex (ERE) rules. A client request is permitted +# only if it matches one rule WHOLE-LINE (anchored). +# In compose, a YAML block scalar reads like an +# array — one regex per line: +# ALLOWED_COMMANDS: | +# setup email (add|update) [^ ]+@[^ ]+ [^ ]+ +# setup email list +# Matched commands are word-split and exec'd with +# NO shell (; | & are literal args, not operators). +# Lines starting with # and blank lines are ignored. +# COMMAND_PREFIX — Broker mode, optional: a trusted prefix prepended +# to every validated request, e.g. +# "docker exec -i mailserver setup" so clients send +# just "email add a@b pw" and never see the docker +# plumbing. Operator-set, not validated. +# /etc/bastion/allowed-commands.list (mount) — Broker mode, optional: same +# one-rule-per-line format as ALLOWED_COMMANDS, but +# re-read every session (edit without a restart). +# Additive with ALLOWED_COMMANDS. # HTTP_TOKEN — optional. Enables HTTP listener with Bearer # auth: `Authorization: Bearer `. # HTTP_BASIC_AUTH — optional. Enables HTTP listener with Basic @@ -113,6 +143,8 @@ RUN chmod 0755 /usr/local/bin/start-container /usr/local/bin/bastion-list-keys & # denied" until you drop a key in.) # --------------------------------------------------------------------------- ENV FORCE_COMMAND="" +ENV ALLOWED_COMMANDS="" +ENV COMMAND_PREFIX="" ENV HTTP_TOKEN="" ENV HTTP_BASIC_AUTH="" ENV HTTP_PORT=8080 diff --git a/README.md b/README.md index 5478072..2b09dd4 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![Image Size](https://img.shields.io/badge/image-~105MB-lightgrey)](#whats-inside) [![License](https://img.shields.io/badge/license-MIT-lightgrey)](#license) -A **minimal SSH + HTTP bastion** for routing one preconfigured command per authenticated session. Authenticate by SSH key or HTTP bearer token, the container runs whatever you point `FORCE_COMMAND` at — `docker exec` into a sibling container, `./deploy.sh`, `nginx -s reload`, anything — and streams the output back. +A **minimal SSH + HTTP bastion** for routing commands per authenticated session. Authenticate by SSH key or HTTP bearer token, and the container either runs one preconfigured command (`FORCE_COMMAND` — `docker exec` into a sibling, `./deploy.sh`, `nginx -s reload`) or, in **broker mode**, runs whatever the client asks for *as long as it matches a regex allowlist* (`ALLOWED_COMMANDS`) — then streams the output back. **Why it exists:** giving an agent or CI bot `docker exec` access usually means handing them the docker socket and trusting their entire toolchain not to misbehave. A bastion with a hard-coded `FORCE_COMMAND` is the inverse: the credential authorizes *one specific thing*, the surface is sshd + busybox httpd, and the same image works for a dozen different roles by varying `FORCE_COMMAND`. @@ -158,6 +158,88 @@ curl --fail-with-body -H "Authorization: Bearer $BASTION_HTTP_TOKEN" \ `curl --fail-with-body` makes the CI step fail (non-zero exit) if the bastion returns 4xx/5xx, with the body printed — so a `nginx -t` syntax error in the new config shows up in the CI log without extra wiring. +## Quick Start — manage docker-mailserver (broker mode) + +The two examples above hard-code *one* command. **Broker mode** is the inverse: the client supplies the command and the bastion runs it only if it matches a regex allowlist. This is how you give a management UI (a Nuxt "mail manager", a cron job, a bot) a *menu* of allowed operations without handing it a shell. + +Here one bastion fronts [docker-mailserver](https://github.com/docker-mailserver/docker-mailserver)'s `setup` CLI. The app sends `email add jane@example.com ` over SSH on the internal network; the bastion validates it and runs `docker exec -i mailserver setup email add jane@example.com `. + +```yaml +services: + bastion-mail: + image: blaxsoftware/bastion:latest + restart: unless-stopped + environment: + # Trusted prefix prepended to every validated request, so clients + # send clean `email add …` and never see the docker plumbing. + COMMAND_PREFIX: "docker exec -i mailserver setup" + + # The whitelist. A YAML block scalar reads like a string array: + # one extended-regex (ERE) rule per line. A request is allowed only + # if it matches a rule WHOLE-LINE (anchored). Matched commands run + # WITHOUT a shell — ; | & $() are literal args, never operators — + # so keep argument classes tight ([^ ]+, not .*). + ALLOWED_COMMANDS: | + email add [^ ]+@[^ ]+ [^ ]+ + email update [^ ]+@[^ ]+ [^ ]+ + email del [^ ]+@[^ ]+ + email list + alias add [^ ]+@[^ ]+ [^ ]+ + alias del [^ ]+@[^ ]+ [^ ]+ + alias list + quota set [^ ]+@[^ ]+ [0-9]+[KMGT]? + quota del [^ ]+@[^ ]+ + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./docker-data/bastion/users.d:/etc/bastion/users.d + - ./docker-data/bastion/keys:/etc/ssh/keys + networks: [web] # same network as the `mailserver` container + +networks: + web: + external: true +``` + +From the mail-manager (same docker network, no host port needed): + +```bash +# allowed → runs `docker exec -i mailserver setup email add …` +ssh agent@bastion-mail "email add jane@example.com $(openssl rand -base64 18 | tr -d /+=)" + +ssh agent@bastion-mail "email list" + +# refused → exits 126, nothing runs +ssh agent@bastion-mail "email del jane@example.com; rm -rf /" +``` + +A complete copy-paste setup (compose + a live-editable `allowed-commands.list`) is in [`examples/docker-mailserver/`](examples/docker-mailserver/). + +## Command broker mode + +Set `ALLOWED_COMMANDS` (or mount `/etc/bastion/allowed-commands.list`) and the bastion switches from "one fixed command" to "any command the client asks for, **if** it passes the allowlist". `FORCE_COMMAND` is then optional and ignored. + +**How a request is judged** + +1. The client-supplied command (SSH `SSH_ORIGINAL_COMMAND`, or the HTTP `X-Bastion-Command` header) is taken verbatim. +2. Multi-line requests are rejected outright (so a benign first line can't smuggle a second). +3. It is matched **whole-line, anchored** against each rule (`grep -Eqx`). First match wins. +4. On a match: the (optional) `COMMAND_PREFIX` and the request are **word-split with globbing off and `exec`'d directly — there is no `sh -c`.** `;` `|` `&` `` ` `` `$()` `<` `>` become literal arguments, never shell operators. On no match: refused, exit `126`, logged as `[broker] DENY: …`. + +**Why shell-free execution matters.** It means the regex is a *containment* boundary, not just a *filter*. Even a careless rule like `email .*` cannot escalate to command injection — the worst case is that `setup` receives a weird argument. (Contrast the `sh -c` path used by `FORCE_COMMAND` mode, where a metacharacter *would* be interpreted.) + +**Writing rules** + +- One ERE per line; `#` comments and blank lines ignored; surrounding whitespace trimmed (so indented YAML block lines work). +- Anchoring is implicit — write `email list`, not `^email list$` (both work). +- Use tight argument classes: `[^ ]+` ("a run of non-spaces") beats `.*`. Add alternation for fixed verbs: `email (add|update|del|list)`. +- Values that must reach the target intact **cannot contain whitespace** (word-splitting) or be quoted (no shell). Generate passwords/tokens from a space-free alphabet — e.g. `openssl rand -base64 24 | tr -d /+=`. + +**Two sources, additive:** `ALLOWED_COMMANDS` (env, snapshot at boot) **+** `/etc/bastion/allowed-commands.list` (bind mount, re-read every request — edit without a restart). + +**SSH vs HTTP.** SSH is the recommended transport (clean stdout/stderr separation). The HTTP path works too — `POST`/`GET` with `Authorization` + `X-Bastion-Command: ` — but merges the broker's stderr into the response body (the CGI uses `2>&1`, same as the deploy examples). + +**Where the audit line goes.** The broker prints `[broker] ALLOW: …` / `[broker] DENY: …` on the *command's* stderr — so over SSH it returns to the client on the channel's stderr stream, and over HTTP it is folded into the response body (`2>&1`). It does **not** land in the bastion container's own `docker logs` (sshd does not redirect a ForceCommand's stderr to the daemon log). If you want a container-side audit trail, tee it — e.g. have `COMMAND_PREFIX`/the wrapper append to a logfile, or run a syslog sidecar. + ## Two channels, two shapes | Channel | Best for | TTY? | Streaming? | @@ -187,7 +269,10 @@ Zero authorized keys is now a warning, not a startup failure — the bastion run | Variable | Default | Description | |-------------------------|----------------------------------------|----------------------------------------------------------------------------| -| `FORCE_COMMAND` | *(required)* | The command run on every authenticated session. Shell metacharacters OK. | +| `FORCE_COMMAND` | *(required unless `ALLOWED_COMMANDS` set)* | FORCE_COMMAND mode: the command run on every authenticated session. Shell metacharacters OK. Ignored in broker mode. | +| `ALLOWED_COMMANDS` | *(unset)* | Broker mode: newline-separated ERE allowlist. Set this (or mount the file below) to enable broker mode. Each rule must match a request whole-line. | +| `COMMAND_PREFIX` | *(unset)* | Broker mode, optional: trusted prefix prepended to every validated request (e.g. `docker exec -i mailserver setup`). | +| *(mount)* `/etc/bastion/allowed-commands.list` | *(unset)* | Broker mode, optional: same format as `ALLOWED_COMMANDS`, re-read every request (live edits, no restart). Additive. | | `HTTP_BASIC_AUTH` | *(unset)* | Enables HTTP with Basic auth. Value is `user:password`. Works with `curl -u`, `curl https://user:pass@host/…`, and browser URL bars. | | `HTTP_TOKEN` | *(unset)* | Enables HTTP with Bearer auth. Clients send `Authorization: Bearer `. Mutually exclusive with `HTTP_BASIC_AUTH` (basic takes precedence). | | `HTTP_PORT` | `8080` | Port for the HTTP listener (when either auth var is set). | @@ -207,7 +292,8 @@ Zero authorized keys is now a warning, not a startup failure — the bastion run ## What's inside - **openssh-server** — hardened config: key-only auth, no forwarding, no PAM, no user env, global `ForceCommand` directive (clients cannot bypass). -- **busybox httpd** (busybox-extras) — minimal HTTP listener for the URL path; CGI-driven; only starts when `HTTP_TOKEN` is set. +- **bastion-broker** — the allowlist gate for broker mode: whole-line ERE match, then shell-free word-split `exec`. Shared by the SSH and HTTP paths. +- **busybox httpd** (busybox-extras) — minimal HTTP listener for the URL path; CGI-driven; only starts when `HTTP_TOKEN` or `HTTP_BASIC_AUTH` is set. - **docker-cli + docker-cli-compose** — so `FORCE_COMMAND` can target containers through a mounted docker socket. Group membership is auto-aligned to the host socket's GID at boot. - **tini** — PID 1, signal handling, zombie reaping. - **bash, ca-certificates, tzdata.** @@ -223,7 +309,7 @@ Practical checklist: 1. **Key-only SSH, no passwords** — enforced in `sshd_config`. 2. **HTTP requires auth** — either basic auth (via busybox httpd's `-c` conf, with `REMOTE_USER` set on the authenticated CGI) or bearer token (validated by the CGI script). No anonymous path. 3. **No agent / TCP / X11 forwarding, no port tunnels** — enforced in `sshd_config`. -4. **ForceCommand cannot be bypassed.** Clients can request any command (`ssh user@host arbitrary-thing`); sshd ignores it and runs `/etc/bastion/force-command`. `SSH_ORIGINAL_COMMAND` is dropped. The bastion user's login shell is `/bin/sh` (not `nologin` — that would break ForceCommand itself, since sshd invokes the user's shell as `shell -c ""`), but it has no path to anything other than the wrapper. +4. **ForceCommand cannot be bypassed.** Clients can request any command (`ssh user@host arbitrary-thing`); sshd ignores it and runs `/etc/bastion/force-command`. In FORCE_COMMAND mode `SSH_ORIGINAL_COMMAND` is dropped entirely. In **broker mode** it is *read* but only ever run if it matches the allowlist whole-line, and even then via shell-free word-split `exec` (no `sh -c`), so the regex is a containment boundary, not just a filter — see [Command broker mode](#command-broker-mode). The bastion user's login shell is `/bin/sh` (not `nologin` — that would break ForceCommand itself, since sshd invokes the user's shell as `shell -c ""`), but it has no path to anything other than the wrapper. 5. **`PermitUserEnvironment no`, `PermitUserRC no`** — clients cannot inject env vars or rc files. 6. **Bind host ports to `127.0.0.1` or hide them behind traefik+TLS unless you genuinely need them publicly open on raw TCP.** The traefik path with `entrypoints: websecure` and `tls: true` is the recommended public exposure. 7. **Rotate `HTTP_TOKEN` regularly.** Generate with `openssl rand -hex 32`, store in `.env`, never commit. @@ -237,21 +323,26 @@ Practical checklist: start-container (entrypoint) ├─ generate host keys (idempotent, persisted via /etc/ssh/keys bind mount) ├─ merge AUTHORIZED_KEYS_HOST + AUTHORIZED_KEYS_REPO into authorized_keys - ├─ write /etc/bastion/force-command wrapper from $FORCE_COMMAND + ├─ pick mode: broker if $ALLOWED_COMMANDS / allowed-commands.list, else force-command + │ ├─ broker: snapshot allowlist + prefix; wrapper → bastion-broker + │ └─ force-command: write /etc/bastion/force-command wrapper from $FORCE_COMMAND ├─ align docker socket group membership to host GID (if socket is mounted) - ├─ start httpd → /var/www/cgi-bin/run (if $HTTP_TOKEN is set) + ├─ start httpd → /var/www/cgi-bin/run (if $HTTP_BASIC_AUTH or $HTTP_TOKEN is set) └─ exec sshd -D -e -ssh client - └─ key auth as `agent` - └─ ForceCommand /etc/bastion/force-command - └─ exec sh -c "$FORCE_COMMAND" - -http client - └─ Authorization: Bearer - └─ /var/www/cgi-bin/run validates token - └─ exec /etc/bastion/force-command - └─ exec sh -c "$FORCE_COMMAND" +FORCE_COMMAND mode broker mode +------------------ ----------- +ssh client ssh client + └ key auth as `agent` └ key auth as `agent` + └ ForceCommand wrapper └ ForceCommand wrapper + └ exec sh -c "$FORCE_COMMAND" └ exec bastion-broker "$SSH_ORIGINAL_COMMAND" + ├ match vs allowlist (whole-line, anchored) +http client └ exec $COMMAND_PREFIX $request (no shell) + └ Authorization header + └ /cgi-bin/run validates auth http client + └ exec force-command wrapper └ Authorization header + └ exec sh -c "$FORCE_COMMAND" └ /cgi-bin/run validates auth + └ exec bastion-broker "$X-Bastion-Command" ``` ## License diff --git a/config/sshd_config b/config/sshd_config index b0ddd01..66faf28 100644 --- a/config/sshd_config +++ b/config/sshd_config @@ -2,9 +2,12 @@ # docker-bastion — hardened sshd config # # Every authenticated session is routed through /etc/bastion/force-command, -# which is generated at container start from $FORCE_COMMAND. The bastion -# user has /sbin/nologin as its shell so there is no fallback if the -# ForceCommand wrapper is missing or fails — the session simply ends. +# which is generated at container start. In FORCE_COMMAND mode it runs the +# fixed command; in broker mode it hands SSH_ORIGINAL_COMMAND to +# bastion-broker, which runs it only if it matches the regex allowlist. +# (The bastion user's shell is /bin/sh, not nologin — sshd invokes the +# ForceCommand as `shell -c ""`, so nologin would break it. Security +# comes from ForceCommand + this config, not from the login shell.) # =========================================================================== Port 22 diff --git a/examples/docker-mailserver/allowed-commands.list b/examples/docker-mailserver/allowed-commands.list new file mode 100644 index 0000000..bd8a0d0 --- /dev/null +++ b/examples/docker-mailserver/allowed-commands.list @@ -0,0 +1,39 @@ +# =========================================================================== +# docker-mailserver broker allowlist +# =========================================================================== +# One extended-regex (ERE) rule per line. A client request is permitted only +# if it matches a rule WHOLE-LINE (anchored ^…$). Blank lines and lines +# starting with # are ignored. This file is re-read on every request, so +# edits take effect without restarting the bastion. +# +# COMMAND_PREFIX in the compose file prepends "docker exec -i mailserver +# setup", so the rules below describe only the `setup` sub-commands — clients +# send e.g. email add jane@example.com and never see docker. +# +# Matched commands are word-split and run WITHOUT a shell, so ; | & $() are +# literal arguments, not operators. Values that must arrive intact cannot +# contain spaces — generate passwords from a space-free alphabet (hex / +# base64url) on the caller side. +# +# Argument classes use [^[:space:]] ("any non-space run") rather than .* so a +# rule can never match trailing junk. Tighten further to taste. +# =========================================================================== + +# ---- email accounts ------------------------------------------------------- +# add / update require an address and a password argument (no interactive +# prompt is possible over a non-TTY transport, so the password is mandatory). +email add [^[:space:]]+@[^[:space:]]+ [^[:space:]]+ +email update [^[:space:]]+@[^[:space:]]+ [^[:space:]]+ +email del [^[:space:]]+@[^[:space:]]+ +email list +email restrict (add|del|list) (send|receive)( [^[:space:]]+@[^[:space:]]+)? + +# ---- aliases -------------------------------------------------------------- +alias add [^[:space:]]+@[^[:space:]]+ [^[:space:]]+ +alias del [^[:space:]]+@[^[:space:]]+ [^[:space:]]+ +alias list + +# ---- quotas --------------------------------------------------------------- +# QUOTA is a size like 1G / 512M / 0 (0 = unlimited). +quota set [^[:space:]]+@[^[:space:]]+ [0-9]+[KMGT]? +quota del [^[:space:]]+@[^[:space:]]+ diff --git a/examples/docker-mailserver/docker-compose.yml b/examples/docker-mailserver/docker-compose.yml new file mode 100644 index 0000000..c76d3f3 --- /dev/null +++ b/examples/docker-mailserver/docker-compose.yml @@ -0,0 +1,95 @@ +# =========================================================================== +# docker-mailserver management bastion (broker mode) +# =========================================================================== +# A single bastion that lets a sidecar app (e.g. a Nuxt "mail manager") run a +# WHITELIST of docker-mailserver `setup` sub-commands — and nothing else. +# +# The app talks to the bastion over the internal docker network via SSH: +# ssh agent@bastion-mail "email add jane@example.com " +# The bastion validates the request against ALLOWED_COMMANDS, and on a match +# runs docker exec -i mailserver setup email add jane@example.com +# against the host docker socket. No match → refused, nothing runs. +# +# Drop this `bastion-mail` service into the same compose project as your +# `mailserver` container (or any compose project on the same external +# network), then `docker compose up -d bastion-mail`. +# =========================================================================== + +services: + bastion-mail: + image: blaxsoftware/bastion:latest + restart: unless-stopped + + environment: + # ---------------------------------------------------------------- + # COMMAND_PREFIX — trusted, operator-set. Prepended to every + # validated request so clients send clean `email add …` commands + # and never see the docker plumbing. `setup` is docker-mailserver's + # in-container admin CLI; `-i` (not `-it`) because the caller has no + # TTY over a scripted SSH/HTTP call. + # ---------------------------------------------------------------- + COMMAND_PREFIX: "docker exec -i mailserver setup" + + # ---------------------------------------------------------------- + # ALLOWED_COMMANDS — the whitelist. A YAML block scalar (`|`) reads + # like a string array: one extended-regex (ERE) rule per line. A + # request is permitted only if it matches a rule WHOLE-LINE. + # + # Matched commands run WITHOUT a shell (; | & $() are literal args), + # so the regex is the entire authorization boundary — keep argument + # classes tight ([^ ]+ rather than .*). Values that must arrive + # intact can't contain spaces; generate passwords space-free. + # + # Prefer the mounted file (see volumes) if you'd rather edit rules + # without redeploying — the two sources are additive. + # ---------------------------------------------------------------- + ALLOWED_COMMANDS: | + email add [^ ]+@[^ ]+ [^ ]+ + email update [^ ]+@[^ ]+ [^ ]+ + email del [^ ]+@[^ ]+ + email list + alias add [^ ]+@[^ ]+ [^ ]+ + alias del [^ ]+@[^ ]+ [^ ]+ + alias list + quota set [^ ]+@[^ ]+ [0-9]+[KMGT]? + quota del [^ ]+@[^ ]+ + + volumes: + # REQUIRED — the host docker socket, so `docker exec` can reach the + # mailserver container. This is host-root-equivalent; the allowlist + # is what keeps it scoped. + - /var/run/docker.sock:/var/run/docker.sock + + # Authorized clients — drop one pubkey per identity. The mail-manager + # app's key goes here; read live, no restart to add/revoke. + - ./docker-data/bastion/users.d:/etc/bastion/users.d + + # Persist the bastion's SSH host identity across rebuilds (bind mount, + # never a named volume — `down -v` would wipe it and clients would see + # a changed host key). + - ./docker-data/bastion/keys:/etc/ssh/keys + + # OPTIONAL — live-editable allowlist (additive with ALLOWED_COMMANDS + # above). Edit the file and the next request picks it up; no restart. + # - ./allowed-commands.list:/etc/bastion/allowed-commands.list:ro + + # No host port published: the mail-manager reaches the bastion by + # service name on the shared network (ssh agent@bastion-mail). Uncomment + # to also expose SSH on the host for debugging — bind to localhost. + # ports: + # - "127.0.0.1:2222:22" + + networks: [web] + + # Your docker-mailserver container — referenced by name in COMMAND_PREFIX. + # Shown here for context; usually it already lives in its own compose file. + # mailserver: + # image: ghcr.io/docker-mailserver/docker-mailserver:latest + # container_name: mailserver + # hostname: mail.example.com + # networks: [web] + # # …ports/volumes/env per the docker-mailserver docs… + +networks: + web: + external: true diff --git a/scripts/bastion-broker b/scripts/bastion-broker new file mode 100644 index 0000000..713cd69 --- /dev/null +++ b/scripts/bastion-broker @@ -0,0 +1,106 @@ +#!/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 < /etc/bastion/force-command.cmd -chmod 0644 /etc/bastion/force-command.cmd -cat > /etc/bastion/force-command <<'WRAPPER' +if [ "$BROKER_MODE" -eq 1 ]; then + # ---- Broker mode ----------------------------------------------------- + # Snapshot the env allowlist to a file the broker reads at session time + # (sshd does not pass the daemon env to a ForceCommand session). The + # optional /etc/bastion/allowed-commands.list bind-mount is read live by + # the broker in addition to this snapshot. + printf '%s\n' "$ALLOWED_COMMANDS_VALUE" > /etc/bastion/allowed-commands.env + chmod 0644 /etc/bastion/allowed-commands.env + printf '%s' "$COMMAND_PREFIX_VALUE" > /etc/bastion/command-prefix + chmod 0644 /etc/bastion/command-prefix + + # The wrapper hands the client-requested command to the broker, which + # validates it against the allowlist and either exec's it or refuses. + cat > /etc/bastion/force-command <<'WRAPPER' +#!/bin/sh +# Auto-generated by docker-bastion start-container (broker mode). +# Routes the client-requested command through the allowlist gate. sshd sets +# SSH_ORIGINAL_COMMAND to whatever the client asked to run; the broker +# decides whether it is permitted. There is no fallback command. +export HOME=/home/agent +exec /usr/local/bin/bastion-broker "${SSH_ORIGINAL_COMMAND:-}" +WRAPPER + chmod 0755 /etc/bastion/force-command + + rule_count=$(awk '{ sub(/^[[:space:]]+/, ""); sub(/[[:space:]]+$/, "") } NF && $0 !~ /^#/' \ + /etc/bastion/allowed-commands.env "$ALLOWED_LIST_FILE" 2>/dev/null | wc -l | tr -d ' ') + echo " Broker mode: $rule_count allow-rule(s)" + [ -n "$COMMAND_PREFIX_VALUE" ] && echo " Command prefix: $COMMAND_PREFIX_VALUE" + [ -f "$ALLOWED_LIST_FILE" ] && echo " Live rules file: $ALLOWED_LIST_FILE (re-read each session)" + if [ "$rule_count" -eq 0 ]; then + echo " WARN: broker mode active but zero allow-rules — every request will be refused." + fi +else + # ---- FORCE_COMMAND mode (default, unchanged) ------------------------- + printf '%s\n' "$FORCE_COMMAND_VALUE" > /etc/bastion/force-command.cmd + chmod 0644 /etc/bastion/force-command.cmd + + cat > /etc/bastion/force-command <<'WRAPPER' #!/bin/sh # Auto-generated by docker-bastion start-container. # sshd invokes this script for every authenticated session; the HTTP CGI @@ -143,8 +196,9 @@ fi # no args, "$@" expands to nothing and behavior is identical to before. exec sh -c "$(cat /etc/bastion/force-command.cmd)" sh "$@" WRAPPER -chmod 0755 /etc/bastion/force-command -echo " $FORCE_COMMAND_VALUE" + chmod 0755 /etc/bastion/force-command + echo " $FORCE_COMMAND_VALUE" +fi # --------------------------------------------------------------------------- # 5) Docker socket — if mounted, align group membership so the agent user @@ -166,7 +220,7 @@ if [ -S /var/run/docker.sock ]; then addgroup "$SSH_USER" "$grp_name" 2>/dev/null || true echo " Added $SSH_USER to $grp_name" else - echo " WARN: /var/run/docker.sock not mounted — docker-based FORCE_COMMAND will fail." + echo " WARN: /var/run/docker.sock not mounted — any docker-based command (FORCE_COMMAND or broker) will fail." fi # --------------------------------------------------------------------------- @@ -198,6 +252,11 @@ fi # output streams back as the command produces it. # --------------------------------------------------------------------------- echo "[5/6] HTTP listener..." +# In broker mode the HTTP CGI takes the command to run from the +# X-Bastion-Command request header and routes it through bastion-broker +# (same allowlist as SSH). In FORCE_COMMAND mode it exec's the fixed +# wrapper as before. busybox httpd forwards request headers to CGI as +# HTTP_, so X-Bastion-Command → HTTP_X_BASTION_COMMAND. if [ -n "${HTTP_BASIC_AUTH:-}" ]; then echo " Auth: Basic — enabling httpd on port ${HTTP_PORT}" mkdir -p /var/www/cgi-bin @@ -209,8 +268,18 @@ if [ -n "${HTTP_BASIC_AUTH:-}" ]; then # the conf holds the plaintext password. chown "${SSH_USER}:${SSH_USER}" /etc/bastion/httpd.conf chmod 0600 /etc/bastion/httpd.conf - # CGI: auth already done by httpd before we got here. - cat > /var/www/cgi-bin/run <<'CGI' + if [ "$BROKER_MODE" -eq 1 ]; then + # CGI: httpd validated the password; the command comes from the + # X-Bastion-Command header and is gated by the broker. + cat > /var/www/cgi-bin/run <<'CGI' +#!/bin/sh +# Auto-generated (broker mode, basic auth). +printf 'Content-Type: text/plain\r\nCache-Control: no-cache\r\nX-Accel-Buffering: no\r\n\r\n' +exec /usr/local/bin/bastion-broker "${HTTP_X_BASTION_COMMAND:-}" 2>&1 +CGI + else + # CGI: auth already done by httpd before we got here. + cat > /var/www/cgi-bin/run <<'CGI' #!/bin/sh # Auto-generated. Auth was validated by busybox httpd via httpd.conf # before this script ran — REMOTE_USER holds the authenticated username. @@ -229,6 +298,7 @@ esac printf 'Content-Type: text/plain\r\nCache-Control: no-cache\r\nX-Accel-Buffering: no\r\n\r\n' exec /etc/bastion/force-command $BUMP_ARG 2>&1 CGI + fi chmod 0755 /var/www/cgi-bin/run # -c CONFFILE = auth + content-type rules; httpd reads it as root before # dropping to -u USER. CGI scripts then run as USER. @@ -253,7 +323,23 @@ CGI elif [ -n "${HTTP_TOKEN:-}" ]; then echo " Auth: Bearer — enabling httpd on port ${HTTP_PORT}" mkdir -p /var/www/cgi-bin - cat > /var/www/cgi-bin/run <<'CGI' + if [ "$BROKER_MODE" -eq 1 ]; then + cat > /var/www/cgi-bin/run <<'CGI' +#!/bin/sh +# Auto-generated (broker mode, bearer auth). Bearer check first, then the +# X-Bastion-Command header is routed through the allowlist gate. +case "${HTTP_AUTHORIZATION:-}" in + "Bearer ${HTTP_TOKEN}") ;; + *) + printf 'Status: 401 Unauthorized\r\nContent-Type: text/plain\r\nWWW-Authenticate: Bearer\r\n\r\nUnauthorized\n' + exit 0 + ;; +esac +printf 'Content-Type: text/plain\r\nCache-Control: no-cache\r\nX-Accel-Buffering: no\r\n\r\n' +exec /usr/local/bin/bastion-broker "${HTTP_X_BASTION_COMMAND:-}" 2>&1 +CGI + else + cat > /var/www/cgi-bin/run <<'CGI' #!/bin/sh # Auto-generated. Bearer auth handled here in the CGI. case "${HTTP_AUTHORIZATION:-}" in @@ -266,6 +352,7 @@ esac printf 'Content-Type: text/plain\r\nCache-Control: no-cache\r\nX-Accel-Buffering: no\r\n\r\n' exec /etc/bastion/force-command 2>&1 CGI + fi chmod 0755 /var/www/cgi-bin/run if [ "${HTTP_AS_ROOT:-0}" = "1" ]; then echo " HTTP_AS_ROOT=1 — httpd + CGI run as root" @@ -288,9 +375,16 @@ echo "[6/6] sshd config check..." echo "==========================================" echo " SSH: port ${SSH_PORT}" -[ -n "${HTTP_TOKEN:-}" ] && echo " HTTP: port ${HTTP_PORT} (token-protected)" +[ -n "${HTTP_BASIC_AUTH:-}" ] && echo " HTTP: port ${HTTP_PORT} (basic auth)" +[ -n "${HTTP_TOKEN:-}" ] && echo " HTTP: port ${HTTP_PORT} (bearer token)" echo " User: ${SSH_USER}" -echo " ForceCommand: ${FORCE_COMMAND_VALUE}" +if [ "$BROKER_MODE" -eq 1 ]; then + echo " Mode: broker (client command, allowlist-gated)" + [ -n "$COMMAND_PREFIX_VALUE" ] && echo " Prefix: ${COMMAND_PREFIX_VALUE}" +else + echo " Mode: force-command (fixed)" + echo " ForceCommand: ${FORCE_COMMAND_VALUE}" +fi echo "==========================================" # -D = foreground, -e = log to stderr (so docker logs picks it up).