← Projects
DeckIn developmentLive demo ↗
deck / project specs

Security

Deck's threat model and the layers that protect a login-free panel that runs processes on the machine.

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

ThreatExampleDefense
Network accessSomeone on the same Wi-Fi reaches port 4004Bind to 127.0.0.1 only
DNS rebindingA malicious domain starts resolving to 127.0.0.1Host must be localhost/127.0.0.1 on the server’s port
CSRFA site in another tab runs fetch or submits a form to /api/gitSame-origin Sec-Fetch-Site/Origin, SameSite=Strict session cookie, JSON-only POST
ClickjackingThe panel embedded in an invisible iframeX-Frame-Options: DENY and frame-ancestors 'none'
Command injection; rm -rf ~ in a text fieldProcesses without a shell, arguments as an array
Argument injectionSending --force or --sql - as a valueThe client never sends flags; the server builds the argv from typed parameters
Path traversal../../.ssh/id_rsa as a file nameContainment check in the expected folder on every read and write
Secret leakageDatabase password showing up in the browserSecrets stay in the secrets directory and only deck reads them
Malicious updateA tampered release replaces the binaryMandatory Ed25519 signature in deck update
The public demo talking to your panelThe hosted site tries to call localhost:4004connect-src 'self' CSP on the demo

Protection layers

no

yes

no (page)

yes

no

yes

no

yes

no

yes

no

yes

Request

Host is localhost/127.0.0.1 on the deck ui port?

403

Is it /api/*?

Serve the page + session cookie

Same origin? Sec-Fetch-Site / Origin

403

Cookie from this boot's session?

403

POST with JSON Content-Type?

403

Declared action? Valid parameters?

400

Run without shell, stdin closed, process group

no

yes

no (page)

yes

no

yes

no

yes

no

yes

no

yes

Request

Host is localhost/127.0.0.1 on the deck ui port?

403

Is it /api/*?

Serve the page + session cookie

Same origin? Sec-Fetch-Site / Origin

403

Cookie from this boot's session?

403

POST with JSON Content-Type?

403

Declared action? Valid parameters?

400

Run without shell, stdin closed, process group

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.

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"]}
},
},
TypeAcceptsRejects
Numerico123412a, -1, 1; rm
NomeGitfeature/AB#12-tela-x, a..b, a@{1}, a.lock, spaces
ListaNomesGitweb, api or ["web","api"]any invalid item
Texto(limit)single-line text up to the limitline breaks, control characters, a leading -
Enum(options)one of the values, often from deck.yamlanything 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/.yaml inside 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
HeaderValue
Content-Security-Policydefault-src 'self'; scripts and styles from the same origin only; connect-src 'self'; frame-ancestors 'none'
X-Frame-OptionsDENY
X-Content-Type-Optionsnosniff
Referrer-Policyno-referrer
Cross-Origin-Opener-Policy / Cross-Origin-Resource-Policysame-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:

  1. the Ed25519 signature of checksums.txt is valid for the public key embedded in the binary;
  2. the SHA-256 of the downloaded binary matches checksums.txt;
  3. 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.