This document describes the threat model, security guarantees, and audit guidance for Devcontainer Bridge (dbr).
- Network exposure of forwarded ports. All listeners bind to loopback only (
127.0.0.1/[::1]). A forwarded port is never reachable from the network. - Arbitrary command execution from containers. The host daemon accepts a fixed set of protocol messages. A container cannot instruct the host to run arbitrary commands. The only host-side actions are binding loopback ports and opening validated URLs.
- URL-based attacks. Only
http://andhttps://URLs are accepted. Schemes likefile://,javascript:, andftp://are rejected. URLs are length-capped and rate-limited. - Resource exhaustion. Control messages are capped at 64 KB. Per-container forward limits (128), total container limits (64), and rate limiting on URL opens (5/sec) prevent a runaway or malicious container from overwhelming the host.
- Protocol abuse. Malformed JSON, oversized messages, and unexpected message types are handled gracefully without crashing.
- Malicious code on the host. If an attacker has code execution on the host, they can already access anything on loopback.
dbrdoes not make this worse. - Container-to-container isolation. Containers are isolated by Docker networking.
dbrdoes not create cross-container communication paths. - Encrypted transport. The control and data channels use plaintext TCP on loopback. Since both endpoints are on the same machine (or within the Docker Desktop VM), TLS is unnecessary — the same trust boundary as Docker Desktop port publishing,
kubectl port-forward, and SSH-Ltunnels. - Authentication between daemons. Any process on the host can connect to the control port. This matches the security model of Docker Desktop (any local process can talk to the Docker socket) and
kubectl(any local process can use the forwarded port).
dbr uses a two-tier binding model that balances container reachability with host security:
| Listener | Default bind | Port | Source |
|---|---|---|---|
| Control channel | auto-detected | 19285 | src/control.rs |
| Data channel | auto-detected | 19286 | src/host/mod.rs |
The bind address for control and data ports is auto-detected at startup:
--bind-addrexplicitly set -- uses the specified address, no auto-detection.--no-docker-detectflag set -- binds to127.0.0.1, no auto-detection.- Docker detected (via
docker info) -- binds to0.0.0.0(all interfaces) so containers can reach the host via Docker Desktop's gateway IP (host.docker.internal). - No Docker detected -- binds to
127.0.0.1(loopback only).
This auto-detection means the daemon only exposes ports on all interfaces when Docker is actually running, minimizing unnecessary network exposure. Binding to 0.0.0.0 is required for Docker Desktop on macOS, where containers reach the host via a gateway IP (host.docker.internal resolves to an address like 192.168.65.254), not via 127.0.0.1.
When bound to 0.0.0.0, this is safe because:
- The control protocol is designed for untrusted clients: all messages are validated, bounded (64 KB), and parsed strictly. Unknown message types, oversized fields, and malformed JSON are rejected.
- Resource limits prevent abuse: max 64 containers, max 128 forwards per container, max 1024 pending connections, 5 URL opens/sec rate limit.
- No privileged operations are exposed: the only host-side actions are binding loopback ports (Tier 2) and opening validated HTTP/HTTPS URLs.
- The
--bind-addrand--no-docker-detectflags allow explicit control over the bind address.
| Listener | Bind address | Port | Source |
|---|---|---|---|
| Forwarded ports | [::1] then 127.0.0.1 |
per-port | src/host/listener.rs:48-62 |
Forwarded ports always bind to loopback only ([::1] with 127.0.0.1 fallback), regardless of the --bind-addr setting. These ports expose container services to the host user and must never be network-accessible.
Use --bind-addr or --no-docker-detect to control which interfaces the control and data ports listen on:
# Default: auto-detect (0.0.0.0 if Docker running, 127.0.0.1 otherwise)
dbr host-daemon
# Explicit bind address (overrides auto-detection)
dbr host-daemon --bind-addr 0.0.0.0
# Restrict to loopback only (skip Docker detection)
dbr host-daemon --no-docker-detect
# Restrict to loopback only (explicit)
dbr host-daemon --bind-addr 127.0.0.1The two-tier approach follows established patterns:
- Docker Desktop — publishes container ports to
localhostonly, but its own API socket listens on all interfaces within the VM - kubectl port-forward — binds to
127.0.0.1by default for user-facing ports - SSH local forwarding (
-L) — binds to loopback by default, butGatewayPortsallows binding to all interfaces when needed
On a typical developer workstation, the control and data ports accept only the dbr protocol (not arbitrary user traffic), making network exposure low-risk. Forwarded ports, which expose actual container services, remain loopback-only.
When a container sends an OpenUrl message, the host daemon validates the URL before passing it to open (macOS) or xdg-open (Linux).
- Scheme whitelist: Only
http://andhttps://are accepted (case-insensitive on both the container client and host daemon). All other schemes (file://,ftp://,javascript:,data:, etc.) are rejected withBrowserError::InvalidScheme. - Length cap: URLs longer than 2048 characters are rejected with
BrowserError::UrlTooLong. - Control character rejection: URLs containing ASCII control characters (newlines, null bytes, tabs, etc.) are rejected with
BrowserError::InvalidCharacters. This prevents log injection and argument confusion. - Rate limiting: A sliding window allows at most 5 URL opens per second. Excess requests are rejected with
BrowserError::RateLimited.
The URL is passed as a single argument to Command::new("open").arg(url) (or xdg-open). It is not passed through a shell, so shell metacharacters in the URL cannot cause command injection. See src/host/browser.rs:144-150.
When a container port is forwarded to a different host port (due to conflicts), localhost:PORT and 127.0.0.1:PORT in URLs are rewritten to use the correct host port. Only these two host patterns are rewritten — external hostnames are never modified.
Every control message is bounded to 64 KB (MAX_MESSAGE_SIZE in src/control.rs:17). The read_message function uses a bounded read strategy: it checks buffer length against the limit before allocating, preventing memory exhaustion from a peer that sends data without a newline terminator.
- Messages must be valid JSON matching a known
Messagevariant (internally tagged with"type"). - Unknown
"type"values are deserialization errors. - Unknown fields within a known message type are deserialization errors (
deny_unknown_fieldsis enforced on theMessageenum andForwardInfo). - Missing required fields are deserialization errors.
- Port values (
u16) that are negative or exceed 65535 are rejected by serde deserialization. - The data channel handshake (
ConnectReady) uses the same bounded read, then switches to raw TCP proxying — no further JSON parsing occurs on data connections.
container_idandhostnameinRegistermessages are limited to 256 characters. Oversized identifiers are rejected withRegisterAck{success: false}.conn_idvalues inConnectReadyandConnectFailedare limited to 128 characters. Oversized values are silently dropped.
| Limit | Value | Location |
|---|---|---|
| Max containers | 64 | src/host/mod.rs:47 |
| Max forwards per container | 128 | src/host/mod.rs:50 |
| Max pending connections | 1024 | src/host/proxy.rs:65 |
| Control message size | 64 KB | src/control.rs:17 |
| Container ID / hostname length | 256 chars | src/host/mod.rs:53 |
| Connection ID length | 128 chars | src/host/mod.rs:56 |
| URL length | 2048 chars | src/host/browser.rs:15 |
| URL open rate | 5/sec | src/host/browser.rs:18 |
| ConnectRequest timeout | 10 sec | src/host/proxy.rs:20 |
| Heartbeat interval | 30 sec | src/host/mod.rs:38 |
| Missed pongs before disconnect | 3 | src/host/mod.rs:41 |
The host daemon sends Ping messages every 30 seconds on each container's control connection. If 3 consecutive pings go unanswered, the container is considered dead and all its forwards are torn down. This prevents leaked listeners from accumulating when containers crash without a clean disconnect.
Neither daemon requires root or elevated privileges:
- Container daemon reads
/proc/net/tcpand/proc/net/tcp6, which are world-readable in Linux. - Container daemon binds no ports — it only initiates outbound TCP connections.
- Host daemon binds to unprivileged ports (19285, 19286, and forwarded ports >= 1024 by default).
- No Docker socket access. The container daemon does not need
/var/run/docker.sock. It communicates with the host daemon over TCP only.
The minimum forwarded port is configurable (default: 1024) to prevent privileged port forwarding without explicit opt-in.
All security-relevant events are logged with structured fields:
- Container registration and disconnection (with container ID and hostname)
- Port forward and unforward (with container ID, port, process name, PID)
- URL opens (with original and rewritten URL)
- Port conflicts and alternative port assignment
- Rate limit rejections
- Heartbeat timeouts
- Connection errors
For integration with SIEM or log aggregation tools, use --log-format json:
dbr host-daemon --log-format json --log-file /var/log/dbr.json
Each log line is a JSON object with timestamp, level, target, message, and structured fields.
| Level | What is logged |
|---|---|
error |
Unrecoverable failures (bind errors, internal errors) |
warn |
Rejected operations (rate limits, invalid URLs, oversized messages) |
info |
Normal operations (register, forward, unforward, URL open, disconnect) |
debug |
Connection-level details (accept, bridge, heartbeat) |
trace |
Raw protocol messages (for debugging) |
The TOML config file parser (~/.config/dbr/config.toml) uses deny_unknown_fields to reject unrecognized field names. This prevents silent misconfiguration from typos — a field like contrl_port (misspelled) will cause an error instead of being silently ignored with the default value taking effect.
- Static binaries: Release binaries are statically linked (musl on Linux), with no runtime dependencies.
- SHA256 checksums: Every release publishes checksums alongside the binaries for verification.
- No unsafe code: The project targets zero
unsafeblocks. All memory safety is enforced by the Rust compiler. - Dependency auditing:
cargo auditandcargo deny checkare run in CI to catch known vulnerabilities and license issues in dependencies.
The project uses a focused set of well-maintained crates:
| Crate | Purpose |
|---|---|
tokio |
Async runtime |
serde + serde_json |
Protocol serialization |
clap |
CLI parsing |
tracing + tracing-subscriber |
Structured logging |
thiserror |
Error types |
uuid |
Connection ID generation |
After starting the host daemon, confirm the two-tier binding model:
macOS:
# Show all dbr listeners
lsof -iTCP -sTCP:LISTEN -P -n | grep dbrLinux:
# Show all listeners bound by the dbr process
ss -tlnp | grep dbrExpected output:
- Control port (19285) and data port (19286) should be on
*:PORTor0.0.0.0:PORT(when Docker detected or--bind-addr 0.0.0.0used) or127.0.0.1:PORT(when no Docker detected,--no-docker-detectused, or--bind-addr 127.0.0.1used). - Forwarded ports should always show
127.0.0.1:PORTor[::1]:PORT. If you see0.0.0.0on a forwarded port, something is wrong.
# Inside the container — should NOT be present
ls -la /var/run/docker.sock
# Expected: No such file or directory# From the project root
cargo geiger
# Or search manually:
grep -r "unsafe" src/ --include="*.rs"cargo audit
cargo deny check# Send an oversized message to the control port — should be rejected
python3 -c "import socket; s=socket.socket(); s.connect(('127.0.0.1',19285)); s.send(b'x'*70000+b'\n'); print(s.recv(1024))"The daemon should handle this gracefully without crashing or allocating excessive memory.