<feed xmlns='http://www.w3.org/2005/Atom'>
<title>mitmux/PLAN.md, 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-08-28T07:42:00+00:00</updated>
<entry>
<title>Fourth plugin: bpscanner, a Backslash Powered Scanner-style detector</title>
<updated>2026-08-28T07:42:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-08-28T07:42:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=2ee4a95c9ccc701482c88f58840568738354dc36'/>
<id>urn:sha1:2ee4a95c9ccc701482c88f58840568738354dc36</id>
<content type='text'>
- Phase 1 plugin ecosystem complete

The last of the four "cheap IPC win" plugins identified in the
original research pass. Different mechanism from the other three,
deliberately: where paramminer finds parameters that shouldn't exist,
bpscanner tests parameters that already do, asking a more general
question than a signature-based scanner does - does the backend treat
syntactically-significant characters ('"\&lt;&gt;(){}$;|&amp;, covering SQL
quoting, HTML/JS, shell metacharacters, and template syntax at once)
differently than an equal-length string of inert filler? That question
doesn't need to know what the backend is built on, the whole appeal of
the real tool this borrows its name and idea from.

For each existing query parameter, sends two same-length replacement
values wrapped in a stable marker - one filler, one special-character
- and checks whether the marker itself came back intact, not just
whether the response looks different overall. An endpoint that never
reflects the parameter at all naturally produces "both intact: false,"
which correctly isn't a finding - the marker-reflection design avoids
false-positiving on the common case of a parameter that's read but
never echoed.

Verified live against a deliberately realistic scenario: an origin
with one endpoint that strips a few special characters before
reflecting a parameter (a naive-sanitizer/WAF-like pattern) and one
that reflects verbatim (the control case) - the sanitizing endpoint
was correctly tagged with the exact differential
(control_marker_intact: true, special_marker_intact: false), the
verbatim endpoint correctly left alone.

This closes Phase 1: every plugin identified as a "cheap IPC win" - no
new protocol capability needed beyond tag_entry itself - is now real,
working, and live-verified (authcheck, paramminer, jslibscan,
bpscanner).
</content>
</entry>
<entry>
<title>Third plugin: jslibscan, a Retire.js-style passive JS library scanner</title>
<updated>2026-08-27T19:21:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-08-27T19:21:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=8748df8a30b038e429aeeff1684e1014f42a68ee'/>
<id>urn:sha1:8748df8a30b038e429aeeff1684e1014f42a68ee</id>
<content type='text'>
The first purely passive plugin in the reference set: subscribe,
inspect a response body already captured by ordinary proxying, tag -
no repeat calls at all, unlike authcheck and paramminer. Deliberately
included as the safest possible plugin to try first, since it never
sends anything of its own.

Checks any JS library version string found in a response against a
small, explicitly-illustrative built-in table (jQuery, Lodash,
Handlebars, Moment.js, AngularJS - one well-known vulnerable-version
threshold each), tagging a match jslibscan:hit with the version found,
the fix version, and a plain-language advisory. Not a maintained
vulnerability feed the way real Retire.js's database is, and says so
in its own package doc; CVE numbers deliberately omitted in favor of
describing the vulnerability class, rather than asserting a specific
identifier this reference implementation hasn't independently verified.

Found and fixed a real regex bug by testing live rather than trusting
the code: the first version failed to match jQuery's own actual banner
comment ("jQuery v1.8.3") because the separator pattern only allowed a
single character between library name and version digits, and that
banner has two (space, then "v"). Fixed with a bounded non-greedy gap
verified against three real-world version-string shapes at once
(banner comment, minified filename, cache-busting query string) before
going back into the plugin.

