Skip to content

Commit a1f142c

Browse files
committed
fix(labels): strip privileged/env-file/env-from from label-sourced jobs
Container labels can define jobs (job-exec, job-run, job-service-run). The AllowHostJobsFromLabels policy stripped host bind mounts from job-run and job-service-run (issue #462) but left three privilege-bearing keys unfiltered on every job type, and never routed job-exec through the policy at all: - privileged: a job-exec with privileged=true reached `docker exec --privileged`, the entry point to a privileged-container escape. RunJob/RunServiceJob have no Privileged field, so this was job-exec-specific — and job-exec was decoded outside the policy block, so it skipped filtering entirely. - env-file: read a file from ofelia's own filesystem view into the job env. - env-from: copied another container's whole environment into the job. All three were honored regardless of source, on both the initial-load and the live-reconcile paths (both funnel through buildFromDockerContainers). Strip these keys from every label-sourced job map before decode, gated on allow-host-jobs-from-labels and matched by normalized key so casing and separator variants (Privileged, ENV_FILE, ...) are caught the same way the decoder matches them. The key is removed in place, so the job still runs — unprivileged and without host / cross-container environment injection — and each strip logs a SECURITY POLICY VIOLATION for operator triage. The strip lives in buildFromDockerContainers, so both entry points are covered in one place; the INI configuration path is trusted and untouched. Adds cli/docker_labels_privilege_escalation_test.go covering each vector neutralised with the policy off and honored with it on, plus the case/separator-insensitive match and the no-over-block case. Security-Advisory: GHSA-h7m7-v83x-vfp3 Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
1 parent 633f695 commit a1f142c

2 files changed

Lines changed: 281 additions & 0 deletions

File tree

cli/docker-labels.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,22 @@ func (c *Config) buildFromDockerContainers(containers []DockerContainerInfo) err
145145
// https://github.com/netresearch/ofelia/issues/462.
146146
runJobs = filterJobsWithHostEscalation(runJobs, "job-run", c.logger)
147147
serviceJobs = filterJobsWithHostEscalation(serviceJobs, "job-service-run", c.logger)
148+
149+
// GHSA-h7m7-v83x-vfp3: privilege-bearing keys the volume /
150+
// volumes-from filter above does not cover. `privileged` on a
151+
// job-exec reaches `docker exec --privileged` (a container-escape
152+
// primitive); `env-file` reads a file from ofelia's filesystem
153+
// view into the job env; `env-from` copies a sibling container's
154+
// whole environment. All three are honored on every job type
155+
// regardless of source. Crucially, job-exec is not routed through
156+
// filterJobsWithHostEscalation at all (it has no volume semantics),
157+
// so without this strip it bypasses the policy entirely. Strip the
158+
// keys in place (rather than dropping the job) so the job still
159+
// runs — unprivileged and without host / cross-container env — and
160+
// log one SECURITY POLICY VIOLATION per stripped key.
161+
stripLabelHostEscalationKeys(execJobs, "job-exec", c.logger)
162+
stripLabelHostEscalationKeys(runJobs, "job-run", c.logger)
163+
stripLabelHostEscalationKeys(serviceJobs, "job-service-run", c.logger)
148164
}
149165

