From 6effdee19cdbe1ed6ea1cf6d958ce839678f41a5 Mon Sep 17 00:00:00 2001
From: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Date: Thu, 3 Sep 2026 20:18:06 +0500
Subject: [PATCH] Reject out-of-range to_date/to_timestamp template fields.

DDD, IDDD, SSSSS, RM, IW, and ID accepted values outside their documented domains.
Check the parsed field at read time, because a later 0 means the field was unset.
Recover a minus swallowed as a separator before SSSSS, matching TZH.
Reject leftover roman digits after RM, so XIII is not taken as XII.

Bug: #19650,19651
Author: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Reported-by: chunling qin <303677365@qq.com>
---
 src/backend/utils/adt/formatting.c     | 70 ++++++++++++++++++++++++--
 src/test/regress/expected/horology.out | 44 ++++++++++++++++
 src/test/regress/sql/horology.sql      | 14 ++++++
 3 files changed, 123 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/formatting.c b/src/backend/utils/adt/formatting.c
index 900fa8f20e5..569d4252e5b 100644
--- a/src/backend/utils/adt/formatting.c
+++ b/src/backend/utils/adt/formatting.c
@@ -1093,6 +1093,8 @@ static bool from_char_set_mode(TmFromChar *tmfc, const FromCharDateMode mode,
 							   Node *escontext);
 static bool from_char_set_int(int *dest, const int value, const FormatNode *node,
 							  Node *escontext);
