<feed xmlns='http://www.w3.org/2005/Atom'>
<title>mitmux/internal/proxy/proxy.go, branch main</title>
<subtitle>Terminal-based intercepting HTTP proxy.
</subtitle>
<id>https://srdusr.com/git/mitmux/atom?h=main</id>
<link rel='self' href='https://srdusr.com/git/mitmux/atom?h=main'/>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/'/>
<updated>2026-06-30T12:52:00+00:00</updated>
<entry>
<title>WebSocket interception</title>
<updated>2026-06-30T12:52:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-06-30T12:52:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=384573a2dc5e3b8e2a7bdfe2ce949f2c52ba2c52'/>
<id>urn:sha1:384573a2dc5e3b8e2a7bdfe2ce949f2c52ba2c52</id>
<content type='text'>
The last "known limitation": a ws://wss:// connection stops being
one-shot request/response the instant its 101 Switching Protocols
lands, and forward()'s normal write-response-then-record flow has no
way to represent that. Scoped to HTTP/1.1 client legs (HTTP/2 can't be
hijacked for raw post-response access the way HTTP/1.1 can, and
browsers open a dedicated HTTP/1.1 connection for WebSocket regardless
of the surrounding page's protocol, so this isn't a real-world gap).

internal/proxy/websocket.go decodes each RFC 6455 frame's opcode and
payload for capture while relaying the exact same raw bytes it read
unmodified - this is capture, not tampering, matching the rest of the
codebase's raw-bytes-as-source-of-truth stance. One row per frame, not
per reassembled message (fragmentation is rare in real-world
WebSocket traffic; not worth buffering an unbounded number of pending
fragments to handle it). forward() branches on a matching 101 into
handleWebSocketUpgrade, which hijacks the client connection, relays
the handshake response raw, records the upgrade request/response to
history normally, then relays frames bidirectionally into a new
ws_messages table - reachable from the TUI's detail view via `w`.

Found and fixed two real bugs by actually driving a WebSocket
connection through a running daemon, not by reading the code:
stripHopByHop was deleting Connection/Upgrade from every outgoing
request (correct for an ordinary request per RFC 7230, catastrophic
for one asking to upgrade - every WebSocket attempt silently became a
426); and the relay tore the whole connection down the instant either
side saw a close frame, before the peer's own close-frame reply could
be relayed back, producing an abrupt EOF instead of a clean close.

Verified live end to end on both paths a real client uses: ws://
(plain HTTP forward-proxying) against a Python websockets echo
server, and wss:// (CONNECT-tunneled, TLS-intercepted) against the
same server behind TLS - text, binary, and extended-length frames,
plus a full close handshake with both directions' close frames
present, confirmed via the actual bytes captured in ws_messages.
</content>
</entry>
<entry>
<title>SOCKS5 upstream proxy chaining</title>
<updated>2026-06-24T12:40:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-06-24T12:40:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=e01afcbf0de00af52fe90959ba77288679e3303d'/>
<id>urn:sha1:e01afcbf0de00af52fe90959ba77288679e3303d</id>
<content type='text'>
Extends -upstream-proxy to accept a socks5://[user:pass@]host:port
prefix, using golang.org/x/net/proxy (already an indirect dependency
via http2, so no new module) rather than hand-rolling the client side
of RFC 1928/1929. parseSOCKS5 is the single place that decides which
kind of upstream a given UpstreamProxy string names; dialViaProxy
(CONNECT/TLS path) and dialUpstreamPlain (plain-HTTP path) both check
it first and fall through to the existing HTTP CONNECT behavior
otherwise. SOCKS5 needs no absolute-form request adjustment on the
plain-HTTP path the way HTTP-proxy chaining does, since it tunnels
straight to the target rather than expecting a proxy-aware request.

Tested against a real, minimal SOCKS5 server built for the test suite
(exercises dialSOCKS5's actual wire behavior, not a mock of the
client library), plus live against a real standalone SOCKS5 relay
process: both a plain HTTP and an HTTPS request through mitmux were
confirmed, via the relay's own log, to have actually traversed it.
</content>
</entry>
<entry>
<title>Client (mutual-TLS) certificates</title>
<updated>2026-06-16T20:57:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-06-16T20:57:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=23c8ab359c2108654d57176e233d2c099b398f31'/>
<id>urn:sha1:23c8ab359c2108654d57176e233d2c099b398f31</id>
<content type='text'>
Adds internal/clientcert: a cert/key pair matched to hosts by the same
substring-or-regex pattern model as scope.Rule, so mitmux can present
a client certificate on an upstream TLS handshake that requires one -
the previous behavior was a hard handshake failure with no way to
authenticate. Wired into both places mitmux dials an https:// upstream
over its own TLS client connection: proxy.go's handleConnect (live
proxied traffic) and repeat.go's dialForRepeat (Repeater/Intruder
resends), both through a new Server.clientCertFor(host) helper.

