| Age | Commit message (Collapse) | Author | Files | Lines |
|
- 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 ('"\<>(){}$;|&, 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).
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
Generalizes Intrude beyond Sniper to all four of Burp's attack modes
(proxy.AttackMode). Sniper and Battering ram only ever need one shared
payload set; Pitchfork and Cluster bomb are inherently per-position, so
they take one payload set per §marked§ position instead.
Request-set generation (intrudeValues) is pure and side-effect free,
so the total request count is validated against the existing 1000
cap before anything is dispatched - Cluster bomb's product is checked
incrementally, one payload set at a time, so a pathological product
bails out before ever trying to enumerate it. This also makes the
combinatorics unit-testable without a live target.
IntrudeResultMsg now reports Values (one substitution per marked
position, in order) instead of a single Position/Payload pair, since
three of the four modes touch multiple positions per request.
TUI: `a` cycles the attack mode. Pitchfork/Cluster bomb reuse the
existing single Payloads pane rather than a new multi-widget editor -
sets are separated by a `---` delimiter line, in position order.
Verified live against a real daemon: all four modes produce the
expected substitution values and request counts, and pitchfork
correctly rejects a payload-set count that doesn't match the
template's marked positions.
|
|
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.
|
|
Explicitly requested - this is a real terminal app meant to work in any
terminal (the name is a naming convention, not a tmux runtime
dependency), and should be genuinely mouse-driven, not keyboard-only.
Enabled via tea.WithMouseCellMotion() (SGR mouse mode, the same
protocol nvim and most modern TUI apps use); coexists with tmux's own
mouse mode the same way it would for any other terminal app.
Scope was decided by a real, verified library constraint, not
convenience: bubbles/table exposes no way to learn its own scroll
offset (confirmed by reading its source - no YOffset accessor, the
rendered window comes from unexported fields via a second internal
layer of viewport scrolling on top of that). Mapping a click's screen
coordinates to a specific table row can't be done without reaching into
that library's private internals, which this deliberately doesn't do -
silently selecting the wrong row on a misjudged click is worse than not
supporting precise click-to-row at all.
What's shipped, the reliable subset:
- Wheel scroll everywhere there's something to scroll: tables via the
already-exported MoveUp/MoveDown (no scroll-state assumptions
needed), viewports via their own native wheel handling (bubbles/
viewport already has this - nothing in the codebase was routing
tea.MouseMsg to it yet), and vi-modal text editors via new
viTextarea.ScrollUp/ScrollDown (bubbles/textarea has zero native
mouse handling at all, confirmed the same way - feeds wheel events as
repeated up/down keypresses through the same tested movement path
h/j/k/l already use).
- Right-click opens a context menu (cmd/mitmux/contextmenu.go) - a
horizontal strip taking over the status/help line, the same "replace
the bottom of the screen" pattern confirmPrompt and the export/import
prompts already use, rather than a floating popup positioned at the
click (lipgloss/bubbletea have no compositor for splicing an overlay
into an arbitrary screen position - not worth building just for
this). Wired into every list-based view: history
(view/repeater/intruder/flag-unflag/delete), rules
(edit/enable-disable/delete), scope (enable-disable/delete). Menu
items ARE reliably clickable, unlike table rows - the menu renders
its own strip, so every item's width is fully known rather than
hidden behind a library's unexported scroll state. Navigable by mouse
click or j/k/arrows+enter; esc or right-clicking again dismisses.
Deferred, not silently dropped: click-to-select-a-different-row (same
scroll-offset limitation), and click-to-switch-pane-focus in Repeater/
Intruder (tractable via the same Y-coordinate math WindowSizeMsg
already computes, just not done yet - keyboard tab already covers it,
so lower priority than what shipped).
Verified live in tmux by injecting real SGR mouse escape sequences
directly into the pane (tmux send-keys -l with hand-built ESC [ <
Cb;Cx;Cy M/m sequences, since tmux has no built-in "synthesize a click"
primitive) against a running daemon with real captured entries:
wheel-down/up on the history table correctly moved the selected-row
highlight (confirmed via ANSI-aware capture, not just "no crash");
right-click opened the menu with the right actions; clicking directly
on a computed menu-item position correctly triggered that exact action
(clicked "delete", saw the correct entry's ID in the resulting confirm
prompt); keyboard navigation inside the menu moved the highlight
correctly; esc and a second right-click both dismissed cleanly with no
side effects; wheel events in Repeater's text pane and Detail's
viewport caused no crash and left vi-mode state intact; an empty rules
table's right-click correctly no-opped; adding a real rule then
right-clicking and clicking "disable" correctly toggled it off
(confirmed via the rendered checkmark disappearing).
go build/vet/gofmt/test/mod tidy all clean.
|
|
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.
|
|
Closes the interop loop HAR export opened - traffic can now move both
directions between mitmux and any other HAR-producing tool (browser
DevTools, Burp, Postman), not just out.
cmd/mitmux/har.go: rawRequestFromHAR/rawResponseFromHAR reconstruct
HTTP/1.1 wire bytes from HAR's structured fields - the mirror image of
harEntryFromDetail on the export side. Deliberately tolerant of a HAR
file that didn't come from mitmux at all: lowercase header names,
"HTTP/2" in httpVersion, missing optional fields like postData, a
redirectURL nobody filled in. Content-Encoding and Transfer-Encoding
headers are stripped from the reconstructed response before writing it
- HAR's content.text is already decoded per spec, so re-emitting those
headers would describe framing the body no longer has and break any
client that tried to decode it again - and a Content-Length is computed
if the HAR didn't carry a consistent one. importEntriesFromHAR converts
a whole document, skipping (not failing on) any entry that fails to
convert, same reasoning as export's own skip-and-continue for a
malformed capture.
Imported entries are always request_exact=false/response_exact=false:
reconstructed from structured HAR fields, the same situation an HTTP/2
capture is already in, never claiming to be the literal bytes that were
actually on the wire for the original request.
internal/ipc: ImportEntry (the slim shape the client sends - the daemon
just stores what it's given, all HAR parsing happens client-side) and
an "import" request type; Client.Import returns how many entries were
actually inserted, a per-entry store failure is skipped rather than
aborting the batch. Server-side, imported entries are tagged
source="import" so source:import finds them in search, same as
source:repeater/source:intruder already work.
TUI: 'I' from the history list prompts for a HAR path (same modal
pattern as export, in reverse - reading instead of writing), then
reloads the list and status once the import completes.
Verified live: exported real captured traffic to HAR, cleared history
entirely, imported the same file back and got both entries with
correct content (byte-different after the round trip - headers get
reordered/reformatted - but semantically identical, correctly labeled
"reconstructed" rather than falsely "exact"); hand-built a HAR mimicking
a real Chrome DevTools export (lowercase headers, HTTP/2, a base64-
encoded binary PNG body, several optional fields omitted) and confirmed
it imports cleanly with the binary body decoded correctly (PNG magic
bytes verified byte-for-byte); confirmed a missing file and invalid
JSON both fail with a clear error and no crash, history left untouched.
go build/vet/gofmt/test/mod tidy all clean.
|
|
Both extend the existing export system by dispatching on the file
extension the user types, rather than adding a separate format-
selection control - consistent with how any "save as" dialog already
works, and requires no new UI beyond what export already has.
Bulk export ('E' from the history list): .har (existing default) or
.csv. CSV is a summary table (id/method/host/path/status/sizes/timing/
flag/source) built directly from the already-loaded Summary rows -
deliberately lighter and faster than HAR, which needs a per-entry fetch
from the daemon to get raw bytes. This mirrors Burp's own "export as
CSV" being a listing for a report/spreadsheet, not a full-fidelity
capture format - HAR already covers that need.
Single-entry export ('e' from Detail view): .txt (existing default,
unchanged) or .sh/.curl - the request re-serialized as a runnable curl
command line (Burp/DevTools' "copy as curl"), for handing to someone
else or re-running standalone without mitmux. Every value is shell-
quoted (single-quote wrapping with '\'' escaping for embedded quotes) -
a captured or edited request is exactly the kind of content that might
contain shell metacharacters, so naive string concatenation would risk
producing a command that does something other than what it appears to
when pasted into a shell. Verified by actually executing a generated
command against the real target and confirming the response matched
the original request.
CSV export needed one thing HAR didn't: a guard against CSV/formula
injection. Method, host, path, and error all ultimately trace back to a
request line or Host header - content this tool exists specifically to
inspect from potentially hostile traffic - and a field starting with
=, +, -, @, tab, or CR is a formula to Excel/LibreOffice/Sheets when the
exported file is later opened, which can call out to other cells,
external data, or worse depending on the app and its settings. csvSafe
prefixes any such field with a single quote before writing - the
standard mitigation per OWASP's own CSV injection guidance - so every
affected spreadsheet application treats it as literal text instead.
This is the same class of bug as the terminal-injection fix from the
earlier robustness audit, just for a different output format: captured
content controlling whatever later processes it, rather than the
terminal that renders it.
cmd/mitmux/csv.go: csvFromSummaries + csvSafe, unit tested including the
formula-injection neutralization specifically. cmd/mitmux/export.go:
curlCommand + shellQuote, plus singleEntryFormat/bulkExportFormat
extension-dispatch helpers, all unit tested (curl generation, malformed-
request handling, shell-quote escaping of embedded quotes).
go build/vet/gofmt/test/mod tidy all clean.
|
|
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.
|
|
Single-entry export (previous commit) covers "attach this one to a
report"; this covers the other common need - getting a batch of
captured traffic into another tool. HAR 1.2 was chosen specifically for
interop: Chrome/Firefox DevTools, Burp, Postman, and others can all
import it, which a mitmux-specific format couldn't do.
cmd/mitmux/har.go: harEntryFromDetail parses one entry's raw request/
response bytes into HAR's structured fields (method, url, httpVersion,
headers, query string, status, body) via the same net/http parsing the
proxy's own capture path and prettyResponse already use elsewhere in
this codebase - reused, not reimplemented. A binary body is base64-
encoded (HAR's "encoding" field) instead of passed through as a JSON
string: encoding/json replaces invalid UTF-8 with U+FFFD by default,
which would silently corrupt exactly the bodies (images, protobufs,
...) where byte-exactness matters. harDocFrom builds the full document
from a batch of entries, skipping (not failing on) any that fail to
parse - one malformed capture, e.g. a deliberately broken Repeater
request, shouldn't block exporting everything else.
'E' from the history list exports the current view - the visible,
filtered set if a search is active, everything otherwise, same
"respects the active filter" behavior 'x' delete already has and
explicitly the opposite of 'X' clear-all, which always targets
everything regardless of filter. Reuses the same modal path-prompt
state as single-entry export (exportEditing/exportInput), discriminated
by a new exportBulk bool so both share one text-input widget and
key-handling pattern rather than duplicating it. Since the list only
ever holds Summary metadata (no raw bytes), exporting has to fetch each
entry's full detail first - exportHAR does this as a sequence of Get
calls inside a single tea.Cmd, which runs in bubbletea's own command
goroutine, so the UI stays responsive through what's effectively a
blocking round trip per entry. A failed fetch is skipped the same way a
failed parse is; the status line reports the total skipped either way,
not just entries written, so a partial export is visible rather than
silently different from what was expected.
Verified live in tmux against a running daemon: exported 3 real
captured entries (including a binary PNG response) to a HAR file,
validated the output is well-formed JSON with the correct HAR 1.2
structure, and confirmed the base64-encoded image entry decodes back to
byte-identical PNG data (magic bytes checked). Separately applied a
search filter (3 entries -> 2) and confirmed 'E' exported exactly the 2
filtered entries, not all 3 - the same filter-respecting behavior the
status line and help text both claim.
go build/vet/gofmt/test/mod tidy all clean.
|
|
No way existed to get data out of mitmux at all short of querying the
SQLite file directly. 'e' from Detail view exports the selected entry's
raw request and response bytes to a file - a modal path-prompt (same
pattern as Intruder's grep-match/extract edit buffers: enter writes and
confirms, esc cancels), prefilled with a sensible default filename
(mitmux-entry-<id>.txt).
Deliberately plain text, not a structured format: for "attach this to a
report" or "grep it later" - the actual use case - the raw bytes as
text are the whole point, matching this tool's own raw-bytes-first
philosophy rather than reformatting them into something else first.
Detail-view-only (like pretty-print), not also from the history list:
exporting needs the full EntryDetail with raw bytes, which is already
loaded there, so this avoids adding a second load-then-prompt path for
one keystroke of convenience.
Each side is annotated when it isn't a wire-exact capture - "(truncated
- hit capture size limit)" or "(reconstructed, not wire-exact)",
matching the same distinction Detail view's own labels already make -
so the exported file carries the same trust information the UI shows,
not a blanker claim that could mislead whoever reads the export later
without the tool's own context.
cmd/mitmux/export.go: exportEntryText (pure formatting, unit tested
including the non-exact annotation paths) and writeExportFile (a
thin os.WriteFile wrapper - a relative path resolves against the
process's CWD, same as any other command-line tool; no ~ expansion,
that's shell behavior, not something a bare file write should
reimplement).
Verified live in tmux: exported a real captured entry, confirmed the
written file byte-for-byte via cat - correct header, exact request and
response bytes including chunked body - and confirmed esc correctly
cancels without writing anything.
go build/vet/gofmt/test/mod tidy all clean.
|
|
Store had full CRUD for match-and-replace rules but no way to delete or
prune history at all - it only ever grew, with no way to remove an
accidental capture or start a new engagement clean short of manually
deleting the DB file outside the tool entirely.
internal/store: DeleteEntry(id) removes one history row and its
history_fts search index row in a transaction. ClearHistory() empties
both tables entirely; rules are untouched. internal/ipc: new
"delete_entry" and "clear_history" request types, Client.DeleteEntry/
ClearHistory methods.
TUI: 'x' deletes the selected history entry, 'X' clears the whole
database. Both gated behind a y/n confirmation - a small reusable
confirmPrompt/confirmYes model state, checked first in the history
list's key handling, so any key other than y/Y safely cancels rather
than falling through to whatever that key normally does elsewhere (this
also means ctrl+c during a pending confirmation cancels the prompt
rather than quitting - a deliberate fail-safe, not an oversight: quick
to dismiss, and a second ctrl+c then quits normally).
'X' is explicitly NOT scoped to an active search filter - it always
clears the true total (read from daemon status, not len(m.entries),
which would understate the count under a filter and make the
confirmation prompt itself misleading about what's about to happen).
Verified live in tmux against a running daemon with real captured
entries: 'x' shows "delete #N? y/n", 'n' cancels with the entry
untouched, 'y' deletes it and the list/count both refresh correctly;
'X' shows "clear all N history entries (not just this view)? y/n" with
the true count, 'y' empties the database (confirmed via direct SQLite
query: both history and history_fts at 0 rows afterward) and the TUI
correctly shows "history (0)" / "0 requests"; 'x'/'X' on an empty list
correctly no-op without crashing.
go build/vet/gofmt/test/mod tidy all clean.
|
|
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" && 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.
|
|
Trusting the CA was previously "import ca.pem into whatever's making
the requests" with no further help. -install-ca generates the CA if
needed and prints copy-pasteable, OS-specific steps, then exits without
starting the proxy.
Deliberately instructions-only, never auto-executing anything: Linux
trust-store tooling varies enough across distros (trust vs
update-ca-trust vs update-ca-certificates) that guessing wrong and
running the wrong command unattended is worse than asking, and
installing a root CA is a system-wide trust change affecting every TLS
connection on the machine, not just mitmux's own traffic - running the
printed command themselves keeps the user in control of that.
internal/ca/install.go: InstallInstructions(goos, caPath) dispatches by
OS. Linux detects trust (p11-kit - Arch, also on Fedora) /
update-ca-trust (RHEL/Fedora/CentOS) / update-ca-certificates
(Debian/Ubuntu/Gentoo) via PATH lookup and prints whichever is actually
present, plus separate certutil/NSS instructions for Firefox/Chrome's
own certificate store (which doesn't always follow the system trust
store on Linux). macOS (security add-trusted-cert) and Windows
(certutil -addstore / Import-Certificate) are implemented from each
platform's standard documented tooling but not verified live - no
macOS/Windows machine was available to test against, unlike Linux.
commandExists is a package var (not a direct exec.LookPath call) so
tests can fake which tools are "present" and exercise every detection
branch deterministically, independent of what's actually installed on
whatever machine runs `go test`.
Verified live: built mitmuxd, ran -install-ca against a throwaway CA
dir on this (Arch Linux) machine - correctly detected `trust` and
`certutil` on PATH and printed accurate commands, confirmed the CA
files were actually generated, confirmed no proxy/daemon process was
left running (exits immediately after printing), and confirmed running
it a second time reuses the existing CA (identical file hash) rather
than regenerating.
go build/vet/gofmt/test/mod tidy all clean.
|
|
Payload processing: an optional case rule (upper/lower) and an optional
encode rule (URL/Base64/Hex/HTML) applied to every payload line before
it's substituted into the request, cycled with 'c'/'e'. Case always
runs before encode - folding an already-encoded value would corrupt it
(e.g. uppercasing Base64 padding). Applied entirely client-side in
startIntrude() (payload_rules.go): a pure string transform with no
proxy-side state, so it needs no protocol changes and reuses the
Decoder's own urlEncodeAll.
Grep-match/grep-extract: two optional Go regexps, edited with 'm'/'v'
using the same modal edit-buffer pattern as the history list's '/'
search (enter validates-and-commits, esc reverts to the last-confirmed
pattern, an unparseable regexp is rejected with an error rather than
silently accepted). Evaluated server-side, in internal/ipc/server.go's
"intrude" handler, against each result's actual entry.ResponseRaw -
that's where the real response bytes already are, and it's how Burp's
own grep options work (matched against the real response, not a
client-refetched copy). Grep-match flags a result (new Match column);
grep-extract captures the first submatch, or the whole match if the
pattern has no capturing group (new Extract column). Both patterns are
compiled once before the attack starts and apply for that run only, not
retroactively if changed mid-attack.
All four new keys (c/e/m/v) are gated to normal mode, checked in the
view's outer key switch before ever reaching the template/payloads
vi-textareas - otherwise they'd be either untypeable letters or steal
keystrokes mid-edit. Same discipline as the Repeater tab keys.
internal/ipc: Request gained GrepMatch/GrepExtract string fields (for
"intrude"), IntrudeResultMsg gained GrepMatch bool/GrepExtract string,
and the client Intrude() helper takes the two pattern strings as new
trailing parameters.
Verified live in tmux against a running daemon and real httpbin.org
traffic: built a template with a §marked§ query param, payloads 1/2/3,
grep-match `"id": "2"` and grep-extract `"id": "([0-9]+)"`, ran the
attack and confirmed the Match column flagged only the payload=2 row
and Extract correctly pulled 1/2/3 from each response respectively;
cycled case/encode through all states; confirmed an invalid regexp
(`[abc`) is rejected with a visible error and esc correctly reverts to
the last-confirmed pattern instead of committing the invalid one.
(Also confirmed, incidentally: a batch of vi normal-mode two-key
commands like "gg"/"dd" sent as one multi-character tmux send-keys
argument doesn't reliably reach the app as separate keystrokes - a
tmux scripting artifact, not a bug in the vi-mode implementation, which
works correctly when each key is sent as its own event, as any real
keypress would be.)
go build/vet/gofmt/test/mod tidy all clean.
|
|
Repeater previously had one shared request/response buffer - sending a
new entry to Repeater silently overwrote whatever was already open,
even mid-edit. Replaced the singular reqArea/respView/repeaterScheme/
etc. model fields with a []*repeaterTab slice plus an active index;
'r' now opens a new tab and switches to it, existing tabs stay put.
New keys, all gated to normal mode so they stay inert while typing
(]/[ show up in JSON bodies constantly, and ctrl+w is the textarea's
own delete-word-backward that must still work mid-edit):
] next tab
[ previous tab
ctrl+w close the active tab (falls back to a neighbor, or to the
history list if it was the last one)
Async send results now carry the tab index they belong to, so a slow
send whose response lands after the user has switched tabs (or closed
one) updates the right tab rather than whichever happens to be active
when the result arrives; the status line and response pane only reflect
it live if that tab is still the one being viewed.
Verified live in tmux against a running daemon: opened two tabs from
different history entries, confirmed independent buffers, sent from a
background tab while another was active and confirmed the result routed
to the correct (non-visible) tab, switched with ]/[, closed with ctrl+w
down to zero tabs (falls back to the history list), and confirmed [, ],
and ctrl+w are all correctly inert in insert mode (typed "[a]" literally,
ctrl+w did textarea's word-delete instead of closing the tab).
go build/vet/gofmt/test/mod tidy all clean.
|
|
First of the remaining "worth considering" items. A self-contained
tool ('d' from the history list, not seeded from any entry - this is
for arbitrary snippets, pasted tokens, encoded parameter values) with
a vi-modal input pane and a live output pane that updates on every
keystroke and every transform switch (tab/shift+tab cycles through the
8 transforms).
decoder.go is pure logic, deliberately kept separate from the TUI
wiring so it's directly testable: urlEncodeAll implements strict RFC
3986 percent-encoding (space -> %20) rather than using Go's
url.QueryEscape, whose form-encoding behavior (space -> '+') isn't what
"URL encode" means to a pentester reaching for this tool. Base64 decode
tries standard/URL-safe/padded/unpadded encodings in turn rather than
requiring the user to know which one they're looking at - real pasted
data is as likely to be one as the other. Decode failures return a
visible "(error: ...)" placeholder rather than blanking the output, so
a bad guess at the transform is obviously wrong rather than looking
like nothing happened.
decoder_test.go covers each transform directly, three "this input isn't
valid for this transform" error cases, and a round-trip matrix (all 4
encode/decode pairs against 5 inputs chosen to be awkward for at least
one encoding - spaces, slashes, HTML-special characters, empty string,
embedded newlines) confirming encode-then-decode always recovers the
original.
Single-transform only, not chained/pipelined like Burp's Decoder - v1
scope, tracked in PLAN.md.
Verified live: typed text and watched the output pane update in real
time; confirmed URL-encoding, then cycled to Base64 via tab and watched
it re-encode the same input live; confirmed the active-transform
highlighting via raw ANSI codes in the captured pane; fed invalid input
to Base64 decode and confirmed the error placeholder renders instead of
silently showing stale output; confirmed esc correctly backs out to the
history list.
|
|
Next item off the "worth considering" list from the Burp/ZAP/Caido gap
research. Mark an entry with 'c' (from the history list or detail
view - no fetch yet, just remembers the ID), then 'c' on a different
entry fetches both and opens a colored unified diff of either side's
request or response, tab to switch between them.
Unified (git-diff style: +/- prefixed lines) rather than Burp's
side-by-side two-pane layout - a two-column view fights terminal width
for anything but a wide window, and unified reuses the same scrollable
viewport pattern already used everywhere else in this TUI rather than
needing new layout machinery. Uses github.com/pmezard/go-difflib
(SequenceMatcher-based, a tested port of Python's difflib) rather than
hand-rolling LCS/Myers diff, which has real edge cases worth not
reinventing. CRLF is normalized to LF before diffing - display-only,
same reasoning as the JSON pretty-printer - so an HTTP/1.1 exact
capture doesn't show every single line as changed purely from an
invisible trailing \r.
Verified live against two real, distinctly different captured POST
requests (different form bodies, different Content-Length): the request
diff correctly isolated exactly the two changed lines with the
unchanged headers shown as context, colors confirmed via raw ANSI
codes in the captured pane output (red 203 for removed, green 42 for
added) rather than assumed from the code, and the response tab showed
a correct independent diff of the two responses (Date header, JSON
body). Also confirmed the "same entry marked twice" path shows a hint
rather than silently doing something confusing.
|
|
Last of the "should build soon" items from the Burp/ZAP/Caido gap
research - Burp's row highlighting and Caido's Findings both serve
the same real workflow: mark something interesting mid-engagement,
revisit later. Scoped to a boolean flag (★) rather than full free-text
notes/comments, which would need their own text-input overlay for
comparatively modest extra value over a simple marker - tracked as a
real follow-up in PLAN.md, not dropped silently.
internal/store: history gains a flagged column (migrated in for
existing databases the same way source was) plus Store.SetFlagged and
Summary/Entry.Flagged. Search's structured-filter layer (added last
commit for status:/source:) gains flagged:true/false alongside them -
extractStructured already existed for exactly this kind of "pull it out
before it reaches FTS5" filter. internal/ipc gains a "set_flagged"
request. cmd/mitmux: 'f' toggles the flag on the selected history row
(applied optimistically to local state, persisted async - a drift
between local and server state on failure is an acceptable trade-off
for a marker this low-stakes), shown as a ★ column in the list and in
the detail view's title.
store_test.go covers the flagged: parsing (true/false spellings, and
a "looks like it but isn't" case - flagged:maybe - falling through as
literal search text, matching the existing pattern for status:).
Verified live: toggling 'f' shows the star immediately, flagged:true
correctly filtered to just that entry, and a direct SQLite check
confirmed the flag actually persisted to the database (flagged=1),
not just reflected in local UI state.
|
|
Implements build-order step 7, the last (optional) item. Scoped to
Sniper only - one payload set, one §-marked position fuzzed at a time,
others held at their base value - since that covers most real Intruder
usage; battering ram / pitchfork / cluster bomb aren't implemented.
Sequential sending, capped at 1000 generated requests as a fixed safety
limit.
internal/proxy: repeat.go's Repeat() is refactored into a shared
sendRaw(..., source) primitive so Intrude can reuse the exact same
raw-byte send/record path with source="intruder" instead of
duplicating it. intrude.go adds ParseMarkers/buildRequest (marker
parsing and payload substitution, covered by intrude_test.go - this is
fiddly byte-splicing logic, worth locking down with real tests rather
than trusting it by inspection) and Intrude(), which walks positions ×
payloads calling sendRaw and streaming each result through a callback.
internal/ipc gains a dedicated streaming "intrude" connection (same
shape as Subscribe, but blocking sends rather than drop-on-slow-
consumer - each result is the attack's actual data, not a
notification). cmd/mitmux gains an Intruder view: editable request
template (ctrl+p inserts a § marker at the cursor - typing § directly
also works, ctrl+p just doesn't require a keyboard layout that can
produce it), editable payload list, and a live results table wired to
the existing detail view (selecting a row and hitting enter opens the
full request/response for that specific attack request).
Verified live against real external traffic: a Sniper attack against
httpbin.org/status/§200§ with payloads 200/404/500 produced exactly the
three corresponding real status codes back (not a canned/local result),
confirmed the three requests landed in history tagged source="intruder"
with the § markers correctly stripped from what was actually sent, and
confirmed opening a result row's full detail from the results table.
This closes out the full build order from PLAN.md (steps 1-7).
|
|
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.
|
|
Implements build-order step 3. Adds:
- internal/store: SQLite (WAL, single-writer) history table, raw
request/response blobs plus metadata for the list view.
- internal/proxy: request/response capture wired into forward(). HTTP/1.1
legs are captured byte-exact via a teeConn that records wire bytes as
they're read, taken right after the message is fully drained (so no
manual re-reading/replaying is needed - RoundTrip's own streaming does
the draining). HTTP/2 legs (no meaningful "raw bytes" of their own -
multiplexed, HPACK-compressed framing) are reconstructed instead, and
marked as such in storage.
- internal/ipc: JSON-over-Unix-socket protocol between mitmuxd (owns the
proxy and the DB) and any client - list/get for queries, subscribe for
a live push stream of newly captured entries. Keeps the proxy engine
independent of the UI, per the architecture sketch.
- cmd/mitmux: Bubble Tea TUI - a live-updating history table and a
request/response detail view with raw bytes.
Two real bugs surfaced during testing and got fixed before commit:
1. http.Transport's HTTP/2 auto-dispatch does a literal *tls.Conn type
assertion on the dialed connection; wrapping it in a capturing teeConn
broke that silently, and HTTP/2 framing got parsed as HTTP/1.1 text.
Fixed by dropping http.Transport for the upstream leg entirely in
favor of an explicit per-protocol round trip (see PLAN.md stack note).
2. singleConnListener wrapped the client teeConn *inside* a
closeSignalConn, so ConnContext's type assertion for it silently
failed and HTTP/1.1 client-side capture never activated. Fixed the
wrap order; verified via direct SQLite inspection that request_exact
flips back to 1 and the stored bytes are genuinely wire-exact
(preserved chunked-encoding framing, original header casing/order).
Verified live: plain HTTP, HTTPS H1.1, HTTPS H2, and a POST with a body,
checked against the raw stored bytes directly in SQLite; IPC list/get/
subscribe against a throwaway client; and the TUI driven end-to-end in a
tmux session (list, detail view, tab between request/response, live
update on a new request while sitting on the list).
|
|
Implements build-order step 1: headless proxy daemon (mitmuxd) with
plaintext HTTP passthrough and raw CONNECT tunneling, plus root CA
generation/persistence for later TLS interception. Verified live
against real HTTP and HTTPS requests through the proxy.
|