fix(providers): cap Retry-After in googlebooks/hardcover (bookshelf-0sha) #569
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-0sha"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
maxRetryAfterWait = 60 * time.Secondconst to bothgooglebooksandhardcovermetadata providers (matching the existingaudnexuscap from #530/bookshelf-3g0l)parseRetryAfterretry_after_clamped=trueat the call site (Warn level) when the server-supplied header exceeds the cap, per logging-standardTest plan
make buildgreenmake testgreen (all packages)make coveragegreen — 100% on both changed packagesgolangci-lint runclean on changed packagesParseRetryAfterunit tests cover delay-seconds cap, HTTP-date cap, within-cap, past date, empty, negativeCloses bead bookshelf-0sha on merge.
CODE REVIEW: APPROVED
Phase 0: DEMO Verification
No DEMO block is expected for this resilience fix — the feature is verified through tests (CI is green). Proceeding directly to spec and code review.
Phase 1: Spec Compliance
Bead description: cap
Retry-Afterat 60s in googlebooks + hardcover providers, clamping both delay-seconds and HTTP-date forms, with a clamp Warn log. 100% coverage on both packages.All requirements are met:
maxRetryAfterWait = 60 * time.Secondconstant added to bothgooglebooks/provider.goandhardcover/provider.goparseRetryAfterretry_after_clamped=trueWarn log arg appended at call site when clampedPhase 2: Code Quality
Security spot-check — hostile header values:
1e300):secs * float64(time.Second)overflowsfloat64to+Inf;time.Duration(+Inf)yieldsmath.MaxInt64(Go's defined behavior for float→int conversion overflow).math.MaxInt64 > maxRetryAfterWaitis true, so the clamp fires correctly. No panic, no negative wait.secs <= 0branch returns 0 immediately. Safe.time.Until(t)yields a large positive duration, caught by the> maxRetryAfterWaitcheck. Safe.d <= 0returns 0. Safe.strconv.ParseFloatandhttp.ParseTimefail, returns 0. Safe.No secrets logged.
redactKeyon the URL is retained from the existing code.Findings:
[MINOR] internal/metadata/googlebooks/provider.go:305 —
retry_after_clampedlog flag fires as a false positive for exactly-60 second header valuesThe condition
retryAfter == maxRetryAfterWait && rawRetryAfter != ""is true both when a value was clamped (e.g."9999") AND when the server sends exactly"60"(the cap boundary). In the latter case nothing was clamped but the log entry says it was. The correct approach isretryAfter == maxRetryAfterWait && parseRetryAfterRaw(rawRetryAfter) > maxRetryAfterWait, or comparing the raw parsed value to the cap before clamping. Low operational impact since 60s is an unlikely real-world value, but the log is inaccurate. Same issue inhardcover/provider.go:478.[MINOR] internal/metadata/googlebooks/ratelimit_test.go — no test for
retry_after_clampedlog field being set/unset at the integration levelThe unit tests for
parseRetryAftercover the clamp, and the ratelimit integration tests verify the clamped sleep duration, but no test checks thatretry_after_clamped=trueappears in the log for an over-cap value and is absent for a within-cap value. This is a minor test-coverage gap on the observability path (not a correctness gap since the log write path is trivial). Same gap in hardcover tests.Conventions:
var (...)grouping at top of function: not applicable here (no new multi-var blocks added).parseRetryAfterimplementations are well within limits (<30 lines, nesting <4).REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Security Review: bookshelf-0sha — Retry-After cap (googlebooks + hardcover)
Scope
Reviewed the diff for PR #569 (
bd-bookshelf-0sha):parseRetryAfterin bothinternal/metadata/googlebooks/provider.goandinternal/metadata/hardcover/provider.gonow clamps server-supplied Retry-After values at 60 s; both delay-seconds and HTTP-date forms are clamped; aretry_after_clampedfield is appended to the Warn log when the cap fires.Float → Duration Overflow Analysis (delay-seconds path)
The conversion
time.Duration(secs * float64(time.Second))is the critical path. Go spec says converting a float to int when the value is outside the representable range is implementation-dependent; on amd64/arm64 (CVTTSS2SI/FCVTZS) +Inf converts tomath.MinInt64(most-negative int64).For a hostile
Retry-After: 1e300:secs = 1e300 > 0— thesecs <= 0guard passessecs * float64(time.Second)overflows float64 to+Inftime.Duration(+Inf)wraps tomath.MinInt64(negative)d > maxRetryAfterWaitis false (negative < 60 s) — cap check bypassedHowever, the call site guard
if retryAfter > 0 { retryAfterSleep(ctx, retryAfter) }prevents any sleep on a negative value. Net effect: an overflow value is silently treated as "no Retry-After header" (no sleep) rather than being clamped. This is safer than pre-patch (which would have produced a massive sleep) and does not cause a DoS. No panic, no negative-argument sleep. The security goal — bounding the maximum sleep — is achieved for the realistic hostile range (values up to ~9.2×10⁹ seconds, far past any plausible rate-limit signal).Not a blocker. Minor semantic gap: a Retry-After of
1e300is skipped rather than capped. A hardening fix (if secs > float64(maxRetryAfterWait)/float64(time.Second) { return maxRetryAfterWait }before the Duration conversion) would make intent explicit, but the security property holds either way.Findings
[MINOR] internal/metadata/googlebooks/provider.go:440 — float→int64 overflow for astronomically large Retry-After values (> ~9.2×10⁹ s) silently bypasses the cap
The
secs <= 0guard runs before the cap guard. A value like1e300passes thesecs <= 0check, thensecs * float64(time.Second)overflows to+Inf, which wraps tomath.MinInt64on conversion totime.Duration. Thed > maxRetryAfterWaitcheck is thenfalseand the cap is not applied. The call-siteif retryAfter > 0guard prevents any sleep, so the result is "skip Retry-After" rather than "cap at 60 s." No DoS or panic; the security property holds for any realistic hostile value. Suggested hardening: addif secs > float64(maxRetryAfterWait)/float64(time.Second) { return maxRetryAfterWait }immediately afterif secs <= 0. Same applies tointernal/metadata/hardcover/provider.go:658.[MINOR] internal/metadata/googlebooks/provider.go:305 —
retry_after_clamped: trueis a false positive when server sends exactly 60 sCondition is
retryAfter == maxRetryAfterWait && rawRetryAfter != "". A legitimateRetry-After: 60parses to exactlymaxRetryAfterWaitwithout being clamped, yet the log recordsretry_after_clamped=true. This is a misleading observability signal, not a security issue. Fix: change the condition toretryAfter == maxRetryAfterWait && rawRetryAfter != "" && parsedSecsOrDateExceededCap(), or compare the raw seconds to the cap before conversion. Same issue atinternal/metadata/hardcover/provider.go:478.No Issues Found
redactKey(reqURL)is applied consistently to all Google Books log sites (lines 278, 303, 323, 339, 347, 364). Hardcover passes the API key viaAuthorization: Bearerheader (not URL), and the 429 log path logs onlyqueryKey(a sanitized description fromqueryDescription, not the key) andrawRetryAfter— no credential exposure.rawRetryAfteris a header value logged as a structured field (not interpolated into a string), so no log-injection risk.time.Until(t)naturally returns negative for past dates (already guarded byd <= 0); future dates get clamped by the newd > maxRetryAfterWaitcheck before return.maxRetries = 3; the cap only reduces the sleep duration.Retry-After: 9999would sleep ~2.7 hours per attempt × 3 = ~8 hours pinning a goroutine. Post-patch: capped at 60 s per attempt.REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Security Review: bookshelf-0sha — Retry-After cap (googlebooks + hardcover)
Scope
Reviewed the diff for PR #569 (
bd-bookshelf-0sha):parseRetryAfterin bothinternal/metadata/googlebooks/provider.goandinternal/metadata/hardcover/provider.gonow clamps server-supplied Retry-After values at 60 s; both delay-seconds and HTTP-date forms are clamped; aretry_after_clampedfield is appended to the Warn log when the cap fires.Float to Duration Overflow Analysis (delay-seconds path)
The conversion
time.Duration(secs * float64(time.Second))is the critical path. Go spec says converting a float to int when the value is outside the representable range is implementation-dependent; on amd64/arm64 +Inf converts to math.MinInt64 (most-negative int64).For a hostile
Retry-After: 1e300:secs = 1e300 > 0— thesecs <= 0guard passessecs * float64(time.Second)overflows float64 to +Inftime.Duration(+Inf)wraps to math.MinInt64 (negative)d > maxRetryAfterWaitis false (negative < 60 s) — cap check bypassed, returns negativeThe call-site guard
if retryAfter > 0 { retryAfterSleep(ctx, retryAfter) }prevents any sleep on a negative value. Net effect: an overflow value is silently treated as "no Retry-After header" (no sleep) rather than being clamped. This is safer than pre-patch (which would have produced a massive sleep) and does not cause a DoS. No panic. The security goal is achieved for the realistic hostile range.Findings
[MINOR] internal/metadata/googlebooks/provider.go:440 — float->int64 overflow for extremely large Retry-After values silently bypasses the cap
The
secs <= 0guard runs before the cap guard. A value like1e300passessecs <= 0, thensecs * float64(time.Second)overflows to +Inf, which wraps to math.MinInt64 on conversion totime.Duration. Thed > maxRetryAfterWaitcheck is then false and the cap is not applied. The call-siteif retryAfter > 0guard prevents any sleep, so the result is "skip Retry-After" rather than "cap at 60 s." No DoS or panic; security property holds for any realistic hostile value. Suggested hardening: addif secs > float64(maxRetryAfterWait)/float64(time.Second) { return maxRetryAfterWait }immediately afterif secs <= 0. Same applies to internal/metadata/hardcover/provider.go:658.[MINOR] internal/metadata/googlebooks/provider.go:305 —
retry_after_clamped: trueis a false positive when server sends exactly 60 sCondition is
retryAfter == maxRetryAfterWait && rawRetryAfter != "". A legitimateRetry-After: 60parses to exactlymaxRetryAfterWaitwithout being clamped, yet the log recordsretry_after_clamped=true. Misleading observability signal, not a security issue. Same at internal/metadata/hardcover/provider.go:478.No Issues Found
redactKey(reqURL)applied consistently to all Google Books log sites. Hardcover passes the API key via Authorization header (not URL); the 429 log path logs onlyqueryKey(sanitized description) andrawRetryAfter. No credential exposure.rawRetryAfteris logged as a structured field, not interpolated into a string. No log-injection risk.time.Until(t)naturally returns negative for past dates (guarded byd <= 0); future dates get the newd > maxRetryAfterWaitclamp.maxRetries = 3; cap only reduces sleep duration.Retry-After: 9999slept ~2.7 hours per attempt x 3 attempts. Post-patch: capped at 60 s per attempt.REVIEW VERDICT: 0 blocker, 0 major, 2 minor
a06665fbd3130e7e3cbe