From 3afc70999b41b3e4c0d66aa71431fe524807255a Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 03:32:31 +0000 Subject: [PATCH] Accept a chunked upstream body of exactly the maximum size (closes #248) Once MaxResponseSize bytes were read, limitedReader.Read refused the next read without looking for the end of the body. A body sent with a Content-Length gets io.EOF with its last bytes, but the end of a chunked body can come in a later read, so a chunked body of exactly the limit was refused. Once the limit is used up, Read now reads one more byte: at the end of the body it returns io.EOF, and with more data it returns ErrResponseTooLarge, as does every later Read. Model: opus-5-5 --- TODO.md | 9 +++++++++ internal/httpfetcher/httpfetcher.go | 23 +++++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index b85bf37..c046779 100644 --- a/TODO.md +++ b/TODO.md @@ -38,6 +38,15 @@ P2: security: per-origin rate limiting commented entry under `example.com`. Getting Started and "Running under upaas" in `README.md` say that every image on an allowlisted host is served without a signature, so an allowlisted host must be one the operator controls. +- 2026-10-15 a chunked upstream body of exactly `upstream_max_response_size` + bytes is accepted (closes #248): once the limit was used up, the fetcher's + next read refused the body without looking for its end. Go's HTTP client + returns the end of a body sent with a `Content-Length` together with its last + bytes, but the end of a chunked body can come in a later read, so such a body + was refused after it had been downloaded whole, and the image routes + answered 500. Once the limit is used up, the fetcher now reads one more byte: + if the body has ended it is accepted, and if there is more it is refused with + `ErrResponseTooLarge`, as before. - 2026-10-13 `fit=outside` no longer makes an output above 8192 on a side (closes #257): it scales the image to cover the requested size, and the side scaled past that size was held to no maximum, so `1x8192?fit=outside` of a diff --git a/internal/httpfetcher/httpfetcher.go b/internal/httpfetcher/httpfetcher.go index 9d09295..1428040 100644 --- a/internal/httpfetcher/httpfetcher.go +++ b/internal/httpfetcher/httpfetcher.go @@ -660,14 +660,33 @@ func dialSSRFSafe( return nil, fmt.Errorf("%w to %s", errConnectFailed, host) } -// limitedReader wraps a reader and limits the number of bytes read. +// limitedReader wraps a reader and refuses a body longer than remaining +// bytes with ErrResponseTooLarge. remaining goes below 0 once the body is +// found to be longer. type limitedReader struct { reader io.Reader remaining int64 } +// Read reads at most the remaining bytes. Once they are read, it reads one +// more byte to tell the end of the body from more data: at the end it +// returns the wrapped reader's error, io.EOF; with more data it returns +// ErrResponseTooLarge, as does every later Read. func (r *limitedReader) Read(p []byte) (int, error) { - if r.remaining <= 0 { + if r.remaining < 0 { + return 0, ErrResponseTooLarge + } + + if r.remaining == 0 { + var extra [1]byte + + n, err := r.reader.Read(extra[:]) + r.remaining -= int64(n) + + if n == 0 { + return 0, err + } + return 0, ErrResponseTooLarge }