From dd97dc7bdd3cac820ddbe5c6365c4636e5ae0200 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Thu, 3 Sep 2026 10:57:48 +0300 Subject: [PATCH 1/3] Reject timestamptz values that do not fit the 64-bit wire format DateTime values beyond PostgreSQL's timestamp range were passed to the binary construction unchecked, which truncates them to 64 bits. The server then receives a different, in-range timestamp and stores it instead of rejecting it. The two endpoints of the range are the infinity sentinels, so a finite DateTime could also be stored as infinity. The sibling timestamp extension already guards its input this way. Co-authored-by: Claude --- lib/postgrex/extensions/timestamptz.ex | 15 +++++++++++++-- test/calendar_test.exs | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/postgrex/extensions/timestamptz.ex b/lib/postgrex/extensions/timestamptz.ex index acd2d89c..888e5bdb 100644 --- a/lib/postgrex/extensions/timestamptz.ex +++ b/lib/postgrex/extensions/timestamptz.ex @@ -37,8 +37,19 @@ defmodule Postgrex.Extensions.TimestampTZ do ## Helpers def encode_elixir(%DateTime{utc_offset: 0, std_offset: 0} = datetime) do - microsecs = DateTime.to_unix(datetime, :microsecond) - <<8::int32(), microsecs - @us_epoch::int64()>> + microsecs = DateTime.to_unix(datetime, :microsecond) - @us_epoch + + # PostgreSQL rejects timestamps outside its own range, but only when it + # receives the value we meant to send. Anything that does not fit in a + # signed 64-bit integer is silently truncated by the binary construction + # below and arrives as a different, in-range timestamp; the two endpoints + # are the infinity sentinels. + if microsecs > @minus_infinity and microsecs < @plus_infinity do + <<8::int32(), microsecs::int64()>> + else + raise ArgumentError, + "#{inspect(datetime)} is beyond the range PostgreSQL can represent in a timestamptz" + end end def encode_elixir(%DateTime{} = datetime) do diff --git a/test/calendar_test.exs b/test/calendar_test.exs index 36c0df6a..3ff2a3c0 100644 --- a/test/calendar_test.exs +++ b/test/calendar_test.exs @@ -299,6 +299,24 @@ defmodule CalendarTest do assert message =~ "timestamp out of range" end + test "encode timestamptz beyond the 64-bit wire range", context do + # These do not fit in the signed 64-bit microsecond wire format. Truncating + # them sends PostgreSQL a different, in-range timestamp that it accepts, so + # the value has to be rejected here instead. + sentinel = + DateTime.from_naive!(NaiveDateTime.new!(294_277, 1, 9, 4, 0, 54, {775_807, 6}), "Etc/UTC") + + wraps_into_range = DateTime.from_naive!(NaiveDateTime.new!(585_706, 1, 1, 0, 0, 0), "Etc/UTC") + + assert_raise ArgumentError, ~r/beyond the range/, fn -> + query("SELECT $1::timestamptz", [sentinel]) + end + + assert_raise ArgumentError, ~r/beyond the range/, fn -> + query("SELECT $1::timestamptz", [wraps_into_range]) + end + end + @tag :capture_log test "decode infinity", context do assert_raise ArgumentError, fn -> query("SELECT 'infinity'::timestamp", []) end From 2d2f893f174d17e44983cdca4208dc6e68d6e0ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Thu, 3 Sep 2026 10:22:25 +0200 Subject: [PATCH 2/3] Gate new test on 1.19.0 --- test/calendar_test.exs | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/test/calendar_test.exs b/test/calendar_test.exs index 3ff2a3c0..c84c8431 100644 --- a/test/calendar_test.exs +++ b/test/calendar_test.exs @@ -299,21 +299,23 @@ defmodule CalendarTest do assert message =~ "timestamp out of range" end - test "encode timestamptz beyond the 64-bit wire range", context do - # These do not fit in the signed 64-bit microsecond wire format. Truncating - # them sends PostgreSQL a different, in-range timestamp that it accepts, so - # the value has to be rejected here instead. - sentinel = - DateTime.from_naive!(NaiveDateTime.new!(294_277, 1, 9, 4, 0, 54, {775_807, 6}), "Etc/UTC") - - wraps_into_range = DateTime.from_naive!(NaiveDateTime.new!(585_706, 1, 1, 0, 0, 0), "Etc/UTC") - - assert_raise ArgumentError, ~r/beyond the range/, fn -> - query("SELECT $1::timestamptz", [sentinel]) - end - - assert_raise ArgumentError, ~r/beyond the range/, fn -> - query("SELECT $1::timestamptz", [wraps_into_range]) + if Version.match?(System.version(), ">= 1.19.0") do + test "encode timestamptz beyond the 64-bit wire range", context do + # These do not fit in the signed 64-bit microsecond wire format. Truncating + # them sends PostgreSQL a different, in-range timestamp that it accepts, so + # the value has to be rejected here instead. + sentinel = + DateTime.from_naive!(NaiveDateTime.new!(294_277, 1, 9, 4, 0, 54, {775_807, 6}), "Etc/UTC") + + wraps_into_range = DateTime.from_naive!(NaiveDateTime.new!(585_706, 1, 1, 0, 0, 0), "Etc/UTC") + + assert_raise ArgumentError, ~r/beyond the range/, fn -> + query("SELECT $1::timestamptz", [sentinel]) + end + + assert_raise ArgumentError, ~r/beyond the range/, fn -> + query("SELECT $1::timestamptz", [wraps_into_range]) + end end end From 835abd4e382bc5dbbc769c679d6499c4eb847353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Thu, 3 Sep 2026 10:30:31 +0200 Subject: [PATCH 3/3] Apply suggestion from @josevalim --- test/calendar_test.exs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/calendar_test.exs b/test/calendar_test.exs index c84c8431..16db094c 100644 --- a/test/calendar_test.exs +++ b/test/calendar_test.exs @@ -306,13 +306,14 @@ defmodule CalendarTest do # the value has to be rejected here instead. sentinel = DateTime.from_naive!(NaiveDateTime.new!(294_277, 1, 9, 4, 0, 54, {775_807, 6}), "Etc/UTC") - - wraps_into_range = DateTime.from_naive!(NaiveDateTime.new!(585_706, 1, 1, 0, 0, 0), "Etc/UTC") - + + wraps_into_range = + DateTime.from_naive!(NaiveDateTime.new!(585_706, 1, 1, 0, 0, 0), "Etc/UTC") + assert_raise ArgumentError, ~r/beyond the range/, fn -> query("SELECT $1::timestamptz", [sentinel]) end - + assert_raise ArgumentError, ~r/beyond the range/, fn -> query("SELECT $1::timestamptz", [wraps_into_range]) end