Conversation
## Problem The addon only hooked `request`, `responseheaders` and `dns_request`, so the allow/deny rules only ever saw HTTP/S and DNS. Anything mitmproxy could not parse as one of those fell through to its generic relay layers — `TCPLayer`, `UDPLayer`, `RawQuicLayer` — which copy bytes between client and server verbatim and fire no hook the addon listens to. A process in the agent container could therefore open a plain socket to any address and port and reach it. No hostname was needed, so refusing the DNS lookup did not help. Reported as GHSA-wxg8-36fw-3j2j. ## Fix **A `next_layer` hook.** Script addons run before mitmproxy's own `NextLayer` in the default addon chain, and `NextLayer` skips a decision another addon already made — so the addon keeps its own `NextLayer` instance, asks it what mitmproxy would pick, and replaces a raw relay layer with a `DenyLayer` that closes the client connection and never dials the server. Anything else is left exactly as the chooser built it. The hook fails closed: mitmproxy swallows an addon's exception and carries on, so the chooser call is wrapped and the layer is assigned before anything else can raise. **`connection_strategy=lazy` on the base mitmweb command.** Without it mitmproxy connects to the destination before `next_layer` runs, so a flow about to be denied has already sent a SYN — no payload, but still a signal out of the sandbox. Cursor already ran with this flag; it is not a streaming option, so it moved out of the Cursor-only streaming flags. **`rawtcp=false` on the base mitmweb command.** `next_layer` cannot reach one relay: on an HTTP `101 Switching Protocols` with a non-WebSocket upgrade, `HttpStream.flow_done` builds a `TCPLayer` child directly and emits no hook. A single request to an allowed host then became an unfiltered byte pipe — I reproduced a payload landing on an arbitrary IP:port this way with the `next_layer` hook already in place. With `rawtcp` off mitmproxy closes the connection instead. One cost: a plain TCP connection carrying something other than HTTP is now answered as a malformed HTTP request rather than by `DenyLayer`, so it is dropped without a log line. `DenyLayer` still handles UDP and QUIC, and remains the backstop if `rawtcp` is ever turned back on. Documented in network-rules.md. Nothing sandcat supports is lost: SSH is already unusable in the container (no keys, and git SSH remotes are rewritten to HTTPS), and traffic between containers in the same compose project never goes through the proxy. Docs: the README and index claimed the engine covered "all other TCP/UDP traffic". They now say what it does cover, and network-rules.md has a section on non-HTTP traffic. ## Verification Unit tests for the hook, and a live run against the real proxy stack (wireguard tunnel, real mitmproxy 12.2.3, a throwaway container in wg-client's network namespace as the agent, and an attacker-controlled listener on an isolated docker network): | | before | after | |---|---|---| | raw TCP payload to an arbitrary IP:port | delivered | dropped | | raw UDP payload to an arbitrary IP:port | delivered | dropped | | payload via a non-WebSocket 101 upgrade | delivered | dropped | | SYN reaching the denied destination | yes | no | | HTTPS GET (allowed rule) | 200, h2 | 200, h2 | | HTTPS POST (denied rule) | 403 | 403 | | DNS | resolves | resolves |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The addon only hooked
request,responseheadersanddns_request, so the allow/deny rules only ever saw HTTP/S and DNS. Anything mitmproxy could not parse as one of those fell through to its generic relay layers —TCPLayer,UDPLayer,RawQuicLayer— which copy bytes between client and server verbatim and fire no hook the addon listens to.A process in the agent container could therefore open a plain socket to any address and port and reach it. No hostname was needed, so refusing the DNS lookup did not help.
Reported as GHSA-wxg8-36fw-3j2j.
Fix
A
next_layerhook. Script addons run before mitmproxy's ownNextLayerin the default addon chain, andNextLayerskips a decision another addon already made — so the addon keeps its ownNextLayerinstance, asks it what mitmproxy would pick, and replaces a raw relay layer with aDenyLayerthat closes the client connection and never dials the server. Anything else is left exactly as the chooser built it. The hook fails closed: mitmproxy swallows an addon's exception and carries on, so the chooser call is wrapped and the layer is assigned before anything else can raise.connection_strategy=lazyon the base mitmweb command. Without it mitmproxy connects to the destination beforenext_layerruns, so a flow about to be denied has already sent a SYN — no payload, but still a signal out of the sandbox. Cursor already ran with this flag; it is not a streaming option, so it moved out of the Cursor-only streaming flags.rawtcp=falseon the base mitmweb command.next_layercannot reach one relay: on an HTTP101 Switching Protocolswith a non-WebSocket upgrade,HttpStream.flow_donebuilds aTCPLayerchild directly and emits no hook. A single request to an allowed host then became an unfiltered byte pipe — I reproduced a payload landing on an arbitrary IP:port this way with thenext_layerhook already in place. Withrawtcpoff mitmproxy closes the connection instead.One cost: a plain TCP connection carrying something other than HTTP is now answered as a malformed HTTP request rather than by
DenyLayer, so it is dropped without a log line.DenyLayerstill handles UDP and QUIC, and remains the backstop ifrawtcpis ever turned back on. Documented innetwork-rules.md.Nothing sandcat supports is lost: SSH is already unusable in the container (no keys, and git SSH remotes are rewritten to HTTPS), and traffic between containers in the same compose project never goes through the proxy.
Docs: the README and index claimed the engine covered "all other TCP/UDP traffic". They now say what it does cover, and
network-rules.mdhas a section on non-HTTP traffic.Verification
Unit tests for the hook, and a live run against the real proxy stack — wireguard tunnel, real mitmproxy 12.2.3, a throwaway container in wg-client's network namespace standing in for the agent, and an attacker-controlled listener on an isolated docker network:
Real agent container
The table above was measured with throwaway containers joined to
wg-client's network namespace. Becauserawtcp=falsechanges how every non-HTTP byte stream on a TCP port is handled, I also built the actual agent image (Dockerfile.app, node + python stacks) and ran the full stack, with the project rules set to thenode,pythonandgithubpresets:https://api.anthropic.com/v1/modelshttps://api.github.com/-k)git clone --depth 1+git ls-remoteover HTTPSnpm init+npm install left-padregistry.npmjs.org,pypi.org/simple/six/,astral.shfiles.pythonhosted.org/— the round trip is what matters)https://example.com/(not in the presets)DNS deny/dev/tcpserver connectfor it in the proxy logOn that last row: in wireguard mode mitmproxy is itself the TCP endpoint, so the socket opens and the write succeeds locally whatever the policy. The proxy log is the authoritative check — every
server connect 1.1.1.1entry was port 53, the configured DNS upstream, and none were the test's port.Two things I could not exercise, neither related to this change: the python stack image ships no
pip,uvorensurepip, so PyPI was only reached over plain HTTPS rather than through an installer; and devbox's own update check toreleases.jetify.comis denied, which is correct for the preset list I used (nixcoverssearch.devbox.sh, not that host).Not fixed here
The fix depends on mitmproxy internals —
mitmproxy.addons.next_layer.NextLayer, the layer classes, and the101code path. The version pin incli/lib/constants.bashis load-bearing; a bump needs mitmproxy'snext_layer.pyre-read againstRAW_RELAY_LAYERS. If any of those imports disappear the addon fails to load,dns.confis never written, the healthcheck never passes and the sandbox does not start — it fails closed.Separately: the rules match the hostname the client supplies (
Hostheader or SNI), and nothing binds it to the address the container actually connected to. An allowed host string therefore reaches any IP. That is a different bug from this one and needs its own fix.🤖 Generated with Claude Code