diff options
| author | spiperac <[email protected]> | 2026-06-15 06:42:13 +0200 |
|---|---|---|
| committer | spiperac <[email protected]> | 2026-06-15 06:42:13 +0200 |
| commit | 0a477fd288d064d265395c88dbd0f261b723804e (patch) | |
| tree | 9b129f8f41c907285b89ed8385679934bb59c14f /internal/http/rawrequest.go | |
Initial release
Diffstat (limited to 'internal/http/rawrequest.go')
| -rw-r--r-- | internal/http/rawrequest.go | 327 |
1 files changed, 327 insertions, 0 deletions
diff --git a/internal/http/rawrequest.go b/internal/http/rawrequest.go new file mode 100644 index 0000000..090787a --- /dev/null +++ b/internal/http/rawrequest.go @@ -0,0 +1,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" +} |
