Frontend Labs
⋯

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.


Generated from week9.md · 8 Oct 2026 16:36