I unlock agent account + use /bin/sh shell so ForceCommand actually fires

Two OpenSSH-on-alpine quirks caught by a real ssh attempt:

1) alpine's `adduser -D` leaves shadow password as `!`, which
   OpenSSH 9.x treats as 'account locked' and refuses even for
   pubkey auth (logs: 'User agent not allowed because account is
   locked'). Sed-replace `!` with `*` post-create — no password
   set, but account NOT locked.

2) Setting the login shell to /sbin/nologin defeats ForceCommand,
   because sshd executes the forced command as
   `<login-shell> -c "<command>"`. nologin then prints
   'This account is not available' and exits. Use /bin/sh instead;
   the security boundary is ForceCommand + sshd_config, not the
   shell — clients cannot bypass ForceCommand to ask for an
   interactive shell.

README security section updated to reflect both points.
This commit is contained in:
Fabian @ Blax Software 2026-05-28 11:35:54 +02:00
parent 74b3983ff4
commit 8c5b89b5af
2 changed files with 16 additions and 4 deletions

View File

@ -36,12 +36,24 @@ RUN apk add --no-cache \
tzdata
# ---------------------------------------------------------------------------
# Bastion user — UID/GID 1000, /sbin/nologin shell (ForceCommand is the only path)
# Bastion user — UID/GID 1000. Login shell = /bin/sh.
#
# Why not /sbin/nologin?
# sshd's ForceCommand wraps the command in `<user-shell> -c "..."`. With
# nologin as the shell, every ForceCommand invocation just prints
# "This account is not available" and exits — defeating the whole point.
# Security comes from ForceCommand + sshd_config, not from the shell;
# clients cannot bypass ForceCommand to ask for an interactive shell.
# ---------------------------------------------------------------------------
ARG SSH_UID=1000
ARG SSH_GID=1000
RUN addgroup -g ${SSH_GID} agent && \
adduser -D -u ${SSH_UID} -G agent -s /sbin/nologin agent && \
adduser -D -u ${SSH_UID} -G agent -s /bin/sh agent && \
# Alpine's `adduser -D` leaves the shadow password field as `!`, which
# OpenSSH 9.x interprets as "account locked" and refuses even for pubkey
# auth (logs: "User agent not allowed because account is locked").
# Replace with `*` — no password set, but account NOT locked.
sed -i 's/^agent:!:/agent:*:/' /etc/shadow && \
mkdir -p /home/agent/.ssh && \
chown -R agent:agent /home/agent/.ssh && \
chmod 700 /home/agent/.ssh

View File

@ -200,7 +200,7 @@ Mount one, both, or neither — though neither = startup failure.
## What's inside
- **openssh-server** — hardened config: key-only auth, no forwarding, no PAM, no user env, `/sbin/nologin` login shell, global `ForceCommand` directive.
- **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.
- **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.
@ -217,7 +217,7 @@ Practical checklist:
1. **Key-only SSH, no passwords** — enforced in `sshd_config`.
2. **Token-only HTTP** — no path is open without `Authorization: Bearer`.
3. **No agent / TCP / X11 forwarding, no port tunnels** — enforced in `sshd_config`.
4. **Login shell is `/sbin/nologin`** — no fallback if `ForceCommand` somehow misfires.
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 "<forced-command>"`), 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.