Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,19 @@ jobs:
ASSET: ${{ steps.meta.outputs.asset }}
DIST: ${{ runner.temp }}/dist
run: |
# `--bundle` writes signature, certificate, and transparency-log proof
# in one file. The separate `--output-signature`/`--output-certificate`
# flags are deprecated and ignored under the current bundle format,
# which is what made this step fail with an empty bundle path.
# The `.sigstore.json` suffix is what OpenSSF Scorecard's
# Signed-Releases check recognises.
cosign sign-blob --yes \
--output-signature "$DIST/$ASSET.sig" \
--output-certificate "$DIST/$ASSET.pem" \
--bundle "$DIST/$ASSET.sigstore.json" \
"$DIST/$ASSET"

# Real SLSA provenance, but API-based rather than a release asset, so
# scorecard's file-suffix probe does not see it; the `.sig` uploaded
# alongside is what carries the Signed-Releases score.
# scorecard's file-suffix probe does not see it; the `.sigstore.json`
# bundle uploaded alongside is what carries the Signed-Releases score.
- name: Attest SLSA build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
Expand All @@ -129,8 +134,7 @@ jobs:
name: ${{ matrix.target }}-${{ matrix.variant }}
path: |
${{ runner.temp }}/dist/${{ steps.meta.outputs.asset }}
${{ runner.temp }}/dist/${{ steps.meta.outputs.asset }}.sig
${{ runner.temp }}/dist/${{ steps.meta.outputs.asset }}.pem
${{ runner.temp }}/dist/${{ steps.meta.outputs.asset }}.sigstore.json
if-no-files-found: error

# ---- Publish Release -----------------------------------------------------
Expand Down
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Precedence is **`deny-overrides`** (XACML terminology), evaluated in a fixed ord
Container creation, exec, lifecycle mutation, `/build`, `/commit`, and everything not listed above.

### Body inspection
For profiles that permit `/containers/create`, the request body is inspected and rejected if it sets `Privileged`, `CapAdd`, `SecurityOpt`, `Devices`, `PidMode`, `IpcMode`, or `UsernsMode`. Bind and volume mounts are permitted — orchestrators legitimately need them, and the profile is documented as trusted-caller-only.
For profiles that permit `/containers/create`, the request body is inspected and rejected if it sets `Privileged`, `CapAdd`, `SecurityOpt`, `Devices`, `DeviceRequests`, `PidMode`, `IpcMode`, or `UsernsMode` — at the top level or nested under `HostConfig` — or sets `NetworkMode` to `host`. Docker reads these fields from `HostConfig`, so both levels are inspected. Bind and volume mounts are permitted — orchestrators legitimately need them, and the profile is documented as trusted-caller-only.

### Profiles
| Profile | Intent |
Expand All @@ -135,7 +135,7 @@ Each blocked endpoint is mapped to its NIST SP 800-190 and CIS Docker Benchmark

- Structured JSON logs via `tracing`, named per **OpenTelemetry semantic conventions**
- **W3C Trace Context** — an inbound `traceparent` is propagated upstream
- `/metrics` in **OpenMetrics/Prometheus** text format: allow and deny counters by endpoint and profile, plus request latency
- `/metrics` in **OpenMetrics/Prometheus** text format: allow and deny counters
- Health endpoint, reachable from a `--health-check` subcommand since the scratch image has no shell

