Files
lneto/examples/httphi-fuzz/main-httplinux.go
T
Pat Whittingslow d05cd14018 httphi router refactor (#176)
* httphi: RouterConfig refactor to enable DefaultRouterConfig

* RequestHeader not case sensitive anymore

* httphi: use DefaultRouterConfig in examples

* httphi: remove gated stage complexity

Misusing Stage methods by calling them once header has been written is totally harmless as far as I can tell. We simplify the codebase on this occasion by removing the headerWritten check for all stage methods

* httphi: improve APIs

* httphi: remove status
2026-08-03 15:33:43 -07:00

466 lines
16 KiB
Go

//go:build !tinygo && linux
package main
import (
"flag"
"io"
"log/slog"
"net"
"os"
"strconv"
"sync/atomic"
"time"
"github.com/soypat/lneto"
"github.com/soypat/lneto/http/httphi"
"github.com/soypat/lneto/http/httpraw"
)
const (
kB = 1 << 10
listenPort = 8080
memoryPerConn = 4 * kB
readTimeout = 2 * time.Second
)
// Credentials the endpoints check. They are in the source on purpose: this is a
// target to point a fuzzer at, and a scan is only interesting when something is
// there to be found.
const (
adminUser = "admin"
adminPass = "hunter2"
sessionToken = "s3cr3t-session-token"
)
// Fixed corpora the handlers answer from, so a path scan separates hits from
// misses instead of finding one status code everywhere.
var (
users = [...]string{"alice", "bob", "carol"}
files = [...]string{"readme.txt", "logo.png", "notes.md"}
)
var (
flagPort = flag.Int("port", listenPort, "TCP port to listen on")
flagVerbose = flag.Bool("v", false, "log every request to stderr; a fuzzer at full rate makes this expensive")
flagThreads = flag.Int("threads", 8, "Number of goroutines to spawn.")
)
func main() {
flag.Parse()
if err := run(); err != nil {
println("Error:", err.Error())
os.Exit(1)
}
println("DONE")
}
func run() error {
ln, err := net.Listen("tcp", ":"+strconv.Itoa(*flagPort))
if err != nil {
return err
}
defer ln.Close()
print("listening on http://localhost:", *flagPort, "\n")
var server Server
// One scratch per router goroutine: the router serves that many requests at
// once, so a handler always finds one waiting for it.
server.initScratch(*flagThreads)
// "{$}" matches the empty path and nothing else, so an unregistered path
// gets a 404 instead of the homepage. A bare "/" is a catch-all.
server.Handle("GET /{$}", server.homepage)
server.Handle("GET /health", server.health)
server.Handle("GET /search", server.search)
server.Handle("POST /login", server.login)
server.Handle("GET /admin", server.admin)
server.Handle("GET /users/{id}", server.user)
server.Handle("GET /files/{path...}", server.file)
server.Handle("POST /upload", server.upload)
server.Handle("/echo", server.echo) // No method: any method matches.
var router httphi.Router
cfg := httphi.DefaultRouterConfig(*flagThreads, memoryPerConn, server.mux.MaxPathValues())
err = router.Configure(&server.mux, cfg)
if err != nil {
return err
}
defer router.Shutdown()
for {
conn, err := ln.Accept()
if err != nil {
return err
}
// The connection owns the idle policy: a peer that opens a socket and
// then stalls fails its read instead of holding a router goroutine.
conn.SetReadDeadline(time.Now().Add(readTimeout))
err = router.Handle(conn)
if err != nil {
// Every goroutine is busy and the queue is full. Dropping the
// connection is the backpressure: memory stays bounded.
slog.Warn("dropped connection", slog.String("remote", conn.RemoteAddr().String()), slog.String("err", err.Error()))
conn.Close()
}
}
}
const (
htmlHead = `<html><body bgcolor="#000080" text="#00FF00"><center>` +
`<marquee><font face="Comic Sans MS" size="5" color="#FFFF00">` +
`*** WELCOME TO MY HOMEPAGE ***</font></marquee>` +
`<h1><blink>YOU ARE VISITOR #`
htmlTail = `!</blink></h1>` +
`<font color="#FF00FF">Sign my guestbook!</font><br><hr>` +
`<a href="/search?q=go">/search</a> | ` +
`<a href="/users/alice">/users/{id}</a> | ` +
`<a href="/files/">/files/</a> | ` +
`<a href="/admin">/admin</a> | ` +
`<a href="/echo">/echo</a> | ` +
`<a href="/health">/health</a>` +
`<br><hr>Best viewed in Netscape Navigator</center></body></html>`
// maxPage bounds the rendered page: both halves plus the visitor number.
maxPage = len(htmlHead) + 20 + len(htmlTail)
)
// Compile-time check that a scratch's render buffer holds the largest page a
// handler builds. A page outgrowing it would grow the buffer on the heap,
// which is the one thing this server is written not to do.
const _ = uint(outBufferSize - maxPage)
type Server struct {
// Visits counts served requests.
Visits atomic.Uint64
mux httphi.MuxSlice
// scratch is a fixed pool of per-request working memory, see [scratch].
scratch chan *scratch
}
// Sizes of a [scratch]. Every one of these is memory spent once per pooled
// scratch, so the pool's size times the sum below is what the handlers cost.
const (
formBufferSize = kB
formNumPairs = 16
cookieBufferSize = 512
cookieNumPairs = 8
multipartBufferSize = kB
maxMultipartParts = 8
outBufferSize = 2 * kB
tmpBufferSize = 256
)
// scratch is the memory a handler works in for the length of one request: the
// [Exchange] buffer holds the request header and the response header, and
// everything a handler parses or renders on top of that lives here.
//
// Parsers are handed their buffer once and forbidden to grow, so a request that
// sends more than the buffer holds is answered an error rather than served from
// memory the server never budgeted for.
type scratch struct {
form httpraw.Form
cookie httpraw.Cookie
// parts is reused across requests: its [httpraw.MultipartHeader] values keep
// the buffers their Name and Filename were copied into.
parts []httphi.MultipartSink
uploads countingSink
formBuf [formBufferSize]byte
cookieBuf [cookieBufferSize]byte
mpBuf [multipartBufferSize]byte
// out renders the response body, tmp holds a decoded value being read out of
// the request. They are separate because a decode reads into one while the
// body is being built in the other.
out [outBufferSize]byte
tmp [tmpBufferSize]byte
}
// initScratch fills the pool with n scratches and fixes each parser to its
// buffer. Sizing n to the router's goroutine count bounds handler memory the
// same way the router bounds its own.
func (sv *Server) initScratch(n int) {
sv.scratch = make(chan *scratch, n)
for range n {
s := new(scratch)
s.form.Reset(s.formBuf[:0], formNumPairs)
s.form.EnableBufferGrowth(false)
s.cookie.Reset(s.cookieBuf[:0], cookieNumPairs)
s.cookie.EnableBufferGrowth(false)
s.parts = make([]httphi.MultipartSink, 0, maxMultipartParts)
sv.scratch <- s
}
}
// acquireScratch takes a scratch out of the pool, blocking while none is free.
// With the pool sized to the router's fixed goroutine count it never blocks:
// a handler running is a goroutine that has not returned its scratch yet.
func (sv *Server) acquireScratch() *scratch { return <-sv.scratch }
func (sv *Server) releaseScratch(s *scratch) { sv.scratch <- s }
// Handle registers a handler and wraps it in the middleware every request runs
// through: the visit counter and, when asked for, the request log.
func (sv *Server) Handle(pattern string, handler httphi.HandlerFunc) {
sv.mux.Handle(pattern, func(exch *httphi.Exchange) {
sv.Visits.Add(1)
if *flagVerbose {
println(exch.RequestMethod().String(), exch.MuxPattern())
}
handler(exch)
})
}
func (sv *Server) homepage(exch *httphi.Exchange) {
s := sv.acquireScratch()
defer sv.releaseScratch(s)
page := append(s.out[:0], htmlHead...)
page = strconv.AppendUint(page, sv.Visits.Load(), 10)
page = append(page, htmlTail...)
exch.Respond(httphi.StatusOK, "text/html", page)
}
func (sv *Server) health(exch *httphi.Exchange) {
exch.RespondString(httphi.StatusOK, "text/plain", "ok\n")
}
// search reads the query string, i.e: "/search?q=go+lang&limit=2". Values are
// percent and '+' encoded on the wire, so this is the decoder's surface: a
// malformed escape is answered 400 and never half decoded into the response.
func (sv *Server) search(exch *httphi.Exchange) {
s := sv.acquireScratch()
defer sv.releaseScratch(s)
const decode = true
query, present := exch.RequestQueryAppend(s.tmp[:0], "q", decode)
if !present {
exch.RespondString(httphi.StatusBadRequest, "text/plain", "missing or malformed q parameter\n")
return
}
limit := len(users)
if raw, present := exch.RequestQueryValue("limit"); present {
n, ok := atoiBounded(raw, len(users))
if !ok {
exch.RespondString(httphi.StatusBadRequest, "text/plain", "limit must be a non-negative integer\n")
return
}
limit = n
}
body := append(s.out[:0], "query: "...)
body = append(body, query...)
body = append(body, '\n')
for _, user := range users[:limit] {
body = append(body, user...)
body = append(body, '\n')
}
exch.Respond(httphi.StatusOK, "text/plain", body)
}
// login reads "application/x-www-form-urlencoded" pairs out of the request body
// and the query string alike, the body winning a key both carry. It is where a
// credential scan lands:
//
// ffuf -X POST -u http://localhost:8080/login -d 'user=admin&pass=FUZZ' \
// -H 'Content-Type: application/x-www-form-urlencoded' -w passwords.txt -fc 401
func (sv *Server) login(exch *httphi.Exchange) {
s := sv.acquireScratch()
defer sv.releaseScratch(s)
// Checked ahead of the parse so a body in some other encoding is told what
// is wrong with it, the parser reporting only that it would not parse.
contentType := exch.RequestContentType()
if contentType != nil && !httpraw.MediaTypeIs(contentType, "application/x-www-form-urlencoded") {
exch.RespondString(httphi.StatusUnsupportedMediaType, "text/plain", "expected application/x-www-form-urlencoded\n")
return
}
const parseQuery, queryWins = true, false
err := exch.RequestParseForm(&s.form, parseQuery, queryWins)
if err != nil {
// A body larger than formBufferSize or more pairs than formNumPairs land
// here too: the form was told not to grow, so it refuses instead.
exch.RespondString(httphi.StatusBadRequest, "text/plain", "malformed or oversized form\n")
return
}
if err = s.form.Decode(); err != nil {
exch.RespondString(httphi.StatusBadRequest, "text/plain", "malformed percent escape in form\n")
return
}
user, pass := s.form.Get("user"), s.form.Get("pass")
if string(user) != adminUser || string(pass) != adminPass {
exch.RespondString(httphi.StatusUnauthorized, "text/plain", "bad credentials\n")
return
}
exch.StageHeader("Set-Cookie", "session="+sessionToken+"; Path=/; HttpOnly")
exch.RespondString(httphi.StatusOK, "text/plain", "welcome "+adminUser+"\n")
}
// admin is gated on the cookie [Server.login] hands out, so a scan of it fuzzes
// the cookie parser:
//
// ffuf -u http://localhost:8080/admin -b 'session=FUZZ' -w tokens.txt -fc 403
func (sv *Server) admin(exch *httphi.Exchange) {
s := sv.acquireScratch()
defer sv.releaseScratch(s)
err := exch.RequestParseCookie(&s.cookie, "Cookie")
if err != nil {
exch.RespondString(httphi.StatusUnauthorized, "text/plain", "no cookie\n")
return
}
if string(s.cookie.Get("session")) != sessionToken {
exch.RespondString(httphi.StatusForbidden, "text/plain", "forbidden\n")
return
}
body := append(s.out[:0], "admin panel\n"...)
// A valueless attribute, i.e: "session=...; debug", is stored with an empty
// key, so a plain Get would never find it.
if s.cookie.HasKeyOrSingleValue("debug") {
body = append(body, "requests served: "...)
body = strconv.AppendUint(body, sv.Visits.Load(), 10)
body = append(body, '\n')
}
exch.Respond(httphi.StatusOK, "text/plain", body)
}
// user serves the "{id}" wildcard, a single path segment. Segments are bound
// raw, so the value is decoded here and "/users/al%69ce" reaches alice.
func (sv *Server) user(exch *httphi.Exchange) {
s := sv.acquireScratch()
defer sv.releaseScratch(s)
const decode = true
id, err := exch.PathValueAppend(s.tmp[:0], "id", decode)
if err != nil {
exch.RespondString(httphi.StatusBadRequest, "text/plain", "malformed percent escape in path\n")
return
}
for _, user := range users {
if string(id) == user {
body := append(s.out[:0], `{"user":"`...)
body = append(body, id...)
body = append(body, "\"}\n"...)
exch.Respond(httphi.StatusOK, "application/json", body)
return
}
}
exch.RespondString(httphi.StatusNotFound, "text/plain", "no such user\n")
}
// file serves the "{path...}" wildcard, which takes the rest of the path
// including its slashes, so "/files/" and "/files/a/b" both reach here. That
// makes it what a recursive scan walks: ffuf -u http://localhost:8080/files/FUZZ -recursion.
func (sv *Server) file(exch *httphi.Exchange) {
s := sv.acquireScratch()
defer sv.releaseScratch(s)
path := exch.PathValue("path")
if len(path) == 0 {
body := append(s.out[:0], "index of /files/\n"...)
for _, file := range files {
body = append(body, file...)
body = append(body, '\n')
}
exch.Respond(httphi.StatusOK, "text/plain", body)
return
}
for _, file := range files {
if string(path) == file {
body := append(s.out[:0], "contents of "...)
body = append(body, path...)
body = append(body, '\n')
exch.Respond(httphi.StatusOK, "text/plain", body)
return
}
}
exch.RespondString(httphi.StatusNotFound, "text/plain", "no such file\n")
}
// upload streams a "multipart/form-data" body, counting each file part instead
// of storing it. Parts declare no length, so the body is read a bufferful at a
// time and the header of a part that outgrows the buffer is refused 413.
//
// ffuf -X POST -u http://localhost:8080/upload -w names.txt \
// -H 'Content-Type: multipart/form-data; boundary=X' \
// -d $'--X\r\nContent-Disposition: form-data; name="f"; filename="FUZZ"\r\n\r\ndata\r\n--X--\r\n'
func (sv *Server) upload(exch *httphi.Exchange) {
s := sv.acquireScratch()
defer sv.releaseScratch(s)
if !httpraw.MediaTypeIs(exch.RequestContentType(), "multipart/form-data") {
exch.RespondString(httphi.StatusUnsupportedMediaType, "text/plain", "expected multipart/form-data\n")
return
}
s.uploads.n = 0
// The sink is a field of the scratch, so handing it over as an io.WriteCloser
// boxes a pointer that is already on the heap and allocates nothing.
parts, err := exch.ReadMultiparts(s.parts[:0], s.mpBuf[:], func(hdr *httpraw.MultipartHeader) io.WriteCloser {
if len(hdr.Filename) == 0 {
return nil // A plain field, not a file: keep the header, drop the content.
}
return &s.uploads
})
// Kept even on failure: the headers parsed so far own buffers worth reusing.
// The slice grows with the number of parts, which only the connection's read
// deadline bounds, so a real server would cap it.
s.parts = parts
if err != nil {
if err == lneto.ErrShortBuffer {
exch.RespondString(httphi.StatusRequestEntityTooLarge, "text/plain", "part header too large\n")
} else {
exch.RespondString(httphi.StatusBadRequest, "text/plain", "malformed multipart body\n")
}
return
}
body := append(s.out[:0], "parts: "...)
body = strconv.AppendInt(body, int64(len(parts)), 10)
body = append(body, '\n')
for i := range parts {
body = append(body, parts[i].Header.Name...)
if len(parts[i].Header.Filename) > 0 {
body = append(body, " -> "...)
body = append(body, parts[i].Header.Filename...)
}
body = append(body, '\n')
}
body = append(body, "bytes stored: "...)
body = strconv.AppendInt(body, s.uploads.n, 10)
body = append(body, '\n')
exch.Respond(httphi.StatusOK, "text/plain", body)
}
// echo hands back the request line and the header block as the parser stored
// it, which is what tells a header fuzzer what its input turned into.
func (sv *Server) echo(exch *httphi.Exchange) {
s := sv.acquireScratch()
defer sv.releaseScratch(s)
body := append(s.out[:0], exch.RequestMethodBytes()...)
body = append(body, ' ')
body = append(body, exch.RequestTarget()...)
body = append(body, '\n')
if value := exch.RequestHeader("X-Fuzz"); value != nil {
body = append(body, "x-fuzz: "...)
body = append(body, value...)
body = append(body, '\n')
}
body = append(body, "-- parsed header --\n"...)
body = exch.RequestHeaderV1Raw().AppendHeaders(body)
exch.Respond(httphi.StatusOK, "text/plain", body)
}
// countingSink discards a multipart part and counts what it discarded, standing
// in for the file a real upload would write.
type countingSink struct{ n int64 }
func (c *countingSink) Write(b []byte) (int, error) { c.n += int64(len(b)); return len(b), nil }
func (c *countingSink) Close() error { return nil }
// atoiBounded parses a decimal number and clamps it to max, reporting false for
// anything that is not one. It works off the bytes rather than converting to a
// string, which would allocate on a path every request takes.
func atoiBounded(b []byte, max int) (int, bool) {
const maxDigits = 9 // Bounded so the accumulator below cannot overflow.
if len(b) == 0 || len(b) > maxDigits {
return 0, false
}
n := 0
for _, c := range b {
if c < '0' || c > '9' {
return 0, false
}
n = n*10 + int(c-'0')
}
return min(n, max), true
}