@marcel91200 / caddy Mirror · read-only / commits / e096ca95
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 08:30 UTC

caddyauth: isolate provider responses to prevent cross-provider clobbering (#7904)

* caddyauth: isolate provider responses to prevent cross-provider clobbering When multiple authentication providers are configured, each was handed the real ResponseWriter, so a failing provider that wrote to the response (a 401 challenge or a login redirect) could clobber the response of another provider or of the successful handler chain. Because provider map iteration order is randomized, which provider's side effects won was nondeterministic. A single provider now receives the real ResponseWriter unchanged — no buffering, and Flusher/Hijacker/Pusher/ReaderFrom preserved exactly as before. Only with multiple providers does each get its own buffered writer; those writers embed caddyhttp.ResponseWriterWrapper so the underlying capabilities remain type-assertable via http.ResponseController (Flush is suppressed while buffering so a provider cannot prematurely commit the response), and the buffered body is size-capped to avoid unbounded memory. On success the winning provider's headers (e.g. a Set-Cookie) are copied to the real writer and the chain proceeds. On total failure one provider's challenge headers are applied (a redirect is sent as a full response), otherwise the auth error is returned so handle_errors runs and a header-only challenge (like basic auth) still returns 401. Fixes #5190 * caddyauth: return total consumed bytes and drain error from ReadFrom ReadFrom drained a source that exceeded the buffer cap but reported only the bytes retained in the buffer and dropped any error from the drain, violating the io.ReaderFrom contract: a caller such as io.Copy would see fewer bytes than were actually consumed from the source, and a read failure during the drain was silently swallowed. Return the retained-plus-drained total and propagate the drain error. * caddyauth: preserve Flusher and Hijacker on the buffered writer The buffered writer used for multi-provider isolation embeds caddyhttp.ResponseWriterWrapper, which promotes only Header, Write and WriteHeader from the wrapped ResponseWriter and adds Push, ReadFrom and Unwrap. Flush and Hijack were therefore reachable only through http.ResponseController; a provider doing a plain w.(http.Flusher) or w.(http.Hijacker) assertion silently lost them once a second provider was configured. Declare both on bufferedResponseWriter. Flush is a no-op so a provider cannot prematurely commit a buffered response, and FlushError keeps the same suppression for ResponseController, which prefers it over Flush. Hijack delegates through the embedded wrapper, mirroring responseRecorder.Hijack in the caddyhttp package. The existing capability test only probed via http.ResponseController, which is why this went unnoticed. It now asserts each capability both by direct type assertion and through the controller, and uses two non-authenticating providers so the probe is guaranteed to run — map iteration order previously allowed a succeeding provider to break out of the loop before the probe executed. Also replace two header copy loops with maps.Copy, fixing the mapsloop lint failures.

SillyZir committed Jul 31, 2026 at 22:19 UTC e096ca9503188f057c69a049f709fdade6077631
2 files changed +472 -1
modules/caddyhttp/caddyauth/caddyauth.go
+167 -1
@@ -15,7 +15,12 @@
15 package caddyauth
16
17 import (
18 + "bufio"
19 + "bytes"
20 "fmt"
21 + "io"
22 + "maps"
23 + "net"
24 "net/http"
25
26 "go.uber.org/zap"
@@ -88,8 +93,26 @@ func (a Authentication) ServeHTTP(w http.ResponseWriter, r *http.Request, next c
93 var hasCandidate bool
94 var authed bool
95 var err error
96 +
97 + // With a single provider there is nothing to isolate it from, so it is
98 + // given the real ResponseWriter directly — no buffering, and every writer
99 + // capability (Flusher, Hijacker, Pusher, ReaderFrom, ...) is preserved
100 + // exactly as before. Only when MULTIPLE providers are configured can a
101 + // failing provider's response clobber another provider's or the successful
102 + // handler chain's, so there each provider gets its own bounded buffered
103 + // writer. See https://github.com/caddyserver/caddy/issues/5190.
104 + isolate := len(a.Providers) > 1
105 +
106 + var winner *bufferedResponseWriter
107 + var failed []*bufferedResponseWriter
108 for provName, prov := range a.Providers {
92 - user, authed, err = prov.Authenticate(w, r)
109 + pw := w
110 + var bw *bufferedResponseWriter
111 + if isolate {
112 + bw = newBufferedResponseWriter(w)
113 + pw = bw
114 + }
115 + user, authed, err = prov.Authenticate(pw, r)
116 if err != nil {
117 if c := a.logger.Check(zapcore.ErrorLevel, "auth provider returned error"); c != nil {
118 c.Write(zap.String("provider", provName), zap.Error(err))
@@ -100,8 +123,12 @@ func (a Authentication) ServeHTTP(w http.ResponseWriter, r *http.Request, next c
123 continue
124 }
125 if authed {
126 + winner = bw
127 break
128 }
129 + if isolate {
130 + failed = append(failed, bw)
131 + }
132 if userHasInfo(user) {
133 candidate = user
134 hasCandidate = true
@@ -111,18 +138,157 @@ func (a Authentication) ServeHTTP(w http.ResponseWriter, r *http.Request, next c
138 if hasCandidate {
139 setAuthUserPlaceholders(repl, "http.auth.candidate", candidate)
140 }
141 + // When isolating, no failed provider's response reached the real
142 + // writer, so apply one provider's challenge headers (e.g. a
143 + // WWW-Authenticate, or a Location); a redirecting provider takes
144 + // precedence and is sent as a full response. A single provider already
145 + // wrote its challenge directly to the real writer. Either way, fall
146 + // through to the auth error so handle_errors runs and a challenge that
147 + // set only headers (like basic auth) still returns 401, not 200.
148 + if isolate {
149 + if replay := pickReplay(failed); replay != nil {
150 + maps.Copy(w.Header(), replay.header)
151 + if replay.statusCode >= 300 && replay.statusCode < 400 {
152 + w.WriteHeader(replay.statusCode)
153 + _, _ = w.Write(replay.buf.Bytes())
154 + return nil
155 + }
156 + }
157 + }
158 return caddyhttp.Error(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
159 }
160
161 + // When isolating, the winning provider wrote to its buffer; copy the
162 + // headers it set (e.g. a Set-Cookie establishing a new session) onto the
163 + // real writer. Its status/body are NOT replayed: the request is
164 + // authenticated and continues down the handler chain, which produces the
165 + // actual response. (A single provider already wrote its headers directly.)
166 + if winner != nil {
167 + maps.Copy(w.Header(), winner.header)
168 + }
169 +
170 setAuthUserPlaceholders(repl, "http.auth.user", user)
171
172 return next.ServeHTTP(w, r)
173 }
174
175 +// pickReplay chooses which failed provider's buffered response to send when
176 +// no provider authenticated: a redirect (3xx) wins, otherwise the first
177 +// provider that wrote anything (status, body, or headers).
178 +func pickReplay(failed []*bufferedResponseWriter) *bufferedResponseWriter {
179 + var replay *bufferedResponseWriter
180 + for _, bw := range failed {
181 + if bw.statusCode >= 300 && bw.statusCode < 400 {
182 + return bw
183 + }
184 + if replay == nil && (bw.statusCode != 0 || bw.buf.Len() > 0 || len(bw.header) > 0) {
185 + replay = bw
186 + }
187 + }
188 + return replay
189 +}
190 +
191 func userHasInfo(user User) bool {
192 return user.ID != "" || len(user.Metadata) > 0
193 }
194
195 +// maxBufferedAuthResponse caps how much of a single provider's response body
196 +// is buffered during multi-provider authentication, so a misbehaving provider
197 +// cannot cause unbounded memory use. Authentication challenges are tiny; this
198 +// limit is far larger than any legitimate challenge.
199 +const maxBufferedAuthResponse = 1 << 20 // 1 MiB
200 +
201 +// bufferedResponseWriter captures a single provider's response — headers,
202 +// status, and a size-capped body — so it can be discarded or replayed once
203 +// the outcome of the whole provider set is known (only used when more than
204 +// one provider is configured).
205 +//
206 +// It implements every capability interface a provider may hold on the real
207 +// writer, so both plain type assertions (w.(http.Flusher)) and
208 +// http.ResponseController keep working exactly as they did before isolation:
209 +// Pusher and ReaderFrom come from the embedded caddyhttp.ResponseWriterWrapper,
210 +// Hijacker and Flusher are declared below. Body writes are captured rather
211 +// than streamed, and flushing is suppressed while buffering. A provider that
212 +// hijacks the connection mid-authentication escapes to the real writer
213 +// (isolation cannot apply once hijacked); auth providers are not expected to
214 +// stream or hijack during Authenticate.
215 +type bufferedResponseWriter struct {
216 + *caddyhttp.ResponseWriterWrapper
217 + header http.Header
218 + statusCode int
219 + buf bytes.Buffer
220 + overflowed bool
221 +}
222 +
223 +func newBufferedResponseWriter(w http.ResponseWriter) *bufferedResponseWriter {
224 + return &bufferedResponseWriter{
225 + ResponseWriterWrapper: &caddyhttp.ResponseWriterWrapper{ResponseWriter: w},
226 + header: make(http.Header),
227 + }
228 +}
229 +
230 +func (bw *bufferedResponseWriter) Header() http.Header { return bw.header }
231 +
232 +func (bw *bufferedResponseWriter) WriteHeader(statusCode int) {
233 + if bw.statusCode == 0 {
234 + bw.statusCode = statusCode
235 + }
236 +}
237 +
238 +func (bw *bufferedResponseWriter) Write(data []byte) (int, error) {
239 + bw.WriteHeader(http.StatusOK)
240 + if room := maxBufferedAuthResponse - bw.buf.Len(); room < len(data) {
241 + bw.overflowed = true
242 + if room > 0 {
243 + bw.buf.Write(data[:room])
244 + }
245 + // Report a full write so the provider does not error; the body is a
246 + // discardable challenge and only its capped prefix is retained.
247 + return len(data), nil
248 + }
249 + return bw.buf.Write(data)
250 +}
251 +
252 +// ReadFrom captures the body (respecting the size cap) instead of the
253 +// embedded wrapper's pass-through to the real writer, which would leak the
254 +// provider's body past isolation. It returns the total number of bytes
255 +// consumed from r — retained and drained alike — and any read error,
256 +// per the io.ReaderFrom contract.
257 +func (bw *bufferedResponseWriter) ReadFrom(r io.Reader) (int64, error) {
258 + bw.WriteHeader(http.StatusOK)
259 + room := int64(maxBufferedAuthResponse - bw.buf.Len())
260 + n, err := bw.buf.ReadFrom(io.LimitReader(r, room))
261 + if err != nil {
262 + return n, err
263 + }
264 + extra, err := io.Copy(io.Discard, r)
265 + if extra > 0 {
266 + bw.overflowed = true
267 + }
268 + return n + extra, err
269 +}
270 +
271 +// Flush implements http.Flusher. Flushing is suppressed while a provider's
272 +// response is buffered, so a provider that flushes during Authenticate cannot
273 +// prematurely commit the response to the client. It is declared explicitly
274 +// (rather than left to FlushError) so that providers doing a plain
275 +// w.(http.Flusher) assertion still find one, as they do on the real writer.
276 +func (bw *bufferedResponseWriter) Flush() {}
277 +
278 +// FlushError is the same suppression for http.ResponseController.Flush, which
279 +// prefers FlushError over Flush. See https://github.com/caddyserver/caddy/issues/6144.
280 +func (bw *bufferedResponseWriter) FlushError() error { return nil }
281 +
282 +// Hijack implements http.Hijacker by delegating to the real writer: once a
283 +// provider takes over the connection there is no response left to isolate.
284 +// This mirrors responseRecorder.Hijack in the caddyhttp package. The
285 +// controller is built on the embedded wrapper, whose Unwrap reaches the real
286 +// writer, and returns http.ErrNotSupported if that writer cannot hijack.
287 +func (bw *bufferedResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
288 + //nolint:bodyclose
289 + return http.NewResponseController(bw.ResponseWriterWrapper).Hijack()
290 +}
291 +
292 func setAuthUserPlaceholders(repl *caddy.Replacer, namespace string, user User) {
293 repl.Set(namespace+".id", user.ID)
294 for k, v := range user.Metadata {
modules/caddyhttp/caddyauth/caddyauth_test.go
+305
@@ -15,8 +15,12 @@
15 package caddyauth
16
17 import (
18 + "bufio"
19 + "bytes"
20 "context"
21 "errors"
22 + "io"
23 + "net"
24 "net/http"
25 "net/http/httptest"
26 "testing"
@@ -195,3 +199,304 @@ type staticAuthenticator struct {
199 func (a staticAuthenticator) Authenticate(http.ResponseWriter, *http.Request) (User, bool, error) {
200 return a.user, a.authed, a.err
201 }
202 +
203 +// writingAuthenticator writes to the response during Authenticate (as a real
204 +// provider might: a challenge/redirect on failure, a Set-Cookie on success).
205 +type writingAuthenticator struct {
206 + authed bool
207 + write func(w http.ResponseWriter)
208 +}
209 +
210 +func (a writingAuthenticator) Authenticate(w http.ResponseWriter, _ *http.Request) (User, bool, error) {
211 + if a.write != nil {
212 + a.write(w)
213 + }
214 + if a.authed {
215 + return User{ID: "u"}, true, nil
216 + }
217 + return User{}, false, nil
218 +}
219 +
220 +func serveAuth(providers map[string]Authenticator, next caddyhttp.Handler) *httptest.ResponseRecorder {
221 + a := Authentication{Providers: providers, logger: zap.NewNop()}
222 + req, _ := newRequestWithReplacer()
223 + rr := httptest.NewRecorder()
224 + _ = a.ServeHTTP(rr, req, next)
225 + return rr
226 +}
227 +
228 +// A failing provider that writes a redirect must not clobber the response
229 +// when another provider authenticates the request. Provider map iteration
230 +// order is randomized, so exercise both orderings. #5190
231 +func TestFailingProviderDoesNotClobberSuccess(t *testing.T) {
232 + redirecter := writingAuthenticator{write: func(w http.ResponseWriter) {
233 + w.Header().Set("Location", "/login")
234 + w.WriteHeader(http.StatusFound)
235 + }}
236 + succeeder := writingAuthenticator{authed: true}
237 +
238 + for i := 0; i < 20; i++ {
239 + reached := false
240 + rr := serveAuth(map[string]Authenticator{
241 + "redirect": redirecter, "succeed": succeeder,
242 + }, caddyhttp.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) error {
243 + reached = true
244 + w.WriteHeader(http.StatusOK)
245 + return nil
246 + }))
247 + if !reached {
248 + t.Fatalf("run %d: handler chain not reached despite successful auth", i)
249 + }
250 + if rr.Code != http.StatusOK {
251 + t.Fatalf("run %d: got status %d, want 200 (failing provider leaked its redirect)", i, rr.Code)
252 + }
253 + if loc := rr.Header().Get("Location"); loc != "" {
254 + t.Fatalf("run %d: failing provider's Location header leaked: %q", i, loc)
255 + }
256 + }
257 +}
258 +
259 +// The successful provider's headers (e.g. a Set-Cookie for a new session)
260 +// must reach the client even though its response is otherwise buffered. #5190
261 +func TestSuccessfulProviderHeadersPreserved(t *testing.T) {
262 + succeeder := writingAuthenticator{authed: true, write: func(w http.ResponseWriter) {
263 + w.Header().Set("Set-Cookie", "session=abc; Path=/")
264 + }}
265 + rr := serveAuth(map[string]Authenticator{"succeed": succeeder},
266 + caddyhttp.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) error {
267 + w.WriteHeader(http.StatusOK)
268 + return nil
269 + }))
270 + if got := rr.Header().Get("Set-Cookie"); got != "session=abc; Path=/" {
271 + t.Fatalf("successful provider's Set-Cookie not preserved: got %q", got)
272 + }
273 + if rr.Code != http.StatusOK {
274 + t.Fatalf("got status %d, want 200", rr.Code)
275 + }
276 +}
277 +
278 +// When every provider fails, a challenge still reaches the client and a
279 +// redirect takes precedence over a plain failure. #5190
280 +func TestAllFailReplaysRedirect(t *testing.T) {
281 + plain := writingAuthenticator{write: func(w http.ResponseWriter) {
282 + w.WriteHeader(http.StatusUnauthorized)
283 + }}
284 + redirecter := writingAuthenticator{write: func(w http.ResponseWriter) {
285 + w.Header().Set("Location", "/login")
286 + w.WriteHeader(http.StatusFound)
287 + }}
288 + rr := serveAuth(map[string]Authenticator{
289 + "plain": plain, "redirect": redirecter,
290 + }, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
291 + t.Fatal("handler chain must not run when auth fails")
292 + return nil
293 + }))
294 + if rr.Code != http.StatusFound {
295 + t.Fatalf("got status %d, want 302 (redirect should win the replay)", rr.Code)
296 + }
297 + if loc := rr.Header().Get("Location"); loc != "/login" {
298 + t.Fatalf("redirect Location not replayed: %q", loc)
299 + }
300 +}
301 +
302 +// A provider that fails by setting only a challenge header (no status/body) —
303 +// which is exactly what basic auth does (WWW-Authenticate) — must still result
304 +// in a 401 with that header, not a 200. #5190
305 +func TestHeaderOnlyChallengeStillReturns401(t *testing.T) {
306 + basicish := writingAuthenticator{write: func(w http.ResponseWriter) {
307 + w.Header().Set("WWW-Authenticate", `Basic realm="test"`)
308 + }}
309 + err := (func() error {
310 + a := Authentication{Providers: map[string]Authenticator{"basic": basicish}, logger: zap.NewNop()}
311 + req, _ := newRequestWithReplacer()
312 + return a.ServeHTTP(&recordingStatusWriter{ResponseWriter: httptest.NewRecorder()}, req,
313 + caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
314 + t.Fatal("handler chain must not run when auth fails")
315 + return nil
316 + }))
317 + })()
318 + var handlerErr caddyhttp.HandlerError
319 + if !errors.As(err, &handlerErr) {
320 + t.Fatalf("expected a 401 HandlerError, got %v (a header-only challenge must not return nil/200)", err)
321 + }
322 + if handlerErr.StatusCode != http.StatusUnauthorized {
323 + t.Fatalf("expected status 401, got %d", handlerErr.StatusCode)
324 + }
325 +}
326 +
327 +// recordingStatusWriter records whether WriteHeader was called and with what
328 +// status, to prove a header-only challenge does not emit a 200.
329 +type recordingStatusWriter struct {
330 + http.ResponseWriter
331 + wroteHeader bool
332 + status int
333 +}
334 +
335 +func (rw *recordingStatusWriter) WriteHeader(status int) {
336 + rw.wroteHeader = true
337 + rw.status = status
338 + rw.ResponseWriter.WriteHeader(status)
339 +}
340 +
341 +// capabilityWriter implements the writer capabilities providers may rely on,
342 +// recording whether each was actually invoked.
343 +type capabilityWriter struct {
344 + http.ResponseWriter
345 + flushed bool
346 + hijacked bool
347 +}
348 +
349 +func (cw *capabilityWriter) Flush() { cw.flushed = true }
350 +func (cw *capabilityWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
351 + cw.hijacked = true
352 + return nil, nil, nil
353 +}
354 +
355 +// With a SINGLE provider there is nothing to isolate, so the provider must
356 +// receive the real writer untouched — all capabilities preserved and writes
357 +// streamed directly. This is the no-regression guarantee for the common case.
358 +func TestSingleProviderGetsRealWriter(t *testing.T) {
359 + var gotReal bool
360 + prov := writingAuthenticator{authed: true, write: func(w http.ResponseWriter) {
361 + _, gotReal = w.(*capabilityWriter) // the real writer, not a buffered wrapper
362 + if f, ok := w.(http.Flusher); ok {
363 + f.Flush()
364 + }
365 + }}
366 + cw := &capabilityWriter{ResponseWriter: httptest.NewRecorder()}
367 + a := Authentication{Providers: map[string]Authenticator{"only": prov}, logger: zap.NewNop()}
368 + req, _ := newRequestWithReplacer()
369 + if err := a.ServeHTTP(cw, req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
370 + return nil
371 + })); err != nil {
372 + t.Fatalf("unexpected error: %v", err)
373 + }
374 + if !gotReal {
375 + t.Error("single provider did not receive the real ResponseWriter")
376 + }
377 + if !cw.flushed {
378 + t.Error("single provider's Flush did not reach the real writer")
379 + }
380 +}
381 +
382 +// With MULTIPLE providers the buffered writer must still satisfy every
383 +// capability interface the real writer satisfies, so external providers that
384 +// check for Flusher/Hijacker/Pusher/ReaderFrom do not change behavior. Both
385 +// access paths are checked: a plain type assertion, which is what most
386 +// existing providers do, and http.ResponseController.
387 +func TestBufferedWriterPreservesCapabilities(t *testing.T) {
388 + var (
389 + ran bool
390 + assertFlusher, assertHijacker, assertPusher, assertReaderFrom bool
391 + ctrlFlush, ctrlHijack bool
392 + )
393 + probe := writingAuthenticator{write: func(w http.ResponseWriter) {
394 + ran = true
395 + flusher, ok := w.(http.Flusher)
396 + assertFlusher = ok
397 + if ok {
398 + flusher.Flush() // must be suppressed, not forwarded
399 + }
400 + _, assertHijacker = w.(http.Hijacker)
401 + _, assertPusher = w.(http.Pusher)
402 + _, assertReaderFrom = w.(io.ReaderFrom)
403 +
404 + ctrl := http.NewResponseController(w)
405 + ctrlFlush = ctrl.Flush() == nil // FlushError suppressed → nil while buffering
406 + _, _, herr := ctrl.Hijack()
407 + ctrlHijack = herr == nil
408 + }}
409 + // Neither provider authenticates: providers are iterated from a map, so a
410 + // succeeding one could break out of the loop before the probe ever runs.
411 + // Two non-authenticating providers still enable isolation (len > 1) and
412 + // guarantee the probe is exercised on every run.
413 + other := writingAuthenticator{}
414 + cw := &capabilityWriter{ResponseWriter: httptest.NewRecorder()}
415 + a := Authentication{Providers: map[string]Authenticator{"probe": probe, "other": other}, logger: zap.NewNop()}
416 + req, _ := newRequestWithReplacer()
417 + _ = a.ServeHTTP(cw, req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error { return nil }))
418 +
419 + if !ran {
420 + t.Fatal("probe provider never ran; the test asserts nothing")
421 + }
422 + for _, tc := range []struct {
423 + name string
424 + got bool
425 + }{
426 + {"w.(http.Flusher)", assertFlusher},
427 + {"w.(http.Hijacker)", assertHijacker},
428 + {"w.(http.Pusher)", assertPusher},
429 + {"w.(io.ReaderFrom)", assertReaderFrom},
430 + {"http.ResponseController.Flush", ctrlFlush},
431 + {"http.ResponseController.Hijack", ctrlHijack},
432 + } {
433 + if !tc.got {
434 + t.Errorf("%s failed on the buffered writer but succeeds on the real writer", tc.name)
435 + }
436 + }
437 + // Flush during buffering must be suppressed — it must NOT reach the real writer.
438 + if cw.flushed {
439 + t.Error("a provider's Flush leaked to the real writer during buffering")
440 + }
441 +}
442 +
443 +// A provider that writes an enormous body must not be buffered without bound.
444 +func TestBufferedResponseIsSizeCapped(t *testing.T) {
445 + huge := 4 * maxBufferedAuthResponse
446 + flood := writingAuthenticator{write: func(w http.ResponseWriter) {
447 + n, err := w.Write(make([]byte, huge))
448 + if err != nil || n != huge {
449 + t.Errorf("provider write short/errored: n=%d err=%v", n, err)
450 + }
451 + }}
452 + succeeder := writingAuthenticator{authed: true}
453 + bw := newBufferedResponseWriter(httptest.NewRecorder())
454 + // drive the flood provider through a buffered writer directly to inspect the cap
455 + flood.Authenticate(bw, httptest.NewRequest(http.MethodGet, "/", nil))
456 + if bw.buf.Len() > maxBufferedAuthResponse {
457 + t.Fatalf("buffered %d bytes, exceeds cap %d", bw.buf.Len(), maxBufferedAuthResponse)
458 + }
459 + if !bw.overflowed {
460 + t.Error("expected overflow to be flagged when a provider exceeds the buffer cap")
461 + }
462 + _ = succeeder
463 +}
464 +
465 +// ReadFrom must honor the io.ReaderFrom contract even when the size cap is
466 +// exceeded: report the total bytes consumed from the source (retained and
467 +// drained alike) and propagate any error encountered while draining.
468 +func TestBufferedWriterReadFromReportsTotalAndDrainError(t *testing.T) {
469 + extra := int64(1234)
470 + total := int64(maxBufferedAuthResponse) + extra
471 + bw := newBufferedResponseWriter(httptest.NewRecorder())
472 + n, err := bw.ReadFrom(bytes.NewReader(make([]byte, total)))
473 + if err != nil {
474 + t.Fatalf("ReadFrom() error = %v", err)
475 + }
476 + if n != total {
477 + t.Errorf("ReadFrom() = %d, want total consumed %d", n, total)
478 + }
479 + if bw.buf.Len() != maxBufferedAuthResponse {
480 + t.Errorf("retained %d bytes, want cap %d", bw.buf.Len(), maxBufferedAuthResponse)
481 + }
482 + if !bw.overflowed {
483 + t.Error("expected overflow to be flagged when the source exceeds the buffer cap")
484 + }
485 +
486 + drainErr := errors.New("drain failure")
487 + bw = newBufferedResponseWriter(httptest.NewRecorder())
488 + n, err = bw.ReadFrom(io.MultiReader(
489 + bytes.NewReader(make([]byte, maxBufferedAuthResponse)),
490 + errorReader{err: drainErr},
491 + ))
492 + if !errors.Is(err, drainErr) {
493 + t.Errorf("ReadFrom() error = %v, want the drain error %v", err, drainErr)
494 + }
495 + if n != int64(maxBufferedAuthResponse) {
496 + t.Errorf("ReadFrom() = %d, want %d bytes consumed before the error", n, maxBufferedAuthResponse)
497 + }
498 +}
499 +
500 +type errorReader struct{ err error }
501 +
502 +func (er errorReader) Read([]byte) (int, error) { return 0, er.err }