Verified live end to end: a real daemon, a real origin serving both an
outdated jQuery 1.8.3 banner and a current 3.7.1 one - the outdated
file was correctly tagged with the right version and threshold, the
current one correctly left alone. The same run incidentally
reconfirmed the regex fix was real: an entry captured moments earlier
against the buggy binary sat right next to the correctly-tagged one,
itself untagged.
</content>
</entry>
<entry>
<title>Second plugin: paramminer, a Param Miner-style hidden parameter prober</title>
<updated>2026-08-25T23:06:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-08-25T23:06:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=cfe01fef65af082dccfc69b2fc78cb07a36ac2b4'/>
<id>urn:sha1:cfe01fef65af082dccfc69b2fc78cb07a36ac2b4</id>
<content type='text'>
For every distinct GET endpoint (deduplicated in-memory so revisiting
a URL doesn't rerun the whole wordlist each time), sends a fresh
baseline resend plus one probe per candidate from a ~40-entry wordlist
of parameter names real backends surprisingly often read even when
never part of any observed request (debug, admin, redirect, role,
token, and similar). A probe whose response differs from baseline by
more than a small threshold (body length, or a different status
outright) is a likely hit, tagged paramminer:hit with the parameter
name and both response sizes as evidence. Deliberately GET-only with a
modest wordlist, not exhaustive POST/JSON-aware probing - same "small
honest v1" reasoning as authcheck's single-identity simplification.

Same discipline as authcheck: speaks the wire protocol directly, no
internal/ipc import, proving PLUGINS.md's documented protocol is
actually sufficient on its own.

Found and documented two real, non-obvious net/http behaviors while
building this: Request.Write ignores the RequestURI field entirely
(confirmed directly - a deliberately stale RequestURI still produced
the correct output, since Write derives the request line from
Request.URL instead) and silently adds a default User-Agent header if
the cloned request didn't already have one. Neither affects
correctness here since baseline and every probe get identical
treatment, but both are worth knowing before reusing this resend
pattern elsewhere.

Verified live end to end: a real daemon, a real Python origin with a
genuinely hidden debug parameter that substantially changes the
response, and a control endpoint that's stable regardless of any extra
parameter - the hidden-parameter endpoint was correctly tagged with
exactly the right parameter name, the stable one correctly left alone,
confirmed via tag: search and visually in the TUI with the JSON-array
tag payload rendering correctly.
</content>
</entry>
<entry>
<title>Wire-format JSON tag consistency fix, and the first real plugin</title>
<updated>2026-08-25T13:58:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-08-25T13:58:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=9e94bcbc939afd38b45f9ef42e1b1666fafd8d45'/>
<id>urn:sha1:9e94bcbc939afd38b45f9ef42e1b1666fafd8d45</id>
<content type='text'>
Writing PLUGINS.md as an authoritative external spec surfaced a real,
pre-existing bug: store.Summary/Entry/EntryTag/WSMessage, rules.Rule,
scope.Rule, and clientcert.Cert had no JSON struct tags at all, so Go's
default marshaling serialized them PascalCase ("ID", "StartedAt")
while the rest of the protocol (EntryDetail's own fields, every
Request/Response wrapper field) uses snake_case. Confirmed live against
a real daemon before touching anything: a raw socket "list" request
came back with "ID"/"StartedAt"/"StatusCode", exactly the mismatch
suspected. Nothing outside this repo's own Go code consumes this wire
format yet, so this was a free, purely additive fix rather than
something to work around - every affected struct now tags snake_case
consistently.

plugins/authcheck is the first real plugin: an Autorize-style
authorization checker. For every proxied request carrying an
Authorization or Cookie header, resends it with that header stripped
and compares status classes - a resend that still succeeds where the
original did too is a likely missing-function-level-access-control
bug, tagged authcheck:bypass with structured detail. Deliberately
speaks the wire protocol directly (its own local request/response/
summary/entryDetail structs mirroring the real ones field-for-field,
not imported from internal/ipc) rather than taking the shortcut a Go
plugin could - proof the documented protocol is actually sufficient on
its own, since that's all a non-Go plugin author has to work with.

Verified live end to end: a real daemon, a real Python origin with one
endpoint that looks like it enforces auth but doesn't (vulnerable by
design) and one that actually does (the control case) - the broken
endpoint was correctly tagged, the secure one correctly left alone, no
false positive, confirmed both via the stored tag data directly and
visually in the TUI (tmux, real keystrokes): the Tags column badge, T's
tag list, and the tag detail view's JSON-colorized data (ANSI-verified,
not eyeballed) all showing the plugin's actual findings.
</content>
</entry>
<entry>
<title>Plugin protocol foundation: tag_entry, tag search/sort, TUI tag view</title>
<updated>2026-08-04T07:35:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-08-04T07:35:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=dde73a349a68f942a5bd89b27ac980d7973148e0'/>
<id>urn:sha1:dde73a349a68f942a5bd89b27ac980d7973148e0</id>
<content type='text'>
The prerequisite for the plugin ecosystem: any external process - any
language - that can reach the control socket can now tag a history
entry with a short string marker and an opaque JSON data blob, stored
in a new entry_tags table rather than requiring the plugin stay
connected for a later live round-trip. A plugin does its analysis
once; the data it attaches is what a human reviewing the entry later
actually sees.

