Something went wrong. Try again.
A community based topic aggregation platform built on atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204// Package reqbody is the single place request-body size limits and JSON// decoding live. Every handler that reads a JSON request body goes through// DecodeJSON so the cap, the 413 semantics, and the trailing-data rejection// are uniform across the API surface — scripts/test-audit.sh (run inside// `make ci`) counts any raw request-body decode outside this package as a// violation.//// The package is a dependency-free leaf on purpose: internal/api/xrpc already// depends on internal/atproto/oauth (transitively), so a helper living in// xrpc could never be used by the OAuth handlers without an import cycle.// Both import this instead.package reqbody
import ( "encoding/json" "errors" "fmt" "io" "net/http")
// Limit is a request-body cap in bytes. It is a named type so call sites read// as a tier choice rather than an arbitrary integer; DecodeJSON panics on a// non-positive value instead of quietly rejecting every request (that is what// http.MaxBytesReader would do with a zero cap).type Limit int64
// Size tiers for request bodies. Handlers pick the smallest tier that fits// their legitimate payload — an attacker then can't spend more of our memory// than the endpoint's real traffic ever would. The tiers are described by// payload shape, not by endpoint list, so the docs don't rot as endpoints// come and go.const ( // LimitTiny fits token-and-identifier payloads: a captcha token, a vote // subject, a record URI. Legitimate bodies are well under 2 KB. LimitTiny Limit = 4 << 10 // 4 KiB
// LimitSmall fits short structured requests: a subject DID or handle // plus a few scalar fields (block/subscribe targets, reports, session // refresh, registration metadata). LimitSmall Limit = 10 << 10 // 10 KiB
// LimitMedium fits user-authored text without inline media: comments, // suggestion bodies. LimitMedium Limit = 100 << 10 // 100 KiB
// LimitLarge fits the biggest text payloads: full post bodies with // embeds and facets. Post embeds carry blob references (CIDs), never // image bytes. LimitLarge Limit = 1 << 20 // 1 MiB
// LimitImage fits base64-encoded image payloads: the profile AND // community endpoints accept inline avatar/banner bytes, up to 1 MB + // 2 MB raw per their lexicons, which is ~4 MB as base64 plus the // record's text fields. 10 MB leaves comfortable slack. LimitImage Limit = 10_000_000
// LimitGlobal is the router-wide backstop applied to every request // before any handler runs. It must stay >= the largest per-endpoint // tier (enforced at compile time below); per-endpoint tiers do the // real work under it. LimitGlobal Limit = LimitImage)
// Compile-time guard: if LimitGlobal ever undercuts the largest endpoint// tier, the conversion below goes negative and the package stops compiling.const _ = uint64(LimitGlobal - LimitImage)
// ErrTrailingData is the cause carried by the MalformedError returned when// bytes follow the decoded JSON value — concatenated objects, padding, or a// stray closing bracket. Callers that want to count smuggling probes// separately from ordinary bad JSON can errors.Is against it.var ErrTrailingData = errors.New("trailing data after JSON value")
// TooLargeError reports a request body that exceeded the endpoint's limit.// Handlers map it to 413 Payload Too Large. Limit is the cap that actually// tripped — under the router backstop plus an endpoint tier, that is// whichever wrapper the read exhausted first.type TooLargeError struct { Limit int64
err error}
func (e *TooLargeError) Error() string { return fmt.Sprintf("request body exceeds %d byte limit", e.Limit)}
// Unwrap exposes the underlying *http.MaxBytesError so errors.As chains// written against the stdlib type keep working.func (e *TooLargeError) Unwrap() error { return e.err}
// MalformedError reports a body that was readable but not a single valid// JSON value: syntax errors, type mismatches, trailing data, or unknown// fields when WithDisallowUnknownFields is set. Handlers map it to 400.type MalformedError struct { err error}
func (e *MalformedError) Error() string { if e == nil || e.err == nil { return "malformed request body" } return "malformed request body: " + e.err.Error()}
func (e *MalformedError) Unwrap() error { if e == nil { return nil } return e.err}
// DecodeOption adjusts DecodeJSON's strictness.type DecodeOption func(*decodeConfig)
type decodeConfig struct { disallowUnknownFields bool}
// WithDisallowUnknownFields rejects bodies containing JSON fields the// destination struct does not declare. Off by default: atProto tolerates// unknown fields, and older clients must not break when the schema grows.// Turn it on for bot-facing endpoints where unexpected fields are a probe.func WithDisallowUnknownFields() DecodeOption { return func(c *decodeConfig) { c.disallowUnknownFields = true }}
// DecodeJSON caps r.Body at limit bytes and decodes exactly one JSON value// into dst, which must be a non-nil pointer (a non-pointer is a programmer// error and panics rather than masquerading as a client 400).//// The cap uses http.MaxBytesReader rather than io.LimitReader because it// yields a typed *http.MaxBytesError (surfaced here as *TooLargeError)// instead of silently truncating into a confusing syntax error. (Its second// defense — telling the server to close the connection — does not fire in// this stack: chi's middleware wraps the ResponseWriter without forwarding// net/http's private requestTooLarge signal. net/http still bounds how much// of a rejected body it drains, so the miss costs nothing material.)//// DecodeJSON replaces r.Body with the capped reader, so any later read of// the body in the same handler stays bounded too.//// After the value, the body must be at clean EOF. Trailing data// (concatenated objects, padding, stray brackets) is a smuggling smell and// returns a *MalformedError wrapping ErrTrailingData — and because the check// reads, not peeks-and-drops, an over-limit body can never sneak through as// a false success: the size error is classified, never swallowed.func DecodeJSON(w http.ResponseWriter, r *http.Request, limit Limit, dst any, opts ...DecodeOption) error { if limit <= 0 { panic(fmt.Sprintf("reqbody.DecodeJSON: limit must be positive, got %d", int64(limit))) }
var cfg decodeConfig for _, opt := range opts { opt(&cfg) }
r.Body = http.MaxBytesReader(w, r.Body, int64(limit))
dec := json.NewDecoder(r.Body) if cfg.disallowUnknownFields { dec.DisallowUnknownFields() }
if err := dec.Decode(dst); err != nil { return classifyDecodeError(err, dst) }
// dec.More() is the wrong tool here: it swallows read errors (an // over-limit body would look like a clean end) and returns false on a // stray closing bracket. Reading one more token surfaces all three // outcomes distinctly: io.EOF is the only clean result. if _, err := dec.Token(); !errors.Is(err, io.EOF) { if err == nil { return &MalformedError{err: ErrTrailingData} } return classifyDecodeError(err, dst) }
return nil}
// classifyDecodeError maps a json.Decoder error to the package's typed// errors, panicking on the one shape that is a server bug rather than// client input.func classifyDecodeError(err error, dst any) error { var invalidUnmarshal *json.InvalidUnmarshalError if errors.As(err, &invalidUnmarshal) { // A non-pointer or nil dst would otherwise surface as 400 // "invalid request body" on every call — a dead endpoint whose // logs blame the clients. chi's Recoverer turns this into a 500 // and a stack trace, which is what a wiring bug deserves. panic(fmt.Sprintf("reqbody.DecodeJSON: dst must be a non-nil pointer, got %T", dst)) } var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { return &TooLargeError{Limit: maxBytesErr.Limit, err: maxBytesErr} } return &MalformedError{err: err}}