Security model

Pied Kingfisher is designed for a read-only, least-privilege posture. It runs git commands on remote hosts over SSH and renders the output in a browser. This page documents every security mechanism that keeps those operations safe.

Allowlisted operations only

Only two git commands are ever executed:

  • git diff — show uncommitted working-tree changes

  • git status --short — determine whether a repo is dirty

No writes. No pushes. No force-anything. The code does not import subprocess with arbitrary user-supplied strings. The allowed commands are assembled from discrete, validated parts.

shell=False everywhere

Every subprocess call — local git, remote SSH, repo discovery — is invoked with shell=False. The command is always a Python list of strings, never a concatenated shell command string. This prevents shell injection regardless of what appears in repo paths or machine names.

Input validation at startup and enrollment time

Target names

Target names are validated against ^[A-Za-z0-9][A-Za-z0-9._-]*$ at config-load time and at /register time. Unknown target names return 404 Not Found. The name is never interpolated into a shell command.

Repo paths and SSH hostnames

  • Repo paths must not start with - (prevents them from being interpreted as flags).

  • SSH hostnames and usernames must not start with - (same reason).

  • Paths containing " are rejected during enrollment repo verification.

Machine destinations

SSH destinations from config.yaml must not start with -.

Shell-aware quoting for remote commands

When difflab SSH-es into a remote host it must pass a command string over the SSH channel. That string is interpreted by the remote shell, so quoting must match the remote shell type.

  • POSIX hosts: shlex.quote() — produces POSIX-compliant single-quoted strings.

  • Windows hosts (PowerShell): double-quoted strings with internal " characters rejected at enrollment time, since reliable escaping across PowerShell versions is not guaranteed.

The remote shell type is detected automatically during enrollment (POSIX find attempted first; PowerShell fallback on failure) and stored in registry.yaml so subsequent diff fetches use the correct quoting.

Token-gated enrollment

POST /register requires a bearer token that must match DIFFLAB_ENROLL_TOKEN. If the environment variable is unset, the endpoint returns 503 Service Unavailable and enrollment is disabled entirely. Requests with a missing or wrong token return 401 Unauthorized.

Keep the token long (at least 32 random characters) and treat it like a password. Rotate it by restarting the container with a new value.

XSS prevention

All diff and status output from git — including file names, hunk headers, and changed lines — is HTML-escaped before being inserted into the page. Jinja2 autoescape is enabled globally; no template uses | safe on user-controlled data. A <script> tag in a diff line renders as literal text, not executable code.

Forced-command gate

The SSH public key served by GET /pubkey includes a forced-command prefix that invokes the git-gate.sh (POSIX) or git-gate.ps1 (Windows) script from the gate/ directory:

command="/usr/local/lib/difflab/git-gate.sh",no-pty,no-agent-forwarding,no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAA... difflab

The no-pty,no-agent-forwarding,…​ options alone do not restrict which commands the key holder can run — they only prevent interactive shells, port forwarding, and agent forwarding. Without command=, a stolen key could still execute arbitrary commands (id, cat /etc/passwd, etc.). The command= option is the actual command restriction; the other options are defence in depth on top of it.

What the gate allows

The gate tokenises $SSH_ORIGINAL_COMMAND and accepts only these three shapes:

  • git -C <path> --no-pager diff

  • git -C <path> --no-pager diff --numstat

  • git -C <path> status --short

Everything else — including id, cat, bash, any extra argument, any shell metacharacter — is rejected with difflab: command not permitted on stderr and exit 1. The path extracted from the command is passed to exec git with proper shell quoting, so no secondary interpretation of the path occurs.

Installing the gate on a new host

Before adding the container’s public key to a host’s authorized_keys, install the gate:

Linux/macOS:

sudo mkdir -p /usr/local/lib/difflab
sudo cp gate/git-gate.sh /usr/local/lib/difflab/git-gate.sh
sudo chmod 755 /usr/local/lib/difflab/git-gate.sh

Then authorize with the gated key line from GET /pubkey (POSIX default):

curl -s http://difflab.example.com:8747/pubkey >> ~/.ssh/authorized_keys

Windows (elevated PowerShell):

New-Item -ItemType Directory -Force "C:\ProgramData\difflab"
Copy-Item gate\git-gate.ps1 "C:\ProgramData\difflab\git-gate.ps1"

Then authorize with the Windows gate line from GET /pubkey?os=windows:

(Invoke-WebRequest http://difflab.example.com:8747/pubkey?os=windows).Content |
    Add-Content "$env:ProgramData\ssh\administrators_authorized_keys"

Because the gate only allows git commands, the auto-discovery scan during POST /register (which runs find or Get-ChildItem) will be blocked once the gate is in place. For gated hosts, use the repos field in the registration body to supply an explicit list of repository paths instead of relying on root scanning.

Windows authorized_keys note

Windows OpenSSH stores keys for administrator accounts in C:\ProgramData\ssh\administrators_authorized_keys. The file is owned by SYSTEM and Administrators; add the key there (elevated shell required).

Restricted authorized_keys options

In addition to command=, the authorized_keys line includes:

no-pty,no-agent-forwarding,no-port-forwarding,no-X11-forwarding

These prevent interactive shell allocation, SSH agent forwarding, and port forwarding, providing defence in depth alongside the forced-command restriction.

SSH key storage

The container’s SSH keypair is generated with ssh-keygen -t ed25519 on first start and stored in $DIFFLAB_DATA. It is never transmitted over the network except as the public key on GET /pubkey. Mount $DIFFLAB_DATA as a named Docker volume so the keypair persists across container upgrades.

Timeout

Every git and SSH call is subject to a 30-second timeout. A slow or hung remote host returns a 502 Bad Gateway response rather than blocking the server.

What difflab cannot do

  • It cannot write to, commit to, push to, or modify any repository.

  • It cannot open an interactive shell on any enrolled host.

  • It cannot forward network connections.

  • It does not store credentials beyond the SSH keypair it generates itself.