Day 61 — HTTP/1.1 From Scratch
Learning Objectives
- Parse HTTP manually using
bufio.Reader— understand the wire format - Know why HTTP request smuggling attacks are possible
- Implement keep-alive properly
Key Concepts
HTTP/1.1 Wire Format:
Request:
GET /api/data?id=1 HTTP/1.1\r\n ← request line
Host: example.com\r\n ← headers (key: value)
Content-Type: application/json\r\n
Content-Length: 42\r\n
\r\n ← empty line = end of headers
{"key":"value","other":"data"} ← body (Content-Length bytes)
Response:
HTTP/1.1 200 OK\r\n
Content-Type: application/json\r\n
Content-Length: 19\r\n
\r\n
{"status":"success"}
HTTP Request Smuggling (why it works):
Some proxies use Content-Length, some use Transfer-Encoding: chunked
When they disagree on where the body ends, a second request can be injected
Example: proxy reads 100 bytes as body; backend reads 20 bytes as body + 80 as new request
OWASP A10 (Server-Side Request Forgery / HTTP Desync)
Defense: normalize headers, reject ambiguous requests
Exercise: Raw HTTP/1.1 Server
package main
import (
"bufio"
"fmt"
"net"
"strconv"
"strings"
)
type HTTPRequest struct {
Method string
Path string
Proto string
Headers map[string]string
Body []byte
}
// parseRequest parses a raw HTTP/1.1 request from bufio.Reader
func parseRequest(r *bufio.Reader) (*HTTPRequest, error) {
// Request line: "GET /path HTTP/1.1"
line, err := r.ReadString('\n')
if err != nil { return nil, err }
line = strings.TrimRight(line, "\r\n")
parts := strings.SplitN(line, " ", 3)
if len(parts) != 3 {
return nil, fmt.Errorf("invalid request line: %q", line)
}
req := &HTTPRequest{
Method: parts[0],
Path: parts[1],
Proto: parts[2],
Headers: make(map[string]string),
}
// Validate method (allowlist — reject unexpected methods early)
validMethods := map[string]bool{"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true, "HEAD": true, "OPTIONS": true}
if !validMethods[req.Method] {
return nil, fmt.Errorf("invalid method: %q", req.Method)
}
// Headers: "Key: Value\r\n" until empty line
for {
line, err := r.ReadString('\n')
if err != nil { return nil, err }
line = strings.TrimRight(line, "\r\n")
if line == "" { break } // empty line = end of headers
colonIdx := strings.IndexByte(line, ':')
if colonIdx < 0 {
return nil, fmt.Errorf("invalid header: %q", line)
}
key := strings.TrimSpace(strings.ToLower(line[:colonIdx]))
val := strings.TrimSpace(line[colonIdx+1:])
// Security: limit header count and size
if len(req.Headers) > 100 {
return nil, fmt.Errorf("too many headers")
}
if len(key)+len(val) > 8192 {
return nil, fmt.Errorf("header too large")
}
req.Headers[key] = val
}
// Body: read exactly Content-Length bytes
// SECURITY: reject both Content-Length AND Transfer-Encoding (smuggling vector)
_, hasTE := req.Headers["transfer-encoding"]
clStr, hasCL := req.Headers["content-length"]
if hasTE && hasCL {
return nil, fmt.Errorf("ambiguous body framing: both Content-Length and Transfer-Encoding")
}
if hasCL {
cl, err := strconv.ParseInt(clStr, 10, 64)
if err != nil || cl < 0 {
return nil, fmt.Errorf("invalid Content-Length: %q", clStr)
}
if cl > 10*1024*1024 { // 10MB limit
return nil, fmt.Errorf("body too large: %d bytes", cl)
}
req.Body = make([]byte, cl)
if _, err := r.Read(req.Body); err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
}
return req, nil
}
func writeResponse(conn net.Conn, status int, body string) {
statusText := map[int]string{200: "OK", 400: "Bad Request", 405: "Method Not Allowed", 413: "Payload Too Large"}
text := statusText[status]
if text == "" { text = "Unknown" }
fmt.Fprintf(conn, "HTTP/1.1 %d %s\r\nContent-Type: text/plain\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
status, text, len(body), body)
}
func handleHTTPConn(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
req, err := parseRequest(reader)
if err != nil {
writeResponse(conn, 400, "bad request: "+err.Error())
return
}
response := fmt.Sprintf("Method: %s\nPath: %s\nHost: %s\n",
req.Method, req.Path, req.Headers["host"])
writeResponse(conn, 200, response)
}
func main() {
ln, _ := net.Listen("tcp", ":8080")
fmt.Println("Raw HTTP/1.1 server on :8080")
fmt.Println("Test: curl http://localhost:8080/api/test")
for {
conn, err := ln.Accept()
if err != nil { return }
go handleHTTPConn(conn)
}
}
Real-World Context
HTTP request smuggling (OWASP A10) exploits disagreement between proxy and backend on where one request ends and the next begins. Your proxy must: (1) reject requests with both Content-Length and Transfer-Encoding, (2) normalize Transfer-Encoding before forwarding. net/http handles this for you, but knowing why helps you evaluate WAF rules.