From: CPANSec Security Scanner Bot <cpan-security@security.metacpan.org>
Subject: [PATCH] Date::Manip: restrict numeric date fields to ASCII digits

CVE-2026-60074.  The parse regexes capture year, month and day with the
\d shorthand, which on a character string matches the whole Unicode
decimal digit property \p{Nd} and not just [0-9].
Date::Manip::Base::check validates the captured fields with numeric
comparisons alone, and _parse_check stores the numified fields, so a
field whose leading characters are ASCII digits numifies to an in-range
prefix and satisfies every test.  With U+0664 ARABIC-INDIC DIGIT FOUR as
the final character of the year, ParseDate("202\x{664}-03-08") returns
0202030800:00:00; a non-ASCII digit in the month or day field shifts
those fields the same way ("2026-1\x{662}-08" returns January,
"2026-03-1\x{665}" returns the 1st), and one in a fractional minute
field drops the fraction.

Fix: spell every numeric field capture in the date and time parsers as
[0-9] rather than \d, so a field holding a non-ASCII digit fails to
match and the string does not parse.  Both validators are hardened as
well, since Date::Manip::Date's set() passes caller supplied values
straight to them: check() requires the year, month and day to be ASCII
digit strings before range testing them, and check_time() uses [0-9] in
its format test.