## Configuration
Expand Down Expand Up @@ -164,7 +164,7 @@ endpoints = ["/containers/create", "/exec"]
methods = ["POST"]
```

Merge semantics follow **RFC 7386** (JSON Merge Patch) rather than ad-hoc precedence.
Merge is a monotonic union-append over the profile defaults: `allow`/`include` add grants and `deny`/`exclude` carve them out under `deny-overrides`. A file or environment rule can only add to `allow` — use `deny`/`exclude` to subtract.

### Compatibility
A shim accepts the Tecnativa/linuxserver `docker-socket-proxy` environment variables (`CONTAINERS=1`, `IMAGES=1`, `POST=0`, …) and translates them into endpoint patterns, making this a drop-in replacement for the incumbent.
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **Container-create body inspection now checks the nested `HostConfig`.** The
Docker daemon reads `Privileged`, `CapAdd`, `SecurityOpt`, `Devices`,
`PidMode`, `IpcMode`, and `UsernsMode` from `HostConfig`, but the guard only
inspected the top level, so `{"HostConfig":{"Privileged":true}}` slipped
through and started a privileged container. Both levels are now inspected,
`DeviceRequests` and `NetworkMode: host` are refused too, and explicit
`false`/empty/`null` no longer over-block.
- **The `container-runtime` profile no longer cross-products methods ×
endpoints.** Its read and write grants merged into one set, silently allowing
every write method on every readable endpoint — including
`DELETE /volumes/{id}` and `DELETE /networks/{id}`. Allow rules are now
independent, so each grant stays method-AND-endpoint.
- **Create-body inspection follows the effective policy, not the profile enum.**
`--profile none` plus an allowlist granting `POST /containers/create`, and the
`CONTAINERS=1 POST=1` compatibility shim, now inspect create bodies instead of
forwarding them unexamined.
- **Malformed request paths return 400**, not 403, matching the documented
pipeline contract.
- **Chunked over-limit bodies return 413**, not 502, when the streamed size
limit fires mid-forward.
- **`--log-level` is no longer shadowed by an ambient `RUST_LOG`.**
- **Upgraded (101) connections are drained on graceful shutdown**, so `docker
exec` sessions get a bounded window to finish instead of being severed.

### Changed
- **Merge semantics documented as a monotonic union-append under
`deny-overrides`**, not RFC 7386 JSON Merge Patch (which was documented but
never implemented).
- **Release signing** writes a `.sigstore.json` Sigstore bundle via `cosign
sign-blob --bundle`, replacing the deprecated `--output-signature`/
`--output-certificate` flags that made the step fail.

## [0.3.0] — 2026-08-12

YAML allowlists, drop-in compatibility with the section variables other socket
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ Use the opt-in `container-runtime` profile for Docker-backed orchestrators. It s
DOCKER_PROXY_PROFILE=container-runtime docker-socket-proxy
```

For profiles that permit `/containers/create`, the request body is inspected and rejected if it sets `Privileged`, `CapAdd`, `SecurityOpt`, `Devices`, `PidMode`, `IpcMode`, or `UsernsMode`. Bind and volume mounts are permitted — orchestrators need them, and this profile is trusted-caller-only by design.
For profiles that permit `/containers/create`, the request body is inspected and rejected if it sets `Privileged`, `CapAdd`, `SecurityOpt`, `Devices`, `DeviceRequests`, `PidMode`, `IpcMode`, or `UsernsMode` — at the top level or nested under `HostConfig` — or sets `NetworkMode` to `host`. Bind and volume mounts are permitted — orchestrators need them, and this profile is trusted-caller-only by design.

## Known Limitations

Expand Down
14 changes: 11 additions & 3 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,16 @@ The remaining work is standards conformance, not features.
`container-runtime`, and `none` profiles; wildcard matcher; TOML or YAML
`allow`/`deny`/
`include`/`exclude`; environment modifiers; API-version normalization;
create-body inspection. `deny` and `exclude` hold independent rules, so
separate sources cannot merge into one narrower condition.
create-body inspection. `allow`, `deny`, and `exclude` all hold independent
rules, so separate sources cannot merge into one narrower condition — or, on
the allow side, one wider cross-product than any single source wrote.

Container-create bodies are inspected at both the top level and under
`HostConfig`, where the daemon actually reads `Privileged`, `CapAdd`,
`SecurityOpt`, `Devices`, `DeviceRequests`, `PidMode`, `IpcMode`, and
`UsernsMode`; `NetworkMode: host` is refused too. Inspection runs whenever the
effective policy permits `POST /containers/create`, not only under
`container-runtime`.