Stored in a new client_certs table, mirroring the existing scope_rules
persistence pattern. The TUI (`t` from history) is add-only like
scope, for the same reason: delete and re-add covers changing
anything, and it's a rarely-touched, low-cardinality list. The add
form takes cert/key file paths and reads them once at save time - PEM
content, not the path, is what's stored and later presented, so a
cert keeps working even if the original file moves afterward.

Verified live against a real mutual-TLS-requiring origin server:
without a matching cert the handshake correctly fails; with one
configured, the origin receives it and the request succeeds; toggling
it off reproduces the failure, confirming the enable/disable path
works end to end.
</content>
</entry>
<entry>
<title>Body match-and-replace rules</title>
<updated>2026-06-09T13:46:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-06-09T13:46:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=a2362fc08c31b23fb971279f8b160555123ad2f6'/>
<id>urn:sha1:a2362fc08c31b23fb971279f8b160555123ad2f6</id>
<content type='text'>
Extends match-and-replace rules to request/response bodies, not just
headers. A body rule materializes the body into memory (bounded by
the same maxCaptureBytes cap as history capture) instead of streaming
it straight through - the opposite of the normal path, so it's only
paid when a body rule is actually configured. A body over the cap
passes through byte-exact and unmodified rather than being partially
rewritten.

Response Content-Length is recomputed explicitly when a rule changes
body length: unlike http.Request.Write, http.ResponseWriter doesn't
derive it from resp.ContentLength on its own, so a stale header would
otherwise corrupt response framing for the client.

The history audit trail still shows the original, pre-rule bytes on
both legs; only the wire traffic reflects the rewrite. Verified live
against a real daemon: origin receives the rewritten request body,
client receives the rewritten response body with correct
Content-Length, and history keeps the unmodified bytes.

Adds a Part selector (header/body) to the Rules add/edit form and
table in the TUI.
</content>
</entry>
<entry>
<title>Serve the CA certificate for browsers/mobile at http://mitmux.cert/</title>
<updated>2026-06-05T21:30:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-06-05T21:30:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=f3558f18ecabba983aba001468b06394471e6d8b'/>
<id>urn:sha1:f3558f18ecabba983aba001468b06394471e6d8b</id>
<content type='text'>
Browser/mobile setup previously meant "find ca.pem on disk and import
it manually" - awkward on a phone or tablet especially, which has no
convenient way to get a file onto the device at all short of emailing
it to yourself or similar. Any client already configured to proxy
through mitmux can now just visit http://mitmux.cert/ and get the cert
directly, with Content-Type: application/x-x509-ca-cert triggering
iOS/Android's native "install this certificate" prompt.

Same idea as mitmproxy's own http://mitm.it/, arrived at independently
rather than reusing their domain - mitmux.cert isn't a registered TLD,
so it can never collide with a real site someone meant to visit.
Deliberately HTTP-only: fetching it over HTTPS would require the client
to already trust mitmux's CA to MITM that very connection, the exact
chicken-and-egg problem this exists to solve, so it's not attempted on
the CONNECT/TLS path at all.

internal/proxy/proxy.go: isCertDownloadHost matches the hostname
case-insensitively regardless of port; handleHTTP checks it before
ever dialing upstream and answers directly via serveCACert, using the
CA's own CertPEM bytes already held in memory. Short-circuits before
record() is ever reached, so the download itself never pollutes
history.

Verified live: fetched http://mitmux.cert/ through a real running
proxy and confirmed the downloaded bytes are byte-identical to the
actual ca.pem on disk (diff, not just "the request succeeded");
confirmed a request with an explicit port and a path still matches;
confirmed normal proxying to an unrelated host is completely
unaffected; confirmed via direct SQLite query that the cert-download
requests never appear in history while a normal request in the same
session does.