Every date string in a 42 entry corpus of legitimate formats (ISO 8601
basic and extended, ordinal and week dates, m/d/y, RFC 2822 with a
numeric offset, ctime, named months and zones, "1st Sunday in March
2026", "in 3 days", "noon", 24:00 times, fractional hours and minutes)
parses to the same value before and after.

diff --git a/lib/Date/Manip/Base.pm b/lib/Date/Manip/Base.pm
index 4195056..5de1637 100644
--- a/lib/Date/Manip/Base.pm
+++ b/lib/Date/Manip/Base.pm
@@ -603,6 +603,15 @@ sub check {
    my($self,$date) = @_;
    my($y,$m,$d,$h,$mn,$s) = @$date;
 
+   # The range tests below numify each field, and numifying truncates a
+   # string at the first character which is not an ASCII digit, so a
+   # field such as "202\x{664}" would pass them as 202.  Require ASCII
+   # digits.
+
+   foreach my $val ($y,$m,$d) {
+      return 0  if (! defined($val)  ||  $val !~ /^[0-9]+$/);
+   }
+
    return 0  if (! $self->check_time([$h,$mn,$s])  ||
                  $y<1  ||  $y>9999  ||
                  $m<1  ||  $m>12);
@@ -617,7 +626,7 @@ sub check_time {
    my($self,$hms) = @_;
    my($h,$mn,$s) = @$hms;
 
-   return 0  if ("$h:$mn:$s" !~ /^\d\d?:\d\d?:\d\d?$/o  ||
+   return 0  if ("$h:$mn:$s" !~ /^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}$/o  ||
                  $h > 24  ||  $mn > 59  ||  $s > 59  ||
                  ($h == 24  &&  ($mn  ||  $s)));
    return 1;
diff --git a/lib/Date/Manip/Date.pm b/lib/Date/Manip/Date.pm
index 7699921..ead5e11 100644
--- a/lib/Date/Manip/Date.pm
+++ b/lib/Date/Manip/Date.pm
@@ -837,16 +837,16 @@ BEGIN {
          ###
 
          if ($f eq 'Y') {
-            $re .= '(?<y>\d\d\d\d)';
+            $re .= '(?<y>[0-9]{4})';
 
          } elsif ($f eq 'y') {
-            $re .= '(?<y>\d\d)';
+            $re .= '(?<y>[0-9]{2})';
 
          } elsif ($f eq 'm') {
-            $re .= '(?<m>\d\d)';
+            $re .= '(?<m>[0-9]{2})';
 
          } elsif ($f eq 'f') {
-            $re .= '(?:(?<m>\d\d)| ?(?<m>\d))';
+            $re .= '(?:(?<m>[0-9]{2})| ?(?<m>[0-9]))';
 
          } elsif (exists $mon_form{$f}) {
             my $abb = $$dmb{'data'}{'rx'}{'month_abb'}[0];
@@ -854,13 +854,13 @@ BEGIN {
             $re .= "(?:(?<mon_name>$nam)|(?<mon_abb>$abb))";
 
          } elsif ($f eq 'j') {
-            $re .= '(?<doy>\d\d\d)';
+            $re .= '(?<doy>[0-9]{3})';
 
          } elsif ($f eq 'd') {
-            $re .= '(?<d>\d\d)';
+            $re .= '(?<d>[0-9]{2})';
 
          } elsif ($f eq 'e') {
-            $re .= '(?:(?<d>\d\d)| ?(?<d>\d))';
+            $re .= '(?:(?<d>[0-9]{2})| ?(?<d>[0-9]))';
 
          } elsif (exists $day_form{$f}) {
             my $abb  = $$dmb{'data'}{'rx'}{'day_abb'}[0];
@@ -876,41 +876,41 @@ BEGIN {
             $re .= "(?<nth>$nth)"
 
          } elsif ($f eq 'H'  ||  $f eq 'I') {
-            $re .= '(?<h>\d\d)';
+            $re .= '(?<h>[0-9]{2})';
 
          } elsif ($f eq 'k'  ||  $f eq 'i') {
-            $re .= '(?:(?<h>\d\d)| ?(?<h>\d))';
+            $re .= '(?:(?<h>[0-9]{2})| ?(?<h>[0-9]))';
 
          } elsif ($f eq 'p') {
             my $ampm = $$dmb{data}{rx}{ampm}[0];
             $re .= "(?<ampm>$ampm)";
 
          } elsif ($f eq 'M') {
-            $re .= '(?<mn>\d\d)';
+            $re .= '(?<mn>[0-9]{2})';
 
          } elsif ($f eq 'S') {
-            $re .= '(?<s>\d\d)';
+            $re .= '(?<s>[0-9]{2})';
 
          } elsif (exists $z_form{$f}) {
             $re .= $dmt->_zrx('zrx');
 
          } elsif ($f eq 's') {
-            $re .= '(?<epochs>\d+)';
+            $re .= '(?<epochs>[0-9]+)';
 
          } elsif ($f eq 'o') {
-            $re .= '(?<epocho>\d+)';
+            $re .= '(?<epocho>[0-9]+)';
 
          } elsif ($f eq 'G') {
-            $re .= '(?<g>\d\d\d\d)';
+            $re .= '(?<g>[0-9]{4})';
 
          } elsif ($f eq 'W') {
-            $re .= '(?<w>\d\d)';
+            $re .= '(?<w>[0-9]{2})';
 
          } elsif ($f eq 'L') {
-            $re .= '(?<l>\d\d\d\d)';
+            $re .= '(?<l>[0-9]{4})';
 
          } elsif ($f eq 'U') {
-            $re .= '(?<u>\d\d)';
+            $re .= '(?<u>[0-9]{2})';
 
          } elsif ($f eq 'c') {
             $format = '%a %b %e %H:%M:%S %Y' . $format;
@@ -1168,15 +1168,15 @@ sub _iso8601_rx {
 
    if ($rx eq 'cdate'  ||  $rx eq 'tdate') {
 
-      my $y4  = '(?<y>\d\d\d\d)';
-      my $y2  = '(?<y>\d\d)';
+      my $y4  = '(?<y>[0-9]{4})';
+      my $y2  = '(?<y>[0-9]{2})';
       my $m   = '(?<m>0[1-9]|1[0-2])';
       my $d   = '(?<d>0[1-9]|[12][0-9]|3[01])';
       my $doy = '(?<doy>00[1-9]|0[1-9][0-9]|[1-2][0-9][0-9]|3[0-5][0-9]|36[0-6])';
       my $w   = '(?<w>0[1-9]|[1-4][0-9]|5[0-3])';
       my $dow = '(?<dow>[1-7])';
-      my $yod = '(?<yod>\d)';
-      my $cc  = '(?<c>\d\d)';
+      my $yod = '(?<yod>[0-9])';
+      my $cc  = '(?<c>[0-9]{2})';
 
       my @cdaterx =
         (
@@ -1238,9 +1238,9 @@ sub _iso8601_rx {
       my $h24b   = '(?<h24>24(?:00){0,2})';
       my $h      = '(?<h>[0-9])';
 
-      my $fh     = '(?:[\.,](?<fh>\d*))'; # fractional hours (keep)
-      my $fm     = '(?:[\.,](?<fm>\d*))'; # fractional seconds (keep)
-      my $fs     = '(?:[\.,]\d*)'; # fractional hours (discard)
+      my $fh     = '(?:[\.,](?<fh>[0-9]*))'; # fractional hours (keep)
+      my $fm     = '(?:[\.,](?<fm>[0-9]*))'; # fractional seconds (keep)
+      my $fs     = '(?:[\.,][0-9]*)'; # fractional hours (discard)
 
       my $zrx    = $dmt->_zrx('zrx');
 
@@ -1445,9 +1445,9 @@ sub _other_rx {
          $f1 = "[.,]";
          $f2 = "[.,:]";
       }
-      my $fh     = "(?:$f1(?<fh>\\d*))";  # fractional hours (keep)
-      my $fm     = "(?:$f1(?<fm>\\d*))";  # fractional minutes (keep)
-      my $fs     = "(?:$f2\\d*)";         # fractional seconds
+      my $fh     = "(?:$f1(?<fh>[0-9]*))";  # fractional hours (keep)
+      my $fm     = "(?:$f1(?<fm>[0-9]*))";  # fractional minutes (keep)
+      my $fs     = "(?:${f2}[0-9]*)";       # fractional seconds
 
       # AM/PM
 
@@ -1533,10 +1533,10 @@ sub _other_rx {
 
       # Do NOT replace <m> and <d> with a regular expression to
       # match 1-12 since the DateFormat config may reverse the two.
-      my $y4  = '(?<y>\d\d\d\d)';
-      my $y2  = '(?<y>\d\d)';
-      my $m   = '(?<m>\d\d?)';
-      my $d   = '(?<d>\d\d?)';
+      my $y4  = '(?<y>[0-9]{4})';
+      my $y2  = '(?<y>[0-9]{2})';
+      my $m   = '(?<m>[0-9]{1,2})';
+      my $d   = '(?<d>[0-9]{1,2})';
       my $sep = '(?<sep>[\s\.\/\-])';
 
       my @daterx =
@@ -1555,11 +1555,11 @@ sub _other_rx {
       my $abb = $$dmb{'data'}{'rx'}{'month_abb'}[0];
       my $nam = $$dmb{'data'}{'rx'}{'month_name'}[0];
 
-      my $y4  = '(?<y>\d\d\d\d)';
-      my $y2  = '(?<y>\d\d)';
-      my $m   = '(?<m>\d\d?)';
-      my $d   = '(?<d>\d\d?)';
-      my $dd  = '(?<d>\d\d)';
+      my $y4  = '(?<y>[0-9]{4})';
+      my $y2  = '(?<y>[0-9]{2})';
+      my $m   = '(?<m>[0-9]{1,2})';
+      my $d   = '(?<d>[0-9]{1,2})';
+      my $dd  = '(?<d>[0-9]{2})';
       my $mmm = "(?:(?<mmm>$abb)|(?<month>$nam))";
       my $sep = '(?<sep>[\s\.\/\-])';
 
@@ -1610,7 +1610,7 @@ sub _other_rx {
       my $abb = $$dmb{'data'}{'rx'}{'month_abb'}[0];
       my $nam = $$dmb{'data'}{'rx'}{'month_name'}[0];
 
-      my $y4  = '(?<y>\d\d\d\d)';
+      my $y4  = '(?<y>[0-9]{4})';
       my $mmm = "(?:(?<mmm>$abb)|(?<month>$nam))";
       my $sep = '(?<sep>[\s\.\/\-])';
 
@@ -1656,11 +1656,11 @@ sub _other_rx {
       my $special  = $$dmb{'data'}{'rx'}{'offset_time'}[0];
 
       $special     = "(?<special>$special)";
-      my $secs     = "(?<epoch>[-+]?\\d+)";
+      my $secs     = "(?<epoch>[-+]?[0-9]+)";
       my $abb      = $$dmb{'data'}{'rx'}{'month_abb'}[0];
       my $mmm      = "(?<mmm>$abb)";
-      my $y4       = '(?<y>\d\d\d\d)';
-      my $dd       = '(?<d>\d\d)';
+      my $y4       = '(?<y>[0-9]{4})';
+      my $dd       = '(?<d>[0-9]{2})';
       my $h24      = '(?<h>2[0-3]|[01][0-9])';      # 00-23
       my $mn       = '(?<mn>[0-5][0-9])';           # 00-59
       my $ss       = '(?<s>[0-5][0-9])';            # 00-59
@@ -1697,7 +1697,7 @@ sub _other_rx {
       my $nth_wom  = $$dmb{'data'}{'rx'}{'nth_wom'}[0];
       my $special  = $$dmb{'data'}{'rx'}{'offset_date'}[0];
 
-      my $y        = '(?:(?<y>\d\d\d\d)|(?<y>\d\d))';
+      my $y        = '(?:(?<y>[0-9]{4})|(?<y>[0-9]{2}))';
       my $mmm      = "(?:(?<mmm>$abb)|(?<month>$nam))";
       $next        = "(?<next>$next)";
       $last        = "(?<last>$last)";
@@ -1732,7 +1732,7 @@ sub _other_rx {
                                           # nth day in MMM [YYYY]
 
          "${nth}\\s+${wf}\\s*$y?",        # DoW Nth week [YYYY]
-         "${wf}\\s+(?<n>\\d+)\\s*$y?",    # DoW week N [YYYY]
+         "${wf}\\s+(?<n>[0-9]+)\\s*$y?",  # DoW week N [YYYY]
 
          "${special}",                    # today, tomorrow
          "${special}\\s+${wf}",           # today week
