// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo // Package httplimit reads a response body from a configured provider within // a byte bound. Every outbound client (FarmManager, Discourse, the Temporal // OAuth token endpoint, and any client added later) reads through here, so // a provider, or whoever stands where its URL points, cannot grow this // process's heap without limit (security-audit-remediation-3 design D5). // // A bounded response is either complete or refused, never silently // truncated: a truncated JSON document fails later with a worse message, // and a truncated error body would misquote the provider. package httplimit import ( "encoding/json" "errors" "fmt" "io" "net/http" ) // DefaultResponseBytes is the bound a client uses unless it knows its // response is smaller. 8 MiB: the largest legitimate response today is // FarmManager's site list, a few hundred bytes per site. const DefaultResponseBytes int64 = 8 << 20 // TooLargeError reports a response that exceeded its bound. Host names the // provider the response came from, so an operator reading the log knows // which bound to raise, or which provider to distrust. type TooLargeError struct { Bound int64 Host string } func (e *TooLargeError) Error() string { return fmt.Sprintf("response from %s exceeds %d bytes", e.Host, e.Bound) } // ReadResponse reads resp's body whole when it fits within max bytes and // closes it either way. A body longer than max returns *TooLargeError and // no bytes; reading stops at max+1, so the process never holds more than // the bound for one response. func ReadResponse(resp *http.Response, max int64) ([]byte, error) { defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, max+1)) if err != nil { return nil, err } if int64(len(body)) > max { return nil, &TooLargeError{Bound: max, Host: hostOf(resp)} } return body, nil } // DecodeResponse decodes resp's JSON body into v within the same bound // ReadResponse applies, and closes the body. Beyond the bound it returns // *TooLargeError and leaves v as the decoder left it. func DecodeResponse(resp *http.Response, max int64, v any) error { body, err := ReadResponse(resp, max) if err != nil { return err } return json.Unmarshal(body, v) } // IsTooLarge reports whether err is, or wraps, a TooLargeError. func IsTooLarge(err error) bool { var tl *TooLargeError return errors.As(err, &tl) } func hostOf(resp *http.Response) string { if resp.Request != nil && resp.Request.URL != nil { return resp.Request.URL.Host } return "provider" }