srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/plugins/authcheck
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/authcheck')
-rw-r--r--plugins/authcheck/main.go265
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
+}