Also documented (no code needed): any standard proxy-switcher extension
(FoxyProxy, etc.) or a phone/tablet's own Wi-Fi proxy setting already
works with mitmux exactly like it would with Burp/ZAP/Caido, since it's
a normal forward proxy speaking the standard protocol. This was true
before but never actually spelled out in the README for the phone/
tablet case specifically, which is a real, common daily workflow.

go build/vet/gofmt/test/mod tidy all clean.
</content>
</entry>
<entry>
<title>Target scope: filter what gets recorded, not what gets proxied</title>
<updated>2026-05-20T07:29:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-05-20T07:29:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=51811b67018515366bd56f3c3b21aed11d906db2'/>
<id>urn:sha1:51811b67018515366bd56f3c3b21aed11d906db2</id>
<content type='text'>
The proxy captured and stored literally everything with no way to
exclude unrelated traffic - every CDN asset, analytics beacon, and
third-party tracker request on a real engagement pollutes history and
search right alongside the traffic that actually matters.

internal/scope: Rule{Enabled, Pattern, IsRegex} and InScope(rules,
host). A non-regex pattern matches by case-insensitive substring
against the host - "example.com" matches "example.com",
"www.example.com", and "api.example.com" alike, covering "this domain
and its subdomains" without inventing a separate wildcard syntax.
IsRegex mirrors the same toggle match-and-replace rules already use,
for one consistent mental model across both rule types in this tool.
An empty or all-disabled rule set means everything is in scope - the
behavior before scope existed at all, unchanged, so a fresh install or
a user who never opens the scope view keeps recording everything
rather than silently nothing.

Deliberately a recording filter, not access control: out-of-scope
traffic still proxies completely normally, reaching its destination and
the client exactly as before. internal/proxy's forward() already writes
the response to the client before record() ever runs, so the scope
check (new in record()) can only affect whether the exchange gets
stored, never whether it happens. Blocking out-of-scope traffic outright
would be a materially different, much riskier feature - a wrong scope
pattern could silently break the very traffic someone's trying to test,
which is a far worse failure mode than a noisier history. Repeat/Intrude
(recordRaw, a separate function from record()) deliberately don't go
through the scope check at all: a user explicitly resending or fuzzing
a specific request wants to see the result regardless of scope, which
exists to cut passive-capture noise, not second-guess a deliberate
action.

internal/store: new scope_rules table (CREATE TABLE IF NOT EXISTS, no
migration needed - it's a new table, not a new column on an existing
one) plus List/Add/SetEnabled/Delete, mirroring the existing
match-and-replace rules CRUD exactly. internal/ipc: scope_list/
scope_add/scope_delete/scope_toggle request types and matching Client
methods; scope_add validates a regex pattern compiles before persisting,
same reasoning and same fix as the earlier rules_save validation (an
invalid regex should be rejected up front, not silently never match at
apply time with zero feedback).

TUI: 's' from the history list opens scope management, mirroring the
Rules view's own list+form pattern but simpler (add-only, no
edit-in-place - a pattern and a regex toggle don't need a five-field
form, delete-and-re-add covers changing one).

Verified live in tmux against a running daemon: added a substring scope
rule for one host, sent requests to both a matching and a non-matching
host - the non-matching one proxied successfully (client got its 200)
but was never recorded, the matching one was recorded normally;
confirmed a Repeater resend of the excluded host WAS recorded despite
being out of scope; toggled the rule off and confirmed recording
resumed for everything; added and confirmed a regex-mode rule saves and
displays correctly; deleted a rule and confirmed the list returns to
empty ("no rules means everything is recorded").

go build/vet/gofmt/test/mod tidy all clean.
</content>
</entry>
<entry>
<title>Bound slow-loris connections and hung TLS handshakes</title>
<updated>2026-05-16T09:49:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-05-16T09:49:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=1707cf827592bc3de7ea9c2805b7adea49b3cbc5'/>
<id>urn:sha1:1707cf827592bc3de7ea9c2805b7adea49b3cbc5</id>
<content type='text'>
Neither the main proxy's http.Server nor the per-CONNECT-tunnel one had
any timeouts - a client that opened a connection and either trickled
request headers forever or, on the CONNECT/HTTPS path, completed the
CONNECT handshake and then never sent a TLS ClientHello at all, held
that connection and its goroutine open indefinitely. Confirmed live
before the fix: partial headers with no terminator, and a completed
CONNECT with no ClientHello, both held the connection open 10s+ with no
sign of ever stopping. Low real-world risk at the 127.0.0.1 default,
but multi-listener support means mitmuxd can now bind other interfaces,
making this a real DoS-by-neglect surface rather than a purely
theoretical one.

