srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-05-15 19:48:00 +0200
committersrdusr <[email protected]>2026-05-15 19:48:00 +0200
commitdd1621c0077e079e0693f16fe83f17d50338216e (patch)
tree411a06d723a16aff1077d5d3e724680436aac85a /internal
parentd7d50ae9902d69b2e52d446684b5ab01a5ecab1e (diff)
downloadmitmux-dd1621c0077e079e0693f16fe83f17d50338216e.tar.gz
mitmux-dd1621c0077e079e0693f16fe83f17d50338216e.zip
Stop mislabeling truncated captures as "exact"
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.
Diffstat (limited to 'internal')
-rw-r--r--internal/ipc/ipc.go7
-rw-r--r--internal/ipc/server.go1
-rw-r--r--internal/proxy/capture.go21
-rw-r--r--internal/proxy/proxy.go43
-rw-r--r--internal/proxy/repeat.go38
-rw-r--r--internal/proxy/tee.go29
-rw-r--r--internal/store/store.go36
7 files changed, 113 insertions, 62 deletions
diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go
index 270b8e4..5dac21f 100644
--- a/internal/ipc/ipc.go
+++ b/internal/ipc/ipc.go
@@ -100,6 +100,13 @@ type EntryDetail struct {
ResponseRaw []byte `json:"response_raw"`
RequestExact bool `json:"request_exact"`
ResponseExact bool `json:"response_exact"`
+ // *Truncated is true when the matching *Exact is false specifically
+ // because capture hit its size cap and dropped bytes off the end -
+ // as opposed to false because there was never a wire-exact
+ // representation to begin with (HTTP/2). Only meaningful alongside
+ // a false *Exact.
+ RequestTruncated bool `json:"request_truncated,omitempty"`
+ ResponseTruncated bool `json:"response_truncated,omitempty"`
}
// Client talks to a mitmuxd instance for request/response queries
diff --git a/internal/ipc/server.go b/internal/ipc/server.go
index 94073da..2879b24 100644
--- a/internal/ipc/server.go
+++ b/internal/ipc/server.go
@@ -312,6 +312,7 @@ func detailFromEntry(e *store.Entry) *EntryDetail {
},
RequestRaw: e.RequestRaw, ResponseRaw: e.ResponseRaw,
RequestExact: e.RequestExact, ResponseExact: e.ResponseExact,
+ RequestTruncated: e.RequestTruncated, ResponseTruncated: e.ResponseTruncated,
}
}
diff --git a/internal/proxy/capture.go b/internal/proxy/capture.go
index ec4dc8d..3902e5c 100644
--- a/internal/proxy/capture.go
+++ b/internal/proxy/capture.go
@@ -36,12 +36,19 @@ func (c *cappedTee) Read(p []byte) (int, error) {
// captureRequest returns the raw bytes of r for storage. When tee is
// non-nil (an HTTP/1.1 client connection), the bytes are exactly what
-// was read off the wire. Otherwise (HTTP/2, which has no single "raw
-// bytes" representation - it's multiplexed, HPACK-compressed framing)
-// it's a reconstruction from the parsed request, exact=false.
-func captureRequest(r *http.Request, tee *teeConn, bodyCap *cappedTee) (raw []byte, exact bool) {
+// was read off the wire, unless truncated hits maxCaptureBytes and has
+// to drop bytes off the end - still real wire bytes, just incomplete,
+// which is a different (and less severe) kind of "not exact" than the
+// HTTP/2 case below and worth telling apart in the UI (see
+// store.Entry's *Truncated fields). Otherwise (HTTP/2, which has no
+// single "raw bytes" representation - it's multiplexed, HPACK-
+// compressed framing) it's a reconstruction from the parsed request,
+// exact=false, truncated=false (truncated only applies to a would-be-
+// exact capture).
+func captureRequest(r *http.Request, tee *teeConn, bodyCap *cappedTee) (raw []byte, exact, truncated bool) {
if tee != nil {
- return tee.Take(), true
+ data, truncated := tee.Take()
+ return data, !truncated, truncated
}
dump := r.Clone(r.Context())
@@ -54,9 +61,9 @@ func captureRequest(r *http.Request, tee *teeConn, bodyCap *cappedTee) (raw []by
}
var buf bytes.Buffer
if err := dump.Write(&buf); err != nil {
- return nil, false
+ return nil, false, false
}
- return buf.Bytes(), false
+ return buf.Bytes(), false, false
}
// captureResponse reconstructs raw response bytes from the parsed
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
index 82328db..08b8551 100644
--- a/internal/proxy/proxy.go
+++ b/internal/proxy/proxy.go
@@ -383,8 +383,8 @@ func (s *Server) forward(dial dialer, scheme, hostname string, w http.ResponseWr
started := time.Now()
conn, negotiated, dialErr := dial(r.Context())
if dialErr != nil {
- reqRaw, reqExact := captureRequest(r, clientTee, reqBodyCap)
- s.record(started, time.Since(started), scheme, hostname, r, reqRaw, reqExact, nil, false, 0, dialErr.Error())
+ reqRaw, reqExact, reqTrunc := captureRequest(r, clientTee, reqBodyCap)
+ s.record(started, time.Since(started), scheme, hostname, r, reqRaw, reqExact, reqTrunc, nil, false, false, 0, dialErr.Error())
http.Error(w, dialErr.Error(), http.StatusBadGateway)
return
}
@@ -412,10 +412,10 @@ func (s *Server) forward(dial dialer, scheme, hostname string, w http.ResponseWr
}
duration := time.Since(started)
- reqRaw, reqExact := captureRequest(r, clientTee, reqBodyCap)
+ reqRaw, reqExact, reqTrunc := captureRequest(r, clientTee, reqBodyCap)
if err != nil {
- s.record(started, duration, scheme, hostname, r, reqRaw, reqExact, nil, false, 0, err.Error())
+ s.record(started, duration, scheme, hostname, r, reqRaw, reqExact, reqTrunc, nil, false, false, 0, err.Error())
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
@@ -448,14 +448,15 @@ func (s *Server) forward(dial dialer, scheme, hostname string, w http.ResponseWr
io.Copy(w, resp.Body)
var respRaw []byte
- var respExact bool
+ var respExact, respTrunc bool
if upstreamTee != nil {
- respRaw, respExact = upstreamTee.Take(), true
+ respRaw, respTrunc = upstreamTee.Take()
+ respExact = !respTrunc
} else {
respRaw, respExact = captureResponse(resp, respBodyCap)
}
- s.record(started, duration, scheme, hostname, r, reqRaw, reqExact, respRaw, respExact, resp.StatusCode, "")
+ s.record(started, duration, scheme, hostname, r, reqRaw, reqExact, reqTrunc, respRaw, respExact, respTrunc, resp.StatusCode, "")
}
// enabledRules fetches the current enabled match-and-replace rules for
@@ -471,24 +472,26 @@ func (s *Server) enabledRules(scope string) ([]rules.Rule, error) {
// record stores one history entry and notifies OnEntry.
func (s *Server) record(started time.Time, duration time.Duration, scheme, host string, r *http.Request,
- reqRaw []byte, reqExact bool, respRaw []byte, respExact bool, status int, errMsg string) {
+ reqRaw []byte, reqExact, reqTruncated bool, respRaw []byte, respExact, respTruncated bool, status int, errMsg string) {
if s.store == nil {
return
}
e := &store.Entry{
- StartedAt: started,
- Duration: duration,
- Method: r.Method,
- Scheme: scheme,
- Host: host,
- Path: r.URL.Path,
- StatusCode: status,
- RequestRaw: reqRaw,
- ResponseRaw: respRaw,
- RequestExact: reqExact,
- ResponseExact: respExact,
- Error: errMsg,
+ StartedAt: started,
+ Duration: duration,
+ Method: r.Method,
+ Scheme: scheme,
+ Host: host,
+ Path: r.URL.Path,
+ StatusCode: status,
+ RequestRaw: reqRaw,
+ ResponseRaw: respRaw,
+ RequestExact: reqExact,
+ ResponseExact: respExact,
+ RequestTruncated: reqTruncated,
+ ResponseTruncated: respTruncated,
+ Error: errMsg,
}
id, err := s.store.Insert(e)
if err != nil {
diff --git a/internal/proxy/repeat.go b/internal/proxy/repeat.go
index 3ee7cea..f682b99 100644
--- a/internal/proxy/repeat.go
+++ b/internal/proxy/repeat.go
@@ -38,7 +38,7 @@ func (s *Server) sendRaw(ctx context.Context, scheme, host string, raw []byte, s
conn, err := dialForRepeat(ctx, scheme, host)
if err != nil {
- return s.recordRaw(started, time.Since(started), scheme, host, method, path, raw, nil, 0, err.Error(), source)
+ return s.recordRaw(started, time.Since(started), scheme, host, method, path, raw, nil, false, false, 0, err.Error(), source)
}
defer conn.Close()
// See the matching comment in forward(): without this, a hung
@@ -48,37 +48,39 @@ func (s *Server) sendRaw(ctx context.Context, scheme, host string, raw []byte, s
conn.SetDeadline(time.Now().Add(upstreamTimeout))
if _, err := conn.Write(raw); err != nil {
- return s.recordRaw(started, time.Since(started), scheme, host, method, path, raw, nil, 0, err.Error(), source)
+ return s.recordRaw(started, time.Since(started), scheme, host, method, path, raw, nil, false, false, 0, err.Error(), source)
}
tee := newTeeConn(conn)
resp, err := http.ReadResponse(bufio.NewReader(tee), &http.Request{Method: method})
duration := time.Since(started)
if err != nil {
- return s.recordRaw(started, duration, scheme, host, method, path, raw, nil, 0, err.Error(), source)
+ return s.recordRaw(started, duration, scheme, host, method, path, raw, nil, false, false, 0, err.Error(), source)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
- return s.recordRaw(started, duration, scheme, host, method, path, raw, tee.Take(), resp.StatusCode, "", source)
+ respRaw, truncated := tee.Take()
+ return s.recordRaw(started, duration, scheme, host, method, path, raw, respRaw, !truncated, truncated, resp.StatusCode, "", source)
}
func (s *Server) recordRaw(started time.Time, duration time.Duration, scheme, host, method, path string,
- reqRaw, respRaw []byte, status int, errMsg, source string) (*store.Entry, error) {
+ reqRaw, respRaw []byte, respExact, respTruncated bool, status int, errMsg, source string) (*store.Entry, error) {
e := &store.Entry{
- StartedAt: started,
- Duration: duration,
- Method: method,
- Scheme: scheme,
- Host: host,
- Path: path,
- StatusCode: status,
- RequestRaw: reqRaw,
- ResponseRaw: respRaw,
- RequestExact: true,
- ResponseExact: respRaw != nil,
- Error: errMsg,
- Source: source,
+ StartedAt: started,
+ Duration: duration,
+ Method: method,
+ Scheme: scheme,
+ Host: host,
+ Path: path,
+ StatusCode: status,
+ RequestRaw: reqRaw,
+ ResponseRaw: respRaw,
+ RequestExact: true,
+ ResponseExact: respRaw != nil && respExact,
+ ResponseTruncated: respRaw != nil && respTruncated,
+ Error: errMsg,
+ Source: source,
}
if s.store != nil {
id, err := s.store.Insert(e)
diff --git a/internal/proxy/tee.go b/internal/proxy/tee.go
index f6a385a..bd34d19 100644
--- a/internal/proxy/tee.go
+++ b/internal/proxy/tee.go
@@ -22,8 +22,9 @@ const maxCaptureBytes = 10 << 20 // 10 MiB
// here (call sites synchronize on the request/response boundary itself).
type teeConn struct {
net.Conn
- mu sync.Mutex
- buf []byte
+ mu sync.Mutex
+ buf []byte
+ truncated bool
}
func newTeeConn(c net.Conn) *teeConn {
@@ -34,12 +35,18 @@ func (c *teeConn) Read(p []byte) (int, error) {
n, err := c.Conn.Read(p)
if n > 0 {
c.mu.Lock()
- if room := maxCaptureBytes - len(c.buf); room > 0 {
+ room := maxCaptureBytes - len(c.buf)
+ if room > 0 {
end := n
if end > room {
end = room
}
c.buf = append(c.buf, p[:end]...)
+ if end < n {
+ c.truncated = true
+ }
+ } else {
+ c.truncated = true
}
c.mu.Unlock()
}
@@ -47,13 +54,19 @@ func (c *teeConn) Read(p []byte) (int, error) {
}
// Take returns the bytes read since the last Take call (or since the
-// connection was created) and resets the buffer.
-func (c *teeConn) Take() []byte {
+// connection was created), whether that capture hit maxCaptureBytes and
+// had to drop bytes off the end, and resets both. A caller storing this
+// as an "exact" capture must fold truncated into that decision - bytes
+// silently missing from the tail is exactly the kind of gap "exact"
+// promises doesn't exist, and callers finding it separately or not at
+// all is how a request smuggling investigation loses the one thing it
+// was looking for.
+func (c *teeConn) Take() (data []byte, truncated bool) {
c.mu.Lock()
defer c.mu.Unlock()
- out := c.buf
- c.buf = nil
- return out
+ out, trunc := c.buf, c.truncated
+ c.buf, c.truncated = nil, false
+ return out, trunc
}
// teeListener wraps a net.Listener so every accepted connection is
diff --git a/internal/store/store.go b/internal/store/store.go
index 4de4fa1..19b71b1 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -34,7 +34,9 @@ CREATE TABLE IF NOT EXISTS history (
response_exact INTEGER NOT NULL,
error TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'proxy',
- flagged INTEGER NOT NULL DEFAULT 0
+ flagged INTEGER NOT NULL DEFAULT 0,
+ request_truncated INTEGER NOT NULL DEFAULT 0,
+ response_truncated INTEGER NOT NULL DEFAULT 0
);
CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
@@ -89,6 +91,8 @@ func Open(path string) (*Store, error) {
// on databases that already have it.
db.Exec("ALTER TABLE history ADD COLUMN source TEXT NOT NULL DEFAULT 'proxy'")
db.Exec("ALTER TABLE history ADD COLUMN flagged INTEGER NOT NULL DEFAULT 0")
+ db.Exec("ALTER TABLE history ADD COLUMN request_truncated INTEGER NOT NULL DEFAULT 0")
+ db.Exec("ALTER TABLE history ADD COLUMN response_truncated INTEGER NOT NULL DEFAULT 0")
// Backfill history_fts for rows inserted before it existed. A no-op
// once caught up, since every Insert keeps both tables in sync.
if _, err := db.Exec(`
@@ -121,9 +125,17 @@ type Entry struct {
ResponseRaw []byte // nil if no response was received
RequestExact bool // true if RequestRaw is wire-exact, false if reconstructed (e.g. HTTP/2)
ResponseExact bool
- Error string // network/transport error, if the request never got a response
- Source string // "proxy" or "repeater"
- Flagged bool
+ // Truncated is true when the corresponding *Raw field would have
+ // been an exact capture but hit maxCaptureBytes and had bytes
+ // dropped off the end - distinct from a false *Exact, which also
+ // covers HTTP/2's inherently-reconstructed (never wire-exact to
+ // begin with) captures. Only meaningful when the matching *Exact
+ // field is false; a capture can't be both exact and truncated.
+ RequestTruncated bool
+ ResponseTruncated bool
+ Error string // network/transport error, if the request never got a response
+ Source string // "proxy" or "repeater"
+ Flagged bool
}
// Summary is the lightweight metadata used for the history list view -
@@ -164,10 +176,12 @@ func (s *Store) Insert(e *Entry) (int64, error) {
res, err := tx.Exec(
`INSERT INTO history
(started_at, duration_ms, method, scheme, host, path, status_code,
- request_raw, response_raw, request_exact, response_exact, error, source)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ request_raw, response_raw, request_exact, response_exact, error, source,
+ request_truncated, response_truncated)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.StartedAt.UnixMilli(), e.Duration.Milliseconds(), e.Method, e.Scheme, e.Host, e.Path,
statusCode, e.RequestRaw, e.ResponseRaw, boolToInt(e.RequestExact), boolToInt(e.ResponseExact), e.Error, source,
+ boolToInt(e.RequestTruncated), boolToInt(e.ResponseTruncated),
)
if err != nil {
return 0, fmt.Errorf("insert history entry: %w", err)
@@ -394,15 +408,17 @@ func (s *Store) Get(id int64) (*Entry, error) {
row := s.db.QueryRow(
`SELECT id, started_at, duration_ms, method, scheme, host, path,
COALESCE(status_code, 0), request_raw, response_raw,
- request_exact, response_exact, error, source, flagged
+ request_exact, response_exact, error, source, flagged,
+ request_truncated, response_truncated
FROM history WHERE id = ?`,
id,
)
var e Entry
var startedAt, durationMs int64
- var reqExact, respExact, flagged int
+ var reqExact, respExact, flagged, reqTrunc, respTrunc int
if err := row.Scan(&e.ID, &startedAt, &durationMs, &e.Method, &e.Scheme, &e.Host, &e.Path,
- &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &reqExact, &respExact, &e.Error, &e.Source, &flagged); err != nil {
+ &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &reqExact, &respExact, &e.Error, &e.Source, &flagged,
+ &reqTrunc, &respTrunc); err != nil {
return nil, fmt.Errorf("get history entry %d: %w", id, err)
}
e.StartedAt = time.UnixMilli(startedAt)
@@ -410,6 +426,8 @@ func (s *Store) Get(id int64) (*Entry, error) {
e.RequestExact = reqExact != 0
e.ResponseExact = respExact != 0
e.Flagged = flagged != 0
+ e.RequestTruncated = reqTrunc != 0
+ e.ResponseTruncated = respTrunc != 0
return &e, nil
}