From fb3dfa027ccfdccfb900e62c1d86d94107802e32 Mon Sep 17 00:00:00 2001 From: Raphael Schweikert Date: Thu, 27 Aug 2026 12:27:29 +0200 Subject: [PATCH 1/3] Allow Request#getDateHeader to parse dates in RFC 9651 Structured Field Values format --- .../util/http/ConcurrentDateFormat.java | 7 +++++- .../tomcat/util/http/FastHttpDateFormat.java | 22 +++++++++++++++---- .../util/http/TestFastHttpDateFormat.java | 20 +++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/java/org/apache/tomcat/util/http/ConcurrentDateFormat.java b/java/org/apache/tomcat/util/http/ConcurrentDateFormat.java index ce38872686fc..6d28acfb34a2 100644 --- a/java/org/apache/tomcat/util/http/ConcurrentDateFormat.java +++ b/java/org/apache/tomcat/util/http/ConcurrentDateFormat.java @@ -28,7 +28,7 @@ * A thread safe wrapper around {@link SimpleDateFormat} that does not make use of ThreadLocal and - broadly - only * creates enough SimpleDateFormat objects to satisfy the concurrency requirements. */ -public class ConcurrentDateFormat { +public class ConcurrentDateFormat implements FastHttpDateFormat.TryParseDateToTimestamp { private final String format; private final Locale locale; @@ -88,6 +88,11 @@ public Date parse(String source) throws ParseException { } } + @Override + public long tryParseDate(String dateString) throws ParseException { + return parse(dateString).getTime(); + } + private SimpleDateFormat createInstance() { SimpleDateFormat sdf = new SimpleDateFormat(format, locale); sdf.setTimeZone(timezone); diff --git a/java/org/apache/tomcat/util/http/FastHttpDateFormat.java b/java/org/apache/tomcat/util/http/FastHttpDateFormat.java index 3abf32b67d30..07957a38b5ab 100644 --- a/java/org/apache/tomcat/util/http/FastHttpDateFormat.java +++ b/java/org/apache/tomcat/util/http/FastHttpDateFormat.java @@ -50,8 +50,9 @@ private FastHttpDateFormat() { private static final ConcurrentDateFormat FORMAT_RFC5322; private static final ConcurrentDateFormat FORMAT_OBSOLETE_RFC850; private static final ConcurrentDateFormat FORMAT_OBSOLETE_ASCTIME; + private static final TryParseDateToTimestamp FORMAT_RFC9651; - private static final ConcurrentDateFormat[] httpParseFormats; + private static final TryParseDateToTimestamp[] httpParseFormats; static { // All the formats that use a timezone use GMT @@ -60,9 +61,19 @@ private FastHttpDateFormat() { FORMAT_RFC5322 = new ConcurrentDateFormat(DATE_RFC5322, Locale.US, tz); FORMAT_OBSOLETE_RFC850 = new ConcurrentDateFormat(DATE_OBSOLETE_RFC850, Locale.US, tz); FORMAT_OBSOLETE_ASCTIME = new ConcurrentDateFormat(DATE_OBSOLETE_ASCTIME, Locale.US, tz); + FORMAT_RFC9651 = dateString -> { + if(dateString == null || !dateString.startsWith("@")) { + throw new ParseException("Date " + dateString + " not in RFC 9651 format", 0); + } + try { + return Long.parseLong(dateString.substring(1)); + } catch (NumberFormatException e) { + throw new ParseException("Unable to parse number in date string " + dateString, 1); + } + }; httpParseFormats = - new ConcurrentDateFormat[] { FORMAT_RFC5322, FORMAT_OBSOLETE_RFC850, FORMAT_OBSOLETE_ASCTIME }; + new TryParseDateToTimestamp[] { FORMAT_RFC5322, FORMAT_OBSOLETE_RFC850, FORMAT_OBSOLETE_ASCTIME, FORMAT_RFC9651 }; } /** @@ -147,7 +158,7 @@ public static long parseDate(String value) { long date = -1; for (int i = 0; (date == -1) && (i < httpParseFormats.length); i++) { try { - date = httpParseFormats[i].parse(value).getTime(); + date = httpParseFormats[i].tryParseDate(value); updateParseCache(value, Long.valueOf(date)); } catch (ParseException e) { // Ignore @@ -185,5 +196,8 @@ private static void updateParseCache(String key, Long value) { parseCache.put(key, value); } - + @FunctionalInterface + interface TryParseDateToTimestamp { + long tryParseDate(String dateString) throws ParseException; + } } diff --git a/test/org/apache/tomcat/util/http/TestFastHttpDateFormat.java b/test/org/apache/tomcat/util/http/TestFastHttpDateFormat.java index 20e771ef1ea9..219e2f341b3b 100644 --- a/test/org/apache/tomcat/util/http/TestFastHttpDateFormat.java +++ b/test/org/apache/tomcat/util/http/TestFastHttpDateFormat.java @@ -1,3 +1,4 @@ + /* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with @@ -72,4 +73,23 @@ public void testGetCurrentDate() { Assert.assertTrue("Saw [" + changes + "] changes in formatted date", changes > 1 && changes < 5); } } + + @Test + public void testRfc9651Date_valid() { + Assert.assertEquals(0L, FastHttpDateFormat.parseDate("@0")); + Assert.assertEquals(1659578233L, FastHttpDateFormat.parseDate("@1659578233")); // Example from RFC + Assert.assertEquals(-62135596800L, FastHttpDateFormat.parseDate("@-62135596800")); // Example from RFC + Assert.assertEquals(253402214400L, FastHttpDateFormat.parseDate("@253402214400")); // Example from RFC + } + + @Test + public void testRfc9651Date_invalid() { + Assert.assertEquals(-1L, FastHttpDateFormat.parseDate("@")); + Assert.assertEquals(-1L, FastHttpDateFormat.parseDate("@-1")); // Technically valid but we use -1 as a sentinel + Assert.assertEquals(-1L, FastHttpDateFormat.parseDate(" @1")); + Assert.assertEquals(-1L, FastHttpDateFormat.parseDate("@0000-")); + Assert.assertEquals(-1L, FastHttpDateFormat.parseDate("@15+")); + Assert.assertEquals(-1L, FastHttpDateFormat.parseDate("@12p")); + Assert.assertEquals(-1L, FastHttpDateFormat.parseDate("@0x15")); + } } From ac5e2c31d2b5036451f64861e7d8b9911c0f28f3 Mon Sep 17 00:00:00 2001 From: Raphael Schweikert Date: Thu, 27 Aug 2026 14:45:26 +0200 Subject: [PATCH 2/3] Correctly interpret the structured field as epoch in seconds, not milliseconds --- .../apache/tomcat/util/http/FastHttpDateFormat.java | 10 +++++++++- .../tomcat/util/http/TestFastHttpDateFormat.java | 10 ++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/java/org/apache/tomcat/util/http/FastHttpDateFormat.java b/java/org/apache/tomcat/util/http/FastHttpDateFormat.java index 07957a38b5ab..fd94e843f833 100644 --- a/java/org/apache/tomcat/util/http/FastHttpDateFormat.java +++ b/java/org/apache/tomcat/util/http/FastHttpDateFormat.java @@ -66,7 +66,8 @@ private FastHttpDateFormat() { throw new ParseException("Date " + dateString + " not in RFC 9651 format", 0); } try { - return Long.parseLong(dateString.substring(1)); + // An RFC 9651 timestamp is in seconds, not milliseconds. + return Long.parseLong(dateString.substring(1)) * 1_000; } catch (NumberFormatException e) { throw new ParseException("Unable to parse number in date string " + dateString, 1); } @@ -198,6 +199,13 @@ private static void updateParseCache(String key, Long value) { @FunctionalInterface interface TryParseDateToTimestamp { + /** + * Tries to parse the given string as a date and returns it as a timestamp. + * + * @param dateString the string representation of the date trying to be parsed + * @return the number of *milli*seconds since January 1, 1970, 00:00:00 GMT + * @throws ParseException if dateString cannot be parsed + */ long tryParseDate(String dateString) throws ParseException; } } diff --git a/test/org/apache/tomcat/util/http/TestFastHttpDateFormat.java b/test/org/apache/tomcat/util/http/TestFastHttpDateFormat.java index 219e2f341b3b..395405f63457 100644 --- a/test/org/apache/tomcat/util/http/TestFastHttpDateFormat.java +++ b/test/org/apache/tomcat/util/http/TestFastHttpDateFormat.java @@ -77,16 +77,18 @@ public void testGetCurrentDate() { @Test public void testRfc9651Date_valid() { Assert.assertEquals(0L, FastHttpDateFormat.parseDate("@0")); - Assert.assertEquals(1659578233L, FastHttpDateFormat.parseDate("@1659578233")); // Example from RFC - Assert.assertEquals(-62135596800L, FastHttpDateFormat.parseDate("@-62135596800")); // Example from RFC - Assert.assertEquals(253402214400L, FastHttpDateFormat.parseDate("@253402214400")); // Example from RFC + Assert.assertEquals(1000L, FastHttpDateFormat.parseDate("@1")); + Assert.assertEquals(-1000L, FastHttpDateFormat.parseDate("@-1")); + Assert.assertEquals(1659578233000L, FastHttpDateFormat.parseDate("@1659578233")); // Example from RFC + Assert.assertEquals(-62135596800000L, FastHttpDateFormat.parseDate("@-62135596800")); // Example from RFC + Assert.assertEquals(253402214400000L, FastHttpDateFormat.parseDate("@253402214400")); // Example from RFC } @Test public void testRfc9651Date_invalid() { Assert.assertEquals(-1L, FastHttpDateFormat.parseDate("@")); - Assert.assertEquals(-1L, FastHttpDateFormat.parseDate("@-1")); // Technically valid but we use -1 as a sentinel Assert.assertEquals(-1L, FastHttpDateFormat.parseDate(" @1")); + Assert.assertEquals(-1L, FastHttpDateFormat.parseDate("@ 1")); Assert.assertEquals(-1L, FastHttpDateFormat.parseDate("@0000-")); Assert.assertEquals(-1L, FastHttpDateFormat.parseDate("@15+")); Assert.assertEquals(-1L, FastHttpDateFormat.parseDate("@12p")); From eac28ab2932a2d38802cc497a9d0b27cb01b5d06 Mon Sep 17 00:00:00 2001 From: Raphael Schweikert Date: Thu, 27 Aug 2026 14:48:58 +0200 Subject: [PATCH 3/3] =?UTF-8?q?Don=E2=80=99t=20use=20exception=20handling?= =?UTF-8?q?=20for=20control=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tomcat/util/http/ConcurrentDateFormat.java | 8 ++++++-- .../tomcat/util/http/FastHttpDateFormat.java | 17 ++++++----------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/java/org/apache/tomcat/util/http/ConcurrentDateFormat.java b/java/org/apache/tomcat/util/http/ConcurrentDateFormat.java index 6d28acfb34a2..9fbb5df4ab7e 100644 --- a/java/org/apache/tomcat/util/http/ConcurrentDateFormat.java +++ b/java/org/apache/tomcat/util/http/ConcurrentDateFormat.java @@ -89,8 +89,12 @@ public Date parse(String source) throws ParseException { } @Override - public long tryParseDate(String dateString) throws ParseException { - return parse(dateString).getTime(); + public long tryParseDate(String dateString) { + try { + return parse(dateString).getTime(); + } catch (ParseException e) { + return -1; + } } private SimpleDateFormat createInstance() { diff --git a/java/org/apache/tomcat/util/http/FastHttpDateFormat.java b/java/org/apache/tomcat/util/http/FastHttpDateFormat.java index fd94e843f833..f747c600887e 100644 --- a/java/org/apache/tomcat/util/http/FastHttpDateFormat.java +++ b/java/org/apache/tomcat/util/http/FastHttpDateFormat.java @@ -63,13 +63,13 @@ private FastHttpDateFormat() { FORMAT_OBSOLETE_ASCTIME = new ConcurrentDateFormat(DATE_OBSOLETE_ASCTIME, Locale.US, tz); FORMAT_RFC9651 = dateString -> { if(dateString == null || !dateString.startsWith("@")) { - throw new ParseException("Date " + dateString + " not in RFC 9651 format", 0); + return -1; } try { // An RFC 9651 timestamp is in seconds, not milliseconds. return Long.parseLong(dateString.substring(1)) * 1_000; } catch (NumberFormatException e) { - throw new ParseException("Unable to parse number in date string " + dateString, 1); + return -1; } }; @@ -158,13 +158,9 @@ public static long parseDate(String value) { long date = -1; for (int i = 0; (date == -1) && (i < httpParseFormats.length); i++) { - try { - date = httpParseFormats[i].tryParseDate(value); - updateParseCache(value, Long.valueOf(date)); - } catch (ParseException e) { - // Ignore - } + date = httpParseFormats[i].tryParseDate(value); } + updateParseCache(value, Long.valueOf(date)); return date; } @@ -203,9 +199,8 @@ interface TryParseDateToTimestamp { * Tries to parse the given string as a date and returns it as a timestamp. * * @param dateString the string representation of the date trying to be parsed - * @return the number of *milli*seconds since January 1, 1970, 00:00:00 GMT - * @throws ParseException if dateString cannot be parsed + * @return the number of *milli*seconds since January 1, 1970, 00:00:00 GMT or -1 if parsing failed */ - long tryParseDate(String dateString) throws ParseException; + long tryParseDate(String dateString); } }