internal/ipc: new "tag_entry" request (id, tag_plugin, tag, tag_data)
and EntryDetail.Tags (the full record for one entry, populated by
"get"). internal/store: entry_tags table, EntryTag struct, AddEntryTag/
ListEntryTags, a comma-joined Tags aggregate added to List/Search via
a correlated subquery (cheap enough per row that showing a tag badge
in the history list needs no N+1 query), and a new tag: search filter
alongside the existing status:/source:/flagged:.

TUI: a Tags column in the history table (sortable via o/O, the eighth
sort column), T from detail view opens a tag list (mirroring the
WebSocket-messages view's table-then-detail-viewport pattern), enter
on one shows its data - JSON-colorized via the existing jsoncolor.go
if it parses as JSON, sanitized plain text otherwise. Also fixed a
pre-existing gap while touching this: the WebSocket-messages view
never got mouse wheel support when it shipped; wired both it and the
new tags view up together.

PLUGINS.md documents the wire protocol for non-Go plugin authors -
connection model (subscribe vs request/response), the handful of
request types a plugin actually needs, and the trust boundary (the
socket has no auth beyond OS file permissions, same as the TUI's own
access). PLAN.md records the architecture decision (external process
over an embedded scripting language - mirrors the daemon/TUI split
already in place, no interpreter to sandbox, any language) and groups
~20 researched Burp extensions/Pro features into what Phase 1 already
covers (Autorize, Param Miner, Backslash Powered Scanner, Retire.js -
all just subscribe+repeat+tag, no new capability needed), what needs a
second protocol addition (JWT Editor, SAML Raider - live RPC to a
specific connected plugin for interactive actions like re-signing),
and what deserves its own separate project rather than a plugin
(active vulnerability scanning, Collaborator/OAST, a crawler).

Verified live end to end against a real daemon: a throwaway program
simulating a real plugin tagged a captured entry with structured JWT
data over the actual wire protocol; confirmed the tag badge, tag:
search filter, and full tag record all round-tripped correctly through
List/Search/Get. Confirmed in the TUI itself (tmux, real keystrokes):
the Tags column renders, T opens the tag list, entering it shows the
JSON data with real ANSI-verified syntax highlighting (not just
eyeballed), and tag: search filtering works from the history list.
</content>
</entry>
<entry>
<title>CI and release automation via GitHub Actions</title>
<updated>2026-08-04T07:10:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-08-04T07:10:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=4c4c8dd4983092ffa5c6602ed070f401bbbf74f1'/>
<id>urn:sha1:4c4c8dd4983092ffa5c6602ed070f401bbbf74f1</id>
<content type='text'>
ci.yml: make test on every push/PR, Linux only - matches the README's
own honest claim that Linux is the only platform actually run and
verified, rather than having CI pretend untested platforms are
behaviorally covered. A separate cross-build job runs make release on
the same Linux runner as a build-only check across every platform in
the Makefile's matrix (CGO_ENABLED=0 + pure-Go SQLite means no
macOS/Windows runner is needed just to catch a compile break).

release.yml: fires on a v* tag, runs make release, packages each
platform into a single archive (.tar.gz Unix, .zip Windows) plus a
SHA256SUMS file, and attaches them to a GitHub Release via the gh CLI
rather than a third-party action - matches this project's own general
preference for minimizing external dependencies.

