The problem
The panel has no login and, by definition, runs processes on the machine of whoever uses it: git, gh, az,
docker, Playwright, SQL queries. Those processes run with the person’s full credentials. A poorly protected panel
like this becomes a way in: any site open in the same browser could try to send requests to localhost:4004.
That is why the security model starts from a simple rule: the panel is only usable by a page it served itself, on this machine, in this session, and even that page can only request expected actions, with validated values.
Threat model
| Threat | Example | Defense |
|---|---|---|
| Network access | Someone on the same Wi-Fi reaches port 4004 | Bind to 127.0.0.1 only |
| DNS rebinding | A malicious domain starts resolving to 127.0.0.1 | Host must be localhost/127.0.0.1 on the server’s port |
| CSRF | A site in another tab runs fetch or submits a form to /api/git | Same-origin Sec-Fetch-Site/Origin, SameSite=Strict session cookie, JSON-only POST |
| Clickjacking | The panel embedded in an invisible iframe | X-Frame-Options: DENY and frame-ancestors 'none' |
| Command injection | ; rm -rf ~ in a text field | Processes without a shell, arguments as an array |
| Argument injection | Sending --force or --sql - as a value | The client never sends flags; the server builds the argv from typed parameters |
| Path traversal | ../../.ssh/id_rsa as a file name | Containment check in the expected folder on every read and write |
| Secret leakage | Database password showing up in the browser | Secrets stay in the secrets directory and only deck reads them |
| Malicious update | A tampered release replaces the binary | Mandatory Ed25519 signature in deck update |
| The public demo talking to your panel | The hosted site tries to call localhost:4004 | connect-src 'self' CSP on the demo |
Protection layers
1. Loopback only
deck ui listens exclusively on 127.0.0.1 and refuses to start on any other interface. There is no option to allow
other hosts. If access from another machine is ever needed, the intended path is a private network (Tailscale, for
example) to the machine, never exposing the port.
2. Host and origin
Every request must have Host equal to localhost, 127.0.0.1 or [::1] and the server’s port. This blocks DNS
rebinding, where the browser sends Host: attacker-domain:4004.
On /api/* routes, a browser request must be same-origin: Sec-Fetch-Site: same-origin (or none, for someone who
typed the URL), or, when that header is missing, an Origin equal to the host.
3. Per-boot session cookie
When serving any page, deck ui sets a deck_ui_sessao cookie with 32 random bytes, HttpOnly and SameSite=Strict.
Every browser call to the API requires it. The value changes every time deck ui starts: a tab opened before a
restart needs to be reloaded.
Command-line tools (with neither Sec-Fetch-Site nor Origin), such as a local curl, pass without the cookie.
Whoever can already run processes as the user already has access to everything anyway.
4. JSON-only POST
POST is the only method an HTML form on another site can send without a CORS preflight. Requiring
Content-Type: application/json closes that door; PUT and DELETE already require a preflight by nature.
5. Declared actions and typed parameters
This is the main layer. The client never sends a list of arguments. Every action the panel can trigger is declared on the server, with the accepted parameters and the type of each one:
"move-card": { Params: map[string]Param{ "id": obrigatorio(Numerico("ID de card")), "state": obrigatorio(Enum("State", fasesDoBoard)), // phases from deck.yaml }, Monta: func(p map[string]string) []string { return []string{"boards-move-card", "--id", p["id"], "--state", p["state"]} },},| Type | Accepts | Rejects |
|---|---|---|
Numerico | 1234 | 12a, -1, 1; rm |
NomeGit | feature/AB#12-tela | -x, a..b, a@{1}, a.lock, spaces |
ListaNomesGit | web, api or ["web","api"] | any invalid item |
Texto(limit) | single-line text up to the limit | line breaks, control characters, a leading - |
Enum(options) | one of the values, often from deck.yaml | anything else |
Body fields that are not parameters of the action are ignored, so sending {"action":"prune","args":["--force"]} runs
exactly deck git-prune-branches --yes. Each action has a test that checks the built argv and one that checks that
malicious inputs are rejected without running anything.
6. Execution without a shell
Processes are created with arguments as an array, stdin closed and in their own process group. Details in
Architecture.
7. Files
Every route that reads or writes a file resolves the path and checks that it stays inside the expected folder:
- the HTML coverage report only serves files from the report folder of a repository with
coverage; - skills only write
.md/.yamlinside the skill folder, and the id becomes a safe slug; - the test history only reads files listed in its own record, with a UUID-formatted id.
8. Secrets
Passwords, tokens and PATs live in a secrets directory outside the repository (deck secrets path) and are only read
by deck. The database connection screen shows the secret’s name and whether it is set; the connection snippets for
DBeaver, psql or .env come out with a <senha> placeholder. URLs that receive credentials are resolved on the
server from the environment id, never sent by the client.
9. Database
Queries run in a READ ONLY transaction. Writes require explicit confirmation on screen and never apply to production
profiles (names like prd, prod, production) or profiles marked readOnly.
10. Headers
| Header | Value |
|---|---|
Content-Security-Policy | default-src 'self'; scripts and styles from the same origin only; connect-src 'self'; frame-ancestors 'none' |
X-Frame-Options | DENY |
X-Content-Type-Options | nosniff |
Referrer-Policy | no-referrer |
Cross-Origin-Opener-Policy / Cross-Origin-Resource-Policy | same-origin |
The public demo
deck.thecodecodex.com is the same set of screens, with fictitious responses generated in the browser itself.
It runs nothing and knows no real data. The demo is served with the same CSP as deck ui, including
connect-src 'self': the visitor’s browser blocks any attempt to call localhost:4004. An automated test fails if the
two CSPs diverge.
Signed releases
deck update downloads the binary, checksums.txt and checksums.txt.sig from the release and only installs if:
- the Ed25519 signature of
checksums.txtis valid for the public key embedded in the binary; - the SHA-256 of the downloaded binary matches
checksums.txt; - the new binary runs and reports the expected version (
--version).
SHA-256 alone only guarantees integrity: whoever could publish a release would control both the binary and the checksum. The signature guarantees authorship, because the private key lives only in the repository secret used by CI.
Out of scope
- Another process of the same user can call the API without the cookie. This is accepted: whoever already runs code as the user has access to everything the panel has.
- Remote access (phone, another machine) is not supported. This was a conscious decision, recorded in Architecture decisions.