Security
This page documents the agent’s security behavior as implemented, including a few sharp edges worth knowing before you expose an agent.
The api-key model
Section titled “The api-key model”Every task and deploy endpoint (zip-archive, zip-dirs, zip-archive-version, save-artifact,
fetch-url, jar, war, node, docker/push, docker/check) runs one authorization step (CommandAuth) before
anything else — before reading the request body, creating directories, building paths from the
config or logging the request:
- The request carries a key: the
api-keyheader, or the password of an HTTPAuthorization: Basicheader (the agent decodesuser:passwordand takes everything after the first:, so the password may contain:). Other schemes (Bearer, …) and malformed Base64 count as no key. No key — the config is not even read. - The command name from the URL is valid:
0-9 a-z A-Z . _ -, no..— the same rule the operator applies when it creates a command. - The config
<name>.json/<name>.ymlloads. A missing, empty or broken file is refused. - The config
typematches the endpoint (ZIP_ARCHIVE,ZIP_DIRS,ZIP_ARCHIVE_VERSION,SAVE_ARTIFACT,FETCH_URL,JAR,WAR,NODE,DOCKER). Keys belong to a config name, and all configs share oneCONFIG_DIR, so without this asave-artifactkey would unpack an arbitrary archive via/zip-archive/into the samedir. A config withouttype(older, hand-written) is accepted with a warning in the agent log; so is an unknown value — a typo likesave-artifactreads as notype, so the warning is the only sign of it. - The key is one of the config’s
apiKeys. The map value is only a label and is never matched.
Every refusal is the same 401 with the message Unauthorized — an unknown command, a broken
config and a wrong key look identical from outside, and no path of CONFIG_DIR leaves the agent.
The reason (step, command name, the loader’s exception type) goes to the agent log only; keys and
loader exception messages are never logged.
- Keys are stored in plaintext in the config files. There is no hashing and no issuance
mechanism — keys are written into the config by hand. Anyone who can read
config/*.jsoncan read every live secret, so restrict permissions onCONFIG_DIR. - A config without
apiKeys(or with an empty map) refuses every request.
Path handling
Section titled “Path handling”ZIP extraction (Zip Slip guard)
Section titled “ZIP extraction (Zip Slip guard)”zip-archive, zip-dirs, and the node deploy all extract archives through a guarded writer
(SafeFiles.createFileGuarded). It canonicalizes the target directory and each entry’s
destination and requires the destination to stay inside the target directory; a ../ entry or
an absolute-path entry is rejected with a SecurityException. The zip-dirs URL sub-path is
additionally restricted to a strict character whitelist (0-9 a-z A-Z . - _), the segments .
and .. are refused, and the resolved directory must stay inside the canonical dir (a link
inside dir that leads out is rejected); it is created only after authorization.
Versioned publication (zip-archive-version)
Section titled “Versioned publication (zip-archive-version)”zip-archive-version does not use the guarded writer: it
reads the ZIP with commons-compress (which exposes the Unix mode) and refuses, before anything is
created in dir, every entry it cannot write as a plain file inside the version — a \ in a name
(checked on the stored bytes: the library itself turns \ into / for MS-DOS-made entries),
absolute names, empty, . and .. segments, symbolic links and other special entries, duplicate
names, a file that is also a directory. The Unicode Path extra field is ignored, so it cannot
replace a checked name. Writing never follows a link: the version is staged in a fresh
.<version>.new-<random> directory (every directory created one segment at a time, every file with
CREATE_NEW/NOFOLLOW_LINKS), the pointer and the digest are replaced by an atomic rename, a
leftover staging entry that is a link is unlinked, not followed. Modes are explicit — files 0644,
directories 0755 — so the command is not for secrets. Limits (maxUploadBytes, maxBytes by bytes
actually read, maxEntries, at most two uploads in flight per agent) bound what an authorized
client can make the agent store. reloadUrl comes from the config only; redirects are not
followed; reloadHeaders values are never logged and are masked in the control-plane command list.
Docker work directory (TEMP_DIR)
Section titled “Docker work directory (TEMP_DIR)”docker push / docker check extract the task into a fresh directory under TEMP_DIR
(docker-<name>-<ms>-<random>), created exclusively with rwx------ — a directory someone
prepared under the same name is never reused. Before anything is extracted, the whole path of
TEMP_DIR is checked: every directory on the way (links included) and TEMP_DIR itself must be
owned by root or the agent and not writable by group/others, except a sticky directory such as
/tmp. Missing components are created one by one (rwx------), each only in a directory that
passed the same check. A TEMP_DIR below a directory an unprivileged user owns is rejected.
Container /etc/passwd
Section titled “Container /etc/passwd”securityContext.passwdEntry never mounts the host’s /etc/passwd (it would expose every host
account inside the container). With the docker runtime the agent writes a small
container-passwd (root + the rendered entry) into the service directory, which must be trusted
like run — owned by root, not writable by others (checked before each write and delete); the file
is opened without following a link and gets mode 0644. Someone who can write to the service
directory can already change run, which runs as root.
Deleting trees
Section titled “Deleting trees”The extracted task, the node app/target directories and an exploded war are removed
without following symbolic links: a link is deleted itself, its target stays. The start must lie
strictly inside the real path of its parent directory (the temp root or the install directory),
compared by path components. On Linux the tree is deleted relative to open directory descriptors,
so a directory swapped for a link during the deletion stops it instead of redirecting it; this
relies on the install directories themselves being trusted (owned by root, not writable by
others).
save-artifact
Section titled “save-artifact”The api-key is checked before anything touches the disk. The stored file is
<dir>/<version>.<extension>, and the final path must lie inside the canonical dir (links
inside dir that lead out are rejected), like the ZIP guard; the file is opened without
following a link at its place. {version} must not contain ..;
replaceDirChars may turn a substring of it into / for sub-directories inside dir. The
x-dc-agent-file-extension header must be a plain extension ([A-Za-z0-9][A-Za-z0-9._-]*,
no ..). dir, extension and replaceDirChars come from server-side config.
The guard catches links that are already in place; it cannot stop someone who can change dir or
its sub-directories from swapping one for a link during an upload. Keep dir and everything below
it writable only by root (the agent).
Control-plane channel
Section titled “Control-plane channel”/control-plane/api/* is gated behind CONTROL_PLANE_ENABLED and protected by a Bearer token
(CONTROL_PLANE_TOKEN). Whoever holds the token can create commands with their own api keys and
then deploy through them, so with CONTROL_PLANE_ENABLED=true the agent refuses to start if
CONTROL_PLANE_TOKEN is empty or still the published placeholder
(REPLACE_THIS_TEST_CONTROL_PLANE_TOKEN). With the control plane off, the token is not checked.
Hardening checklist
Section titled “Hardening checklist”- Terminate TLS in front of the agent (it speaks plain HTTP) — see Installation.
- Give every exposed endpoint a strong, random
api-key; rotate by listing multiple keys. - Give every config its
type(the operator does it for you); check the agent log for “has no type” warnings. - Lock down filesystem permissions on
CONFIG_DIR. - After upgrading the agent, run
DOCKER_CHECKfor every service: unknown keys indc-docker.yml(typos that used to be ignored, e.g. insecurityContext) now fail the push. - Keep
TEMP_DIR, the deploy directories and everysave-artifactdiron paths only root can change (the agent refuses an unsafeTEMP_DIR). - Set your own
CONTROL_PLANE_TOKEN(required withCONTROL_PLANE_ENABLED=true); leave the control plane off unless needed.