Verified the packaging shell logic locally (not the YAML itself, which
needs a real Actions run) against a real make release output: all 6
platform archives build with correct internal structure, zip selected
only for Windows, SHA256SUMS covers all of them.
</content>
</entry>
<entry>
<title>History sorting, status color-coding, and JSON syntax highlighting</title>
<updated>2026-07-31T14:07:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-07-31T14:07:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=9b630b2cbf9206855534668ebaaaee255cabedd4'/>
<id>urn:sha1:9b630b2cbf9206855534668ebaaaee255cabedd4</id>
<content type='text'>
Filtering already existed (FTS5 search plus status:/source:/flagged:
and column filters). Sorting and highlighting didn't, at all.

Sorting: o/O cycle and reverse the sort column (status, size, time
taken, method, host, path) applied client-side on top of whatever
order List/Search already returned. refreshTable() reorders m.entries
itself, not just what's rendered - every "act on the selected row" key
handler indexes m.table.Cursor() straight into m.entries with no
indirection, so keeping the two in identical order sidesteps an entire
class of "highlighted row and actual target silently disagree" bugs
rather than updating every one of those call sites.

JSON syntax highlighting (cmd/mitmux/jsoncolor.go): walks the token
stream via json.Decoder.Token() with an explicit stack, not recursive
calls, so depth is bounded by memory rather than Go's call stack for
adversarial nesting. Every string re-escaped via json.Marshal before
writing, which is also why the colorized output is deliberately never
run through sanitizeControl afterward (unlike every other raw-text
view here): JSON's own encoding rules already forbid a literal control
character in a string, so re-marshaling neutralizes one as a side
effect of producing valid JSON - running sanitizeControl on top would
instead corrupt the ANSI codes this adds.

Status-code color-coding (2xx green through 5xx red) does not live in
the history/Intruder tables, despite an initial attempt to put it
there. Confirmed live: bubbles/table v1.0.0 (the newest available)
fits cell text to its column width via go-runewidth's Truncate, which
has no ANSI awareness - it counts every character of a color escape
sequence as real display width. Coloring the Status cell silently
deleted the status text from the row; the width-fitting truncation cut
into the escape sequence itself. styledStatus is used once instead, in
detailView's title, a plain string rendered whole with no width
constraint.

Verified live in tmux against a running daemon: ascending/descending
sort by status across six real entries: pretty-printed JSON confirmed
correctly colored and indented via raw ANSI capture, not just
eyeballed; the detail title's status color confirmed red for a 500
entry the same way; the request tab (never JSON) confirmed unaffected.
</content>
</entry>
<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>Browser-launcher helper: throwaway proxied profile, one flag</title>
<updated>2026-06-29T07:58:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-06-29T07:58:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/mitmux/commit/?id=2ade8c807584bff0b60d6b6f278dbde29b13a5ff'/>
<id>urn:sha1:2ade8c807584bff0b60d6b6f278dbde29b13a5ff</id>
<content type='text'>
Adds -launch-browser=chrome|firefox|auto to the mitmux TUI binary.
Resolves an installed browser (PATH first, then common per-OS install
locations), spins up a brand new throwaway profile, configures it to
proxy through the daemon, and opens straight to http://mitmux.cert/ so
installing the CA in that profile is one click.

This is the answer to "build an in-house browser": a bundled GUI
browser is a different, much larger project and works against this
tool's terminal-native positioning - the actually useful part of that
idea is zero-friction setup (proxy + CA-install page, no profile
pollution), which this delivers by launching the user's own browser
in a disposable profile instead of embedding one.

Chrome takes --proxy-server as a flag; Firefox has none, so its
profile gets a generated user.js instead - the only non-interactive
way to configure it. Verified against this machine's real installed
Chrome and Firefox: binary discovery resolves both, auto prefers
chrome-family when both are present, and the generated Firefox prefs
are well-formed. Deliberately did not spawn a live browser window as
part of verification - that's a visible GUI action on whoever runs
it, left for a user to trigger by hand via the flag.
</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>
</feed>
