Frontend Labs
⋯

Day 72 — Request Body Inspection

Learning Objectives

  • Buffer and scan the request body without breaking the downstream handler
  • Dispatch to specialized scanners by Content-Type
  • Enforce body size limits to prevent resource exhaustion

Exercise: Body Inspector

package waf

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "mime"
    "net/http"
    "strings"
)

const maxBodySize = 1 * 1024 * 1024 // 1MB — scan limit

// readBody reads and buffers the request body, enforcing size limit.
// Returns (body, error). On success, body is also restored to r.Body.
func readBody(r *http.Request) ([]byte, error) {
    if r.Body == nil || r.Body == http.NoBody {
        return nil, nil
    }

    limited := io.LimitReader(r.Body, int64(maxBodySize)+1)
    body, err := io.ReadAll(limited)
    r.Body.Close()

    if int64(len(body)) > maxBodySize {
        return nil, fmt.Errorf("body exceeds %d byte scan limit", maxBodySize)
    }

    restoreBody(r, body)
    return body, err
}

func restoreBody(r *http.Request, body []byte) {
    r.Body = io.NopCloser(bytes.NewReader(body))
    r.ContentLength = int64(len(body))
}

// extractInputs returns all user-controlled values from the request
func extractInputs(r *http.Request, body []byte) []string {
    var inputs []string

    // URL query parameters
    for _, vals := range r.URL.Query() {
        inputs = append(inputs, vals...)
    }

    // Headers (scan non-standard headers — standard ones like User-Agent less risky)
    for header, vals := range r.Header {
        if isUserControlledHeader(header) {
            inputs = append(inputs, vals...)
        }
    }

    // Body — dispatch by Content-Type
    if len(body) > 0 {
        ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
        inputs = append(inputs, extractBodyInputs(ct, body)...)
    }

    return inputs
}

func isUserControlledHeader(header string) bool {
    // Headers that might carry user input
    userHeaders := map[string]bool{
        "X-Forwarded-For": true, "Referer": true, "User-Agent": true,
        "X-Real-IP": true, "Content-Disposition": true, "Cookie": true,
    }
    return userHeaders[http.CanonicalHeaderKey(header)]
}

// extractBodyInputs parses body values based on content type
func extractBodyInputs(contentType string, body []byte) []string {
    switch {
    case strings.HasPrefix(contentType, "application/json"):
        return extractJSONValues(body)
    case strings.HasPrefix(contentType, "application/x-www-form-urlencoded"):
        values, _ := url.ParseQuery(string(body))
        var result []string
        for _, v := range values {
            result = append(result, v...)
        }
        return result
    case strings.HasPrefix(contentType, "text/"):
        return []string{string(body)}
    default:
        // For unknown types: scan as raw string
        return []string{string(body)}
    }
}

// extractJSONValues recursively extracts all string values from JSON
func extractJSONValues(data []byte) []string {
    var result []string

    var recurse func(v interface{})
    recurse = func(v interface{}) {
        switch val := v.(type) {
        case string:
            result = append(result, val)
        case map[string]interface{}:
            for _, v := range val {
                recurse(v)
            }
        case []interface{}:
            for _, v := range val {
                recurse(v)
            }
        }
    }

    var parsed interface{}
    if err := json.Unmarshal(data, &parsed); err == nil {
        recurse(parsed)
    }
    return result
}

Real-World Context

Most WAFs only scan URL and headers by default — attackers know this and put payloads in the JSON body. Body scanning adds latency (you must buffer before forwarding). The 1MB limit prevents a 100MB JSON payload from stalling your scanner. The Content-Type dispatch is why WAFs have separate rules for multipart/form-data, JSON, and XML.


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