150166
decodeInto := func(src map[string]map[string]any, dst any) error {
@@ -745,6 +761,65 @@ func filterJobsWithHostEscalation(jobs map[string]map[string]any, jobType string
745761
return filtered
746762
}
747763

764+
// labelHostEscalationStripKeys maps the normalized form (see normalizeKey)
765+
// of each privilege-bearing job key to its canonical label spelling, used
766+
// only in the violation log. Keyed by normalized form because the label
767+
// decoder matches map keys to struct fields case- and separator-
768+
// insensitively (Privileged, ENV_FILE and env-from all decode into the
769+
// same fields), so the strip must catch every variant, not just the
770+
// canonical lowercase-kebab spelling. See GHSA-h7m7-v83x-vfp3.
771+
var labelHostEscalationStripKeys = map[string]string{
772+
normalizeKey("privileged"): "privileged",
773+
normalizeKey("env-file"): "env-file",
774+
normalizeKey("env-from"): "env-from",
775+
}
776+
777+
// stripLabelHostEscalationKeys removes the privilege-bearing keys
778+
// (privileged, env-file, env-from) from each label-sourced job map,
779+
// emitting one SECURITY POLICY VIOLATION log per stripped key. It is the
780+
// companion to filterJobsWithHostEscalation for the vectors that filter
781+
// does not cover:
782+
//
783+
// - privileged — a job-exec with privileged=true reaches
784+
// `docker exec --privileged`, granting all capabilities and unconfining
785+
// seccomp/AppArmor, which enables the standard privileged-container
786+
// escape techniques. RunJob / RunServiceJob have no Privileged field,
787+
// so this is a job-exec-specific escalation.
788+
// - env-file — opens a path in ofelia's own filesystem view and injects
789+
// each KEY=VALUE line as job env, disclosing files an attacker
790+
// container otherwise cannot read (ofelia's mounts, injected config,
791+
// credential files).
792+
// - env-from — copies the entire environment of any named container into
793+
// the job, stealing a sibling container's secrets.
794+
//
795+
// Unlike filterJobsWithHostEscalation (which drops the whole job for a
796+
// volume / volumes-from violation), these keys are stripped in place: the
797+
// job keeps running, just unprivileged and without host / cross-container
798+
// environment injection. The keys are deleted from the map before decode,
799+
// so the corresponding struct fields are never populated. Callers invoke
800+
// this only when AllowHostJobsFromLabels is off; with the flag on the
801+
// operator has opted into honoring these keys from labels.
802+
//
803+
// Deleting map keys during a range over the same map is well-defined in Go.
804+
func stripLabelHostEscalationKeys(jobs map[string]map[string]any, jobType string, logger *slog.Logger) {
805+
for name, job := range jobs {
806+
for key := range job {
807+
canonical, ok := labelHostEscalationStripKeys[normalizeKey(key)]
808+
if !ok {
809+
continue
810+
}
811+
delete(job, key)
812+
logger.Error(fmt.Sprintf("SECURITY POLICY VIOLATION: stripping %s %q key %q (%s) from container labels. "+
813+
"privileged exec, env-file host-file disclosure and env-from cross-container environment theft "+
814+
"enable container-to-host / cross-container privilege escalation from an untrusted self-labeling container. "+
815+
"The job still runs, but without this key. "+
816+
"Set [global] allow-host-jobs-from-labels=true in INI to permit (NOT recommended for multi-tenant hosts). "+
817+
"See https://github.com/netresearch/ofelia/security/advisories/GHSA-h7m7-v83x-vfp3.",
818+
jobType, name, key, canonical))
819+
}
820+
}
821+
}
822+
748823
// extractHostVolumeMounts walks the "volume" param value and returns the
749824
// host-mount specs it contains. The bool return is true when the value's
750825
// type was recognized; false signals a fail-closed condition (an
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// Copyright (c) 2025-2026 Netresearch DTT GmbH
2+
// SPDX-License-Identifier: MIT
3+
4+
package cli
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// This file pins the fix for GHSA-h7m7-v83x-vfp3: privilege-bearing job
14+
// keys sourced from container labels (privileged, env-file, env-from) must
15+
// be stripped when AllowHostJobsFromLabels=false, exactly as the volume /
16+
// volumes-from vectors from #462 already are. Unlike those (which drop the
17+
// whole job), these keys are stripped in place so the job still runs — just
18+
// unprivileged and without host / cross-container environment injection.
19+
//
20+
// Pre-fix, filterJobsWithHostEscalation covered only job-run /
21+
// job-service-run and only inspected volume / volumes-from, so:
22+
// - a job-exec with privileged=true reached `docker exec --privileged`;
23+
// - env-file / env-from leaked host files and sibling-container
24+
// environments on every job type, including the two the filter did run
25+
// on.
26+
// These tests fail on the pre-fix tree (the keys survive) and pass once the
27+
// strip is wired into buildFromDockerContainers.
28+
29+
// baseExecJobLabels returns the minimum labels needed to enable a job-exec
30+
// on the (running) attacker container itself. job-exec runs on non-service
31+
// containers, so no ofelia.service label is required.
32+
func baseExecJobLabels(name string) map[string]string {
33+
return map[string]string{
34+
"ofelia.enabled": "true",
35+
"ofelia.job-exec." + name + ".schedule": "@daily",
36+
"ofelia.job-exec." + name + ".command": "echo ok",
37+
}
38+
}
39+
40+
// theExecJob returns the single parsed exec job. job-exec names are scoped
41+
// per [global] job-exec-label-scope, so the map key is not the bare job
42+
// name; the tests declare exactly one, so read it back by iteration.
43+
func theExecJob(t *testing.T, c *Config) *ExecJobConfig {
44+
t.Helper()
45+
require.Len(t, c.ExecJobs, 1, "expected exactly one parsed exec job")
46+
for _, j := range c.ExecJobs {
47+
return j
48+
}
49+
return nil
50+
}
51+
52+
// TestLabelPolicyStripsPrivilegedFromExecJob is the primary GHSA vector:
53+
// a self-labeling container declares a privileged job-exec, and with the
54+
// default policy (AllowHostJobsFromLabels=false) the privileged bit must
55+
// not survive into the parsed job — otherwise it reaches
56+
// `docker exec --privileged` and enables a container escape.
57+
func TestLabelPolicyStripsPrivilegedFromExecJob(t *testing.T) {
58+
t.Parallel()
59+
labels := baseExecJobLabels("pwn")
60+
labels["ofelia.job-exec.pwn.privileged"] = "true"
61+
62+
c, handler := runHostJobPolicy(t, false, labels)
63+
64+
job := theExecJob(t, c)
65+
assert.False(t, job.Privileged,
66+
"privileged=true from a container label must be stripped when AllowHostJobsFromLabels=false (GHSA-h7m7-v83x-vfp3)")
67+
assert.True(t, handler.HasError("SECURITY POLICY VIOLATION"),
68+
"stripping a privilege-bearing key must log a SECURITY POLICY VIOLATION for operator triage")
69+
assert.True(t, handler.HasError("job-exec"),
70+
"violation log must name the job-exec type")
71+
assert.True(t, handler.HasError("pwn"),
72+
"violation log must name the job for operator triage")
73+
}
74+
75+
// TestLabelPolicyStripsEnvFileFromExecJob pins the env-file file-disclosure
76+
// vector on job-exec: env-file reads a path in ofelia's filesystem view and
77+
// injects it as the job env, so a label-sourced env-file must be stripped.
78+
func TestLabelPolicyStripsEnvFileFromExecJob(t *testing.T) {
79+
t.Parallel()
80+
labels := baseExecJobLabels("leak")
81+
labels["ofelia.job-exec.leak.env-file"] = "/root/.aws/credentials"
82+
83+
c, handler := runHostJobPolicy(t, false, labels)
84+
85+
job := theExecJob(t, c)
86+
assert.Empty(t, job.EnvFile,
87+
"env-file from a container label must be stripped when AllowHostJobsFromLabels=false — it reads files from ofelia's filesystem view (GHSA-h7m7-v83x-vfp3)")
88+
assert.True(t, handler.HasError("SECURITY POLICY VIOLATION"),
89+
"stripping env-file must log a SECURITY POLICY VIOLATION")
90+
assert.True(t, handler.HasError("env-file"),
91+
"violation log must name the env-file vector for operator triage")
92+
}
93+
94+
// TestLabelPolicyStripsEnvFromFromExecJob pins the env-from cross-container
95+
// secret-theft vector on job-exec: env-from copies another container's
96+
// entire environment, so a label-sourced env-from must be stripped.
97+
func TestLabelPolicyStripsEnvFromFromExecJob(t *testing.T) {
98+
t.Parallel()
99+
labels := baseExecJobLabels("steal")
100+
labels["ofelia.job-exec.steal.env-from"] = `["victim-container"]`
101+
102+
c, handler := runHostJobPolicy(t, false, labels)
103+
104+
job := theExecJob(t, c)
105+
assert.Empty(t, job.EnvFrom,
106+
"env-from from a container label must be stripped when AllowHostJobsFromLabels=false — it copies a sibling container's whole environment (GHSA-h7m7-v83x-vfp3)")
107+
assert.True(t, handler.HasError("env-from"),
108+
"violation log must name the env-from vector for operator triage")
109+
}
110+
111+
// TestLabelPolicyStripsEnvFileFromRunJob confirms env-file is stripped on
112+
// job-run too. Pre-fix, job-run passed through filterJobsWithHostEscalation
113+
// but that filter only inspected volume / volumes-from, so env-file leaked
114+
// despite the job being "covered".
115+
func TestLabelPolicyStripsEnvFileFromRunJob(t *testing.T) {
116+
t.Parallel()
117+
labels := baseRunJobLabels("run-leak")
118+
labels["ofelia.job-run.run-leak.env-file"] = "/etc/ofelia-secrets.env"
119+
120+
c, handler := runHostJobPolicy(t, false, labels)
121+
122+
require.Contains(t, c.RunJobs, "run-leak",
123+
"the job itself has no volume/volumes-from vector, so it must survive — only the env-file key is stripped")
124+
assert.Empty(t, c.RunJobs["run-leak"].EnvFile,
125+
"env-file from a container label must be stripped on job-run when AllowHostJobsFromLabels=false (GHSA-h7m7-v83x-vfp3)")
126+
assert.True(t, handler.HasError("env-file"),
127+
"violation log must name the env-file vector")
128+
}
129+
130+
// TestLabelPolicyStripsEnvFromFromServiceJob confirms env-from is stripped
131+
// on job-service-run too.
132+
func TestLabelPolicyStripsEnvFromFromServiceJob(t *testing.T) {
133+
t.Parallel()
134+
labels := map[string]string{
135+
"ofelia.enabled": "true",
136+
"ofelia.service": "true",
137+
"ofelia.job-service-run.svc-leak.schedule": "@daily",
138+
"ofelia.job-service-run.svc-leak.image": "alpine",
139+
"ofelia.job-service-run.svc-leak.command": "env",
140+
"ofelia.job-service-run.svc-leak.env-from": `["victim-container"]`,
141+
}
142+
143+
c, handler := runHostJobPolicy(t, false, labels)
144+
145+
require.Contains(t, c.ServiceJobs, "svc-leak",
146+
"the service job has no host-mount vector, so it survives — only env-from is stripped")
147+
assert.Empty(t, c.ServiceJobs["svc-leak"].EnvFrom,
148+
"env-from from a container label must be stripped on job-service-run when AllowHostJobsFromLabels=false (GHSA-h7m7-v83x-vfp3)")
149+
assert.True(t, handler.HasError("env-from"),
150+
"violation log must name the env-from vector")
151+
}
152+
153+
// TestLabelPolicyStripsPrivilegedCaseInsensitive pins that the strip
154+
// matches every casing / separator variant the decoder would honor. The
155+
// mapstructure decoder matches keys via normalizeKey (lowercase, strip
156+
// - and _), so `Privileged`, `ENV_FILE`, etc. all decode into the fields;
157+
// a naive delete(job, "privileged") would miss them and leave the bypass
158+
// open. env_from -> normalizes to envfrom -> matches EnvFrom.
159+
func TestLabelPolicyStripsPrivilegedCaseInsensitive(t *testing.T) {
160+
t.Parallel()
161+
labels := baseExecJobLabels("mixed-case")
162+
labels["ofelia.job-exec.mixed-case.Privileged"] = "true"
163+
labels["ofelia.job-exec.mixed-case.ENV_FILE"] = "/root/.aws/credentials"
164+
165+
c, _ := runHostJobPolicy(t, false, labels)
166+
167+
job := theExecJob(t, c)
168+
assert.False(t, job.Privileged,
169+
"a mixed-case Privileged label must still be stripped — the decoder matches it case-insensitively, so the strip must too")
170+
assert.Empty(t, job.EnvFile,
171+
"an ENV_FILE label (underscore variant) must still be stripped — normalizeKey collapses it to the same field")
172+
}
173+
174+
// TestLabelPolicyHonorsPrivilegedWhenAllowed is the inverse contract: with
175+
// AllowHostJobsFromLabels=true the operator has opted in (trusted,
176+
// single-tenant), so privileged / env-file / env-from are honored unchanged
177+
// — the strip must not over-block.
178+
func TestLabelPolicyHonorsPrivilegedWhenAllowed(t *testing.T) {
179+
t.Parallel()
180+
labels := baseExecJobLabels("trusted")
181+
labels["ofelia.job-exec.trusted.privileged"] = "true"
182+
labels["ofelia.job-exec.trusted.env-file"] = "/etc/app.env"
183+
184+
c, _ := runHostJobPolicy(t, true, labels)
185+
186+
job := theExecJob(t, c)
187+
assert.True(t, job.Privileged,
188+
"AllowHostJobsFromLabels=true must honor privileged from labels (operator opt-in)")
189+
assert.Equal(t, []string{"/etc/app.env"}, job.EnvFile,
190+
"AllowHostJobsFromLabels=true must honor env-file from labels (operator opt-in)")
191+
}
192+
193+
// TestLabelPolicyKeepsCleanExecJob confirms the strip does not over-block:
194+
// a job-exec with no privilege-bearing keys survives untouched and logs no
195+
// violation.
196+
func TestLabelPolicyKeepsCleanExecJob(t *testing.T) {
197+
t.Parallel()
198+
c, handler := runHostJobPolicy(t, false, baseExecJobLabels("clean"))
199+
200+
job := theExecJob(t, c)
201+
assert.False(t, job.Privileged, "a clean exec job has no privileged bit")
202+
assert.Empty(t, job.EnvFile, "a clean exec job has no env-file")
203+
assert.Empty(t, job.EnvFrom, "a clean exec job has no env-from")
204+
assert.False(t, handler.HasError("SECURITY POLICY VIOLATION"),
205+
"a job with no escalation vectors must not trigger the policy")
206+
}

0 commit comments

Comments
 (0)