summaryrefslogtreecommitdiff
path: root/internal/http/rawrequest.go
blob: 090787a0561650768ad6257ee1324aa8284cf9a7 (plain)
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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
package http

import (
	"bufio"
	"crypto/tls"
	"fmt"
	"io"
	"net"
	"net/http"
	"strconv"
	"strings"
	"time"
)

// RawRequestOptions controls how SendRaw dials and sends.
type RawRequestOptions struct {
	Host      string
	Port      int
	TLS       bool
	RawReq    string
	CookieJar map[string]string // optional; merged into Cookie header
	TimeoutMs int               // 0 means use defaults (10s dial, 30s total)
}

// RawResponse is returned by SendRaw.
type RawResponse struct {
	Raw        string // full response as text (status line + headers + body)
	StatusCode int
	Headers    http.Header
	Body       []byte
	Cookies    []*http.Cookie
}

// SendRaw dials host:port, sends rawReq over a plain or TLS connection, and
// returns the decompressed, decoded response. It is intentionally low-level
// so the caller (Repeater, Intruder) controls the exact bytes on the wire.
//
// The request line is always rewritten to HTTP/1.1 — H2 frames cannot be
// sent as raw text, and a raw socket is always HTTP/1.1.
func SendRaw(opts RawRequestOptions) (*RawResponse, error) {
	addr := net.JoinHostPort(opts.Host, strconv.Itoa(opts.Port))

	timeout := 30 * time.Second
	if opts.TimeoutMs > 0 {
		timeout = time.Duration(opts.TimeoutMs) * time.Millisecond
	}

	var conn net.Conn
	var err error
	if opts.TLS {
		dialer := &net.Dialer{Timeout: timeout}
		tlsCfg := &tls.Config{
			ServerName:         opts.Host,
			InsecureSkipVerify: true, //nolint:gosec
		}
		conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
	} else {
		conn, err = net.DialTimeout("tcp", addr, timeout)
	}
	if err != nil {
		return nil, fmt.Errorf("dial %s: %w", addr, err)
	}
	defer conn.Close()

	if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
		return nil, fmt.Errorf("set deadline: %w", err)
	}

	raw := NormalizeLineEndings(opts.RawReq)
	raw = rewriteRequestLine(raw)
	raw = rewriteHeaders(raw, opts.CookieJar)

	if _, err := fmt.Fprint(conn, raw); err != nil {
		return nil, fmt.Errorf("write request: %w", err)
	}

	httpResp, err := http.ReadResponse(bufio.NewReader(conn), nil)
	if err != nil {
		return nil, fmt.Errorf("read response: %w", err)
	}
	defer httpResp.Body.Close()

	body, err := io.ReadAll(io.LimitReader(httpResp.Body, 2<<20))
	if err != nil {
		return nil, fmt.Errorf("read response body: %w", err)
	}
	body = Decompress(httpResp.Header, body)
	body = TruncateBody(body)
	rawResp := buildRawResponse(httpResp, body)

	return &RawResponse{
		Raw:        rawResp,
		StatusCode: httpResp.StatusCode,
		Headers:    httpResp.Header,
		Body:       body,
		Cookies:    httpResp.Cookies(),
	}, nil
}

// rewriteRequestLine rewrites the first line of a raw HTTP request to use
// HTTP/1.1, regardless of what was captured. HTTP/2 cannot be sent as raw
// text over a socket.
func rewriteRequestLine(raw string) string {
	idx := strings.Index(raw, "\r\n")
	if idx < 0 {
		return raw
	}
	firstLine := raw[:idx]
	parts := strings.Fields(firstLine)
	if len(parts) < 2 {
		return raw
	}
	// Rewrite: METHOD PATH HTTP/1.1
	newFirstLine := parts[0] + " " + parts[1] + " HTTP/1.1"
	return newFirstLine + raw[idx:]
}

// ParseHostFromRaw extracts host, port, and TLS flag from the Host header
// in a raw HTTP request string. The caller should pass the known TLS state
// from the captured transaction when available, as the Host header alone
// cannot distinguish HTTP from HTTPS.
func ParseHostFromRaw(raw string) (host string, port int, useTLS bool) {
	// Check the scheme in the request line first (e.g. absolute-form URLs).
	firstLine := strings.SplitN(raw, "\n", 2)[0]
	parts := strings.Fields(firstLine)
	if len(parts) >= 2 {
		url := parts[1]
		if strings.HasPrefix(url, "https://") {
			useTLS = true
		} else if strings.HasPrefix(url, "http://") {
			useTLS = false
		}
	}

	for line := range strings.SplitSeq(raw, "\n") {
		line = strings.TrimSpace(line)
		if !strings.HasPrefix(strings.ToLower(line), "host:") {
			continue
		}
		hostVal := strings.TrimSpace(line[5:])
		if h, portStr, err := net.SplitHostPort(hostVal); err == nil {
			host = h
			port, _ = strconv.Atoi(portStr)
			// Port in Host header is the definitive signal.
			useTLS = port == 443
		} else {
			host = hostVal
			if net.ParseIP(hostVal) != nil {
				// Bare IP without port — can't tell, default to plaintext.
				port = 80
				useTLS = false
			} else {
				// Domain without port — use whatever we detected from the
				// request line scheme, or fall back to HTTPS.
				if port == 0 {
					if useTLS {
						port = 443
					} else {
						port = 80
					}
				}
				// If we got here without a scheme signal, default to HTTPS
				// since most modern interception is TLS.
				if port == 443 {
					useTLS = true
				}
			}
		}
		return
	}
	return "", 0, false
}

