From 130b0ec50cd7cc0f89aa035d21086a8b66fa3e13 Mon Sep 17 00:00:00 2001 From: Raphael Schweikert Date: Tue, 1 Sep 2026 13:02:13 +0200 Subject: [PATCH] Parse RFC 9651-like date headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9651 specifies @«timestamp» as a new format for date headers. The Deprecation header as specified in RFC 9745, for example, makes use of it. Make sure this can be parsed using `HttpHeaders#getFirstDate` and `HttpHeaders#getFirstZonedDateTime` Signed-off-by: Raphael Schweikert --- .../org/springframework/http/HttpHeaders.java | 8 +++-- .../http/HttpHeadersTests.java | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java index bcedd663b45..5b11c127cc9 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java @@ -29,7 +29,9 @@ import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoField; import java.util.AbstractSet; import java.util.ArrayList; import java.util.Base64; @@ -435,7 +437,9 @@ public class HttpHeaders implements Serializable { private static final DateTimeFormatter[] DATE_PARSERS = new DateTimeFormatter[] { DateTimeFormatter.RFC_1123_DATE_TIME, DateTimeFormatter.ofPattern("EEEE, dd-MMM-yy HH:mm:ss zzz", Locale.US), - DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss yyyy", Locale.US).withZone(GMT) + DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss yyyy", Locale.US).withZone(GMT), + // RFC 9651: Structured Field Values for HTTP + new DateTimeFormatterBuilder().appendLiteral('@').appendValue(ChronoField.INSTANT_SECONDS).toFormatter(Locale.US).withZone(GMT) }; @@ -1621,7 +1625,7 @@ public class HttpHeaders implements Serializable { // No header value sent at all return null; } - if (headerValue.length() >= 3) { + if (headerValue.length() >= 3 || headerValue.startsWith("@")) { // Short "0" or "-1" like values are never valid HTTP date headers... // Let's only bother with DateTimeFormatter parsing for long enough values. diff --git a/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java b/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java index 8970cbfc049..112c17fbed0 100644 --- a/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java +++ b/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java @@ -36,6 +36,9 @@ import java.util.TimeZone; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; import static java.util.stream.Collectors.toList; import static org.assertj.core.api.Assertions.assertThat; @@ -654,6 +657,36 @@ class HttpHeadersTests { assertThat(authorization).isEqualTo("Bearer foo"); } + @ParameterizedTest + @CsvSource(value = { + "@0,0", + "@1,1000", + "@10,10000", + "@-1,-1000", + "@1659578233,1659578233000", // Example from RFC + "@-62135596800,-62135596800000", // Example from RFC + "@253402214400,253402214400000", // Example from RFC + }) + void rfc9651Dates_valid(String value, long timestampMillis) { + headers.set("Deprecation", value); + assertThat(headers.getFirstDate("Deprecation")).isEqualTo(timestampMillis); + } + + @ParameterizedTest + @ValueSource(strings = { + "@", + " @1", + "@ 1", + "@0000-", + "@15+", + "@12p", + "@0x15", + }) + void rfc9651Dates_invalid(String value) { + headers.set("Deprecation", value); + assertThatIllegalArgumentException().isThrownBy(() -> headers.getFirstDate("Deprecation")); + } + @Nested class MapEntriesTests {