From ba17bfc1f23d77949db3ba2e35909d378c8bd272 Mon Sep 17 00:00:00 2001 From: Snehil Shah Date: Mon, 22 Jun 2026 22:28:44 +0530 Subject: [PATCH] Enforce integer bounds in type encoders Signed-off-by: Snehil Shah --- lib/postgrex/extensions/inet.ex | 39 ++++++-- lib/postgrex/extensions/interval.ex | 24 ++++- lib/postgrex/extensions/macaddr.ex | 12 ++- lib/postgrex/extensions/numeric.ex | 20 ++++- lib/postgrex/extensions/tid.ex | 16 +++- lib/postgrex/extensions/tsvector.ex | 10 +++ test/query_test.exs | 133 ++++++++++++++++++++++++++++ test/tsvector_test.exs | 14 +++ 8 files changed, 255 insertions(+), 13 deletions(-) diff --git a/lib/postgrex/extensions/inet.ex b/lib/postgrex/extensions/inet.ex index 4e4839a72..3f5f3b9ff 100644 --- a/lib/postgrex/extensions/inet.ex +++ b/lib/postgrex/extensions/inet.ex @@ -4,22 +4,51 @@ defmodule Postgrex.Extensions.INET do import Postgrex.BinaryUtils, warn: false use Postgrex.BinaryExtension, send: "cidr_send", send: "inet_send" + @octet_range 0..255 + @hextet_range 0..65535 + @ipv4_netmask_range 0..32 + @ipv6_netmask_range 0..128 + def encode(_) do + octet_range = Macro.escape(@octet_range) + hextet_range = Macro.escape(@hextet_range) + ipv4_netmask_range = Macro.escape(@ipv4_netmask_range) + ipv6_netmask_range = Macro.escape(@ipv6_netmask_range) + quote location: :keep do - %Postgrex.INET{address: {a, b, c, d}, netmask: nil} -> + %Postgrex.INET{address: {a, b, c, d}, netmask: nil} + when a in unquote(octet_range) and b in unquote(octet_range) and + c in unquote(octet_range) and d in unquote(octet_range) -> <<8::int32(), 2, 32, 0, 4, a, b, c, d>> - %Postgrex.INET{address: {a, b, c, d}, netmask: n} -> + %Postgrex.INET{address: {a, b, c, d}, netmask: n} + when a in unquote(octet_range) and b in unquote(octet_range) and + c in unquote(octet_range) and d in unquote(octet_range) and + n in unquote(ipv4_netmask_range) -> <<8::int32(), 2, n, 1, 4, a, b, c, d>> - %Postgrex.INET{address: {a, b, c, d, e, f, g, h}, netmask: nil} -> + %Postgrex.INET{address: {a, b, c, d, e, f, g, h}, netmask: nil} + when a in unquote(hextet_range) and b in unquote(hextet_range) and + c in unquote(hextet_range) and d in unquote(hextet_range) and + e in unquote(hextet_range) and f in unquote(hextet_range) and + g in unquote(hextet_range) and h in unquote(hextet_range) -> <<20::int32(), 3, 128, 0, 16, a::16, b::16, c::16, d::16, e::16, f::16, g::16, h::16>> - %Postgrex.INET{address: {a, b, c, d, e, f, g, h}, netmask: n} -> + %Postgrex.INET{address: {a, b, c, d, e, f, g, h}, netmask: n} + when a in unquote(hextet_range) and b in unquote(hextet_range) and + c in unquote(hextet_range) and d in unquote(hextet_range) and + e in unquote(hextet_range) and f in unquote(hextet_range) and + g in unquote(hextet_range) and h in unquote(hextet_range) and + n in unquote(ipv6_netmask_range) -> <<20::int32(), 3, n, 1, 16, a::16, b::16, c::16, d::16, e::16, f::16, g::16, h::16>> other -> - raise DBConnection.EncodeError, Postgrex.Utils.encode_msg(other, Postgrex.INET) + raise DBConnection.EncodeError, + Postgrex.Utils.encode_msg( + other, + "a %Postgrex.INET{} with octets in 0..255 and netmask in 0..32 (IPv4) " <> + "or groups in 0..65535 and netmask in 0..128 (IPv6)" + ) end end diff --git a/lib/postgrex/extensions/interval.ex b/lib/postgrex/extensions/interval.ex index ad28f2911..d57a4468d 100644 --- a/lib/postgrex/extensions/interval.ex +++ b/lib/postgrex/extensions/interval.ex @@ -7,6 +7,8 @@ defmodule Postgrex.Extensions.Interval do @int64_min -9_223_372_036_854_775_808 @int32_max 2_147_483_647 @int32_min -2_147_483_648 + @int64_range @int64_min..@int64_max + @int32_range @int32_min..@int32_max def init(opts) do infinity? = Keyword.get(opts, :allow_infinite_intervals, false) @@ -42,7 +44,7 @@ defmodule Postgrex.Extensions.Interval do %Postgrex.Interval{months: months, days: days, secs: seconds, microsecs: microseconds} -> microseconds = 1_000_000 * seconds + microseconds - <<16::int32(), microseconds::int64(), days::int32(), months::int32()>> + unquote(__MODULE__).encode_interval(microseconds, days, months) %Duration{ year: years, @@ -57,7 +59,7 @@ defmodule Postgrex.Extensions.Interval do months = 12 * years + months days = 7 * weeks + days microseconds = 1_000_000 * (3600 * hours + 60 * minutes + seconds) + microseconds - <<16::int32(), microseconds::int64(), days::int32(), months::int32()>> + unquote(__MODULE__).encode_interval(microseconds, days, months) other -> raise DBConnection.EncodeError, @@ -133,7 +135,7 @@ defmodule Postgrex.Extensions.Interval do %Postgrex.Interval{months: months, days: days, secs: seconds, microsecs: microseconds} -> microseconds = 1_000_000 * seconds + microseconds - <<16::int32(), microseconds::int64(), days::int32(), months::int32()>> + unquote(__MODULE__).encode_interval(microseconds, days, months) other -> raise DBConnection.EncodeError, Postgrex.Utils.encode_msg(other, Postgrex.Interval) @@ -176,6 +178,22 @@ defmodule Postgrex.Extensions.Interval do end end + def encode_interval(microseconds, days, months) do + cond do + months not in @int32_range -> + raise DBConnection.EncodeError, Postgrex.Utils.encode_msg(months, @int32_range) + + days not in @int32_range -> + raise DBConnection.EncodeError, Postgrex.Utils.encode_msg(days, @int32_range) + + microseconds not in @int64_range -> + raise DBConnection.EncodeError, Postgrex.Utils.encode_msg(microseconds, @int64_range) + + true -> + <<16::int32(), microseconds::int64(), days::int32(), months::int32()>> + end + end + def infinity_binary(:inf) do <<16::int32(), @int64_max::int64(), @int32_max::int32(), @int32_max::int32()>> end diff --git a/lib/postgrex/extensions/macaddr.ex b/lib/postgrex/extensions/macaddr.ex index 53cb9a996..e3b8e2afc 100644 --- a/lib/postgrex/extensions/macaddr.ex +++ b/lib/postgrex/extensions/macaddr.ex @@ -3,13 +3,21 @@ defmodule Postgrex.Extensions.MACADDR do import Postgrex.BinaryUtils, warn: false use Postgrex.BinaryExtension, send: "macaddr_send" + @octet_range 0..255 + def encode(_) do + octet_range = Macro.escape(@octet_range) + quote location: :keep do - %Postgrex.MACADDR{address: {a, b, c, d, e, f}} -> + %Postgrex.MACADDR{address: {a, b, c, d, e, f}} + when a in unquote(octet_range) and b in unquote(octet_range) and + c in unquote(octet_range) and d in unquote(octet_range) and + e in unquote(octet_range) and f in unquote(octet_range) -> <<6::int32(), a, b, c, d, e, f>> other -> - raise DBConnection.EncodeError, Postgrex.Utils.encode_msg(other, Postgrex.MACADDR) + raise DBConnection.EncodeError, + Postgrex.Utils.encode_msg(other, "a %Postgrex.MACADDR{} with octets in 0..255") end end diff --git a/lib/postgrex/extensions/numeric.ex b/lib/postgrex/extensions/numeric.ex index 098d6efe2..7a6af41f5 100644 --- a/lib/postgrex/extensions/numeric.ex +++ b/lib/postgrex/extensions/numeric.ex @@ -3,6 +3,8 @@ defmodule Postgrex.Extensions.Numeric do import Postgrex.BinaryUtils, warn: false use Postgrex.BinaryExtension, send: "numeric_send" + @int16_range -32_768..32_767 + def encode(_) do quote location: :keep, generated: true do %Decimal{} = decimal -> @@ -54,7 +56,23 @@ defmodule Postgrex.Extensions.Numeric do weight = max(length(integer_digits) - 1, 0) bin = for digit <- digits, into: "", do: <> - [<> | bin] + [encode_header(num_digits, weight, sign, scale) | bin] + end + + defp encode_header(num_digits, weight, sign, scale) do + cond do + num_digits not in @int16_range -> + raise DBConnection.EncodeError, Postgrex.Utils.encode_msg(num_digits, @int16_range) + + weight not in @int16_range -> + raise DBConnection.EncodeError, Postgrex.Utils.encode_msg(weight, @int16_range) + + scale not in @int16_range -> + raise DBConnection.EncodeError, Postgrex.Utils.encode_msg(scale, @int16_range) + + true -> + <> + end end defp encode_sign(1), do: 0x0000 diff --git a/lib/postgrex/extensions/tid.ex b/lib/postgrex/extensions/tid.ex index 608d89256..353514381 100644 --- a/lib/postgrex/extensions/tid.ex +++ b/lib/postgrex/extensions/tid.ex @@ -3,13 +3,25 @@ defmodule Postgrex.Extensions.TID do import Postgrex.BinaryUtils, warn: false use Postgrex.BinaryExtension, send: "tidsend" + @block_range 0..4_294_967_295 + @tuple_range 0..65_535 + def encode(_) do + block_range = Macro.escape(@block_range) + tuple_range = Macro.escape(@tuple_range) + quote location: :keep do - {block, tuple} -> + {block, tuple} + when is_integer(block) and block in unquote(block_range) and + is_integer(tuple) and tuple in unquote(tuple_range) -> <<6::int32(), block::uint32(), tuple::uint16()>> other -> - raise DBConnection.EncodeError, Postgrex.Utils.encode_msg(other, "a tuple of 2 integers") + raise DBConnection.EncodeError, + Postgrex.Utils.encode_msg( + other, + "a tuple of a 32-bit integer and a 16-bit integer" + ) end end diff --git a/lib/postgrex/extensions/tsvector.ex b/lib/postgrex/extensions/tsvector.ex index 17f282df9..489698f99 100644 --- a/lib/postgrex/extensions/tsvector.ex +++ b/lib/postgrex/extensions/tsvector.ex @@ -4,6 +4,8 @@ defmodule Postgrex.Extensions.TSVector do import Postgrex.BinaryUtils, warn: false use Postgrex.BinaryExtension, send: "tsvectorsend" + @max_position 16383 + def encode(_) do quote location: :keep do values when is_list(values) -> @@ -36,6 +38,14 @@ defmodule Postgrex.Extensions.TSVector do defp encode_positions(%Postgrex.Lexeme{word: word, positions: positions}) do positions = Enum.map(positions, fn {position, weight} -> + # Lexeme positions can range from 1 to 16383 with a silent ceiling at the top. + # Ref: https://www.postgresql.org/docs/current/datatype-textsearch.html#DATATYPE-TSVECTOR + if position < 1 do + raise DBConnection.EncodeError, + Postgrex.Utils.encode_msg(position, 1..@max_position) + end + + position = min(position, @max_position) <> end) diff --git a/test/query_test.exs b/test/query_test.exs index 4442866f3..a35d3184f 100644 --- a/test/query_test.exs +++ b/test/query_test.exs @@ -846,6 +846,22 @@ defmodule QueryTest do assert [[{5, 10}]] = query("select $1::tid;", [{5, 10}]) end + test "encode enforces bounds on tid", context do + # block's range is 0 to 4294967295 + assert %DBConnection.EncodeError{} = + catch_error(query("select $1::tid;", [{4_294_967_295 + 1, 0}])) + + assert %DBConnection.EncodeError{} = + catch_error(query("select $1::tid;", [{0 - 1, 0}])) + + # tuple's range is 0 to 65535 + assert %DBConnection.EncodeError{} = + catch_error(query("select $1::tid;", [{0, 65_535 + 1}])) + + assert %DBConnection.EncodeError{} = + catch_error(query("select $1::tid;", [{0, 0 - 1}])) + end + test "encoding oids as binary fails with a helpful error message", context do assert_raise ArgumentError, ~r"See https://github.com/elixir-ecto/postgrex#oid-type-encoding", @@ -923,6 +939,15 @@ defmodule QueryTest do end) end + test "encode enforces bounds on numeric", context do + # the digit count, weight and scale headers must each fit an int16 + assert %DBConnection.EncodeError{} = + catch_error(query("SELECT $1::numeric", [Decimal.new("1E140000")])) + + assert %DBConnection.EncodeError{} = + catch_error(query("SELECT $1::numeric", [Decimal.new("1E-140000")])) + end + test "encode integers and floats as numeric", context do dec = Decimal.new(1) assert [[dec]] == query("SELECT $1::numeric", [1]) @@ -1055,6 +1080,45 @@ defmodule QueryTest do ]) end + test "encode enforces bounds on interval", context do + # months' range is -2147483648 to 2147483647 + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::interval", [%Postgrex.Interval{months: 2_147_483_647 + 1}]) + ) + + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::interval", [%Postgrex.Interval{months: -2_147_483_648 - 1}]) + ) + + # days' range is -2147483648 to 2147483647 + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::interval", [%Postgrex.Interval{days: 2_147_483_647 + 1}]) + ) + + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::interval", [%Postgrex.Interval{days: -2_147_483_648 - 1}]) + ) + + # microseconds' range is -9223372036854775808 to 9223372036854775807 + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::interval", [ + %Postgrex.Interval{microsecs: 9_223_372_036_854_775_807 + 1} + ]) + ) + + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::interval", [ + %Postgrex.Interval{microsecs: -9_223_372_036_854_775_808 - 1} + ]) + ) + end + @tag min_pg_version: "17.0" test "encode infinite interval" do opts = [database: "postgrex_test", backoff_type: :stop, types: Postgrex.ElixirDurationTypes] @@ -1554,6 +1618,75 @@ defmodule QueryTest do query("SELECT $1::macaddr::text", [%Postgrex.MACADDR{address: {8, 1, 43, 5, 7, 9}}]) end + test "encode enforces bounds on inet", context do + # IPv4 octet's range is 0 to 255 + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::inet", [ + %Postgrex.INET{address: {255 + 1, 0, 0, 0}, netmask: nil} + ]) + ) + + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::inet", [%Postgrex.INET{address: {0 - 1, 0, 0, 0}, netmask: nil}]) + ) + + # IPv4 netmask's range is 0 to 32 + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::inet", [%Postgrex.INET{address: {10, 0, 0, 0}, netmask: 32 + 1}]) + ) + + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::inet", [%Postgrex.INET{address: {10, 0, 0, 0}, netmask: 0 - 1}]) + ) + + # IPv6 group's range is 0 to 65535 + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::inet", [ + %Postgrex.INET{address: {65_535 + 1, 0, 0, 0, 0, 0, 0, 0}, netmask: nil} + ]) + ) + + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::inet", [ + %Postgrex.INET{address: {0 - 1, 0, 0, 0, 0, 0, 0, 0}, netmask: nil} + ]) + ) + + # IPv6 netmask's range is 0 to 128 + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::inet", [ + %Postgrex.INET{address: {1, 0, 0, 0, 0, 0, 0, 0}, netmask: 128 + 1} + ]) + ) + + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::inet", [ + %Postgrex.INET{address: {1, 0, 0, 0, 0, 0, 0, 0}, netmask: 0 - 1} + ]) + ) + end + + test "encode enforces bounds on macaddr", context do + # macaddr octet's range is 0 to 255 + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::macaddr", [%Postgrex.MACADDR{address: {255 + 1, 0, 0, 0, 0, 0}}]) + ) + + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::macaddr", [%Postgrex.MACADDR{address: {0 - 1, 0, 0, 0, 0, 0}}]) + ) + end + test "encode bit string", context do assert [["110"]] == query("SELECT $1::bit(3)::text", [<<1::1, 1::1, 0::1>>]) assert [["110"]] == query("SELECT $1::varbit::text", [<<1::1, 1::1, 0::1>>]) diff --git a/test/tsvector_test.exs b/test/tsvector_test.exs index 369fd4ea0..0e241b6ed 100644 --- a/test/tsvector_test.exs +++ b/test/tsvector_test.exs @@ -49,6 +49,20 @@ defmodule TsvectorTest do ]) end + test "encode caps tsvector positions over 16383 and rejects positions below 1", context do + # Postgres silently caps positions above 16383. + assert [["'x':16383"]] = + query("SELECT $1::tsvector::text", [ + [%Lexeme{word: "x", positions: [{16_383 + 1, nil}]}] + ]) + + # Postgres rejects positions below 1. + assert %DBConnection.EncodeError{} = + catch_error( + query("SELECT $1::tsvector", [[%Lexeme{word: "x", positions: [{1 - 1, nil}]}]]) + ) + end + test "decode basic tsvectors", context do assert [[[%Lexeme{positions: [], word: "1"}]]] = query("SELECT '1'::tsvector", []) assert [[[%Lexeme{positions: [], word: "1"}]]] = query("SELECT '1 '::tsvector", [])