+static bool from_char_in_range(int value, int min, int max, const char *in,
+							   Node *escontext);
 static int	from_char_parse_int_len(int *dest, const char **src, const size_t len,
 									FormatNode *node, Node *escontext);
 static int	from_char_parse_int(int *dest, const char **src, FormatNode *node,
@@ -2156,6 +2158,26 @@ from_char_set_int(int *dest, const int value, const FormatNode *node,
 	return true;
 }
 
+/*
+ * Check that 'value' lies in [min, max].
+ *
+ * Puke if it does not.  This must run at parse time: a later 0 means
+ * the field was unset.
+ *
+ * Returns true on success, false on failure (if escontext points to an
+ * ErrorSaveContext; otherwise errors are thrown).
+ */
+static bool
+from_char_in_range(int value, int min, int max, const char *in,
+				   Node *escontext)
+{
+	if (value < min || value > max)
+		ereturn(escontext, false,
+				(errcode(ERRCODE_DATETIME_FIELD_OVERFLOW),
+				 errmsg("date/time field value out of range: \"%s\"", in)));
+	return true;
+}
+
 /*
  * Read a single integer from the source string, into the int pointed to by
  * 'dest'. If 'dest' is NULL, the result is discarded.
@@ -3293,10 +3315,27 @@ DCH_from_char(FormatNode *node, const char *in, TmFromChar *out,
 				SKIP_THth(s, n->suffix);
 				break;
 			case DCH_SSSS:
-				if (from_char_parse_int(&out->ssss, &s, n, escontext) < 0)
-					return;
-				SKIP_THth(s, n->suffix);
-				break;
+				{
+					bool		neg = false;
+
+					/* Minus may have been taken as a separator (see TZH). */
+					if (*s == '+' || *s == '-')
+					{
+						neg = (*s == '-');
+						s++;
+					}
+					else if (extra_skip > 0 && *(s - 1) == '-')
+						neg = true;
+					if (from_char_parse_int(&out->ssss, &s, n, escontext) < 0)
+						return;
+					if (neg)
+						out->ssss = -out->ssss;
+					if (!from_char_in_range(out->ssss, 0, SECS_PER_DAY - 1,
+											in, escontext))
+						return;
+					SKIP_THth(s, n->suffix);
+					break;
+				}
 			case DCH_tz:
 			case DCH_TZ:
 				{
@@ -3464,11 +3503,17 @@ DCH_from_char(FormatNode *node, const char *in, TmFromChar *out,
 			case DCH_DDD:
 				if (from_char_parse_int(&out->ddd, &s, n, escontext) < 0)
 					return;
+				/* DDD is documented as day 001 to 366 of Gregorian year. */
+				if (!from_char_in_range(out->ddd, 1, 366, in, escontext))
+					return;
 				SKIP_THth(s, n->suffix);
 				break;
 			case DCH_IDDD:
 				if (from_char_parse_int_len(&out->ddd, &s, 3, n, escontext) < 0)
 					return;
+				/* IDDD is documented as day 001 to 371 of ISO year. */
+				if (!from_char_in_range(out->ddd, 1, 371, in, escontext))
+					return;
 				SKIP_THth(s, n->suffix);
 				break;
 			case DCH_DD:
@@ -3484,8 +3529,10 @@ DCH_from_char(FormatNode *node, const char *in, TmFromChar *out,
 			case DCH_ID:
 				if (from_char_parse_int_len(&out->d, &s, 1, n, escontext) < 0)
 					return;
+				if (!from_char_in_range(out->d, 1, DAYS_PER_WEEK, in, escontext))
+					return;
 				/* Shift numbering to match Gregorian where Sunday = 1 */
-				if (++out->d > 7)
+				if (++out->d > DAYS_PER_WEEK)
 					out->d = 1;
 				SKIP_THth(s, n->suffix);
 				break;
@@ -3493,6 +3540,9 @@ DCH_from_char(FormatNode *node, const char *in, TmFromChar *out,
 			case DCH_IW:
 				if (from_char_parse_int(&out->ww, &s, n, escontext) < 0)
 					return;
+				/* IW/WW are documented as week 01 to 53. */
+				if (!from_char_in_range(out->ww, 1, 53, in, escontext))
+					return;
 				SKIP_THth(s, n->suffix);
 				break;
 			case DCH_Q:
@@ -3586,6 +3636,16 @@ DCH_from_char(FormatNode *node, const char *in, TmFromChar *out,
 										  NULL, InvalidOid,
 										  n, escontext))
 					return;
+				{
+					unsigned char c = pg_ascii_toupper((unsigned char) *s);
+
+					/* leftover roman: XIII would match XII */
+					if (c == 'I' || c == 'V' || c == 'X')
+						ereturn(escontext,,
+								(errcode(ERRCODE_DATETIME_FIELD_OVERFLOW),
+								 errmsg("date/time field value out of range: \"%s\"",
+										in)));
+				}
 				if (!from_char_set_int(&out->mm, MONTHS_PER_YEAR - value, n,
 									   escontext))
 					return;
diff --git a/src/test/regress/expected/horology.out b/src/test/regress/expected/horology.out
index 32cf62b6741..7e1ea25f99a 100644
--- a/src/test/regress/expected/horology.out
+++ b/src/test/regress/expected/horology.out
@@ -3770,6 +3770,8 @@ SELECT to_timestamp('2015-02-11 86000', 'YYYY-MM-DD SSSSS');  -- ok
 
 SELECT to_timestamp('2015-02-11 86400', 'YYYY-MM-DD SSSSS');
 ERROR:  date/time field value out of range: "2015-02-11 86400"
+SELECT to_timestamp('2024-01-01 -1', 'YYYY-MM-DD SSSSS');
+ERROR:  date/time field value out of range: "2024-01-01 -1"
 SELECT to_timestamp('1000000000,999', 'Y,YYY');
 ERROR:  value for "Y,YYY" in source string is out of range
 SELECT to_timestamp('0.-2147483648', 'SS.MS');
@@ -3812,6 +3814,48 @@ SELECT to_date('2016 366', 'YYYY DDD');  -- ok
 
 SELECT to_date('2016 367', 'YYYY DDD');
 ERROR:  date/time field value out of range: "2016 367"
+SELECT to_date('2024 1000', 'YYYY DDD');
+ERROR:  date/time field value out of range: "2024 1000"
+SELECT to_date('2024 999', 'IYYY IDDD');
+ERROR:  date/time field value out of range: "2024 999"
+SELECT to_date('2024 372', 'IYYY IDDD');
+ERROR:  date/time field value out of range: "2024 372"
+SELECT to_date('2024 371', 'IYYY IDDD');  -- ok
+  to_date   
+------------
+ 01-05-2025
+(1 row)
+
+SELECT to_date('2024 XIII', 'YYYY RM');
+ERROR:  date/time field value out of range: "2024 XIII"
+SELECT to_date('2024 IIII', 'YYYY RM');
+ERROR:  date/time field value out of range: "2024 IIII"
+SELECT to_date('2024 XII', 'YYYY RM');  -- ok
+  to_date   
+------------
+ 12-01-2024
+(1 row)
+
+SELECT to_date('2024 54', 'IYYY IW');
+ERROR:  date/time field value out of range: "2024 54"
+SELECT to_date('2024 99', 'IYYY IW');
+ERROR:  date/time field value out of range: "2024 99"
+SELECT to_date('2024 53', 'IYYY IW');  -- ok
+  to_date   
+------------
+ 12-30-2024
+(1 row)
+
+SELECT to_date('2024 01 8', 'IYYY IW ID');
+ERROR:  date/time field value out of range: "2024 01 8"
+SELECT to_date('2024 01 0', 'IYYY IW ID');
+ERROR:  date/time field value out of range: "2024 01 0"
+SELECT to_date('2024 01 7', 'IYYY IW ID');  -- ok
+  to_date   
+------------
+ 01-07-2024
+(1 row)
+
 SELECT to_date('0000-02-01','YYYY-MM-DD');  -- allowed, though it shouldn't be
     to_date    
 ---------------
diff --git a/src/test/regress/sql/horology.sql b/src/test/regress/sql/horology.sql
index 8978249a5dc..889e88e6016 100644
--- a/src/test/regress/sql/horology.sql
+++ b/src/test/regress/sql/horology.sql
@@ -656,6 +656,7 @@ SELECT to_timestamp('2015-02-11 86000', 'YYYY-MM-DD SSSS');  -- ok
 SELECT to_timestamp('2015-02-11 86400', 'YYYY-MM-DD SSSS');
 SELECT to_timestamp('2015-02-11 86000', 'YYYY-MM-DD SSSSS');  -- ok
 SELECT to_timestamp('2015-02-11 86400', 'YYYY-MM-DD SSSSS');
+SELECT to_timestamp('2024-01-01 -1', 'YYYY-MM-DD SSSSS');
 SELECT to_timestamp('1000000000,999', 'Y,YYY');
 SELECT to_timestamp('0.-2147483648', 'SS.MS');
 SELECT to_timestamp('613566758', 'W');
@@ -669,6 +670,19 @@ SELECT to_date('2015 366', 'YYYY DDD');
 SELECT to_date('2016 365', 'YYYY DDD');  -- ok
 SELECT to_date('2016 366', 'YYYY DDD');  -- ok
 SELECT to_date('2016 367', 'YYYY DDD');
+SELECT to_date('2024 1000', 'YYYY DDD');
+SELECT to_date('2024 999', 'IYYY IDDD');
+SELECT to_date('2024 372', 'IYYY IDDD');
+SELECT to_date('2024 371', 'IYYY IDDD');  -- ok
+SELECT to_date('2024 XIII', 'YYYY RM');
+SELECT to_date('2024 IIII', 'YYYY RM');
+SELECT to_date('2024 XII', 'YYYY RM');  -- ok
+SELECT to_date('2024 54', 'IYYY IW');
+SELECT to_date('2024 99', 'IYYY IW');
+SELECT to_date('2024 53', 'IYYY IW');  -- ok
+SELECT to_date('2024 01 8', 'IYYY IW ID');
+SELECT to_date('2024 01 0', 'IYYY IW ID');
+SELECT to_date('2024 01 7', 'IYYY IW ID');  -- ok
 SELECT to_date('0000-02-01','YYYY-MM-DD');  -- allowed, though it shouldn't be
 SELECT to_date('100000000', 'CC');
 SELECT to_date('-100000000', 'CC');
-- 
2.53.0

