Skip to content

sec(dind): gate --security-opt seccomp/apparmor=unconfined behind allow_privileged - #174

Merged
luthermonson merged 1 commit into
mainfrom
fix/seccomp-gate
Aug 19, 2026
Merged

sec(dind): gate --security-opt seccomp/apparmor=unconfined behind allow_privileged#174
luthermonson merged 1 commit into
mainfrom
fix/seccomp-gate

Conversation

@luthermonson

Copy link
Copy Markdown
Contributor

Fixes #172. Live on the fleet today with dind.allow_privileged = false, on nodes running public-repo CI.

The bug

checkPrivilegedGate inspected only Privileged and CapAdd. The SecurityOpt loop ran unconditionally inside if req.HostConfig != nil, mapping seccomp=unconfinedoci.WithSeccompUnconfined and apparmor=…:unconfinedoci.WithApparmorProfile(""). Nothing further down re-checked it, and checkPrivilegedGate is the only elevation gate on the create path — checkWindowsSiblingGate is a platform check that runs after.

So a sibling could strip its own seccomp filter while the host was configured to deny the elevation stack.

The fix — one predicate, so gate and applier cannot diverge

That divergence was the bug: a gate checking one field while a later loop applied another. So:

  • elevatingSecurityOpt(opt) (mechanism string, elevating bool) — the single answer to "does this ask for elevation?", handling both = and legacy : spellings.
  • checkPrivilegedGate scans hc.SecurityOpt through it, so there is still exactly one gate.
  • The inline loop became securityOptSpecOpts(), switching on the same predicate's output.

403 body matches the existing privileged-gate shape — what, why, which knob:

--security-opt seccomp=unconfined is disabled on this host: it removes the seccomp
sandbox that keeps an unprivileged sibling container unprivileged
(set dind.allow_privileged = true in ephemerd config to enable)

With allow_privileged = true, behaviour is byte-identical to before, including which values are ignored.

The audit — and the reassuring part

The issue was found by inspection, so the obvious worry was more of the same. There is not.

hostConfig decodes only 11 fields. Everything else in Docker's HostConfig JSON is discarded at decode timeDevices, Sysctls, Ulimits, PidMode, IpcMode, UsernsMode, CapDrop, MaskedPaths, ReadonlyPaths, GroupAdd, CgroupParent, ShmSize, Runtime, OomScoreAdj are not fields on the struct and cannot reach the OCI spec at all. The severity of #172 stands as filed; it does not escalate.

Field by field:

Field Reaches Gated?
Privileged the 11-opt elevation block yes
CapAdd WithAddedCapabilities yes
SecurityOpt seccomp/apparmor unconfined now yes
Binds buildBindMounts by design — #125 / #173 territory, untouched here
Tmpfs withTmpfsMount no — see below
CgroupnsMode WithNamespacedCgroup for private tightens, not loosens
NetworkMode CNI network name only host/none/container:<id> not honoured; the sibling always gets its own netns. No oci.WithHostNamespace anywhere in pkg/dind. Fail-safe.
PortBindings proxies inside the runner's netns not the host's
RestartPolicy, Init decoded, never read ignored

Adjacent paths checked and clean: execCreateRequest does not decode Privileged, so docker exec --privileged is a silent no-op; pkg/buildkit/server.go:233 sets Entitlements: nil, so RUN --security=insecure and network.host are refused by BuildKit itself.

Found, not fixed

  • --tmpfs options replace the nosuid,nodev default wholesale--tmpfs /x:rw,exec,suid,dev yields a mount with neither. Matches real Docker's semantics and impact is capped by the deny-all device cgroup, but it is a user-controlled reduction of a sandbox property on an unprivileged container. Low severity; worth its own issue.
  • Exec process spec is hand-built with User: {UID:0, GID:0} and nil Capabilities. Filed separately as it is unverified and needs a Linux host to settle.
  • Silently ignored fields (--device, --pid=host, --sysctl, --cap-drop, --security-opt no-new-privileges=true, …) all succeed and do nothing. Fail-safe in the security direction — including the cases where the job asked for more hardening — but confusing.

Tests

+253 lines in pkg/dind/privileged_gate_test.go, following the house pattern. Both keys × both spellings, offender not at index 0, 10 unrelated-value cases (label=disable, no-new-privileges=true, seccomp=/path/profile.json, apparmor=docker-default, seccomp=unconfined-ish, empty), open-gate still-works, and a malformed-request test proving 400 still beats 403.

Proven non-vacuous by four mutations, each reverted:

Mutation Result
gate stops scanning SecurityOpt 6 FAIL, incl. both handler 403 tests
applier's case "seccomp" never matches AppliesElevationWhenAsked FAILs — the "still works when open" assertion is real, not just a status check
drop the key:value fallback 4 FAIL, all colon-form
accept any value (over-refuse) 6 FAIL in the unrelated-value cases

go test -count=1 ./pkg/dind/... ./pkg/config/... ok on Windows. GOOS=linux go build ./... and GOOS=linux go vet ./pkg/dind/... exit 0 (which typechecks the linux-only test files); Linux execution not available on this host.

Merge note

Overlaps #173 in pkg/dind/containers.go and pkg/dind/dind.go. The diff was deliberately kept inside the gate region (~lines 210–290) and clear of the bind-staging code, so the two should merge without a fight.

…ow_privileged

HostConfig.SecurityOpt was applied by a loop that ran regardless of
dind.allow_privileged, so a sibling container could strip its own seccomp
filter with `docker run --security-opt seccomp=unconfined` on a host
configured to deny the elevation stack. Same for apparmor=unconfined. The
gate checked Privileged and CapAdd only; the later loop applied a field the
gate never looked at.

Extend the existing gate rather than adding a second one. elevatingSecurityOpt
is now the single answer to "does this --security-opt ask for elevation?" and
both checkPrivilegedGate and the applier (securityOptSpecOpts) read it, so
refusing and applying cannot drift apart again. Both the key=value and the
legacy key:value spellings are covered because the applier accepted both.

Behaviour with allow_privileged = true is unchanged: the options still reach
the OCI spec, which is what setup-buildx's container driver needs. Every other
--security-opt value is still ignored rather than refused. The 403 names the
option, the sandbox it removes, and the config knob that governs it, and it
still runs after request-shape validation so a malformed create keeps its 400.

Fixes #172
@luthermonson
luthermonson merged commit 34ef8e3 into main Aug 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(dind): --security-opt seccomp=unconfined bypasses the allow_privileged gate

1 participant