| Age | Commit message (Collapse) | Author | Files | Lines |
|
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.
|
|
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.
|
|
Two robustness fixes from a hands-on edge-case audit of the TUI
(driving the app in tmux against a daemon fed adversarial history data,
not just code review).
1. Control-character/ANSI injection from captured traffic reaches the
real terminal. mitmux exists specifically to MITM hostile servers,
but every rendered string (table cells, detail/repeater/comparer
text, decoder output) was written straight to stdout via lipgloss
with no escaping. Confirmed live before the fix: a history entry
whose Host contained an OSC title-change sequence changed the actual
tmux pane title; a Path containing a clear-screen CSI sequence
corrupted the TUI's own rendering.
Fixed with sanitizeControl (cmd/mitmux/sanitize.go): replaces ASCII
control bytes with "." before display, preserving \n/\t in multi-line
contexts (sanitizeBlock) and stripping everything including those in
single-line contexts like table cells and titles (sanitizeLine).
Display-only, same pattern as the existing CRLF-normalization and
JSON-pretty-printing transforms - never touches stored bytes or what
Repeater/Intruder actually send. Applied at every render boundary:
history table rows, detail/repeater/comparer titles and body text,
Intruder's grep-extract column (text pulled directly out of an
attacker-controlled response via regex), decoder output, and status
messages. The one deliberate trade-off: Repeater/Intruder's editable
template buffers are seeded with sanitized text too (otherwise
opening a captured request with raw escape bytes would corrupt the
editor's own rendering just by being viewed) - resending it unmodified
sends the sanitized text; typing the original control bytes back in
still sends them verbatim, since only the seed is sanitized, not live
keystrokes. Verified live: re-tested the exact repro (OSC/CSI bytes
in Host/Path/response body) post-fix - renders as literal "." in
place of each control byte, zero corruption, zero title change.
2. Table cursor desync when a live entry arrives on an empty list.
bubbles/table's own SetRows only clamps the cursor's *upper* bound
(cursor > len(rows)-1) - on an empty table the cursor sits at -1, and
going from 0 rows to N rows never re-clamps that lower bound, so every
"row >= 0 && row < len(...)" guard (enter/r/i/f/c) silently no-ops
until the next navigation keypress happens to clamp it. Confirmed
live: a live-captured entry arriving while history was still empty
left every action on it inert - pressing enter did nothing - until an
arrow key was pressed first, with zero error or feedback.
Fixed with setTableRows (calls SetRows then SetCursor(Cursor()), the
latter clamping both bounds correctly), replacing every direct
.SetRows() call across all three tables (history, rules, intruder
results) so the same fix covers all of them, not just the one the
audit happened to catch. Verified live: fresh empty-history daemon,
TUI already running, captured a request via curl, pressed enter
immediately with no prior navigation - opened detail view correctly.
Both found via two parallel fork audits (backend and frontend) that
actually drove the daemon/TUI against adversarial input rather than
reading code; a third backend-focused round of fixes follows separately.
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.
|
|
Closes another top item from the Burp/ZAP/Caido gap research: status-
code and MIME/type filtering alongside free text is used constantly in
practice (Caido's HTTPQL, Burp's proxy history filter). Scoped to
status and source for now - method: already works today via FTS5's own
method column (a plain text match on "POST" is effectively exact for a
short alphanumeric token), so it didn't need special handling.
status_code isn't a text column FTS5 can index, and doesn't benefit
from full-text matching anyway (it's a numeric comparison, not a word
search), so extractStructured pulls status:/source: tokens out of the
query before it reaches FTS5 and turns them into real parameterized SQL
predicates against history's typed columns: status:404 (exact),
status:>=400 / status:!=200 (comparison operators), status:4xx (also
2xx/3xx/5xx - the shorthand people actually reach for: "show me the
errors"), source:repeater/intruder/proxy. Whatever text remains after
extraction still goes through the existing FTS5 path, so "admin
status:200" correctly ANDs a real full-text match with a real status
predicate in one query. When nothing remains (pure "status:4xx"),
Search skips the FTS5 join entirely and queries history directly.
store_test.go covers the parsing (exact/operator/range/source,
combined with free text, and two "looks like it but isn't" cases -
status:banana and the malformed 4-digit status:4004 - to confirm they
fall through as literal search text instead of being misparsed).
Verified live against real varied traffic (status 200/404/500 requests
plus a POST with an "admin" body) - status:4xx matched only the 404;
status:>=400 matched both 404 and 500; "admin status:200" correctly
matched only the POST and excluded the other unrelated 200; source:proxy
matched everything captured so far. All against the actual SQL execution
path, not just the pure parsing function.
|
|
Another item off the Burp/ZAP/Caido gap list: reading raw JSON
responses without any formatting is real daily friction.
pretty.go parses raw response bytes via net/http (reusing its tested
chunked-transfer-encoding and gzip content-encoding handling rather
than reimplementing either) and, if the decoded body is valid JSON,
returns it indented. Framing headers that no longer describe the
reformatted body (Transfer-Encoding, Content-Encoding, Content-Length)
are dropped from the displayed header block since keeping them would
be actively misleading. Falls back to raw on anything that doesn't
parse cleanly.
This is deliberately display-only and off by default: 'p' toggles it
in the detail view's response tab, refreshing the viewport in place;
the underlying raw bytes (what's stored, what would be resent) are
never touched. Not wired into Repeater's response pane or into either
tool's editable request buffer - the whole point of this tool is byte-
exact control, so nothing that could be sent anywhere gets silently
reformatted, only a read-only view a user explicitly asked to reformat.
Verified live against a real response with a known formatting quirk:
httpbin.org's own JSON output uses Python's json.dumps with ", "
separators, leaving a trailing space before each newline (confirmed
directly in the stored raw bytes: "7B 7D 2C 20 0A" - "{}, \n"). Toggling
pretty mode replaced it with Go's canonical json.Indent output, and
toggling back returned the original raw bytes - proving the
reformatting is real, not just passing through the origin's own
formatting.
|
|
Baseline TUI UX that should exist regardless of feature parity -
flagged directly by the Burp/ZAP/Caido comparison research as missing.
A persistent one-line status bar (proxy address, live request count,
current view) is now appended to every screen. Fetched once at startup
via a new "status" IPC request (internal/store gains Store.Count();
internal/ipc gains StatusMsg plus a daemon-side handler reading
proxy.Server.Addr through ipc.NewServer's new proxyAddr parameter), then
kept approximately live by incrementing locally on each "new" subscribe
push rather than re-querying every time.
'?' opens a full keybinding reference from every view, gated so it
never shadows literal text entry - it's a no-op while typing in the
search box, a rule form field, or (checked via viTextarea.Mode())
insert-mode text in Repeater/Intruder, where a URL query string
literally starting with '?' is completely ordinary input. Any key
dismisses it and returns to whichever view opened it.
Every existing height calculation (table, viewport, textarea panes)
had to shrink by one line to make room for the status bar without
pushing content off-screen - done once via a shared `h := msg.Height-1`
in the WindowSizeMsg handler rather than touching each call site
individually.
Verified live: status bar shows the real proxy address and updates its
count after a live-captured request; '?' renders the full reference
from the history list; dismissing returns to the correct prior view;
and specifically confirmed '?' still types literally (tested typing
"?foo=bar" into a Repeater request body in insert mode) rather than
being swallowed by the help shortcut.
|
|
Started a broader pass to close the gap with Burp/ZAP/Caido (feature
comparison researched, tracked in PLAN.md) and to fully support vi
bindings as asked. This commit is the vi-bindings piece.
Checked the actual bubbles library before writing anything: table and
viewport already ship full vi navigation by default (h/j/k/l, ctrl+u/
ctrl+d, g/G) - nothing to build there. textarea and textinput are
Emacs-style with no vi support at all, and textarea's own ctrl+p
("previous line") was silently shadowed by the ctrl+p shortcut I'd
bound for Intruder's marker insertion - a real bug from the last
session, fixed here by moving it to ctrl+g.
vimode.go adds viTextarea, wrapping textarea.Model with a normal/insert
modal layer. Vi commands are translated into the underlying textarea's
own existing keybindings (a synthesized "alt+right" for `w`, "ctrl+k"
for `d$`, etc.) and reuse its tested cursor/line logic rather than
reimplementing text manipulation - this is a front-end over textarea,
not a parser. Covers what's actually used constantly: h/j/k/l, 0/$,
w/b, x, i/a/I/A/o/O, dd/yy/p/P, dw/d$/d0, gg/G, esc-to-normal (esc
never leaves the view while still in insert mode, matching real vi).
Not attempted: registers beyond one yank slot, visual mode, ex
commands, macros, counts. No undo, since textarea itself has none.
Starts in normal mode on focus, per vi convention - not insert.
Replaces the three textarea.Model fields (Repeater's request editor,
Intruder's template and payload editors) with viTextarea, and adds a
vim-style mode indicator ("-- NORMAL --" / "-- INSERT --") to both
views, without which modal editing is unusable - there'd be no way to
tell which mode you're in.
Verified live via a real session: normal-mode letters don't leak into
the buffer as text, gg/x/dd/yy/p all produce the correct edits in
sequence on a real captured request, i enters insert mode and typing
works, esc from insert correctly drops to normal without leaving the
view, a second esc then backs out, and ctrl+g inserts a marker without
colliding with anything in the vi command set.
|
|
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 5. internal/store gains an FTS5 virtual
table (history_fts) kept in sync with every Insert in the same
transaction, indexing method/host/path plus the full raw request and
response text - so search covers headers and bodies, not just metadata.
Store.Search ranks by bm25 relevance. internal/ipc's existing "list"
request grows an optional query field rather than a new message type.
cmd/mitmux gets an inline '/' filter on the history view (bubbles/
textinput), esc to clear; live entries arriving while a filter is
active are held back with a "+N new" indicator rather than guessed at,
since FTS match can't be evaluated against a bare Summary.
Two real bugs found via testing against the actual sqlite3 CLI, not
assumed from docs:
1. This SQLite build doesn't support MATCH/bm25() against an aliased
FTS5 table ("no such column") - only the literal table name resolves.
Fixed by leaving history_fts unaliased in the JOIN.
2. FTS5's query grammar treats a wide range of punctuation as syntax,
not literal characters - confirmed '.', '-', '/', '@', '(', ')' all
produce parse errors (or worse, silently different results, as
hyphens get misparsed as column-filter syntax) in an unquoted
bareword. Since that covers the most common things people search
proxy history for (domains, paths, hyphenated headers, IPs), this
would have made the feature fail by default for its primary use
case. Fixed with prepareFTSQuery: quote every plain token as an FTS5
phrase (syntactically valid regardless of content) while still
recognizing AND/OR/NOT and column:value filters.
Also caught, mid-testing, that a query fix wasn't taking effect - traced
to the daemon still running an old `go run` build from before the fix
while only the TUI had been restarted; not a code bug, but a reminder to
restart both.
Verified live end-to-end: plain-text search matching header/body/JSON
content, a previously-failing dotted-domain search now returning exactly
the right single match, a hyphen/host:-filter case, boolean-free numeric
search, filter-clear returning to the unfiltered list, and the pending-
count indicator when new traffic arrives mid-filter.
|
|
Implements build-order step 4, the feature the plan calls out as used
daily. internal/proxy/repeat.go adds Server.Repeat(scheme, host, raw):
dials fresh (HTTP/1.1-only - raw edited text has no equivalent in
HTTP/2's binary framing), writes raw exactly as given with no framing
correction or header injection, and captures the exact response bytes.
This is deliberately separate from forward()'s parsed-*http.Request path
since Repeater's entire point is letting a malformed/edited request
reach the wire unmodified.
Repeater sends are recorded to the same history table as proxy traffic
(added a "source" column: "proxy" vs "repeater") so they show up in the
unified history view and the live subscribe stream, not a separate silo.
internal/ipc gains a "repeat" request/response pair; cmd/mitmuxd wires
proxy.Server into ipc.NewServer via a small Repeater interface so the
daemon keeps owning all network I/O and the TUI stays a thin client.
cmd/mitmux gains a repeater view (bubbles/textarea for the editable raw
request, a read-only viewport for the response), reachable with 'r' from
either the list or detail view, ctrl+r to send.
One real bug found via testing: bubbles/textarea only understands LF,
but HTTP/1.1 requires CRLF, so loading raw bytes straight into it split
each line in two on render. Fixed by normalizing CRLF<->LF at the editor
boundary only (load: strip \r; send: restore it) - documented as a
narrow, known trade-off for bodies with their own embedded LF line
breaks, which is the cost of being able to edit raw HTTP as text at all.
Verified live: edited and sent a plain-HTTP repeater request (confirmed
in SQLite that the edit - including an intentional extra blank line from
imprecise cursor navigation during testing - went out completely
unmodified, which is the correct behavior: mitmux must never "fix" what
the user typed), and sent an HTTPS repeater request against a freshly
captured entry, both getting real 200 responses with exact response
bytes back.
|
|
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 2. CA gains LeafFor(host), signing and
caching per-host leaf certificates on demand. The proxy's CONNECT
handler now terminates TLS with the client using a matching leaf cert
instead of tunneling raw bytes, and forwards each request upstream
over its own independently negotiated TLS connection.
Client-side and upstream-side ALPN are negotiated separately rather
than one being forced to mirror the other: an http.Transport configured
via http2.ConfigureTransport auto-bridges HTTP/1.1 and HTTP/2 on each
side independently, so e.g. an HTTP/1.1-only client reaching an
HTTP/2-preferring origin still works instead of failing the handshake
(caught by testing curl --http1.1 against example.com before this fix).
Verified live: plain HTTP passthrough, HTTPS with default (H2) and
forced HTTP/1.1 clients, and that requests without the mitmux CA
trusted are correctly rejected.
|
|
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.
|