// ParseRawHeaders parses the headers section of a raw HTTP response string
// into an http.Header map.
func ParseRawHeaders(raw string) http.Header {
	headers := http.Header{}
	lines := strings.Split(raw, "\r\n")
	if len(lines) == 0 {
		return headers
	}
	// Skip the status/request line.
	for _, line := range lines[1:] {
		if line == "" {
			break
		}
		parts := strings.SplitN(line, ": ", 2)
		if len(parts) == 2 {
			headers.Add(parts[0], parts[1])
		}
	}
	return headers
}

// SplitRaw splits a raw HTTP message into its header section and body at the
// first blank line. Handles both CRLF and LF delimiters. The returned header
// section does not include the blank-line separator.
func SplitRaw(raw string) (header string, body []byte) {
	if idx := strings.Index(raw, "\r\n\r\n"); idx >= 0 {
		return raw[:idx], []byte(raw[idx+4:])
	}
	if idx := strings.Index(raw, "\n\n"); idx >= 0 {
		return raw[:idx], []byte(raw[idx+2:])
	}
	return raw, nil
}

// NormalizeLineEndings ensures CRLF throughout the header section only.
// It does NOT touch the body to avoid corrupting binary or multi-byte content.
func NormalizeLineEndings(raw string) string {
	header, body := SplitRaw(raw)
	header = strings.ReplaceAll(header, "\r\n", "\n")
	header = strings.ReplaceAll(header, "\n", "\r\n")
	return header + "\r\n\r\n" + string(body)
}

// rewriteHeaders rewrites the header section of a raw request:
//   - Injects Connection: close (required for single-use raw TCP connections)
//   - Strips Accept-Encoding (we handle decompression ourselves)
//   - Strips Content-Length (recalculated below)
//   - Strips Transfer-Encoding (not valid in HTTP/1.1 raw send context)
//   - Merges Cookie header from jar (replaces existing if jar is non-empty)
//   - Strips default port from Host when it matches the scheme
//   - Appends correct Content-Length based on byte length of body
func rewriteHeaders(raw string, jar map[string]string) string {
	// Split on the canonical blank line separator (normalizeLineEndings runs first).
	parts := strings.SplitN(raw, "\r\n\r\n", 2)
	headerSection := parts[0]
	body := ""
	if len(parts) == 2 {
		body = parts[1]
		// Trim trailing CRLF from body only if it was added by normalisation,
		// not if the user intentionally added trailing newlines.
		// We use the raw byte length for Content-Length regardless.
	}

	lines := strings.Split(headerSection, "\r\n")
	out := make([]string, 0, len(lines)+3)
	connectionSeen := false

	for _, line := range lines {
		lower := strings.ToLower(line)
		switch {
		case strings.HasPrefix(lower, "cookie:"):
			// Drop original Cookie header only if jar has entries to replace it.
			if len(jar) == 0 {
				out = append(out, line)
			}
		case strings.HasPrefix(lower, "accept-encoding:"):
			// Drop — we decompress ourselves so we want uncompressed bytes.
			continue
		case strings.HasPrefix(lower, "content-length:"):
			// Drop — we recalculate below from actual body byte length.
			continue
		case strings.HasPrefix(lower, "transfer-encoding:"):
			// Not valid to send in this raw H1 context.
			continue
		case strings.HasPrefix(lower, "connection:"):
			// Replace whatever was there with Connection: close.
			out = append(out, "Connection: close")
			connectionSeen = true
		case strings.HasPrefix(lower, "host:"):
			hostVal := strings.TrimSpace(line[5:])
			// Strip default ports from Host header.
			if h, port, err := net.SplitHostPort(hostVal); err == nil {
				if port == "443" || port == "80" {
					line = "Host: " + h
				}
			}
			out = append(out, line)
		default:
			out = append(out, line)
		}
	}

	// Ensure Connection: close is present even if the original had no Connection header.
	if !connectionSeen {
		out = append(out, "Connection: close")
	}

	// Append merged Cookie header from jar.
	if len(jar) > 0 {
		cookies := make([]string, 0, len(jar))
		for k, v := range jar {
			cookies = append(cookies, k+"="+v)
		}
		out = append(out, "Cookie: "+strings.Join(cookies, "; "))
	}

	// Append Content-Length based on actual byte length of the body.
	if len(body) > 0 {
		out = append(out, fmt.Sprintf("Content-Length: %d", len(body)))
	}

	return strings.Join(out, "\r\n") + "\r\n\r\n" + body
}

func buildRawResponse(resp *http.Response, body []byte) string {
	var b strings.Builder
	fmt.Fprintf(&b, "HTTP/1.1 %d %s\r\n", resp.StatusCode, http.StatusText(resp.StatusCode))
	for k, vals := range resp.Header {
		for _, v := range vals {
			fmt.Fprintf(&b, "%s: %s\r\n", k, v)
		}
	}
	b.WriteString("\r\n")
	b.Write(body)
	return b.String()
}

// ExtractURL extracts the path from the first line of a raw HTTP request.
func ExtractURL(rawRequest string) string {
	parts := strings.Fields(strings.SplitN(rawRequest, "\n", 2)[0])
	if len(parts) >= 2 {
		return parts[1]
	}
	return "/"
}

// ExtractMethod extracts the HTTP method from the first line of a raw HTTP request.
func ExtractMethod(rawRequest string) string {
	parts := strings.Fields(strings.SplitN(rawRequest, "\n", 2)[0])
	if len(parts) >= 1 {
		return parts[0]
	}
	return "GET"
}