Skip to content

Commit e0350a7

Browse files
authored
mitm: sanitize request read errors (#799)
1 parent b2a84fa commit e0350a7

2 files changed

Lines changed: 214 additions & 1 deletion

File tree

‎cmd/clawpatrol/main.go‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2286,6 +2286,53 @@ func bufferHTTPBodyForMatchTruncated(req *http.Request, capBytes int) (body []by
22862286
return b, false
22872287
}
22882288

2289+
const maxMITMRequestReadLogHostBytes = 255
2290+
2291+
func sanitizeMITMRequestReadLogHost(host string) string {
2292+
if len(host) > maxMITMRequestReadLogHostBytes {
2293+
host = host[:maxMITMRequestReadLogHostBytes]
2294+
}
2295+
if host == "" {
2296+
return "unknown"
2297+
}
2298+
b := []byte(host)
2299+
for i, c := range b {
2300+
switch {
2301+
case c >= 'a' && c <= 'z':
2302+
case c >= 'A' && c <= 'Z':
2303+
case c >= '0' && c <= '9':
2304+
case c == '.', c == '-', c == '_', c == ':', c == '[', c == ']':
2305+
default:
2306+
b[i] = '_'
2307+
}
2308+
}
2309+
return string(b)
2310+
}
2311+
2312+
func mitmRequestReadErrorReason(err error) string {
2313+
if errors.Is(err, io.ErrUnexpectedEOF) {
2314+
return "incomplete_request"
2315+
}
2316+
if errors.Is(err, bufio.ErrBufferFull) {
2317+
return "request_too_large"
2318+
}
2319+
var netErr net.Error
2320+
if errors.As(err, &netErr) {
2321+
if netErr.Timeout() {
2322+
return "timeout"
2323+
}
2324+
return "network_error"
2325+
}
2326+
return "invalid_request"
2327+
}
2328+
2329+
func logMITMRequestReadError(host string, err error) {
2330+
// net/http parser errors can embed the request line or malformed
2331+
// header verbatim. Keep err out of the log and emit only a fixed
2332+
// category derived from its type.
2333+
log.Printf("mitm_request_read_error host=%q reason=%s", sanitizeMITMRequestReadLogHost(host), mitmRequestReadErrorReason(err))
2334+
}
2335+
22892336
// mitmHTTPS handles an SNI-matched TLS connection for an HTTPS-family
22902337
// endpoint (https, kubernetes). It mints a leaf cert, terminates TLS,
22912338
// then loops reading HTTP requests and dispatching each through the
@@ -2332,7 +2379,7 @@ func (g *Gateway) mitmHTTPSWithCertHost(c net.Conn, host, certHost string, ep *c
23322379
req, err := http.ReadRequest(br)
23332380
if err != nil {
23342381
if !errors.Is(err, io.EOF) {
2335-
log.Printf("mitm read req %s: %v", host, err)
2382+
logMITMRequestReadError(host, err)
23362383
}
23372384
return
23382385
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"crypto/tls"
7+
"errors"
8+
"io"
9+
"log"
10+
"net"
11+
"strings"
12+
"testing"
13+
"time"
14+
15+
"github.com/denoland/clawpatrol/internal/config"
16+
)
17+
18+
func TestMITMRequestReadErrorLogIsSanitizedAndBounded(t *testing.T) {
19+
const (
20+
headerMarker = "SYNTHETIC_HEADER_MARKER"
21+
headerSuffix = "SYNTHETIC_HEADER_SUFFIX"
22+
bodyMarker = "SYNTHETIC_BODY_MARKER"
23+
bodySuffix = "SYNTHETIC_BODY_SUFFIX"
24+
maxLogBytes = 512
25+
)
26+
27+
tests := []struct {
28+
name string
29+
payload string
30+
markers []string
31+
}{
32+
{
33+
name: "malformed header",
34+
payload: "GET / HTTP/1.1\r\nX-Test: " + headerMarker + "\x00" + headerSuffix + "\r\n\r\n",
35+
markers: []string{headerMarker, headerSuffix},
36+
},
37+
{
38+
name: "body bytes parsed as a request line",
39+
payload: bodyMarker + strings.Repeat("x", 32<<10) + bodySuffix + "\r\n",
40+
markers: []string{bodyMarker, bodySuffix},
41+
},
42+
}
43+
44+
for _, tt := range tests {
45+
t.Run(tt.name, func(t *testing.T) {
46+
got := runMalformedMITMRequest(t, tt.payload)
47+
for _, marker := range tt.markers {
48+
if strings.Contains(got, marker) {
49+
t.Fatalf("log leaked synthetic request marker %q: %q", marker, got)
50+
}
51+
}
52+
if len(got) > maxLogBytes {
53+
t.Fatalf("log length = %d bytes, want <= %d", len(got), maxLogBytes)
54+
}
55+
if lines := strings.Count(got, "\n"); lines != 1 {
56+
t.Fatalf("log has %d lines, want exactly one: %q", lines, got)
57+
}
58+
if !strings.Contains(got, "mitm_request_read_error") {
59+
t.Fatalf("log = %q, want structured event name", got)
60+
}
61+
if !strings.Contains(got, "host=\"api.example.test\"") {
62+
t.Fatalf("log = %q, want endpoint host metadata", got)
63+
}
64+
if !strings.Contains(got, "reason=invalid_request") {
65+
t.Fatalf("log = %q, want sanitized reason", got)
66+
}
67+
})
68+
}
69+
}
70+
71+
func TestMITMRequestReadErrorReasonUsesFixedCategories(t *testing.T) {
72+
tests := []struct {
73+
name string
74+
err error
75+
want string
76+
}{
77+
{name: "incomplete", err: io.ErrUnexpectedEOF, want: "incomplete_request"},
78+
{name: "too large", err: bufio.ErrBufferFull, want: "request_too_large"},
79+
{name: "timeout", err: &net.DNSError{IsTimeout: true}, want: "timeout"},
80+
{name: "network", err: &net.DNSError{Err: "SYNTHETIC_NETWORK_MARKER"}, want: "network_error"},
81+
{name: "parser", err: errors.New("SYNTHETIC_PARSER_MARKER"), want: "invalid_request"},
82+
}
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
if got := mitmRequestReadErrorReason(tt.err); got != tt.want {
86+
t.Fatalf("reason = %q, want %q", got, tt.want)
87+
}
88+
})
89+
}
90+
}
91+
92+
func TestSanitizeMITMRequestReadLogHost(t *testing.T) {
93+
const suffix = "SYNTHETIC_HOST_SUFFIX"
94+
host := "api.example.test\r\ninjected=true" + strings.Repeat("x", maxMITMRequestReadLogHostBytes) + suffix
95+
got := sanitizeMITMRequestReadLogHost(host)
96+
if len(got) > maxMITMRequestReadLogHostBytes {
97+
t.Fatalf("sanitized host length = %d, want <= %d", len(got), maxMITMRequestReadLogHostBytes)
98+
}
99+
if strings.ContainsAny(got, "\r\n=\"") {
100+
t.Fatalf("sanitized host contains log-structure characters: %q", got)
101+
}
102+
if strings.Contains(got, suffix) {
103+
t.Fatalf("sanitized host retained truncated suffix: %q", got)
104+
}
105+
}
106+
107+
func runMalformedMITMRequest(t *testing.T, payload string) string {
108+
t.Helper()
109+
110+
certs, _ := inMemoryCertCache(t)
111+
g := &Gateway{certs: certs}
112+
g.cfg.Store(&config.Gateway{Policy: &config.Policy{}})
113+
ep := &config.CompiledEndpoint{}
114+
115+
var logs bytes.Buffer
116+
oldOutput := log.Writer()
117+
oldFlags := log.Flags()
118+
oldPrefix := log.Prefix()
119+
log.SetOutput(&logs)
120+
log.SetFlags(0)
121+
log.SetPrefix("")
122+
t.Cleanup(func() {
123+
log.SetOutput(oldOutput)
124+
log.SetFlags(oldFlags)
125+
log.SetPrefix(oldPrefix)
126+
})
127+
128+
serverConn, clientConn := net.Pipe()
129+
done := make(chan struct{})
130+
t.Cleanup(func() {
131+
_ = clientConn.Close()
132+
_ = serverConn.Close()
133+
select {
134+
case <-done:
135+
case <-time.After(2 * time.Second):
136+
t.Errorf("gateway goroutine did not exit during cleanup")
137+
}
138+
})
139+
go func() {
140+
defer close(done)
141+
g.mitmHTTPS(serverConn, "api.example.test", ep)
142+
}()
143+
144+
clientTLS := tls.Client(clientConn, &tls.Config{
145+
InsecureSkipVerify: true,
146+
ServerName: "api.example.test",
147+
})
148+
deadline := time.Now().Add(2 * time.Second)
149+
if err := clientTLS.SetDeadline(deadline); err != nil {
150+
t.Fatalf("set deadline: %v", err)
151+
}
152+
if err := clientTLS.Handshake(); err != nil {
153+
t.Fatalf("client handshake: %v", err)
154+
}
155+
if _, err := io.WriteString(clientTLS, payload); err != nil {
156+
t.Fatalf("write malformed request: %v", err)
157+
}
158+
_ = clientConn.Close()
159+
160+
select {
161+
case <-done:
162+
case <-time.After(2 * time.Second):
163+
t.Fatal("gateway did not exit after malformed request")
164+
}
165+
return logs.String()
166+
}

0 commit comments

Comments
 (0)