Report privately through GitHub Security Advisories on mar0ls/playforge. Please don't open a public issue for anything exploitable.
Accounts and roles, or a single shared password. Which one is in effect depends on whether any accounts exist:
| Accounts | ANSIBLE_GUI_PASSWORD |
Behaviour |
|---|---|---|
| yes | — | Sign in with a username; the role decides what you may call |
| no | set | One shared password, full access, as before |
| no | unset | No authentication at all |
Roles are admin, operator and viewer. Every API route maps to a required
capability, an unmapped route denies everyone, and a test enumerates the real
route table so a new endpoint can't ship without that decision.
Even so, treat operator and above as equivalent to shell access on the machine
running the container, because that is what it is: a run executes on the app host
with the credentials from the vault. Run isolation (below) is what narrows this,
and it is off by default.
- Runs execute on the app host, as the app user, with the credentials from the vault. Anyone who can log in can run an arbitrary playbook against any host the controller can reach.
- The AI agent can write files and run playbooks when those capabilities are enabled. It's bounded by the same trust boundary, not a smaller one.
Deploy it on a trusted network, behind TLS, not on the public internet.
Setting run.isolation (or ANSIBLE_GUI_RUN_ISOLATION=1) runs playbooks in a
sandbox instead of directly on the app host. With the default bwrap mechanism
only /bin, /etc, /usr, /opt (read-only) and the project's own directory
are visible, so a run can't read /data — no master.key, no app.db, no other
project's repository.
The container needs two flags for this to work at all:
security_opt:
- seccomp=unconfined
- systempaths=unconfinedDocker's default seccomp profile blocks the user-namespace clone the sandbox
needs, and its masked /proc paths block the mount it always performs. Measured
on Docker 29.4.0: neither running as root nor adding CAP_SYS_ADMIN helps, and
--privileged works but defeats the point. Both flags are required; nothing else
is.
It fails closed: with the setting on and the flags missing, runs fail rather than quietly running unsandboxed.
Off by default because those flags are a real trade, not a free win. They thin
the barrier between the app container and the host kernel (no syscall filter,
unmasked /proc) in order to put a barrier between a run and your data. Which
matters more depends on whether you're more worried about a bad playbook reading
your credential vault, or about a container escape. Decide deliberately.
docker/podman are also accepted, giving a container per run — but they need
the engine's socket inside the app container, which is root-equivalent access to
the host. That buys run isolation and loses considerably more elsewhere. Prefer
bwrap.
Verified behaviour, not aspiration:
| Area | Behaviour |
|---|---|
| Auth | Accounts when any exist, else the shared password, else open. See the table above. |
| First admin | From ANSIBLE_GUI_ADMIN_USER/_PASSWORD (or their _FILE form) at startup, or via /setup guarded by a one-time token printed to the container log. The token closes the window where anyone reaching the published port could claim the instance before the operator does. |
| Roles | Per-route capability check, fail-closed on unmapped routes. Enforced on the WebSocket too, which HTTP middleware doesn't cover. |
| Revocation | The account is re-read on every request, so disabling or deleting one ends its sessions immediately rather than at token expiry. |
| Passwords | scrypt (N=2^16, r=8, p=1) with per-password salt; cost parameters stored in the hash and upgraded on next sign-in. A missing account costs the same time as a wrong password, so timing can't enumerate usernames. |
| Session | HMAC-signed expiring token, HttpOnly, SameSite=Lax, Secure when served over HTTPS. Signing key derives from the password and the credential master key, so a leaked cookie can't be forged with either alone. |
| Login throttling | 5 failed attempts per client address triggers a lockout, doubling from 30s to a 15min cap. Fails closed: the correct password is refused while locked. |
| WebSockets | The run WebSocket re-checks the origin, the session and the role itself — HTTP middleware doesn't cover the WS scope. Without them: a hostile page could open a socket from a browser the operator already had open, a LAN attacker could open one and run playbooks, and a viewer could bypass the capability check on POST /api/runs by using the socket instead. |
| Credentials | Encrypted at rest with Fernet. The key lives at /data/master.key (0600) or comes from ANSIBLE_GUI_MASTER_KEY. Secrets are never returned by the API. |
| Run secrets | Vault/become passwords and SSH keys are written to 0600 files in a per-run temp dir, which is deleted after the run. |
| Project files | Every path is resolved and confined to the project directory; .. and absolute paths are refused. |
| Rendered output | Model and file content is HTML-escaped before any Markdown formatting is re-introduced, so a prompt-injected reply can't inject script. |
| Cross-site | State-changing requests must pass an origin check (Sec-Fetch-Site, else Origin vs Host), and must carry a signed CSRF token whenever the browser sent the CSRF cookie. Enforced in all three auth modes; the WebSocket handshake checks Origin itself. |
| Headers | CSP, X-Frame-Options: DENY, nosniff, no-referrer on every response. |
Every state-changing request (POST, PUT, PATCH, DELETE) has to show it
was not set in motion by another site. The check prefers Sec-Fetch-Site, the
browser's own verdict, and refuses same-site as well as cross-site — a
subdomain an attacker controls is exactly what SameSite=Lax never covered.
Without that header it compares Origin against the request's own Host, on
host and port only: uvicorn runs without --proxy-headers, so behind the TLS
termination this document recommends it would see http while the browser
reports https, and comparing schemes would refuse every request.
A request carrying neither header is allowed. curl, scripts and
make lab-regression send no Origin, and a hostile page cannot suppress one —
the browser attaches it to every cross-origin request — so absence means no
browser was tricked into sending it.
This runs in all three auth modes, and the default one is the reason it has to.
With no accounts and no ANSIBLE_GUI_PASSWORD there is no session cookie, so
SameSite had nothing to withhold, and an instance on 127.0.0.1 could be
driven by any page open in the same browser. JSON endpoints were shielded by the browser's own
CORS preflight, but multipart/form-data needs no preflight, and
POST /api/projects/import-zip accepts it. tests/test_csrf.py reproduces the
request that used to succeed and now asserts it is refused.
On top of that, a page carries a signed token. The cookie holds a nonce and
the token is an HMAC of that nonce under the server key, so planting a cookie —
what a subdomain can do, since cookies are not origin-scoped — buys nothing
without the key. Plain double-submit, which only checks that the two halves
match, would accept exactly that; tests/test_csrf.py asserts the echo is
refused.
The token is required only when the request carries the CSRF cookie, and that
cookie is issued only with an HTML page. A client that never loaded a page —
curl, a script, make lab-regression — has neither, and is governed by the
Origin check. This is not a way around the token: under SameSite=Lax a forged
cross-site request arrives with no cookies either, so demanding a token from
cookie-less requests would refuse automation without troubling an attacker.
The three real form posts (/login, /setup, /logout) send the token in a
body field, since a form cannot set a header, and each route checks it under the
same rule.
The run WebSocket checks Origin in its handshake. HTTP middleware never
sees that scope, and new WebSocket() cannot set a header, so a token is not
available there — without the check a hostile page could open a socket and run
playbooks with the stored credentials.
SameSite=Lax on the session cookie and form-action 'self' in the CSP are
still in place; they are now further layers rather than the only one.
script-src allows 'unsafe-inline' and 'unsafe-eval'. Every page template
carries an inline <script> block and the vendored htmx builds handlers with
Function()/eval(), so removing them blanks the UI. The policy still blocks
loading script from another origin, and connect-src 'self' means injected
script has nowhere to exfiltrate to. Tightening this needs the inline blocks
moved into files and a nonce; it isn't done yet.
scripts/backup.sh writes an archive containing master.key and your project
repositories. It's created 0600 and backups/ is gitignored, but it is
plaintext at rest — encrypt it before moving it anywhere:
gpg --symmetric --cipher-algo AES256 backups/playforge-backup-*.tar.gzLosing master.key means the encrypted credentials cannot be recovered.
Security fixes go to the newest release and ship as a new patch on the current minor. Older minors are not backported. This is a single-maintainer project, and a support window it could not honour would be worse than saying so plainly.
So: run the latest tag. latest on Docker Hub only ever moves to a stable
release, docker compose pull && docker compose up -d is the whole upgrade, and
migrations run at startup, so skipping several versions at once is fine.