Security Model
Portwing's layered defenses — from socket-level filtering and cryptographic auth to hardened runtime and structured audit logging.
Portwing v0.9.x is a supported pre-v1 release. The controls documented here are implemented and tested, and public interfaces follow the published stability policy. This page describes the product's security model; it is not a compliance certification or substitute for your own threat model.
The Docker socket is root-equivalent on the host that runs it. Anything that can write to /var/run/docker.sock can create a privileged container, mount the host filesystem, and escape to the underlying machine. Portwing is a privileged agent: an authenticated caller has full Docker API access. The controls described here prevent unauthenticated or improperly authorized access and certain classes of injection attacks; they do not limit what an authenticated caller can do with the Docker API. Treat the Portwing token as a root-equivalent credential.
Defense in depth
Portwing's security design layers independent controls so that a failure in any single layer does not immediately compromise the host:
| Layer | Mechanism |
|---|---|
| Socket filter | Sockguard sits between Portwing and the Docker daemon; only explicitly allowlisted Docker API operations pass through |
| Authentication | Standard mode fails closed without credentials; every protected request is authenticated before it reaches the proxy — per-client Ed25519 signatures or a shared token |
| Transport | TLS 1.2+ with AEAD-only cipher suites; ECDHE forward secrecy |
| Container hardening | read_only, cap_drop: ALL, no-new-privileges, non-root by default (UID 65532), secrets via mounted files |
| Input validation | Root-confined symlink-resistant writes, path-traversal guard, env-var denylist, and service-name flag-injection filter on Compose requests |
| Resource limits | Bounded WebSocket frames, response bodies, exec sessions, enrollment bodies, and Argon2 concurrency, plus an IP rate limiter to resist DoS |
| Audit trail | Structured JSON audit log for every auth event, Compose operation, and exec session |
| Supply chain | Cosign keyless signatures, per-archive CycloneDX SBOMs, an image SBOM attestation, and SLSA provenance on every release |
Control 1 — Proxy-first architecture
Every byte that would reach the Docker socket passes through Portwing's HTTP server, which applies authentication before forwarding. There is no path by which an unauthenticated caller can reach the Docker daemon directly.
The catch-all mux entry is registered last and is wrapped with the same auth middleware as every named route:
mux.Handle("/", auth(s.handleDockerProxy))A missing or misconfigured auth middleware causes a build-time or startup-time failure rather than a silent security gap — there is no per-path allowlist to misconfigure by omission.
Standard mode refuses to start when no raw token, Argon2id token hash, or
authorized-keys registry is configured. Local unauthenticated development is
an explicit opt-in (ALLOW_UNAUTHENTICATED=true), and a non-loopback bind
requires the separate ALLOW_UNAUTHENTICATED_REMOTE=true acknowledgement.
This architecture structurally prevents the class of Docker AuthZ plugin bypass vulnerabilities (e.g. unauthenticated Docker socket exposure, request smuggling / header injection into the daemon) where authentication is delegated to a plugin that can be bypassed by crafted requests. Portwing does not use Docker's AuthZ plugin mechanism at all.
Control 2 — Sockguard socket filter
Sockguard is the recommended pairing for production deployments. It provides a second independent enforcement point so that even a fully compromised Portwing process is limited to the operations the operator explicitly allows.
Sockguard is a separate container that mounts the real /var/run/docker.sock read-only, applies an allowlist policy from sockguard.yaml, and exposes a filtered Unix socket on a shared named volume. Portwing points DOCKER_SOCKET at the filtered socket and never touches the raw socket directly.
The hardened compose file (examples/docker-compose.with-sockguard.yml) demonstrates the two-container layout:
services:
sockguard:
image: ghcr.io/codeswhat/sockguard:latest
restart: unless-stopped
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./sockguard.yaml:/etc/sockguard/sockguard.yaml:ro
- sockguard-socket:/var/run/sockguard
environment:
- SOCKGUARD_LISTEN_SOCKET=/var/run/sockguard/sockguard.sock
portwing:
image: ghcr.io/codeswhat/portwing:latest
restart: unless-stopped
depends_on:
- sockguard
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
ports:
- "3000:3000"
volumes:
- sockguard-socket:/var/run/sockguard:ro
- portwing-stacks:/data/stacks
environment:
- DOCKER_SOCKET=/var/run/sockguard/sockguard.sock
- TOKEN_FILE=/run/secrets/portwing_token
secrets:
- portwing_token
secrets:
portwing_token:
file: ./portwing_token.txt
volumes:
sockguard-socket:
portwing-stacks:Control 3 — Hardened container runtime
Both containers in the hardened stack run with the same security options:
| Option | Effect |
|---|---|
read_only: true | The container filesystem is read-only; writes to unexpected paths fail at the kernel level |
cap_drop: ALL | All Linux capabilities are dropped; the process cannot perform privileged syscalls |
no-new-privileges: true | The process cannot regain capabilities via setuid binaries or execve |
| Non-root user | The image runs as the dedicated portwing user (UID 65532) by default; the Docker socket's group is granted narrowly via group_add / supplementalGroups |
| Secrets via files | TOKEN_FILE / TOKEN_HASH_FILE / PRIVATE_KEY_FILE are mounted as Docker secrets; plaintext tokens never appear in environment variable dumps or docker inspect output |
/tmp via tmpfs | A writable tmpfs is mounted at /tmp so scratch space is available without a writable layer |
Control 4 — Per-client Ed25519 authentication
Ed25519 key auth is the recommended option for production. Per-request signatures with nonce replay protection remain strong even when TLS terminates at a load balancer the operator does not fully trust.
When AUTHORIZED_KEYS is configured, each client has a distinct Ed25519 keypair. Signature version 2 requests carry X-Portwing-Key-ID, X-Portwing-Timestamp, X-Portwing-Nonce, X-Portwing-Signature, and X-Portwing-Signature-Version: 2. The agent signs METHOD\nREQUEST-TARGET\nbody-sha256-hex\ntimestamp\nnonce, where the request target contains the escaped path and exact raw query string. Unversioned signatures remain accepted only for query-free requests.
Replay protection uses two complementary mechanisms:
- Timestamp window — requests with a timestamp skew greater than
MAX_CLOCK_SKEW_SECONDS(default 60 s) are rejected withX-Portwing-Reason: timestamp-skew. - Nonce LRU — an in-memory cache (capacity
NONCE_LRU_SIZE, default 10,000) tracks nonces within the timestamp window. Repeated nonces returnX-Portwing-Reason: replay.
Authorized-keys and private-key paths must resolve to regular files. On Unix,
world-readable and group/world-writable credential files are refused; mode
0640 is supported for group-readable secret mounts. The permission check is
made on the opened descriptor to prevent a path-swap race. Hot reload on
SIGHUP adds or removes keys without restarting; the nonce LRU is preserved.
See Authentication for the full setup guide, header format, key rotation, and migration path from token auth.
Control 5 — Argon2id token hashing
When a shared-secret token is necessary, TOKEN_HASH stores an Argon2id hash rather than the plaintext. Three credential sources are evaluated in order:
| Mechanism | Environment variable | Description |
|---|---|---|
| Plaintext | TOKEN / DD_AGENT_SECRET | Token value directly; read from env |
| Plaintext file | TOKEN_FILE / DD_AGENT_SECRET_FILE | Path to a file containing the token |
| Hash | TOKEN_HASH / TOKEN_HASH_FILE | Argon2id PHC string |
The portwing hash-token subcommand generates a hash using OWASP-recommended parameters (m=19456 KiB, t=2, p=1). The token is read from stdin so it never appears in shell history or process listings:
printf '%s' "$TOKEN" | portwing hash-token
# $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>At runtime Portwing compares the incoming token against the stored hash using Argon2id verification, still wrapped with crypto/subtle for the final byte comparison to prevent timing leakage. Cold verification is capped at two concurrent derivations across the agent; excess cold requests receive HTTP 429 before allocating Argon2 memory. A successful verification caches only a fixed-size SHA-256 digest. A compromised environment variable dump or configuration file reveals only the hash.
Control 6 — Timing-safe token comparison
Raw token verifiers retain a fixed-size SHA-256 digest rather than the raw
configured token. Each presented value is hashed and compared to that digest
with crypto/subtle.ConstantTimeCompare, so comparison length is independent
of the submitted token length.
This applies to all accepted credential headers: Authorization: Bearer, X-Portwing-Token, and X-Dd-Agent-Secret.
Control 7 — TLS 1.2+ AEAD-only
TLS_CERT and TLS_KEY must be configured together; setting only one is a
startup error. When configured, Portwing presents TLS with:
| Setting | Value |
|---|---|
| Minimum version | TLS 1.2 |
| Cipher suites (TLS 1.2) | ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-RSA-AES128-GCM-SHA256, ECDHE-ECDSA-CHACHA20-POLY1305, ECDHE-RSA-CHACHA20-POLY1305 |
| Curves | X25519, P-256 |
All TLS 1.2 suites are AEAD modes; CBC and RC4 suites are excluded. TLS 1.3 cipher suites are managed by Go's TLS stack, which selects only AEAD suites by design.
In production, run Portwing in standard mode behind a TLS reverse proxy. This keeps the TLS termination responsibility with a well-maintained proxy (nginx, Caddy, Traefik) and simplifies certificate management.
Control 8 — Compose input guards
Three independent validation steps protect the Compose code path:
Root-confined file writes. Compose upload paths are checked lexically, then
opened through Go's directory-scoped os.Root API. Each directory component
and destination is inspected without following symlinks; the rooted open keeps
the write beneath STACKS_DIR even if a path is swapped concurrently.
if !strings.HasPrefix(resolved, absBase+string(filepath.Separator)) && resolved != absBase {
return "", fmt.Errorf("path %q escapes stacks directory", path)
}A caller supplying ../../etc/passwd, a symlinked directory, or a symlinked
destination receives a validation error; no uploaded data is written outside
the stacks directory.
Env-var denylist. Environment variables passed in ComposeRequest.EnvVars must match ^[a-zA-Z_][a-zA-Z0-9_]*$ and must not be in the denylist:
LD_PRELOAD, LD_LIBRARY_PATH, PATH, DOCKER_HOST, DOCKER_CONFIG, DOCKER_CERT_PATH, DOCKER_TLS_VERIFY, DOCKER_CONTEXT, HOME, SHELL, BASH_ENV, ENV, CDPATH, IFS
The denylist covers dynamic linker hijacking (LD_*), shell init-file injection (BASH_ENV, ENV), and Docker socket redirection (DOCKER_HOST, DOCKER_CONTEXT).
Service-name flag-injection filter. Service names in ComposeRequest.Services are rejected if they start with -. Docker Compose passes service names as positional arguments; a name like --privileged would be interpreted as a flag, potentially escalating privileges or altering Compose behaviour.
These controls structurally prevent the class of root RCE via compose inputs demonstrated in Coolify (CVE-2025-64419 and CVE-2025-66209 through CVE-2025-66213).
Control 9 — Rate limiting
Failed authentication attempts are tracked per remote IP in a sliding one-minute window:
| Parameter | Value |
|---|---|
| Failure threshold | 10 within 60 seconds |
| Response | HTTP 429 until the window expires |
| IP table cap | 10,000 entries (new entries beyond the cap are dropped to prevent map exhaustion — fail-open for tracking, not for auth) |
| Cleanup interval | Every 5 minutes (background goroutine) |
| Per-IP in-flight auth cap | 2 credential verifications |
| Agent-wide cold Argon2 cap | 2 derivations; excess requests return HTTP 429 before allocation |
Malformed and oversized enrollment bodies use a separate per-IP abuse window, while bad enrollment credentials use the authentication-failure window.
Control 10 — Resource caps
| Resource | Limit | Purpose |
|---|---|---|
| WebSocket read | 16 MB | Prevent memory exhaustion from large WebSocket frames |
| HTTP response body read | 100 MB | Bound buffered Docker API responses |
| Legacy edge log response | 100 MB | Bound a buffered dd:container_log_response for older controllers |
| Edge log-stream frame | 256 KiB | Skip an oversized Docker log frame before it enters the controller queue |
| Concurrent edge log streams | 128 | Bound Docker readers and per-stream goroutines independently of message handlers |
| Edge outbound queue | 256 frames | Evict and reconnect a stalled controller instead of growing memory without limit |
| Legacy edge follow-log window | ~7s | Bound a non-streaming follow=true request so it can't hold a message-handler slot indefinitely |
| Exec request body | 10 MB | Limit exec payload size |
| Ed25519 signed-request body | 1 MB | Bound body buffering before signature verification |
| Enrollment request body | 64 KiB | Bound unauthenticated JSON parsing |
| Cold Argon2id derivations | 2 | Bound memory-hard verification across the agent |
| Concurrent exec sessions | 100 | Prevent unbounded goroutine growth |
| Concurrent stream sessions | 100 | Prevent unbounded goroutine growth |
| Rate-limiter IP table | 10,000 entries | Prevent rate-limiter map exhaustion |
Control 11 — Panic recovery
The outermost HTTP handler is wrapped with RecoveryMiddleware, which uses defer/recover to catch any panics in downstream handlers. On recovery it logs the full stack trace at ERROR level and returns HTTP 500. This prevents a single malformed request from crashing the agent process and taking the Docker proxy offline.
Control 12 — Structured audit log and external tamper evidence
Set AUDIT_LOG to stdout, stderr, or a file path to enable structured JSON audit logging. Every security-relevant event produces a JSON line:
event | Triggered when |
|---|---|
api_request | Any authenticated API call completes |
auth_failure | An invalid credential is presented |
rate_limited | An IP is blocked by the rate limiter |
compose_op | A Docker Compose operation runs |
exec_start | An interactive exec tunnel opens |
Example lines:
{"time":"2026-01-15T10:23:45.123Z","level":"INFO","event":"api_request","actor":"203.0.113.42","method":"POST","path":"/_portwing/compose","outcome":"allowed","status":200,"duration_ms":3.14}
{"time":"2026-01-15T10:23:45.200Z","level":"INFO","event":"compose_op","actor":"203.0.113.42","operation":"up","stack":"nginx-stack","outcome":"allowed"}
{"time":"2026-01-15T10:24:01.500Z","level":"INFO","event":"exec_start","actor":"203.0.113.42","container":"abc123def456","exec_id":"e7f8a9b1","outcome":"allowed"}The persistent audit sink is disabled by default (AUDIT_LOG unset), while the
in-memory ring retains 256 recent records by default for the audit APIs. Set
AUDIT_BUFFER_SIZE=0 to disable that retention. Configured log files are opened
in append-only mode at 0600.
The local file is not cryptographically tamper-evident: a principal that can rewrite the host filesystem can truncate, replace, or delete it. For tamper evidence, ship records immediately to append-only or WORM storage under separate credentials and monitor delivery gaps.
See Audit Logging for the full schema and aggregator integration notes.
Control 13 — Signed and verifiable releases
Every release is built in GitHub Actions with reproducible flags (CGO_ENABLED=0 -trimpath -s -w) and attested with:
- Cosign keyless signatures — both the checksums file and the container image are signed via GitHub Actions OIDC. No operator-managed signing keys are required.
- CycloneDX SBOMs — every archive has a sibling named
<archive>.cyclonedx.json; the image carries a separate Buildx OCI SBOM attestation covering its OS packages. - SLSA provenance — for public releases, GitHub build provenance covers every asset listed in
checksums.txt, including archives, packages, and per-archive SBOMs, plus the container manifest.
Verify a release before running it:
VERSION=0.9.1
# Verify the checksums file
cosign verify-blob \
--certificate-identity "https://github.com/CodesWhat/portwing/.github/workflows/release.yml@refs/tags/v${VERSION}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--bundle "checksums.txt.bundle" \
"checksums.txt"
# Verify the container image
cosign verify \
--certificate-identity "https://github.com/CodesWhat/portwing/.github/workflows/release.yml@refs/tags/v${VERSION}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"ghcr.io/codeswhat/portwing:${VERSION}"See Verification for SBOM inspection and SLSA provenance verification.
Control 14 — Static-site browser policy
The marketing and documentation exports generate a Vercel Build Output API
header policy from the final HTML during each build. Every route receives
X-Content-Type-Options: nosniff, X-Frame-Options: DENY, a strict referrer
policy, and a restrictive permissions policy. Each HTML route also receives a
Content Security Policy whose script-src contains only self plus SHA-256
hashes of that route's generated inline scripts; arbitrary inline JavaScript
is not allowed. frame-ancestors 'none', object-src 'none', and
base-uri 'self' further reduce browser-side injection and framing exposure.
The build packages the exact rendered files and their matching route policy in
.vercel/output, preventing local and remote builds from deploying stale
script hashes.
CVE mapping
The table below maps publicly known vulnerability classes to the Portwing controls that structurally prevent them. "Structurally prevents" means the architecture makes exploitation impossible regardless of token or config values — not merely that a patch has been applied.
| CVE / Advisory | Vulnerability class | Portwing control | Why it cannot apply |
|---|---|---|---|
| CVE-2024-41110 | Docker AuthZ plugin bypass via zero-length Content-Length body (CVSS 9.9) | Control 1 — proxy-first | Portwing does not use Docker's AuthZ plugin mechanism; authentication is at the Portwing HTTP layer before the request reaches Docker |
| Docker AuthZ plugin bypass via oversized body | Oversized request body silently dropped before the AuthZ plugin sees it while the daemon still executes the request (incomplete fix for the zero-length-body class above) | Control 1 — proxy-first | Same as CVE-2024-41110; the body-stripping behaviour in Docker's plugin dispatch path is irrelevant because Portwing's auth decision is enforced upstream of it |
| Unauthenticated Docker socket exposure | Proxy forwards to remote agent before verifying the original request is authenticated | Control 1 — proxy-first | Portwing's auth middleware wraps the handler, not the other way around; it cannot be bypassed by ordering |
| Registry-credential exfiltration / request smuggling | Plugin API paths absent from the proxy authorization map, allowing unauthorized users to call privileged endpoints | Control 1 — proxy-first | Portwing's catch-all handler authenticates all paths; there is no per-path allowlist to misconfigure by omission |
| CVE-2025-64419 / CVE-2025-66209–66213 | Coolify: command injection via docker-compose.yaml content allowing root RCE (CVSS up to 10.0) | Control 8 — Compose input guards | Path-traversal protection, env-var key/denylist validation, and service-name flag-injection filter cover this attack surface |
Portwing is a privileged agent: an authenticated caller has full Docker API access. The controls above prevent unauthenticated or improperly authorized access and certain classes of injection attacks. They do not restrict what a legitimately authenticated caller can do with the Docker API. Treat the Portwing token or private key as root-equivalent.
Container env vars are not redacted on /api/containers
GET /api/containers and the container inventory synced to Drydock over the edge WebSocket (dd:container_sync / dd:container_added / _updated) include each container's environment variables as plaintext key/value pairs (RuntimeDetails.Env in internal/adapter/containers.go). This is deliberate: Portwing does not redact or filter env values on this surface, and leaves that responsibility to Drydock (or another downstream consumer) if redaction before display or storage is required.
This is distinct from the MCP inspect_container tool, which reports only an env-var count and never the values — see the README. Any authenticated caller of /api/containers, and any system that receives the edge sync, sees full env var values. This is most relevant to standalone or no-Sockguard deployments, where the caller/consumer trust boundary is wider than "operator only."
Related pages
- Authentication — token, Argon2id hash, and Ed25519 key setup
- Verification — cosign, SBOM, and SLSA provenance
- Audit Logging — structured JSON audit trail
- Sockguard — socket-level Docker API filter
Stability Policy
The semantic-versioning, deprecation, and cross-version compatibility guarantees Portwing will make at v1.0.
Drydock Integration
How Portwing serves as a Drydock agent — the Drydock-native container model, REST/SSE endpoints, the adapter that maps Drydock expectations onto Docker, and how a Drydock controller registers and communicates with the agent.