srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/plugins/authcheck/main.go
blob: 8f2a5ecafce89c7630b1aeb72832f6bb13651f60 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
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
}