diff options
| author | srdusr <[email protected]> | 2026-05-24 09:50:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-05-24 09:50:00 +0200 |
| commit | 0d1ce88962fe31ce1d204634e6ea10998a02827a (patch) | |
| tree | d1ccafa01f166dea867c6eca5a02fbaa477f86f6 /cmd | |
| parent | d522b1177a9e1fdd04888121975f2b3509d19564 (diff) | |
| download | mitmux-0d1ce88962fe31ce1d204634e6ea10998a02827a.tar.gz mitmux-0d1ce88962fe31ce1d204634e6ea10998a02827a.zip | |
Import: bring a HAR file's entries into history
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.
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/mitmux/har.go | 149 | ||||
| -rw-r--r-- | cmd/mitmux/har_test.go | 148 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 89 |
3 files changed, 384 insertions, 2 deletions
diff --git a/cmd/mitmux/har.go b/cmd/mitmux/har.go index 250ba5e..33da596 100644 --- a/cmd/mitmux/har.go +++ b/cmd/mitmux/har.go @@ -9,6 +9,8 @@ import ( "io" "net/http" "net/url" + "strings" + "time" "unicode/utf8" "mitmux/internal/ipc" @@ -231,3 +233,150 @@ func harDocFrom(details []*ipc.EntryDetail) (harDoc, int) { func harMarshal(doc harDoc) ([]byte, error) { return json.MarshalIndent(doc, "", " ") } + +// --- Import: the reverse direction, HAR entries back into raw bytes. +// +// A HAR file being imported didn't necessarily come from mitmux - it +// could be a browser DevTools export, or from another tool entirely - +// so this reconstructs wire bytes from HAR's structured fields rather +// than assuming anything mitmux-specific. Imported entries are always +// marked not-exact: this is a reconstruction from structured data, the +// same as an HTTP/2 capture already is, never the literal bytes that +// were actually on the wire when the original request happened. + +// rawRequestFromHAR reconstructs an HTTP/1.1 request line + headers + +// body from a HAR request object. +func rawRequestFromHAR(req harRequest) (raw []byte, scheme, host, path string, err error) { + u, err := url.Parse(req.URL) + if err != nil { + return nil, "", "", "", fmt.Errorf("parse url %q: %w", req.URL, err) + } + if u.Host == "" { + return nil, "", "", "", fmt.Errorf("url %q has no host", req.URL) + } + scheme, host, path = u.Scheme, u.Host, u.RequestURI() + + proto := req.HTTPVersion + if proto == "" { + proto = "HTTP/1.1" + } + method := req.Method + if method == "" { + method = "GET" + } + + var body []byte + if req.PostData != nil { + if req.PostData.Encoding == "base64" { + if body, err = base64.StdEncoding.DecodeString(req.PostData.Text); err != nil { + return nil, "", "", "", fmt.Errorf("decode base64 request body: %w", err) + } + } else { + body = []byte(req.PostData.Text) + } + } + + var b bytes.Buffer + fmt.Fprintf(&b, "%s %s %s\r\n", method, path, proto) + hasHost, hasCL := false, false + for _, h := range req.Headers { + hasHost = hasHost || strings.EqualFold(h.Name, "Host") + hasCL = hasCL || strings.EqualFold(h.Name, "Content-Length") + fmt.Fprintf(&b, "%s: %s\r\n", h.Name, h.Value) + } + if !hasHost { + fmt.Fprintf(&b, "Host: %s\r\n", host) + } + if len(body) > 0 && !hasCL { + fmt.Fprintf(&b, "Content-Length: %d\r\n", len(body)) + } + b.WriteString("\r\n") + b.Write(body) + return b.Bytes(), scheme, host, path, nil +} + +// rawResponseFromHAR reconstructs an HTTP/1.1 status line + headers + +// body. Returns nil, nil for a HAR entry with no response (status 0) - +// not every captured exchange got one. +func rawResponseFromHAR(resp harResponse) ([]byte, error) { + if resp.Status == 0 { + return nil, nil + } + proto := resp.HTTPVersion + if proto == "" { + proto = "HTTP/1.1" + } + statusText := resp.StatusText + if statusText == "" { + statusText = http.StatusText(resp.Status) + } + + var body []byte + var err error + if resp.Content.Encoding == "base64" { + if body, err = base64.StdEncoding.DecodeString(resp.Content.Text); err != nil { + return nil, fmt.Errorf("decode base64 response body: %w", err) + } + } else { + body = []byte(resp.Content.Text) + } + + var b bytes.Buffer + fmt.Fprintf(&b, "%s %d %s\r\n", proto, resp.Status, statusText) + hasCL := false + for _, h := range resp.Headers { + // Content.Text is already decoded/decompressed per the HAR + // spec - re-emitting Content-Encoding or Transfer-Encoding + // would describe framing the body no longer has, breaking any + // client that tries to decode it again. + if strings.EqualFold(h.Name, "Content-Encoding") || strings.EqualFold(h.Name, "Transfer-Encoding") { + continue + } + hasCL = hasCL || strings.EqualFold(h.Name, "Content-Length") + fmt.Fprintf(&b, "%s: %s\r\n", h.Name, h.Value) + } + if !hasCL { + fmt.Fprintf(&b, "Content-Length: %d\r\n", len(body)) + } + b.WriteString("\r\n") + b.Write(body) + return b.Bytes(), nil +} + +// importEntriesFromHAR parses a HAR document and converts every entry +// it can into an ipc.ImportEntry ready to send to the daemon. An entry +// that fails to convert (an unparseable URL, say) is skipped rather +// than failing the whole import - one bad entry in a large HAR +// shouldn't cost every other one, same reasoning as harDocFrom on the +// export side. +func importEntriesFromHAR(data []byte) (entries []ipc.ImportEntry, skipped int, err error) { + var doc harDoc + if err := json.Unmarshal(data, &doc); err != nil { + return nil, 0, fmt.Errorf("parse HAR: %w", err) + } + for _, e := range doc.Log.Entries { + reqRaw, scheme, host, path, err := rawRequestFromHAR(e.Request) + if err != nil { + skipped++ + continue + } + respRaw, err := rawResponseFromHAR(e.Response) + if err != nil { + skipped++ + continue + } + started, _ := time.Parse(time.RFC3339, e.StartedDateTime) // zero time if unparseable - not fatal + entries = append(entries, ipc.ImportEntry{ + Method: e.Request.Method, + Scheme: scheme, + Host: host, + Path: path, + StatusCode: e.Response.Status, + RequestRaw: reqRaw, + ResponseRaw: respRaw, + StartedAt: started, + Duration: time.Duration(e.Time) * time.Millisecond, + }) + } + return entries, skipped, nil +} diff --git a/cmd/mitmux/har_test.go b/cmd/mitmux/har_test.go index 58bfb99..159b706 100644 --- a/cmd/mitmux/har_test.go +++ b/cmd/mitmux/har_test.go @@ -1,8 +1,10 @@ package main import ( + "bytes" "encoding/base64" "encoding/json" + "strings" "testing" "time" @@ -141,3 +143,149 @@ func TestHarMarshalProducesValidJSON(t *testing.T) { t.Errorf("expected top-level \"log\" key, got %v", out) } } + +func TestRawRequestFromHAR(t *testing.T) { + req := harRequest{ + Method: "POST", + URL: "https://example.com/a?x=1", + HTTPVersion: "HTTP/1.1", + Headers: []harHeader{{Name: "X-Foo", Value: "bar"}}, + PostData: &harPostData{MimeType: "text/plain", Text: "hello"}, + } + raw, scheme, host, path, err := rawRequestFromHAR(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if scheme != "https" || host != "example.com" || path != "/a?x=1" { + t.Errorf("scheme/host/path = %q/%q/%q, want https/example.com//a?x=1", scheme, host, path) + } + s := string(raw) + for _, want := range []string{"POST /a?x=1 HTTP/1.1\r\n", "X-Foo: bar\r\n", "Host: example.com\r\n", "Content-Length: 5\r\n", "\r\n\r\nhello"} { + if !strings.Contains(s, want) { + t.Errorf("raw request missing %q, got:\n%s", want, s) + } + } +} + +func TestRawRequestFromHARBase64Body(t *testing.T) { + req := harRequest{ + Method: "POST", URL: "http://x/", + PostData: &harPostData{Text: base64.StdEncoding.EncodeToString([]byte{0xff, 0x00}), Encoding: "base64"}, + } + raw, _, _, _, err := rawRequestFromHAR(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Contains(raw, []byte{0xff, 0x00}) { + t.Errorf("expected decoded binary body in raw request, got: %q", raw) + } +} + +func TestRawRequestFromHARInvalidURL(t *testing.T) { + req := harRequest{Method: "GET", URL: "://not a url"} + if _, _, _, _, err := rawRequestFromHAR(req); err == nil { + t.Error("expected an error for an invalid URL, got nil") + } +} + +func TestRawRequestFromHARNoHost(t *testing.T) { + req := harRequest{Method: "GET", URL: "/just/a/path"} + if _, _, _, _, err := rawRequestFromHAR(req); err == nil { + t.Error("expected an error for a URL with no host, got nil") + } +} + +func TestRawResponseFromHARNoResponse(t *testing.T) { + raw, err := rawResponseFromHAR(harResponse{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if raw != nil { + t.Errorf("expected nil for a HAR entry with status 0, got %q", raw) + } +} + +func TestRawResponseFromHARStripsEncodingHeaders(t *testing.T) { + resp := harResponse{ + Status: 200, StatusText: "OK", HTTPVersion: "HTTP/1.1", + Headers: []harHeader{ + {Name: "Content-Encoding", Value: "gzip"}, + {Name: "Content-Type", Value: "text/plain"}, + }, + Content: harContent{Text: "hello"}, + } + raw, err := rawResponseFromHAR(resp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := string(raw) + if strings.Contains(s, "Content-Encoding") { + t.Errorf("expected Content-Encoding to be stripped (body is already decoded), got:\n%s", s) + } + if !strings.Contains(s, "Content-Type: text/plain") { + t.Errorf("expected Content-Type preserved, got:\n%s", s) + } + if !strings.Contains(s, "Content-Length: 5") { + t.Errorf("expected a computed Content-Length, got:\n%s", s) + } +} + +func TestImportEntriesFromHARRoundTrip(t *testing.T) { + d := &ipc.EntryDetail{ + Summary: store.Summary{ID: 1, Method: "GET", Scheme: "https", Host: "example.com", Path: "/a", StatusCode: 200}, + RequestRaw: []byte("GET /a HTTP/1.1\r\nHost: example.com\r\n\r\n"), + ResponseRaw: []byte("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello"), + } + doc, skipped := harDocFrom([]*ipc.EntryDetail{d}) + if skipped != 0 { + t.Fatalf("unexpected skips building the HAR: %d", skipped) + } + data, err := harMarshal(doc) + if err != nil { + t.Fatalf("harMarshal: %v", err) + } + + entries, skipped, err := importEntriesFromHAR(data) + if err != nil { + t.Fatalf("importEntriesFromHAR: %v", err) + } + if skipped != 0 { + t.Fatalf("unexpected skips importing: %d", skipped) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + e := entries[0] + if e.Method != "GET" || e.Scheme != "https" || e.Host != "example.com" || e.Path != "/a" { + t.Errorf("unexpected entry metadata: %+v", e) + } + if e.StatusCode != 200 { + t.Errorf("status = %d, want 200", e.StatusCode) + } + if !bytes.Contains(e.ResponseRaw, []byte("hello")) { + t.Errorf("expected response body preserved, got: %q", e.ResponseRaw) + } +} + +func TestImportEntriesFromHARInvalidJSON(t *testing.T) { + if _, _, err := importEntriesFromHAR([]byte("not json")); err == nil { + t.Error("expected an error for invalid JSON, got nil") + } +} + +func TestImportEntriesFromHARSkipsBadEntries(t *testing.T) { + data := []byte(`{"log":{"version":"1.2","entries":[ + {"request":{"method":"GET","url":"://bad"},"response":{}}, + {"request":{"method":"GET","url":"http://good/"},"response":{"status":200,"content":{}}} + ]}}`) + entries, skipped, err := importEntriesFromHAR(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if skipped != 1 { + t.Errorf("skipped = %d, want 1", skipped) + } + if len(entries) != 1 { + t.Errorf("entries = %d, want 1", len(entries)) + } +} diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 35cadaa..7ffe9b9 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -233,6 +233,12 @@ type model struct { exportBulk bool exportInput textinput.Model + // importEditing is the same modal-path-prompt pattern as export, in + // reverse: 'I' from the history list prompts for a HAR file to read + // and insert into history rather than write to. + importEditing bool + importInput textinput.Model + statusMsg string width int height int @@ -325,6 +331,9 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) expIn := textinput.New() expIn.Placeholder = "file path to write" + impIn := textinput.New() + impIn.Placeholder = "HAR file path to read" + return &model{ client: client, subCh: subCh, @@ -347,6 +356,7 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) grepExtractInput: geIn, decoderInput: din, exportInput: expIn, + importInput: impIn, } } @@ -479,6 +489,35 @@ func (m *model) exportCSV(entries []store.Summary, path string) tea.Cmd { } } +type historyImportedMsg struct { + count int + skipped int + err error +} + +// importHAR reads path, converts every entry it can into an +// ipc.ImportEntry (see cmd/mitmux/har.go - the reverse of exportHAR's +// conversion), and sends the batch to the daemon in one request. Runs +// as a tea.Cmd since it's a file read plus a network round trip. +func (m *model) importHAR(path string) tea.Cmd { + client := m.client + return func() tea.Msg { + data, err := os.ReadFile(path) + if err != nil { + return historyImportedMsg{err: err} + } + entries, skipped, err := importEntriesFromHAR(data) + if err != nil { + return historyImportedMsg{err: err} + } + imported, err := client.Import(entries) + if err != nil { + return historyImportedMsg{err: err} + } + return historyImportedMsg{count: imported, skipped: skipped + (len(entries) - imported)} + } +} + func (m *model) loadList() tea.Msg { var entries []store.Summary var err error @@ -897,6 +936,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.grepMatchInput.Width = msg.Width - 2 m.grepExtractInput.Width = msg.Width - 2 m.exportInput.Width = msg.Width - 2 + m.importInput.Width = msg.Width - 2 return m, nil case listLoadedMsg: @@ -969,6 +1009,17 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case historyImportedMsg: + if msg.err != nil { + m.statusMsg = "import error: " + msg.err.Error() + return m, nil + } + m.statusMsg = fmt.Sprintf("imported %d entries", msg.count) + if msg.skipped > 0 { + m.statusMsg += fmt.Sprintf(" (%d skipped - failed to parse or store)", msg.skipped) + } + return m, tea.Batch(m.loadList, m.loadStatus) + case detailLoadedMsg: if msg.err != nil { m.statusMsg = "get error: " + msg.err.Error() @@ -1124,6 +1175,28 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.exportInput, cmd = m.exportInput.Update(msg) return m, cmd } + if m.importEditing { + switch msg.String() { + case "enter": + path := m.importInput.Value() + m.importEditing = false + m.importInput.Blur() + if path == "" { + return m, nil + } + m.statusMsg = "importing..." + return m, m.importHAR(path) + case "esc": + m.importEditing = false + m.importInput.Blur() + return m, nil + case "ctrl+c": + return m, tea.Quit + } + var cmd tea.Cmd + m.importInput, cmd = m.importInput.Update(msg) + return m, cmd + } if m.searching { switch msg.String() { case "enter": @@ -1208,6 +1281,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.exportInput.SetValue("mitmux-export.har") m.exportInput.CursorEnd() return m, m.exportInput.Focus() + case "I": + m.importEditing = true + m.importInput.SetValue("") + return m, m.importInput.Focus() case "d": m.mode = viewDecoder m.statusMsg = "" @@ -1831,6 +1908,7 @@ func (m *model) helpView() string { "x delete the selected entry (asks to confirm)", "X clear ALL history, not just the current filter (asks to confirm)", "E export the current view (respects an active filter) - .har or .csv", + "I import a HAR file - inserts its entries into history (source:import)", "d decoder (URL/Base64/Hex/HTML encode/decode)", "/ search: plain text, host:value, AND/OR/NOT,", " status:404 / status:4xx / status:>=400,", @@ -1931,6 +2009,13 @@ func (m *model) listView() string { b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit - .har (full, default) or .csv (summary table)")) return b.String() } + if m.importEditing { + b.WriteString("import HAR file: ") + b.WriteString(m.importInput.View()) + b.WriteString("\n") + b.WriteString(helpStyle.Render("enter import · esc cancel · ctrl+c quit")) + return b.String() + } if m.confirmPrompt != "" { b.WriteString(statusStyle.Render(m.confirmPrompt)) b.WriteString("\n") @@ -1938,9 +2023,9 @@ func (m *model) listView() string { b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg))) b.WriteString("\n") } - help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (HAR/CSV) · / search · m rules · s scope · ? help · q quit" + help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export · I import · / search · m rules · s scope · ? help · q quit" if m.query != "" { - help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · / search · esc clear filter · s scope · ? help · q quit" + help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · I import · / search · esc clear filter · s scope · ? help · q quit" } b.WriteString(helpStyle.Render(help)) return b.String() |