clientHeaderTimeout (30s) is applied as ReadHeaderTimeout on both
http.Server instances, and clientIdleTimeout (120s) as IdleTimeout on
both - deliberately narrow, bounding only the pre-body header-parsing
phase and idle time between keep-alive requests, not overall request
duration, so a legitimately slow multi-minute upload/download still
works exactly as before (verified: normal HTTP and HTTPS requests both
still succeed after this change).

The CONNECT-tunnel's TLS handshake specifically had no deadline at all
before calling clientTLS.Handshake() - unlike the upstream leg, which
already correctly calls conn.SetDeadline before its own round trip (see
forward()). Now client.SetDeadline(...) is set with the same
clientHeaderTimeout right before the handshake and cleared immediately
after a successful one, so the request/response phase that follows
doesn't inherit a stale handshake-only deadline.

Verified live: a client sending a partial request line with no
terminator was cut off at exactly 30.1s (previously indefinite); a
client completing CONNECT and then never sending a ClientHello was cut
off at exactly 30.1s (previously indefinite); normal HTTP and HTTPS
requests through the proxy both still succeed afterward.

go build/vet/gofmt/test/mod tidy all clean.
</content>
</entry>
<entry>
<title>Stop mislabeling truncated captures as "exact"</title>
<updated>2026-05-15T17:48:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-05-15T17:48:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=dd1621c0077e079e0693f16fe83f17d50338216e'/>
<id>urn:sha1:dd1621c0077e079e0693f16fe83f17d50338216e</id>
<content type='text'>
internal/proxy/tee.go's teeConn silently drops bytes past
maxCaptureBytes (10 MiB) but callers unconditionally marked the result
"exact" anyway. Confirmed live: proxying a 15 MiB response worked
correctly end-to-end (the client got the full, real 15 MiB - proxying
itself is unbounded, only storage is capped), but the stored history
entry was exactly 10485760 bytes with response_exact=1 still set. For a
tool whose core value proposition is "raw bytes are the source of
truth," a silently truncated capture presented as complete could hide
the very evidence a smuggling or parser-differential investigation is
looking for in the tail of a large body - and give false confidence
that it isn't there.

teeConn.Take() now returns (data, truncated) instead of just data;
truncated is true whenever a Read had to drop bytes because the buffer
was already at cap. Every caller (proxy.go's forward() on both the
request and response side, repeat.go's sendRaw for Repeater/Intruder)
now folds truncated into exact - a truncated capture is never marked
exact - and additionally threads a distinct RequestTruncated/
ResponseTruncated bool through store.Entry, ipc.EntryDetail, and the
TUI, since "truncated" and "reconstructed" (HTTP/2, which never had
wire-exact bytes to begin with) are different situations worth telling
apart: a truncated capture is still real wire bytes, just incomplete,
not a synthesized reconstruction. The detail/repeater views now show
"truncated (hit capture size limit)" specifically rather than lumping
it in with "reconstructed", which would have implied more
transformation happened than actually did.

Schema: history gains request_truncated/response_truncated columns via
the same ALTER-TABLE-and-ignore-duplicate-column pattern already used
for source/flagged, so existing databases upgrade in place.

Verified live: a target server returning a 15 MiB body (over the 10 MiB
cap) proxied through cleanly - full body reached the client - while the
stored entry shows length=10485760, response_exact=0,
response_truncated=1 (previously would have shown response_exact=1);
the TUI's Detail view correctly displays "Response (10485760 bytes,
truncated (hit capture size limit))" instead of "exact".

go build/vet/gofmt/test/mod tidy all clean.
</content>
</entry>
<entry>
<title>Multiple proxy listeners and upstream proxy chaining</title>
<updated>2026-04-08T14:11:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-04-08T14:11:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=7b9bf73e46102969d5802300469862296f979ef6'/>
<id>urn:sha1:7b9bf73e46102969d5802300469862296f979ef6</id>
<content type='text'>
Closes the last two items from the original "worth considering" list.

Multiple listeners: -listen takes a comma-separated address list
(-listen "127.0.0.1:8080,127.0.0.1:8081"). Server.Addr became
Server.Addrs; ListenAndServe binds every address up front - before any
of them start serving - so a bad address fails startup immediately
rather than leaving the daemon partially listening, and rolls back
already-opened listeners if a later one fails to bind. All addresses
share the same handler/history/CA/rules: one logical proxy reachable on
more than one address, not several independent proxies in one process.

