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.