diff options
Diffstat (limited to 'plugins/authcheck/main.go')
| -rw-r--r-- | plugins/authcheck/main.go | 265 |
1 files changed, 265 insertions, 0 deletions
diff --git a/plugins/authcheck/main.go b/plugins/authcheck/main.go new file mode 100644 index 0000000..8f2a5ec --- /dev/null +++ b/plugins/authcheck/main.go @@ -0,0 +1,265 @@ +// Command authcheck is a reference mitmux plugin - an Autorize-style +// authorization checker - and, deliberately, a template: it speaks +// mitmux's plugin wire protocol directly (raw JSON over the control +// socket, see PLUGINS.md) rather than importing mitmux's own internal +// Go packages, the same way a plugin written in any other language +// would have to. That's not a style preference - it's what actually +// proves PLUGINS.md's documented protocol is sufficient on its own, +// rather than silently depending on Go-internal conveniences a +// non-Go plugin author wouldn't have access to. +// +// What it does: for every captured request that carries an +// Authorization or Cookie header, resends the exact same request with +// that header stripped and compares the result. A resend that still +// succeeds where the original also succeeded means the endpoint +// doesn't actually enforce the authentication it appears to require - +// a missing-function-level-access-control bug, one of the more common +// real findings this class of check turns up. Matches are tagged +// "authcheck:bypass" on the original entry, with structured detail (the +// original status vs. the anonymous resend's) for the TUI's tag panel. +// +// This is the simplified half of what Burp's Autorize does: Autorize +// additionally supports swapping in a SECOND, lower-privileged +// identity's session and comparing against that - useful for catching +// cross-account IDORs a fully-anonymous check can't see. That needs a +// second credential as input, which this reference version doesn't +// take; a -low-priv-cookie flag doing that is a natural, small +// extension of the same pattern used here. +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "path/filepath" +) + +// request/response mirror internal/ipc's wire structs field-for-field +// (see PLUGINS.md) - defined fresh here, not imported, so this file +// only ever exercises what's actually documented as the public +// protocol. +type request struct { + Type string `json:"type"` + ID int64 `json:"id,omitempty"` + Scheme string `json:"scheme,omitempty"` + Host string `json:"host,omitempty"` + Raw []byte `json:"raw,omitempty"` + TagPlugin string `json:"tag_plugin,omitempty"` + Tag string `json:"tag,omitempty"` + TagData string `json:"tag_data,omitempty"` +} + +type summary struct { + ID int64 `json:"id"` + Method string `json:"method"` + Scheme string `json:"scheme"` + Host string `json:"host"` + Path string `json:"path"` + Source string `json:"source"` +} + +type entryDetail struct { + summary + StatusCode int `json:"status_code"` + RequestRaw []byte `json:"request_raw"` +} + +type response struct { + Type string `json:"type"` + Entries []summary `json:"entries,omitempty"` + New *summary `json:"new,omitempty"` + Detail *entryDetail `json:"detail,omitempty"` + TagID int64 `json:"tag_id,omitempty"` + Error string `json:"error,omitempty"` +} + +// client is a minimal request/response connection - send one request, +// read back one response, repeat. A plugin also needs a second, +// separate connection for "subscribe" (see main): that one is only +// ever written to once and then just read from continuously, so it +// doesn't need this type's request/response pairing at all. +type client struct { + conn net.Conn + enc *json.Encoder + dec *json.Decoder +} + +func dial(path string) (*client, error) { + conn, err := net.Dial("unix", path) + if err != nil { + return nil, err + } + return &client{conn: conn, enc: json.NewEncoder(conn), dec: json.NewDecoder(conn)}, nil +} + +func (c *client) call(req request) (response, error) { + if err := c.enc.Encode(req); err != nil { + return response{}, err + } + var resp response + if err := c.dec.Decode(&resp); err != nil { + return response{}, err + } + if resp.Type == "error" { + return response{}, fmt.Errorf("%s", resp.Error) + } + return resp, nil +} + +func defaultSocketPath() string { + if rt := os.Getenv("XDG_RUNTIME_DIR"); rt != "" { + return filepath.Join(rt, "mitmux.sock") + } + // Matches internal/ca.Dir() without importing it - see this file's + // package doc for why plugins shouldn't reach into mitmux's own Go + // internals even when it would be more convenient. + cfg, err := os.UserConfigDir() + if err != nil { + return "mitmux.sock" + } + return filepath.Join(cfg, "mitmux", "mitmux.sock") +} + +// authHeaders are checked in order; the first one present on a request +// is what gets stripped for the anonymous resend. Checking more than +// one matters: an API might authenticate via a bearer token while a +// browser-driven flow on the same host uses a session cookie, and both +// are worth checking independently rather than only ever picking one. +var authHeaders = []string{"Authorization", "Cookie"} + +type result struct { + OriginalStatus int `json:"original_status"` + ResendStatus int `json:"resend_status"` + StrippedHeader string `json:"stripped_header"` + Verdict string `json:"verdict"` +} + +func main() { + socketPath := flag.String("socket", "", "daemon control socket path (default: same as mitmux itself)") + pluginName := flag.String("name", "authcheck", "name this plugin tags entries as") + flag.Parse() + + path := *socketPath + if path == "" { + path = defaultSocketPath() + } + + actor, err := dial(path) + if err != nil { + log.Fatalf("dial %s: %v", path, err) + } + defer actor.conn.Close() + + subConn, err := net.Dial("unix", path) + if err != nil { + log.Fatalf("dial %s (subscribe): %v", path, err) + } + defer subConn.Close() + if err := json.NewEncoder(subConn).Encode(request{Type: "subscribe"}); err != nil { + log.Fatalf("subscribe: %v", err) + } + + log.Printf("authcheck: watching live traffic on %s", path) + dec := json.NewDecoder(subConn) + for { + var resp response + if err := dec.Decode(&resp); err != nil { + log.Fatalf("subscribe feed closed: %v", err) + } + if resp.Type != "new" || resp.New == nil { + continue + } + sum := *resp.New + // Never touch our own resends - the daemon records a Repeat() + // as source="repeater", and reprocessing it would misfile the + // very control-group requests this check depends on (it also + // wouldn't loop: a resend already has its auth header removed, + // so it would never match authHeaders again - but it's still + // pointless work and pointless noise to try). + if sum.Source != "proxy" { + continue + } + if err := checkEntry(actor, *pluginName, sum.ID); err != nil { + log.Printf("entry #%d: %v", sum.ID, err) + } + } +} + +func checkEntry(c *client, pluginName string, id int64) error { + resp, err := c.call(request{Type: "get", ID: id}) + if err != nil { + return fmt.Errorf("get: %w", err) + } + if resp.Detail == nil { + return fmt.Errorf("get: no detail in response") + } + detail := *resp.Detail + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(detail.RequestRaw))) + if err != nil { + return nil // not a well-formed request we can safely reparse - skip, not fatal + } + + var strippedHeader string + for _, h := range authHeaders { + if req.Header.Get(h) != "" { + strippedHeader = h + break + } + } + if strippedHeader == "" { + return nil // nothing to check + } + + req2, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(detail.RequestRaw))) + if err != nil { + return nil + } + req2.Header.Del(strippedHeader) + + var buf bytes.Buffer + if err := req2.Write(&buf); err != nil { + return fmt.Errorf("rebuild request: %w", err) + } + + repeatResp, err := c.call(request{Type: "repeat", Scheme: detail.Scheme, Host: detail.Host, Raw: buf.Bytes()}) + if err != nil { + return fmt.Errorf("repeat: %w", err) + } + if repeatResp.Detail == nil { + return fmt.Errorf("repeat: no detail in response") + } + resentStatus := repeatResp.Detail.StatusCode + + suspicious := successClass(detail.StatusCode) && successClass(resentStatus) + if !suspicious { + return nil // matches Burp's own default: only surface likely findings, not every check performed + } + + data, err := json.Marshal(result{ + OriginalStatus: detail.StatusCode, + ResendStatus: resentStatus, + StrippedHeader: strippedHeader, + Verdict: "bypass", + }) + if err != nil { + return fmt.Errorf("marshal result: %w", err) + } + + if _, err := c.call(request{Type: "tag_entry", ID: id, TagPlugin: pluginName, Tag: "authcheck:bypass", TagData: string(data)}); err != nil { + return fmt.Errorf("tag_entry: %w", err) + } + log.Printf("#%d %s %s -> possible auth bypass: %d without %s still got %d", + id, detail.Method, detail.Path, detail.StatusCode, strippedHeader, resentStatus) + return nil +} + +func successClass(status int) bool { + return status >= 200 && status < 400 +} |