Upstream proxy chaining: -upstream-proxy host:port (optional http://
prefix, stripped for convenience) routes every outbound connection
through another HTTP CONNECT proxy instead of dialing origins directly.
dialViaProxy does the CONNECT handshake to the upstream and hands back
a plain net.Conn as if it were a direct connection; dialUpstreamTLS
(CONNECT/HTTPS path) and dialUpstreamPlain (plain-HTTP path) both take
an upstreamProxy parameter and route through it when set. The two paths
need different handling: CONNECT/HTTPS is transparent below the tunnel
(once the CONNECT handshake succeeds, TLS and the request on top of it
look identical to a direct connection, so roundTripH2 and the H1 read
side need no changes at all), but plain HTTP has to send an
absolute-form request line to the upstream proxy instead of origin-form
- so roundTripH1 gained a proxyForm parameter, and forward() selects it
based on scheme=="http" &amp;&amp; UpstreamProxy!="".

Chaining into another intercepting/MITM proxy (including another
mitmuxd) needs that proxy's own CA trusted too, or TLS verification
fails - this is inherent to chaining MITM proxies, not a gap here, and
confirmed live below rather than left as a guess.

internal/proxy/dialer_test.go: dialViaProxy against a real local CONNECT
stub (not a mock) - direct dial, successful tunnel-and-echo through a
proxy, and a proxy that refuses the CONNECT with a non-200. All three
exercise the actual network code path, not just the string-building
around it.

Verified live: started a daemon with two -listen addresses, sent
requests through both, confirmed a single shared history; killed it
mid-flight with SIGTERM and confirmed both listeners closed cleanly;
started it with one bad address in the list and confirmed startup
failed immediately with the already-bound port released, no lingering
process. For chaining: sent plain HTTP and HTTPS through a downstream
mitmuxd configured with -upstream-proxy pointing at a genuine
passthrough CONNECT stub (tunnels raw bytes, doesn't MITM) and got real
content back on both; separately chained through a second mitmuxd
instance and got the expected "certificate signed by unknown authority"
error, cleanly recorded in history rather than hanging.

go build/vet/gofmt/test/mod tidy all clean.
</content>
</entry>
<entry>
<title>Match-and-replace: header rewrite rules</title>
<updated>2024-09-23T19:33:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2024-09-23T19:33:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=c2443f27ef5a844f045c038c7689d217d1dbf0c4'/>
<id>urn:sha1:c2443f27ef5a844f045c038c7689d217d1dbf0c4</id>
<content type='text'>
Implements build-order step 6, scoped to headers only for this pass -
see PLAN.md for why bodies are a separate problem (request-body capture
currently depends on streaming straight through, which a body-rewriting
rule would have to interrupt; deciding what "exact" means for a
rule-modified request needs its own pass, not a rushed add-on to this
one).

internal/rules: Rule type and ApplyHeaders, which serializes a Header
map to a raw "Name: value\r\n" block, runs enabled rules' match/replace
over that text, and reparses it - operating on text rather than
per-value substitution is what lets a rule add or remove a header, not
just rewrite one, matching how Burp's header match/replace works.
Invalid rule output (bad regex, unparseable result) leaves the header
map untouched rather than corrupting the request.

internal/store: rules table + CRUD. internal/proxy: forward() fetches
enabled rules for each scope and applies them to outReq.Header /
resp.Header, positioned so the existing capture/history pipeline is
untouched - request_raw keeps showing what the client actually sent and
response_raw what the origin actually sent, while the wire itself
reflects the rules. Deliberate split: match-and-replace transforms
traffic, it doesn't rewrite the audit trail. internal/ipc gains
rules_list/rules_save/rules_delete/rules_toggle. cmd/mitmux gains a
rules view ('m' from history) with add/edit/delete/toggle and a small
form (name, match, replace, scope, regex).

Verified live against real external traffic, not just local echoes:
a request-scope rule rewriting User-Agent, confirmed via httpbin.org's
own header echo that the origin received the rewritten value while curl
sent the real one; a response-scope rule rewriting the Server header,
confirmed the client actually received the rewritten value; disabling a
rule confirmed via a follow-up request that it stops applying; and
throughout, history continued showing the pre-rule original on both
sides, confirming the capture/transform split holds.
</content>
</entry>
</feed>
