You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@commons.apache.org by gg...@apache.org on 2026/05/18 16:38:22 UTC

(commons-lang) branch master updated: DateUtils.parseDateWithLeniency(String, Locale, String[], boolean) doesn't reset its time zone (#1660)

This is an automated email from the ASF dual-hosted git repository.

garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git


The following commit(s) were added to refs/heads/master by this push:
     new 526a03a87 DateUtils.parseDateWithLeniency(String, Locale, String[], boolean) doesn't reset its time zone (#1660)
526a03a87 is described below

commit 526a03a8701f85e422786f0a4d1fadd3f0c249c1
Author: Gary Gregory <ga...@users.noreply.github.com>
AuthorDate: Mon May 18 12:38:16 2026 -0400

    DateUtils.parseDateWithLeniency(String, Locale, String[], boolean) doesn't reset its time zone (#1660)
    
    Calendar.clear() does not reset the time zone. A previous TZ-aware
    pattern (for example, "z", "zz", "Z", "X..") that partially parsed could
    have
    mutated the calendar's time zone via Calendar.setTimeZone(...) before
    failing on the remaining tokens. Restore the caller-supplied zone for
    each
    attempt so the outcome of pattern N+1 does not depend on the partial
    state left by pattern N.
---
 .../org/apache/commons/lang3/time/DateUtils.java   |  4 ++
 .../apache/commons/lang3/time/DateUtilsTest.java   | 53 ++++++++++++++++++++++
 2 files changed, 57 insertions(+)

diff --git a/src/main/java/org/apache/commons/lang3/time/DateUtils.java b/src/main/java/org/apache/commons/lang3/time/DateUtils.java
index cdae1d832..04a24c549 100644
--- a/src/main/java/org/apache/commons/lang3/time/DateUtils.java
+++ b/src/main/java/org/apache/commons/lang3/time/DateUtils.java
@@ -1350,6 +1350,10 @@ private static Date parseDateWithLeniency(final String dateStr, final Locale loc
         for (final String parsePattern : parsePatterns) {
             final FastDateParser fdp = new FastDateParser(parsePattern, tz, lcl);
             calendar.clear();
+            // Calendar.clear() does not reset the time zone. A previous TZ-aware pattern (for example, "z", "zz", "Z", "X..") that partially parsed could have
+            // mutated the calendar's time zone via Calendar.setTimeZone(...) before failing on the remaining tokens. Restore the caller-supplied zone for each
+            // attempt so the outcome of pattern N+1 does not depend on the partial state left by pattern N.
+            calendar.setTimeZone(tz);
             try {
                 if (fdp.parse(dateStr, pos, calendar) && pos.getIndex() == dateStr.length()) {
                     return calendar.getTime();
diff --git a/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java b/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java
index fb878645e..14bb9d9e5 100644
--- a/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java
@@ -1680,5 +1680,58 @@ void testWeekIterator() {
         }
     }
 
+    /**
+     * Failed date parse can poison the calendar's time zone for the next pattern.
+     *
+     * <p>
+     * {@link DateUtils#parseDateWithLeniency} reuses a single {@link Calendar} across pattern attempts. The reset between iterations is
+     * {@link Calendar#clear()}, which does NOT reset the calendar's time zone. A TZ-aware pattern (e.g. starts with {@code "X"}, {@code "Z"}, {@code "z"}) that
+     * successfully consumes its TZ token (mutating the calendar via {@link Calendar#setTimeZone}) before failing on later tokens leaves the calendar carrying
+     * the parsed (foreign) zone. The next pattern in the list then interprets its date fields against that zone, producing a wrong instant.
+     * </p>
+     *
+     * <p>
+     * <b>Construction:</b> input {@code "+0500 01/01/2024"} is parsed against two patterns:
+     * </p>
+     * <ol>
+     * <li>{@code "Z 'X' MM/dd/yyyy"}: the {@code Z} strategy consumes {@code "+0500"} and calls {@code calendar.setTimeZone(GMT+05:00)}; the next literal
+     * {@code 'X'} fails to match {@code " 01"}, so the overall parse returns false. The leaked TZ remains on the shared calendar.</li>
+     * <li>{@code "'+0500 'MM/dd/yyyy"}: a TZ-less pattern that absorbs the literal prefix and parses the date. With the bug, the calendar still carries
+     * GMT+05:00, so the date is interpreted as midnight in GMT+05:00 (5 hours earlier as an instant) than the same date interpreted in the system default
+     * zone.</li>
+     * </ol>
+     *
+     * <p>
+     * The assertion compares {@link Date#getTime()} against a baseline parse using only the second pattern. A naive PoC that only checked calendar fields
+     * (year/month/day) would not detect the bug because those equal in both runs.
+     * </p>
+     *
+     * <p>
+     * Pre-patch: the bug shifts the resulting instant by 5 hours.<br>
+     * Post-patch: {@code parseDateWithLeniency} restores {@code calendar.setTimeZone(TimeZone.getDefault())} before each iteration, so the leaked zone never
+     * reaches the second attempt and both instants match.
+     * </p>
+     */
+    @Test
+    public void testFailedTimeZonePatternDoesNotPoisonNextPatternInstant() throws Exception {
+        final String input = "+0500 01/01/2024";
+        // Baseline: a single TZ-less pattern; no leak source. The calendar uses
+        // TimeZone.getDefault(), so the result is midnight 2024-01-01 in the default zone.
+        final Date baseline = DateUtils.parseDate(input, "'+0500 'MM/dd/yyyy");
+        assertNotNull(baseline, "baseline parse must succeed");
+        // Poisoning sequence:
+        //   pattern 1 = "Z 'X' MM/dd/yyyy"  -> Z consumes "+0500" then setTimeZone(GMT+05:00),
+        //                                       'X' literal mismatches " 01", returns false.
+        //   pattern 2 = "'+0500 'MM/dd/yyyy"-> matches the whole input via literal prefix +
+        //                                       MM/dd/yyyy. With the bug, calendar still has
+        //                                       GMT+05:00 leaked from pattern 1, so the date
+        //                                       is interpreted in GMT+05:00 instead of default.
+        final Date poisoned = DateUtils.parseDate(input, "Z 'X' MM/dd/yyyy", "'+0500 'MM/dd/yyyy");
+        assertNotNull(poisoned, "poisoning sequence parse must succeed via the second pattern");
+        // Compare INSTANTS, not Y/M/D. Calendar fields equal in both runs by coincidence
+        // even when the bug shifts the underlying instant.
+        assertEquals(baseline.getTime(), poisoned.getTime(),
+                "Calendar TZ leaked from a failed prior pattern must not affect the instant produced by a subsequent TZ-less pattern");
+    }
 }