Security posture¶
Drakkar is an internal tool
The operator UI, the /api/v1 JSON and WebSocket surface, and the
optional HTTP ingress are built for a trusted, private network.
Do not expose them to the public internet, to a shared corporate
network you do not control, or to any user population wider than the
engineers who operate the service.
Drakkar ships authentication, rate limiting and a number of input checks. Treat all of them as defence in depth — not a security perimeter. The perimeter is your network and the application you build on top of Drakkar; the framework does not attempt to be one.
Where the boundary actually is¶
A Drakkar worker is infrastructure that sits inside a system you own — what the rest of these docs call a private contour: a VPC, an internal cluster network, an operator-only ingress. It has no user model, no roles, no tenancy, and no notion of an untrusted caller: anyone who can reach a port and present the one configured token — or, in the default configuration, anyone who can reach the port at all — is treated as an operator of that worker.
That is a deliberate design choice, not an omission. It keeps the operator tooling useful, and it means the security decisions live where the context is: with the engineers who deploy the service and with the application in front of it.
Concretely, the responsibility splits like this.
| Layer | Owns |
|---|---|
| Your network | Who can reach the ports at all. This is the real control. |
| Your ingress / reverse proxy | TLS, per-client connection limits, request-rate limiting, read timeouts, and any identity your organisation uses (SSO, mTLS, VPN). |
| Your application | Authorising your end users, validating their input, and deciding what data may leave your system. Drakkar sees messages that already passed through it. |
| Drakkar | Not shipping obvious footguns: bounded requests, no arbitrary file reads, secrets masked in operator views, an opt-in token for casual protection. |
What Drakkar does provide¶
These reduce blast radius and catch mistakes. They are worth turning on. None of them is designed to withstand a determined attacker who already has network access.
| Control | What it does |
|---|---|
| Loopback default | ui.host defaults to 127.0.0.1, so a worker is not reachable off-host until you change it. |
| WebSocket Origin check | Runs on every /ws handshake, token or not. A page that is not itself on loopback cannot open a socket to a worker on the browser’s loopback — the one route that would otherwise reach past the loopback bind. |
| UI bearer token | ui.auth_token gates every page and API route (Authorization: Bearer, or ?token= for browser navigation), compared with secrets.compare_digest. /healthz and /readyz stay public for Kubernetes, and the routes under /api/v1/peer/ stay public for the cache exchange (see below). |
| WebSocket handshake auth | With a token set, /ws authenticates inside the handshake and validates Origin against ui.allowed_ws_origins (or the Host header), rejecting every other cross-origin handshake. |
| Cross-origin read rule | Only three read paths answer cross-origin browser requests: /api/v1/debug/trace, /api/v1/debug/trace-by-label and /api/v1/debug/databases, which the UI reads from every worker of the cluster. They use the same origin rule as /ws. The preflight needs no token; the read itself still needs one when a token is set. No other path sends CORS headers. See below. |
| Startup warning | A worker running the UI without a token logs ui_unauthenticated at startup, naming the bind and both opt-in paths. |
| Independent kill switches | ui.probe_enabled and ui.merge_enabled close the two non-read-only endpoints, whether or not a token is set. |
| Webapp auth + rate limits | Per-client bearer tokens and a per-client rpm sliding window on the HTTP ingress, with max_concurrent shedding over-capacity requests. |
| Path containment | Database download and merge take paths relative to shared.dir and serve only three shapes: history/<cluster>/<name>.db, history/<cluster>/<archive>.db.gz and merged/<name>.db (merge takes only the .db ones). The download answers 404 for any other path, an unsafe segment, a symlink, a path that resolves outside shared.dir, or a missing file; the merge answers 400 for a path of another shape. A worker’s own files in storage.dir are never served by path. The one exception takes no path: GET /api/v1/debug/download-live serves a snapshot of the worker’s own live recorder file, behind the token. |
| Filtered peer rows | The cache exchange applies only the rows a peer originated itself, in the scopes asked of that peer. Peer requests ignore the proxy environment variables and do not follow redirects. |
| Owner-only files | Recorder databases, meta files, archives and merge results are created with mode 0600; storage.dir, each cluster history folder and merged/ are created with mode 0700. |
| Secret masking | The config reference masks secret-flagged fields, executor.env and every *.client_config by key name; the flight recorder applies the same redaction before storing task env. |
| Request bounds | Body size and read-time caps, header-size caps, keep-alive reaping, a bounded probe queue, and deadlines on Kafka reads — so one client cannot hold a worker open indefinitely. |
What Drakkar does not provide¶
Stated plainly, so nobody plans around a control that is not there:
- No user model, roles or permissions. There is one token per surface. A token holder can do everything that surface allows.
- No audit trail of operator actions. The flight recorder records what the pipeline did, not who asked for it.
- No TLS. Both servers speak plain HTTP. Terminate TLS in front.
- No CSRF protection on mutating requests. A browser session on a reachable, unauthenticated UI can be driven by another origin.
- No token on the peer route.
GET /api/v1/peer/cache/changesis public, with or withoutui.auth_token. See The public peer route. - No sandbox around your handler binary. It runs with the worker’s privileges and receives message bytes on stdin unvalidated.
- No authentication between peer workers. Anything that can write to
shared.diris trusted as a peer. Every worker of the fleet trusts the shared folder: anyone who can write it can plant files that the workers archive and serve through the download endpoint. The same holds for the fleet registry: anyone who can write the card folder can publish a card, and the workers list will show it and link to its URL. The UI links onlyhttpandhttpscard URLs, but the Live page opens a connection to every listed worker and sendsui.auth_tokenwith it, so a planted card can collect the token. A planted card also makes every worker send cache exchange requests to its URL, and the worker applies theclusterandglobalrows that URL serves under the card’s worker name (see Outbound requests to card URLs). Protect the card folder like the token itself. Cards hold no secrets — a name, a cluster, a URL, a state, a release and the marks of the newest cache rows — so reading the folder reveals where workers answer and the newestcluster/globalcache key of each worker.
The last two are architectural trust boundaries with their own reasoning —
see the FAQ’s trust model and
SECURITY.md.
Even inside the private network¶
The loopback bind and the operator’s browser¶
The loopback bind stops the network from reaching a worker. It does not stop
the operator’s own browser, which is on that machine: WebSockets are not
subject to CORS, so a page on any site could open ws://127.0.0.1:8080/ws
and read the live task stream. Every handshake is therefore Origin-checked,
with or without a token.
An untokened worker still accepts the cross-origin handshakes the cluster view is built on — one worker’s page opens a socket to each of its peers — because a peer is reached over the network, never at the browser’s own loopback address. What it rejects is a page that is not on loopback asking for a worker that is. Workers co-located on one host, which advertise loopback addresses on different ports, are unaffected.
The gap this leaves is a page served from another loopback port, such as a
local dev server: a worker cannot tell it apart from a co-located sibling.
Set ui.allowed_ws_origins to close it — a non-empty list is a strict
allowlist and nothing outside it connects.
The public peer route¶
Workers pull each other’s cache rows from
GET /api/v1/peer/cache/changes and send no token, so ui.auth_token does
not protect the routes under /api/v1/peer/. Anyone who can reach the UI
port can read the cluster and global cache rows of a worker that serves
the cache exchange — keys, values and timestamps. The route serves only
the rows that worker originated, never local rows, and nothing else of the
worker’s state.
A worker serves the route while the cache, storage.dir and
fleet.cache_exchange are on; otherwise it answers 404. To keep cache
values private:
- keep them in the
localscope, which is never served; - set
fleet.cache_exchange.enabled: falseon workers that need no exchange; - let only the workers and the operators reach the UI port.
Outbound requests to card URLs¶
Every exchange cycle, a worker sends HTTP GET requests to the URL on the
card of each peer it pulls (see Cache).
A card planted in the card folder therefore makes every worker request the
URL it names, which can be any host the workers can reach. The requests
ignore the proxy environment variables and do not follow redirects, and the
answer is read only as a page of cache rows.
The rows such a URL returns are filtered: the worker applies only rows whose
origin is the worker name on the card and whose scope it asked for
(cluster only from a card of its own cluster, global from any). That
stops local rows and rows that claim another worker’s origin. It does not
stop the URL from serving cluster and global rows under the planted
card’s own name, and the worker merges them by last-write-wins like the rows
of any peer. Protect the card folder like the token.
Cross-origin reads¶
The UI page served by one worker reads the trace, the label trace and the
databases list from every worker of its cluster. Those reads are
cross-origin, so three paths answer CORS: /api/v1/debug/trace,
/api/v1/debug/trace-by-label and /api/v1/debug/databases. They use the
origin rule of /ws and the same ui.allowed_ws_origins key:
- A non-empty
ui.allowed_ws_originsis a strict allowlist: only the listed origins pass. - With an empty list, a same-origin request passes.
- With an empty list and
ui.auth_tokenset, every cross-origin read is refused. So with a token, list the origin of every worker of the cluster inui.allowed_ws_origins. Otherwise the page of one worker cannot read the others, and the UI lists them as unreachable. - With an empty list and no token, cross-origin reads pass, except a page that is not on loopback reaching a worker over loopback.
The preflight (OPTIONS) needs no token, because a browser sends none with
it. The read itself still needs the token when one is set. A refused
preflight answers 403; a refused read gets no CORS header, so the browser
hides the answer from the page. No other path sends CORS headers.
Even inside the private network¶
The UI is an operator tool, and operators see a lot. Read-only pages expose
task stdout/stderr, task arguments and environment (after redaction),
cache contents, live event streams, and the resolved configuration. If some
of that is sensitive in your deployment — customer payloads in task output,
for instance — then “reachable by any engineer” may already be wider than
you want. Set ui.auth_token, or keep the UI on loopback and reach it
through a bastion.
If you must widen access¶
Sometimes the UI has to be reachable from more than the host. In that case, in order of how much they buy you:
- Keep it off any untrusted network. A VPN, a private subnet, or an SSH tunnel is worth more than everything below combined.
- Put a reverse proxy in front and let it own TLS, your organisation’s SSO or mTLS, connection limits, and its own read timeouts. See Front it with a proxy.
- Set a strong
ui.auth_token— and treat it as a shared secret with no revocation story, because that is what it is. - Close what you do not need:
ui.probe_enabled: false,ui.merge_enabled: false, andui.enabled: falseentirely on workers nobody debugs. - Restrict
storage.dirandshared.dirpermissions to the worker user, on every host that mounts them. - Remember the public peer route. The token does not cover
/api/v1/peer/; see The public peer route.
Reporting something¶
If you find a way out of these boundaries — authentication bypass with a
token set, path traversal in download or bundle extraction, secrets
reaching the recorder despite redaction, remote code execution without a
trusted binary — that is in scope and welcome. See
SECURITY.md
for private reporting.
Reports that rest on the design assumptions above — “the UI has no auth by default”, “the handler binary is trusted” — will be closed with a pointer to this page.