Skip to content

fix(proxy): drop raw TCP/UDP flows instead of relaying them - #130

Open
adamw wants to merge 1 commit into
masterfrom
fix/block-raw-tcp-udp
Open

adamw wants to merge 1 commit into
masterfrom
fix/block-raw-tcp-udp

Conversation

@adamw

@adamw adamw commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

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 standing in for 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

Real agent container

The table above was measured with throwaway containers joined to wg-client's network namespace. Because rawtcp=false changes 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 the node, python and github presets:

check result
https://api.anthropic.com/v1/models 401 over h2 — reachable, key absent
https://api.github.com/ 200 over h2, verified TLS (no -k)
git clone --depth 1 + git ls-remote over HTTPS ok
npm init + npm install left-pad ok
registry.npmjs.org, pypi.org/simple/six/, astral.sh 200 over h2
files.pythonhosted.org 404 over h2 (no index at / — the round trip is what matters)
https://example.com/ (not in the presets) refused at DNS, DNS deny
raw TCP from the agent via bash /dev/tcp no server connect for it in the proxy log

On 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.1 entry 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, uv or ensurepip, so PyPI was only reached over plain HTTPS rather than through an installer; and devbox's own update check to releases.jetify.com is denied, which is correct for the preset list I used (nix covers search.devbox.sh, not that host).

Not fixed here

The fix depends on mitmproxy internals — mitmproxy.addons.next_layer.NextLayer, the layer classes, and the 101 code path. The version pin in cli/lib/constants.bash is load-bearing; a bump needs mitmproxy's next_layer.py re-read against RAW_RELAY_LAYERS. If any of those imports disappear the addon fails to load, dns.conf is never written, the healthcheck never passes and the sandbox does not start — it fails closed.

Separately: the rules match the hostname the client supplies (Host header 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

## 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 |
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant