[fix] act on the stack-editor review - #100
Merged
Merged
Conversation
Security: - The .dc-prev backup was written with a plain write, which follows a symlink at the destination. Anyone able to create files in the stack's directory — not necessarily anyone with Docker access — could redirect it and have the app, often root, write content they also controlled. Backups now go through a temp file plus a rename; the SSH path uses mktemp, where `cat >` and `cp` had the same weakness. - StackRedeploy skipped the containment check StackWriteComposeFile enforced, so a label could still steer `compose up -d` at a file outside the project. Both now ask one question in one place, which also stops the UI offering an editor whose Save could never succeed. Also: - compat.yml declared a `versions` dispatch input and ignored it; the matrix is now resolved from it. - One resolveStack per compose view instead of two (an SSH round trip and a file read each). - evilStack captured t.Context(), which Go cancels just before t.Cleanup runs, so every pentest fixture container leaked. Six survivors per run. - Redeploy output no longer set twice. - Comments and docs overstated the threat model: setting compose labels needs Docker API access, which is already root-equivalent, so the containment rule is defence in depth rather than a barrier against anything reachable through the app.
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Acts on a code + security review of today's merged work (#97, #98, #99). Two
security findings, four quality ones, and one correction to my own wording.
Nothing here shipped in a tagged release — all of it was caught between merge and
tag.
Security
1. The stack editor's backup could be redirected through a symlink. (High)
Saving a CLI-discovered stack's compose file keeps the previous version as
<name>.dc-prev, written withos.WriteFile— which opens withO_CREAT|O_WRONLY|O_TRUNCand therefore follows a symlink already sitting atthat path. The SSH path had the same weakness twice:
cat > "$tmp"andcp -p src dstboth write through a link.The exploit needs only write access to the stack's directory — not Docker
access, so this was reachable by an account that is otherwise unprivileged (a
deploy user, a CI account). Pre-place
compose.yml.dc-prevas a link to/etc/cron.d/x, put a payload incompose.yml, wait for an operator to hit Save,and Docker Commander — commonly root — writes attacker-controlled content to an
attacker-chosen path.
Backups now go through
writeSiblingAtomic: temp file in the same directory plusa rename, because rename replaces a symlink instead of following it. The SSH
script uses
mktemp(which creates withO_EXCL, so it cannot open a pre-placedlink) and
mv.2.
StackRedeployskipped the containment check that saving enforced. (Medium)StackWriteComposeFilecalledcheckContained();StackRedeploydid not. Acompose path outside the stack's working directory was refused for writes but
still handed to
docker compose up -d, deploying whatever definition sat there.Fixed by moving containment into
editableReason(), which every entry pointalready calls — so the rule can't be enforced on one operation and forgotten on
another. That also fixes a UX bug:
StackEditableused to reporttruefor atarget the write path would refuse, putting an editor in front of the user whose
Save could only ever fail.
Correction to my own wording
The comments,
docs/stacks.mdand the #98 description claimed this guard stopped"anyone able to run a container" from turning a
containersgrant into anarbitrary file write. That was overstated. Setting compose labels requires
direct Docker API access, and the app's
CreateSpecexposes noLabelsfield —so the attacker must already have root-equivalent access to that host. The rule is
worth having as defence in depth; it is not a barrier against an escalation
reachable through Docker Commander. Reworded everywhere it appeared.
Quality
compat.ymldeclared aversionsdispatch input and never used it — thematrix was hard-coded, so dispatching with
["24"]silently ran all five. Nowresolved through a
choosejob andfromJSON.evilStackcapturedt.Context(), which Go cancels just beforet.Cleanupruns, so every cleanup closure using it failed silently — six leaked containers
per run. Now takes the background context. Verified: 0 before, 0 after.
resolveStackcalls per compose view (one for content, one foreditability), each a container list plus a file read — an SSH round trip each.
Collapsed into one
StackCompose.Type of change
Checklist
go test -short ./...andgo vet ./...passgofmtgate is clean (gofmt -l $(git ls-files '*.go')after staging)cd web && npx tsc --noEmit)web/distdocs/and added aCHANGELOG.mdentryNotes for reviewers
Both security fixes were mutation-tested against the vulnerability itself, not
just asserted:
TestWriteSiblingAtomicReplacesASymlink— reverting toos.WriteFilefails itwith
SECURITY: the write followed the symlink and landed in …/victim.TestPentestStackRedeploy_IsContainedToo— removing containment fromeditableReasonfails it withredeploy accepted a compose file outside the stack's working directory.That second mutation run is also how the leak was found. With the guard
removed the attack genuinely succeeded,
compose up -dstarteddctest_evilredeploy-pwned-1, and it survived the run — poisoning the next one,because
ListStacksgroups by label and a stale container contributed itsnow-deleted
config_filespath to the same project. So the pentest now force-removes the whole project label before and after (
freeStack), not just its ownfixture container. A pentest whose guard fails leaves real side effects; its
cleanup has to cover the attack's output, not only the fixture's.
TestStackEditableReasonnow uses real directories rather than fabricatedpaths, since
editableReasonreaches the filesystem. It gained the twoout-of-project cases.
Residual window, stated rather than hidden: the backup is renamed into place
before the new content is. If that second rename fails — same directory, same
filesystem, so realistically only ENOSPC/EIO —
.dc-prevhas been updated withoutthe edit being applied. Closing it entirely would mean applying the edit before the
backup exists, which trades a worse failure for a rarer one. Left as is,
deliberately.
Test data: verified the docker suite leaves zero
dctest*containers behindacross consecutive runs.