**Compatibility** — the section variables Tecnativa's socket proxy uses
(`CONTAINERS`, `POST`, `ALLOW_START`, …) configure the filter directly, checked
Expand Down Expand Up @@ -61,7 +69,7 @@ Engine API surface (`src/docker_api.rs`) at startup; anything matching no real
endpoint is warned about with its source, since a typo in `deny` or `exclude`
is otherwise silent. Shipped patterns are checked by test, not warning.

**Tests** — 78 passing (66 unit, 4 integration against a mock socket, 5
**Tests** — 87 passing (71 unit, 8 integration against a mock socket, 5
asserting the shipped examples still behave as documented, 3 checking policy
patterns against the real Docker Engine API surface).

Expand Down
2 changes: 1 addition & 1 deletion docs/standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Code changes using crates already present, or the +2-crate `tower-http`.
| **RFC 3986 §6** — URI normalization | Percent-decode, remove dot-segments, collapse duplicate slashes before policy matching | Free (hand-rolled) |
| **XACML combining algorithms** | Name and document the precedence as `deny-overrides`; fixes `exclude` matching method-OR-endpoint while `deny` requires method-AND-endpoint | Free (logic) |
| **Kubernetes RBAC** (as a model) | verbs × resources × resourceNames ≈ methods × patterns × wildcards | Free (design) |
| **RFC 7386** — JSON Merge Patch | Principled semantics for the `allow`/`deny`/`include`/`exclude` merge | Free (design) |
| **RFC 7386** — JSON Merge Patch | *Considered, not adopted*: the merge is a monotonic union-append (`allow`/`include` add, `deny`/`exclude` carve out under XACML `deny-overrides`); JSON Merge Patch null-deletes are not implemented | Free (design) |
| **Tecnativa / linuxserver env convention** | `CONTAINERS=1`, `POST=0` compatibility shim → drop-in replacement | Free (logic) |
| **OWASP API4** — resource consumption | `tower-http` `RequestBodyLimitLayer` + `TimeoutLayer` | +2 crates |
| **OpenMetrics / Prometheus** | `/metrics` with allow/deny counters, hand-rolled text exposition over `AtomicU64` | Free |
Expand Down
18 changes: 12 additions & 6 deletions examples/create-inspection.toml
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
# Container creation with the body inspected, not just the endpoint allowed.
#
# Run with `--profile container-runtime`, which is what turns on inspection of
# `POST /containers/create`. Endpoint rules alone cannot express this: the
# Inspection triggers on `POST /containers/create` whenever that endpoint is
# permitted — under `--profile container-runtime` or an allowlist — because the
# difference between a safe create and a container escape is in the body.
#
# Rejected regardless of what this file allows:
# {"Image":"x","Cmd":[],"Privileged":true} → escapes the container
# {"Image":"x","Cmd":[],"CapAdd":["SYS_ADMIN"]} → same, by capability
# {"Image":"x","Cmd":[],"PidMode":"host"} → host namespace
# {"Image":"x"} → no Cmd
# {"Image":"x","HostConfig":{"Privileged":true}} → escapes the container
# {"Image":"x","HostConfig":{"CapAdd":["SYS_ADMIN"]}} → same, by capability
# {"Image":"x","HostConfig":{"PidMode":"host"}} → host namespace
# {"Image":"x","HostConfig":{"DeviceRequests":[...]}} → GPU passthrough
# {"Image":"x","NetworkMode":"host"} → host network namespace
#
# The dangerous fields are rejected at the top level and nested under
# `HostConfig` alike, since that is where Docker reads them from. `false`,
# `[]`, `""`, and `null` are explicit unsets and pass.
#
# Accepted:
# {"Image":"worker:1","Cmd":["run"]}
# {"Image":"worker:1"} → the image's default Cmd is legitimate
#
# Mounts are deliberately permitted; orchestrators require them, and this
# profile is documented as trusted-caller-only.
Expand Down
9 changes: 5 additions & 4 deletions fuzz/fuzz_targets/path_normalizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ fuzz_target!(|data: &[u8]| {
let endpoints = Some(vec![pattern.to_string()]);
filter
.allow_mut()
.extend(methods.clone(), endpoints.clone());
.push(methods.clone(), endpoints.clone());
filter.deny_mut().push(methods.clone(), endpoints.clone());
filter
.exclude_mut()
Expand All @@ -48,9 +48,10 @@ fuzz_target!(|data: &[u8]| {
// The raw decision surface (no normalization).
let _ = filter.check(&method, &path);

// `deny_all()` builds a `None` profile, so `check_head` can only yield
// `BodyRule::None` on success; the body then carries no weight and
// `check_request` must agree. This pins the normalization hand-off.
// Body inspection is keyed on whether the effective policy permits
// `POST /containers/create`, so `check_head` may also yield
// `BodyRule::ContainerCreate` when the injected allow rule opens it. Both
// outcomes must route through `check_request` without panicking.
match filter.check_head(&method, &path) {
Ok(BodyRule::None) => {
assert!(
Expand Down
4 changes: 2 additions & 2 deletions fuzz/fuzz_targets/policy_parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ fuzz_target!(|data: &[u8]| {

// Mirror PolicyLoader::apply_document, but through the public surface only.
if let Some(set) = document.allow {
filter.allow_mut().extend(set.methods, set.endpoints);
filter.allow_mut().push(set.methods, set.endpoints);
}
if let Some(set) = document.include {
filter.allow_mut().extend(set.methods, set.endpoints);
filter.allow_mut().push(set.methods, set.endpoints);
}
if let Some(set) = document.deny {
filter.deny_mut().push(set.methods, set.endpoints);
Expand Down
5 changes: 3 additions & 2 deletions src/docker_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ pub const METHODS: &[&str] = &["GET", "HEAD", "POST", "PUT", "DELETE"];
/// Whether a policy pattern could ever match a real endpoint.
///
/// Wildcards on either side match any single segment, since [`PATHS`] carries
/// `*` where Docker's specification names a parameter.
/// `*` where Docker's specification names a parameter and a policy may name a
/// concrete value there instead.
pub fn matches_known_path(pattern: &str) -> bool {
PATHS.iter().any(|path| {
if pattern.ends_with('/') {
Expand All @@ -143,7 +144,7 @@ pub fn matches_known_path(pattern: &str) -> bool {
pattern_segments.clone().count() == path_segments.len()
&& pattern_segments
.zip(&path_segments)
.all(|(a, b)| a == "*" || b == &a)
.all(|(a, b)| a == "*" || b == &a || *b == "*")
})
}

Expand Down
21 changes: 20 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ pub enum ProxyError {
#[error("access denied: {0}")]
Forbidden(String),

/// Request could not be parsed, as opposed to refused by policy.
#[error("bad request: {0}")]
BadRequest(String),

/// Request body exceeded the configured limit.
#[error("payload too large: {0}")]
TooLarge(String),
Expand All @@ -48,9 +52,10 @@ impl IntoResponse for ProxyError {
let (status, message) = match &self {
ProxyError::Config(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
ProxyError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
ProxyError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
ProxyError::TooLarge(msg) => (StatusCode::PAYLOAD_TOO_LARGE, msg.clone()),
ProxyError::Docker(msg) => (StatusCode::BAD_GATEWAY, msg.clone()),
ProxyError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
ProxyError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
};

let body = json!({ "message": message });
Expand Down Expand Up @@ -96,4 +101,18 @@ mod tests {
StatusCode::INTERNAL_SERVER_ERROR
);
}

#[tokio::test]
async fn bad_request_maps_to_400() {
assert_eq!(
body_of(ProxyError::BadRequest("garbage".into())).await.0,
StatusCode::BAD_REQUEST
);
}

#[tokio::test]
async fn internal_error_body_carries_only_the_message() {
let (_, body) = body_of(ProxyError::Internal("boom".into())).await;
assert_eq!(body["message"], "boom");
}
}
6 changes: 4 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ fn main() {
}

fn init_logging(config: &docker_socket_proxy::config::Config) {
let env_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&config.log_level));
// `log_level` already reflects `RUST_LOG` via clap's `env` binding (with the
// CLI winning), so reading the environment again here would shadow an
// explicit `--log-level`.
let env_filter = EnvFilter::new(&config.log_level);

match config.log_format {
docker_socket_proxy::config::LogFormat::Json => {
Expand Down
17 changes: 15 additions & 2 deletions src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,19 @@ where
);
denial.into_response()
};
// A body-limit rejection is not a policy denial, but it is
// forensically meaningful and leaves the same structured trail.
let reject = |error: ProxyError| {
metrics.record_denied();
warn!(
method = parts.method.as_str(),
path = parts.uri.path(),
profile = ?filter.profile(),
reason = %error,
"request rejected by the enforcement layer"
);
error.into_response()
};

let rule = match filter.check_head(parts.method.as_str(), parts.uri.path()) {
Ok(rule) => rule,
Expand All @@ -111,14 +124,14 @@ where

let body = match rule {
BodyRule::None => match oversized_declaration(&parts.headers, max_body_bytes) {
Some(error) => return Ok(error.into_response()),
Some(error) => return Ok(reject(error)),
None => Body::new(Limited::new(body, max_body_bytes)),
},
rule => {
let collected = match Limited::new(body, max_body_bytes).collect().await {
Ok(collected) => collected.to_bytes(),
Err(error) => {
return Ok(body_error(error, max_body_bytes).into_response());
return Ok(reject(body_error(error, max_body_bytes)));
}
};
if let Err(denial) = SecurityFilter::check_body(rule, &collected) {
Expand Down
24 changes: 21 additions & 3 deletions src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ fn compatibility_filter(
};

let mut filter = SecurityFilter::deny_all();
filter.allow_mut().extend(Some(methods), Some(endpoints));
filter.allow_mut().push(Some(methods), Some(endpoints));
Ok(Some(filter))
}

Expand Down Expand Up @@ -237,7 +237,7 @@ fn apply_document(filter: &mut SecurityFilter, document: PolicyDocument) {
for (name, set) in [("allow", document.allow), ("include", document.include)] {
if let Some(set) = set {
report_unknown(name, set.methods.as_deref(), set.endpoints.as_deref());
filter.allow_mut().extend(set.methods, set.endpoints);
filter.allow_mut().push(set.methods, set.endpoints);
}
}
if let Some(set) = document.deny {
Expand Down Expand Up @@ -298,7 +298,7 @@ fn apply_environment(filter: &mut SecurityFilter, env: &HashMap<String, String>)

for prefix in ["ALLOW", "INCLUDE"] {
let (methods, endpoints) = rule(prefix);
filter.allow_mut().extend(methods, endpoints);
filter.allow_mut().push(methods, endpoints);
}
let (methods, endpoints) = rule("DENY");
filter.deny_mut().push(methods, endpoints);
Expand Down Expand Up @@ -347,6 +347,24 @@ methods = []
assert!(filter.check("GET", "/info").is_ok());
}

#[test]
fn a_file_allow_stays_independent_of_the_profile_allow() {
let filter = from_toml(
r#"
[allow]
methods = ["POST"]
endpoints = ["/images/create"]
"#,
&SecurityProfile::Default,
);

assert!(filter.check("POST", "/images/create").is_ok());
assert!(
filter.check("POST", "/info").is_err(),
"the file's POST must not leak onto the profile's endpoints"
);
}

#[test]
fn include_adds_and_exclude_removes() {
let filter = from_toml(
Expand Down
Loading