From c8c812ff34e2deb31dec81bd7abec810d4b78dce Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 22 Jun 2026 11:00:58 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20clamp=20current=5Fage=20to=20>=3D0=20for?= =?UTF-8?q?=20future=20Date=20headers=20(RFC=209111=20=C2=A74.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- httpcache.go | 15 ++++++++++++--- httpcache_clockskew_test.go | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 httpcache_clockskew_test.go diff --git a/httpcache.go b/httpcache.go index 6819cc9..7ead586 100644 --- a/httpcache.go +++ b/httpcache.go @@ -1145,7 +1145,7 @@ func isActuallyStale(respHeaders http.Header) bool { return true // No date means we can't determine freshness, treat as stale } - currentAge := clock.since(date) + currentAge := clampedAge(date) lifetime := calculateLifetime(respCacheControl, respHeaders, date) // Check if stale-while-revalidate extends freshness @@ -1270,6 +1270,15 @@ type timer interface { var clock timer = &realClock{} +// clampedAge returns now - date clamped to >= 0, the max(0, ...) of apparent_age +// in RFC 9111 Section 4.2.3, so clock skew cannot produce a negative current_age. +func clampedAge(date time.Time) time.Duration { + if age := clock.since(date); age > 0 { + return age + } + return 0 +} + // getFreshness will return one of fresh/stale/transparent based on the cache-control // values of the request and the response // @@ -1294,7 +1303,7 @@ func getFreshness(respHeaders, reqHeaders http.Header) (freshness int) { if err != nil { return stale } - currentAge := clock.since(date) + currentAge := clampedAge(date) // Calculate response lifetime lifetime := calculateLifetime(respCacheControl, respHeaders, date) @@ -1350,7 +1359,7 @@ func checkStaleIfErrorLifetime(respHeaders http.Header, lifetime time.Duration) if err != nil { return false } - currentAge := clock.since(date) + currentAge := clampedAge(date) return lifetime > currentAge } diff --git a/httpcache_clockskew_test.go b/httpcache_clockskew_test.go new file mode 100644 index 0000000..dd5194e --- /dev/null +++ b/httpcache_clockskew_test.go @@ -0,0 +1,21 @@ +package httpcache + +import ( + "net/http" + "testing" + "time" +) + +// TestFreshnessFutureDateClockSkew verifies a future Date header (clock skew) on a response +// with no freshness info is stale: RFC 9111 §4.2.3 clamps apparent_age to max(0, ...). +func TestFreshnessFutureDateClockSkew(t *testing.T) { + resetTest() + + respHeaders := http.Header{} + // Date 2s in the future, no Cache-Control and no Expires (lifetime 0). + respHeaders.Set("Date", time.Now().Add(2*time.Second).UTC().Format(time.RFC1123)) + + if got := getFreshness(respHeaders, http.Header{}); got == fresh { + t.Fatalf("future Date with no freshness info treated as fresh; want stale (RFC 9111 §4.2.3); got %s", freshnessString(got)) + } +}