mirror of
https://github.com/soypat/lneto.git
synced 2026-08-03 06:27:52 +00:00
1884cfc9b7
* add Exchange.WriteBodyString * Mux.MaxPathValues and other improvements * Mux PathValue improvemnt and fixes * MuxSlice more method muxing improvements * diagram out interesting approach to form parsing for clanker * refactor RequestParseForm and achieve greatness in API design * explicit naming of headerCapacityKV value in kvBuffer.Reset * fix Mux bug not matching paths correctly; httpraw HTTP V1 naming applied * rename many examples,use httphi in examples,remove useless maxAwaitingConn field * add ipv4.String * add ipv4 UnspecifiedAddr and BroadcastAddr * add ethernet.String
420 lines
16 KiB
Go
420 lines
16 KiB
Go
package httphi
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/soypat/lneto/http/httpraw"
|
|
)
|
|
|
|
// Fuzz targets in this file are written so that a stored corpus keeps its
|
|
// meaning as the tests grow. Go's corpus files are positional and typed, so an
|
|
// input is only reproducible while the code that decodes it stays fixed. Five
|
|
// rules keep that true, and edits to this file must obey them:
|
|
//
|
|
// 1. A target's signature is frozen: func(t *testing.T, ctrl uint64, data []byte).
|
|
// Adding, removing or reordering a parameter invalidates every stored entry.
|
|
// 2. Control decisions come from ctrl only, wire bytes from data only. Never
|
|
// branch on data, never put payload in ctrl: the two axes mutate apart.
|
|
// 3. ctrl is a bit field read through absolute shifts and masks named below.
|
|
// New knobs claim unused high bits and are only ever appended, never
|
|
// renumbered, and the zero value of a knob must decode to the behaviour that
|
|
// existed before it was added, so old entries keep replaying as they did.
|
|
// 4. No PRNG, no cursor. No rand, no internal.Prand64, and no helper that
|
|
// "reads the next N bits" while advancing a position: inserting one draw
|
|
// ahead of another shifts every later decision, which is the same corpus
|
|
// invalidation a PRNG causes.
|
|
// 5. Nothing ambient: no wall clock, no goroutines, no map iteration order.
|
|
// Targets drive [Handle] on the calling goroutine; [Router] is not fuzzed
|
|
// here precisely because it serves on goroutines of its own.
|
|
const (
|
|
// Bits 0..3 index segSizes: how the request is split across reads.
|
|
ctlSegShift, ctlSegMask = 0, 0xf
|
|
// Bits 4..7 and 8..11 size the request and response halves of the buffer.
|
|
ctlReqBufShift, ctlRespBufShift, ctlBufMask = 4, 8, 0xf
|
|
// Bits 12..13 size the request header field table.
|
|
ctlKVCapShift, ctlKVCapMask = 12, 0x3
|
|
// Bit 14 normalizes outgoing header keys.
|
|
ctlNormalizeShift, ctlNormalizeMask = 14, 0x1
|
|
// Bits 15..16 make that many leading writes to the connection fail.
|
|
ctlFailWriteShift, ctlFailWriteMask = 15, 0x3
|
|
// Bits 17..20 select what the handler does, see the op constants.
|
|
ctlHandlerOpsShift, ctlHandlerOpsMask = 17, 0xf
|
|
// Bits 21..23 count the header fields the handler stages.
|
|
ctlStageCountShift, ctlStageCountMask = 21, 0x7
|
|
// Bit 24 asks AppendQuery for decoded values.
|
|
ctlDecodeShift, ctlDecodeMask = 24, 0x1
|
|
// Bits 25..28 size the scratch buffer handed to form and multipart parsing.
|
|
ctlScratchShift, ctlScratchMask = 25, 0xf
|
|
// Bit 29 makes the multipart sink discard part content.
|
|
ctlDiscardShift, ctlDiscardMask = 29, 0x1
|
|
// Next knob starts at bit 30.
|
|
)
|
|
|
|
// Handler operations, selected by the ctlHandlerOps field. Values are frozen:
|
|
// a new operation takes the next free bit within the field.
|
|
const (
|
|
opStageHeaders = 1 << iota
|
|
opReadBody
|
|
opWriteBody
|
|
opHijack
|
|
)
|
|
|
|
// segSizes are the chunk sizes a request may be delivered in, index 0 meaning
|
|
// "all at once". Splitting a request mid-CRLF or before a colon is what drives
|
|
// [httpraw.HeaderV1.TryParse]'s resumption path. Entries may be appended, never
|
|
// changed: an existing index must keep splitting exactly as it does today.
|
|
var segSizes = [16]int{0, 1, 2, 3, 5, 7, 11, 16, 23, 37, 64, 101, 173, 256, 509, 1024}
|
|
|
|
// ctlField reads a knob out of ctrl. Absolute shift, no cursor, so knobs are
|
|
// independent of one another and of the order they are read in.
|
|
func ctlField(ctrl uint64, shift, mask uint64) uint64 {
|
|
return (ctrl >> shift) & mask
|
|
}
|
|
|
|
// maxFuzzInput bounds the wire data a target accepts. The filter depends only
|
|
// on the input, so it classifies an entry the same way on every run.
|
|
const maxFuzzInput = 16 << 10
|
|
|
|
// fuzzExchange returns an exchange acquired on a connection preloaded with data,
|
|
// both sized and segmented by ctrl.
|
|
func fuzzExchange(t *testing.T, ctrl uint64, data []byte) (*Exchange, *rwconn) {
|
|
t.Helper()
|
|
reqBuf := minRequestHeaderBuffer + int(ctlField(ctrl, ctlReqBufShift, ctlBufMask))*8
|
|
respBuf := minResponseHeaderBuffer + int(ctlField(ctrl, ctlRespBufShift, ctlBufMask))*8
|
|
kvCap := 1 + int(ctlField(ctrl, ctlKVCapShift, ctlKVCapMask))*8
|
|
|
|
conn := newConn("")
|
|
seg := segSizes[ctlField(ctrl, ctlSegShift, ctlSegMask)]
|
|
if seg <= 0 || seg >= len(data) {
|
|
conn.AddReadable(data)
|
|
} else {
|
|
for off := 0; off < len(data); off += seg {
|
|
conn.AddSegment(string(data[off:min(off+seg, len(data))]))
|
|
}
|
|
}
|
|
// Always hang up: a drained connection that never reports EOF reads (0,nil)
|
|
// forever and [Handle] would back off in an unbounded loop.
|
|
conn.Hangup()
|
|
if n := ctlField(ctrl, ctlFailWriteShift, ctlFailWriteMask); n > 0 {
|
|
conn.FailWrites(int(n))
|
|
}
|
|
exch := newExchange(t, conn, ExchangeConfig{
|
|
RawBuf: make([]byte, reqBuf+respBuf),
|
|
RequestBufferLim: reqBuf,
|
|
NumHeaderKVCap: kvCap,
|
|
NormalizeOutgoingKeys: ctlField(ctrl, ctlNormalizeShift, ctlNormalizeMask) != 0,
|
|
NoRequestBufferGrowth: true,
|
|
})
|
|
return exch, conn
|
|
}
|
|
|
|
// checkRequestView asserts the request-target views agree with one another.
|
|
func checkRequestView(t *testing.T, exch *Exchange) {
|
|
t.Helper()
|
|
target, path, query := exch.RequestTarget(), exch.RequestPath(), exch.RequestQuery()
|
|
if !bytes.HasPrefix(target, path) {
|
|
t.Fatalf("path %q is not a prefix of target %q", path, target)
|
|
}
|
|
if len(query) > 0 && !bytes.HasSuffix(target, query) {
|
|
t.Fatalf("query %q is not a suffix of target %q", query, target)
|
|
}
|
|
if len(path)+len(query) > len(target) {
|
|
t.Fatalf("path %q and query %q exceed target %q", path, query, target)
|
|
}
|
|
}
|
|
|
|
// checkResponse asserts that whatever reached the wire is a response a peer
|
|
// could parse: a status line this package produced, followed by a header block
|
|
// that terminates, and that [httpraw] reads back what it wrote.
|
|
func checkResponse(t *testing.T, written string) {
|
|
t.Helper()
|
|
if written == "" {
|
|
return // Hijacked, or a request refused before anything was staged.
|
|
}
|
|
const proto = "HTTP/1.1 "
|
|
if !strings.HasPrefix(written, proto) {
|
|
t.Fatalf("response does not open with a status line: %q", written)
|
|
}
|
|
if len(written) < len(proto)+4 {
|
|
t.Fatalf("status line truncated: %q", written)
|
|
}
|
|
for _, c := range []byte(written[len(proto) : len(proto)+3]) {
|
|
if c < '0' || c > '9' {
|
|
t.Fatalf("status code is not three digits: %q", written)
|
|
}
|
|
}
|
|
if written[len(proto)+3] != ' ' {
|
|
t.Fatalf("status code not followed by a space: %q", written)
|
|
}
|
|
if !strings.Contains(written, "\r\n\r\n") {
|
|
t.Fatalf("header block never terminated: %q", written)
|
|
}
|
|
var resp httpraw.HeaderV1
|
|
const asResponse = true
|
|
if err := resp.ParseBytes(asResponse, []byte(written)); err != nil {
|
|
t.Fatalf("response does not parse back: %s in %q", err, written)
|
|
}
|
|
}
|
|
|
|
// handler2Path is a second registration so lookup does not always match on the
|
|
// first entry of the mux.
|
|
const handler2Path = "/fuzz"
|
|
|
|
// fuzzMux returns a mux serving handler on the paths the seed corpus requests.
|
|
func fuzzMux(handler HandlerFunc) *MuxSlice {
|
|
var mux MuxSlice
|
|
mux.Handle("/", handler)
|
|
mux.Handle(handler2Path, handler)
|
|
return &mux
|
|
}
|
|
|
|
// FuzzHandleRequest drives a whole exchange: a request off the wire through
|
|
// [Handle], a handler staging and writing a response, and back out to the peer.
|
|
func FuzzHandleRequest(f *testing.F) {
|
|
addSeeds(f)
|
|
f.Fuzz(func(t *testing.T, ctrl uint64, data []byte) {
|
|
if len(data) > maxFuzzInput {
|
|
return
|
|
}
|
|
exch, conn := fuzzExchange(t, ctrl, data)
|
|
ops := ctlField(ctrl, ctlHandlerOpsShift, ctlHandlerOpsMask)
|
|
stage := ctlField(ctrl, ctlStageCountShift, ctlStageCountMask)
|
|
|
|
Handle(exch, fuzzMux(func(exch *Exchange) {
|
|
checkRequestView(t, exch)
|
|
if ops&opStageHeaders != 0 {
|
|
// Literal fields: staging request bytes would test the caller's
|
|
// escaping, not this package's framing.
|
|
for i := range stage {
|
|
exch.StageHeader("X-Fuzz", "value")
|
|
exch.StageHeaderIntBase("X-Fuzz-Int", int64(i), 10)
|
|
}
|
|
}
|
|
if ops&opReadBody != 0 {
|
|
var body [64]byte
|
|
for range 64 {
|
|
n, err := exch.ReadBody(body[:])
|
|
if n == 0 && err != nil {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if ops&opWriteBody != 0 {
|
|
exch.WriteBody([]byte("fuzz body"))
|
|
}
|
|
if ops&opHijack != 0 {
|
|
exch.HijackRaw(nil)
|
|
}
|
|
}), nopBackoff)
|
|
|
|
if ctlField(ctrl, ctlFailWriteShift, ctlFailWriteMask) == 0 {
|
|
// A refused write leaves a partial response on purpose, so the
|
|
// wire is only well formed when every write got through.
|
|
checkResponse(t, conn.ViewWritten())
|
|
}
|
|
})
|
|
}
|
|
|
|
// FuzzQueryAndForm drives the request-target query and the form-encoded body,
|
|
// both of which decode percent escapes in place over caller memory.
|
|
func FuzzQueryAndForm(f *testing.F) {
|
|
addSeeds(f)
|
|
f.Fuzz(func(t *testing.T, ctrl uint64, data []byte) {
|
|
if len(data) > maxFuzzInput {
|
|
return
|
|
}
|
|
exch, _ := fuzzExchange(t, ctrl, data)
|
|
decoded := ctlField(ctrl, ctlDecodeShift, ctlDecodeMask) != 0
|
|
scratchLen := 8 + int(ctlField(ctrl, ctlScratchShift, ctlScratchMask))*16
|
|
|
|
Handle(exch, fuzzMux(func(exch *Exchange) {
|
|
// Every pair the iterator yields must be reachable by name, or the
|
|
// two views of the query string disagree.
|
|
const maxPairs = 64
|
|
key, _, rest := httpraw.NextQueryPair(exch.RequestQuery())
|
|
for pairs := 0; key != nil && pairs < maxPairs; pairs++ {
|
|
dec := make([]byte, len(key))
|
|
n, err := httpraw.CopyDecodedPercentURL(dec, key, true)
|
|
if err == nil {
|
|
if n > len(key) {
|
|
t.Fatalf("decoding key %q grew it to %d bytes", key, n)
|
|
}
|
|
if _, present := exch.RequestQueryAppend(nil, string(dec[:n]), decoded); !present {
|
|
t.Fatalf("query pair %q absent from AppendQuery", key)
|
|
}
|
|
}
|
|
key, _, rest = httpraw.NextQueryPair(rest)
|
|
}
|
|
|
|
var form httpraw.Form
|
|
buf := make([]byte, scratchLen)
|
|
if err := exch.RequestParseForm(&form, false, false); err != nil {
|
|
return
|
|
}
|
|
total := 0
|
|
lens := make([]int, form.Len())
|
|
for i := range form.Len() {
|
|
k, v := form.Pair(i)
|
|
lens[i] = len(k) + len(v)
|
|
total += lens[i]
|
|
}
|
|
if total > len(buf) {
|
|
t.Fatalf("form pairs span %d bytes of a %d byte buffer", total, len(buf))
|
|
}
|
|
if err := form.Decode(); err != nil {
|
|
return
|
|
}
|
|
// Decoding replaces escapes in place, so no pair may grow.
|
|
for i := range form.Len() {
|
|
k, v := form.Pair(i)
|
|
if len(k)+len(v) > lens[i] {
|
|
t.Fatalf("pair %d grew from %d to %d bytes on decode", i, lens[i], len(k)+len(v))
|
|
}
|
|
}
|
|
}), nopBackoff)
|
|
})
|
|
}
|
|
|
|
// countSink counts what a multipart part streamed into it and whether the part
|
|
// was closed off.
|
|
type countSink struct {
|
|
written int
|
|
closed bool
|
|
}
|
|
|
|
func (c *countSink) Write(b []byte) (int, error) {
|
|
c.written += len(b)
|
|
return len(b), nil
|
|
}
|
|
|
|
func (c *countSink) Close() error {
|
|
c.closed = true
|
|
return nil
|
|
}
|
|
|
|
// FuzzMultipart drives [Exchange.ReadMultiparts], which streams a body of
|
|
// unknown length through a buffer the caller sized.
|
|
func FuzzMultipart(f *testing.F) {
|
|
addSeeds(f)
|
|
f.Fuzz(func(t *testing.T, ctrl uint64, data []byte) {
|
|
if len(data) > maxFuzzInput {
|
|
return
|
|
}
|
|
exch, _ := fuzzExchange(t, ctrl, data)
|
|
discard := ctlField(ctrl, ctlDiscardShift, ctlDiscardMask) != 0
|
|
bufLen := 16 + int(ctlField(ctrl, ctlScratchShift, ctlScratchMask))*16
|
|
|
|
Handle(exch, fuzzMux(func(exch *Exchange) {
|
|
var sinks []*countSink
|
|
_, err := exch.ReadMultiparts(nil, make([]byte, bufLen), func(hdr *httpraw.MultipartHeader) io.WriteCloser {
|
|
if discard {
|
|
return nil
|
|
}
|
|
sink := new(countSink)
|
|
sinks = append(sinks, sink)
|
|
return sink
|
|
})
|
|
if err != nil {
|
|
return // Sinks are left for the caller to deal with on error.
|
|
}
|
|
total := 0
|
|
for _, sink := range sinks {
|
|
if !sink.closed {
|
|
t.Fatal("ReadMultiparts returned with a part left open")
|
|
}
|
|
total += sink.written
|
|
}
|
|
if total > len(data) {
|
|
t.Fatalf("parts streamed %d bytes out of a %d byte request", total, len(data))
|
|
}
|
|
}), nopBackoff)
|
|
})
|
|
}
|
|
|
|
// addSeeds adds the shared seed corpus. Every seed pins ctrl to zero so its
|
|
// meaning never moves: knobs added later decode their zero value to the
|
|
// behaviour the seed was recorded under.
|
|
func addSeeds(f *testing.F) {
|
|
f.Helper()
|
|
const (
|
|
formType = "Content-Type: application/x-www-form-urlencoded\r\n"
|
|
mpType = "Content-Type: multipart/form-data; boundary=b0undary\r\n"
|
|
)
|
|
seeds := []string{
|
|
// Well formed traffic, so the fuzzer has somewhere to mutate from.
|
|
"GET / HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
"GET /fuzz?q=go&n=1 HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
"POST /fuzz HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 11\r\n\r\na=1&b=2&c=3",
|
|
"POST /fuzz HTTP/1.1\r\nHost: h\r\n" + mpType + "Content-Length: 76\r\n\r\n--b0undary\r\nContent-Disposition: form-data; name=\"f\"\r\n\r\nbody\r\n--b0undary--\r\n",
|
|
|
|
// Framing the RFC leaves room to disagree over, which is where request
|
|
// smuggling lives: two lengths, a length plus a coding, and a coding
|
|
// this package does not decode.
|
|
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 3\r\nContent-Length: 4\r\n\r\nabcd",
|
|
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 3\r\nTransfer-Encoding: chunked\r\n\r\n1\r\na\r\n0\r\n\r\n",
|
|
"POST / HTTP/1.1\r\nHost: h\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n",
|
|
|
|
// Field names are case insensitive, RFC 9110 5.1, so a lookup that
|
|
// misses one of these reads the request differently than the peer wrote it.
|
|
"POST / HTTP/1.1\r\nHost: h\r\ncontent-length: 4\r\n\r\nbody",
|
|
"POST / HTTP/1.1\r\nHost: h\r\nCONTENT-LENGTH: 4\r\n\r\nbody",
|
|
"POST /fuzz HTTP/1.1\r\nHost: h\r\ncontent-type: application/x-www-form-urlencoded\r\ncontent-length: 3\r\n\r\na=1",
|
|
|
|
// Content-Length values that are not a bare digit string, RFC 9112 6.2.
|
|
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: +5\r\n\r\nbody!",
|
|
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: -1\r\n\r\nbody",
|
|
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 1 2\r\n\r\nbody",
|
|
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length:\r\n\r\nbody",
|
|
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 9223372036854775808\r\n\r\nbody",
|
|
|
|
// Line ending and field syntax edges.
|
|
"GET / HTTP/1.1\nHost: h\n\n",
|
|
"GET / HTTP/1.1\r\nHost: h\rX: y\r\n\r\n",
|
|
"GET / HTTP/1.1\r\nHost: h\r\n Continued: fold\r\n\r\n",
|
|
"GET / HTTP/1.1\r\nHost: h\r\nX: va\x00lue\r\n\r\n",
|
|
"GET / HTTP/1.1\r\nHost: h\r\nNoColon\r\n\r\n",
|
|
"GET / HTTP/1.1\r\nHost: h\r\n: novalue\r\n\r\n",
|
|
"GET / HTTP/1.1\r\nHost: h\r\nX:\r\n\r\n",
|
|
"GET / HTTP/1.1\r\nHost: h\r\n\r\n\r\n",
|
|
|
|
// Request lines this package tolerates or must refuse.
|
|
"GET http://h/abs HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
"GET /\r\n\r\n", // HTTP/0.9 simple request, no version.
|
|
" GET / HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
"GET / HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
"/ HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
"GET / HTTP/9.9\r\nHost: h\r\n\r\n",
|
|
|
|
// Percent escapes, including the truncated and the malformed.
|
|
"GET /a%20b?a%20b=c%20d HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
"GET /?q=%2 HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
"GET /?q=%zz HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
"GET /%00?a=%00 HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
"GET /?a=b=c&&=v&debug HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
"GET /?a%3db=1&a+b=2 HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
|
|
// Multipart bodies that end early or never open a part.
|
|
"POST /fuzz HTTP/1.1\r\nHost: h\r\n" + mpType + "Content-Length: 12\r\n\r\n--b0undary\r\n",
|
|
"POST /fuzz HTTP/1.1\r\nHost: h\r\n" + mpType + "Content-Length: 14\r\n\r\n--b0undary--\r\n",
|
|
"POST /fuzz HTTP/1.1\r\nHost: h\r\nContent-Type: multipart/form-data\r\nContent-Length: 4\r\n\r\nbody",
|
|
|
|
// Sizes that crowd the buffers: a long target, many fields, and a body
|
|
// arriving in the same read as the header it follows.
|
|
"GET /" + strings.Repeat("a", 512) + " HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
"GET / HTTP/1.1\r\n" + strings.Repeat("X: y\r\n", 200) + "\r\n",
|
|
"GET / HTTP/1.1\r\n" + strings.Repeat("k", 512) + ": v\r\n\r\n",
|
|
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 4\r\n\r\nbodyTRAILING",
|
|
|
|
// Nothing, and nothing that resembles a request at all.
|
|
"",
|
|
"\r\n\r\n",
|
|
"\x00\x00\x00\x00",
|
|
}
|
|
for _, seed := range seeds {
|
|
f.Add(uint64(0), []byte(seed))
|
|
}
|
|
}
|