From 820809cd371eb03bb6931afa79ea2034a85431ca Mon Sep 17 00:00:00 2001 From: felipe stival Date: Sat, 2 May 2026 18:02:55 -0300 Subject: [PATCH 1/4] fix: use pg_saslprep library to saslprep credentials pgo isn't aligned with upstream Postgres SASLprep --- .../validation_secrets.ex | 2 +- .../client_handler/auth_methods/password.ex | 2 +- lib/supavisor/helpers.ex | 9 +- mix.exs | 1 + mix.lock | 1 + .../integration/protocol_integration_test.exs | 142 +++++++++++++++++- 6 files changed, 149 insertions(+), 8 deletions(-) diff --git a/lib/supavisor/client_authentication/validation_secrets.ex b/lib/supavisor/client_authentication/validation_secrets.ex index 988bae733..e8d993195 100644 --- a/lib/supavisor/client_authentication/validation_secrets.ex +++ b/lib/supavisor/client_authentication/validation_secrets.ex @@ -49,7 +49,7 @@ defmodule Supavisor.ClientAuthentication.ValidationSecrets do defp sasl_secrets_from_password(user, password) do iterations = 4096 salt = :crypto.strong_rand_bytes(16) - salted_password = :pgo_scram.hi(:pgo_sasl_prep_profile.validate([password]), salt, iterations) + salted_password = :pgo_scram.hi(PgSASLprep.scram_normalize(password), salt, iterations) client_key = :pgo_scram.hmac(salted_password, "Client Key") stored_key = :pgo_scram.h(client_key) server_key = :pgo_scram.hmac(salted_password, "Server Key") diff --git a/lib/supavisor/client_handler/auth_methods/password.ex b/lib/supavisor/client_handler/auth_methods/password.ex index cdc12969b..0666c904b 100644 --- a/lib/supavisor/client_handler/auth_methods/password.ex +++ b/lib/supavisor/client_handler/auth_methods/password.ex @@ -88,7 +88,7 @@ defmodule Supavisor.ClientHandler.AuthMethods.Password do else salted_password = :pgo_scram.hi( - :pgo_sasl_prep_profile.validate([password]), + PgSASLprep.scram_normalize(password), sasl_secrets.salt, sasl_secrets.iterations ) diff --git a/lib/supavisor/helpers.ex b/lib/supavisor/helpers.ex index 1fd5cf246..09391090c 100644 --- a/lib/supavisor/helpers.ex +++ b/lib/supavisor/helpers.ex @@ -237,7 +237,8 @@ defmodule Supavisor.Helpers do salt = srv_first.salt i = srv_first.i - salted_password = :pgo_scram.hi(:pgo_sasl_prep_profile.validate(secrets.password), salt, i) + salted_password = + :pgo_scram.hi(PgSASLprep.scram_normalize(IO.iodata_to_binary(secrets.password)), salt, i) client_key = :pgo_scram.hmac(salted_password, "Client Key") stored_key = :pgo_scram.h(client_key) client_first_bare = [<<"n=">>, user_name, <<",r=">>, client_nonce] @@ -291,7 +292,11 @@ defmodule Supavisor.Helpers do %Supavisor.Secrets.SASLSecrets{} = secrets ) do salted_password = - :pgo_scram.hi(:pgo_sasl_prep_profile.validate(password), secrets.salt, secrets.iterations) + :pgo_scram.hi( + PgSASLprep.scram_normalize(IO.iodata_to_binary(password)), + secrets.salt, + secrets.iterations + ) client_key = :pgo_scram.hmac(salted_password, "Client Key") stored_key = :pgo_scram.h(client_key) diff --git a/mix.exs b/mix.exs index 4ea6b752e..9532ba168 100644 --- a/mix.exs +++ b/mix.exs @@ -75,6 +75,7 @@ defmodule Supavisor.MixProject do {:poolboy, git: "https://github.com/supabase/poolboy", tag: "v0.0.3"}, {:syn, "~> 3.3"}, {:pgo, "~> 0.13"}, + {:pg_saslprep, github: "v0idpwn/pg_saslprep"}, {:rustler, "~> 0.36.1"}, {:ranch, "~> 2.0", override: true}, diff --git a/mix.lock b/mix.lock index 72c20c729..468207459 100644 --- a/mix.lock +++ b/mix.lock @@ -46,6 +46,7 @@ "open_api_spex": {:hex, :open_api_spex, "3.21.2", "6a704f3777761feeb5657340250d6d7332c545755116ca98f33d4b875777e1e5", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "f42ae6ed668b895ebba3e02773cfb4b41050df26f803f2ef634c72a7687dc387"}, "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.0", "63ca1742f92f00059298f478048dfb826f4b20d49534493d6919a0db39b6db04", [:mix, :rebar3], [], "hexpm", "3dfbbfaa2c2ed3121c5c483162836c4f9027def469c41578af5ef32589fcfc58"}, "peep": {:hex, :peep, "4.4.0", "4b6289e7258ecf2741041068932455eb8389673bca785623621ddb6935286845", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f289959a9e5fcf92f5a4becaf4dd0df95c58841368f6ad0574b8ea830a7c1453"}, + "pg_saslprep": {:git, "https://github.com/v0idpwn/pg_saslprep.git", "455e2e50757e8e5e091c00be4c1b9818612e3ca2", []}, "pg_types": {:hex, :pg_types, "0.4.0", "3ce365c92903c5bb59c0d56382d842c8c610c1b6f165e20c4b652c96fa7e9c14", [:rebar3], [], "hexpm", "b02efa785caececf9702c681c80a9ca12a39f9161a846ce17b01fb20aeeed7eb"}, "pgo": {:hex, :pgo, "0.14.0", "f53711d103d7565db6fc6061fcf4ff1007ab39892439be1bb02d9f686d7e6663", [:rebar3], [{:backoff, "~> 1.1.6", [hex: :backoff, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:pg_types, "~> 0.4.0", [hex: :pg_types, repo: "hexpm", optional: false]}], "hexpm", "71016c22599936e042dc0012ee4589d24c71427d266292f775ebf201d97df9c9"}, "phoenix": {:hex, :phoenix, "1.7.20", "6bababaf27d59f5628f9b608de902a021be2cecefb8231e1dbdc0a2e2e480e9b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "6be2ab98302e8784a31829e0d50d8bdfa81a23cd912c395bafd8b8bfb5a086c2"}, diff --git a/test/integration/protocol_integration_test.exs b/test/integration/protocol_integration_test.exs index 53e7d58a8..41ef93c45 100644 --- a/test/integration/protocol_integration_test.exs +++ b/test/integration/protocol_integration_test.exs @@ -211,14 +211,148 @@ defmodule Supavisor.Integration.ProtocolIntegrationTest do end end - defp recv_until_ready_for_query(sock, buf) do - {pkts, ""} = Supavisor.Protocol.split_pkts(buf) + describe "fullwidth password (SASLprep private-use bug workaround)" do + # `:pgo_sasl_prep_profile.validate/1` has a buggy private-use range that + # incorrectly rejects characters in the BMP (e.g. fullwidth `!` U+FF01). + @password String.duplicate("!", 8) + @role "fullwidth_pw_user" + @tenant "is_manager" + + setup do + Supavisor.Support.SSLHelper.setup_downstream_certs() + + db_conf = Application.get_env(:supavisor, Supavisor.Repo) + + {:ok, origin} = connect_origin(db_conf) + Postgrex.query!(origin, "DROP ROLE IF EXISTS #{@role};", []) + + Postgrex.query!( + origin, + "CREATE ROLE #{@role} WITH LOGIN PASSWORD '#{@password}';", + [] + ) + + on_exit(fn -> + {:ok, cleanup} = connect_origin(db_conf) + Postgrex.query!(cleanup, "DROP ROLE IF EXISTS #{@role};", []) + end) + + %{ + db_conf: db_conf, + port: Application.get_env(:supavisor, :proxy_port_transaction), + username: "#{@role}.#{@tenant}" + } + end + + test "authenticates over plain TCP (SCRAM)", ctx do + {:ok, sock} = :gen_tcp.connect(~c"127.0.0.1", ctx.port, [:binary, active: false]) + + send_startup(:gen_tcp, sock, ctx) + auth_tail = do_scram_exchange(:gen_tcp, sock, ctx.username, @password) + recv_until_ready_for_query(:gen_tcp, sock, auth_tail) + + :ok = :gen_tcp.send(sock, :pgo_protocol.encode_query_message("SELECT 1")) + {:ok, data} = :gen_tcp.recv(sock, 0, 5000) + assert_data_row(data) + :gen_tcp.close(sock) + end + + test "authenticates over SSL (cleartext password)", ctx do + {:ok, tcp} = :gen_tcp.connect(~c"127.0.0.1", ctx.port, [:binary, active: false]) + :ok = :gen_tcp.send(tcp, Server.ssl_request_message()) + {:ok, "S"} = :gen_tcp.recv(tcp, 1, 5000) + {:ok, ssl} = :ssl.connect(tcp, [verify: :verify_none, active: false], 5000) + + send_startup(:ssl, ssl, ctx) + + {:ok, <>} = :ssl.recv(ssl, 0, 5000) + + pw = @password <> <<0>> + :ok = :ssl.send(ssl, [<>, pw]) + + recv_until_ready_for_query(:ssl, ssl, "") + + :ok = :ssl.send(ssl, :pgo_protocol.encode_query_message("SELECT 1")) + {:ok, data} = :ssl.recv(ssl, 0, 5000) + assert_data_row(data) + :ssl.close(ssl) + end + + defp connect_origin(db_conf) do + Postgrex.start_link( + hostname: db_conf[:hostname], + port: db_conf[:port], + database: db_conf[:database], + password: db_conf[:password], + username: db_conf[:username] + ) + end + + defp send_startup(transport, sock, ctx) do + startup = + :pgo_protocol.encode_startup_message([ + {"user", ctx.username}, + {"database", to_string(ctx.db_conf[:database])} + ]) + + :ok = transport.send(sock, startup) + end + + # Mirrors `:pgo_scram.get_client_final/4` but routes the password through + # `PgSASLprep` instead of the buggy `:pgo_sasl_prep_profile.validate/1`. + defp do_scram_exchange(transport, sock, user, password) do + {:ok, <>} = transport.recv(sock, 0, 5000) + assert "SCRAM-SHA-256" in :pgo_protocol.decode_strings(methods_bin) + + nonce = :pgo_scram.get_nonce(16) + client_first = :pgo_scram.get_client_first(user, nonce) + client_first_size = :erlang.iolist_size(client_first) + sasl_initial = ["SCRAM-SHA-256", 0, <>, client_first] + :ok = transport.send(sock, :pgo_protocol.encode_scram_response_message(sasl_initial)) + + {:ok, <>} = transport.recv(sock, 0, 5000) + sf = :pgo_scram.parse_server_first(server_first, nonce) + salt = :proplists.get_value(:salt, sf) + i = :proplists.get_value(:i, sf) + server_first_raw = :proplists.get_value(:raw, sf) + server_nonce = :proplists.get_value(:nonce, sf) + + salted_password = :pgo_scram.hi(PgSASLprep.scram_normalize(password), salt, i) + client_key = :pgo_scram.hmac(salted_password, "Client Key") + stored_key = :pgo_scram.h(client_key) + client_first_bare = ["n=", user, ",r=", nonce] + client_final_no_proof = ["c=biws,r=", server_nonce] + auth_message = [client_first_bare, ",", server_first_raw, ",", client_final_no_proof] + client_signature = :pgo_scram.hmac(stored_key, auth_message) + client_proof = :pgo_scram.bin_xor(client_key, client_signature) + server_key = :pgo_scram.hmac(salted_password, "Server Key") + server_proof = :pgo_scram.hmac(server_key, auth_message) + + client_final = [client_final_no_proof, ",p=", Base.encode64(client_proof)] + :ok = transport.send(sock, :pgo_protocol.encode_scram_response_message(client_final)) + + {:ok, auth_data} = transport.recv(sock, 0, 5000) + {pkts, rest} = Supavisor.Protocol.split_pkts(auth_data) + assert rest == "" + [<> | tail] = pkts + {:ok, ^server_proof} = :pgo_scram.parse_server_final(server_final) + IO.iodata_to_binary(tail) + end + + defp assert_data_row(data) do + {pkts, _} = Supavisor.Protocol.split_pkts(data) + assert Enum.any?(pkts, &match?(<>, &1)) + end + end + + defp recv_until_ready_for_query(transport \\ :gen_tcp, sock, buf) do + {pkts, rest} = Supavisor.Protocol.split_pkts(buf) if Enum.any?(pkts, &match?(<>, &1)) do :ok else - {:ok, more} = :gen_tcp.recv(sock, 0, 5000) - recv_until_ready_for_query(sock, more) + {:ok, more} = transport.recv(sock, 0, 5000) + recv_until_ready_for_query(transport, sock, rest <> more) end end end From 5d6c44d6e4b46863adefe6fb14dfde36efc87eb8 Mon Sep 17 00:00:00 2001 From: felipe stival Date: Sat, 2 May 2026 18:11:31 -0300 Subject: [PATCH 2/4] lock ref --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 9532ba168..ba6b2e9b2 100644 --- a/mix.exs +++ b/mix.exs @@ -75,7 +75,7 @@ defmodule Supavisor.MixProject do {:poolboy, git: "https://github.com/supabase/poolboy", tag: "v0.0.3"}, {:syn, "~> 3.3"}, {:pgo, "~> 0.13"}, - {:pg_saslprep, github: "v0idpwn/pg_saslprep"}, + {:pg_saslprep, github: "v0idpwn/pg_saslprep", ref: "455e2e50757e8e5e091c00be4c1b9818612e3ca2"}, {:rustler, "~> 0.36.1"}, {:ranch, "~> 2.0", override: true}, diff --git a/mix.lock b/mix.lock index 468207459..84592397d 100644 --- a/mix.lock +++ b/mix.lock @@ -46,7 +46,7 @@ "open_api_spex": {:hex, :open_api_spex, "3.21.2", "6a704f3777761feeb5657340250d6d7332c545755116ca98f33d4b875777e1e5", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "f42ae6ed668b895ebba3e02773cfb4b41050df26f803f2ef634c72a7687dc387"}, "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.0", "63ca1742f92f00059298f478048dfb826f4b20d49534493d6919a0db39b6db04", [:mix, :rebar3], [], "hexpm", "3dfbbfaa2c2ed3121c5c483162836c4f9027def469c41578af5ef32589fcfc58"}, "peep": {:hex, :peep, "4.4.0", "4b6289e7258ecf2741041068932455eb8389673bca785623621ddb6935286845", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f289959a9e5fcf92f5a4becaf4dd0df95c58841368f6ad0574b8ea830a7c1453"}, - "pg_saslprep": {:git, "https://github.com/v0idpwn/pg_saslprep.git", "455e2e50757e8e5e091c00be4c1b9818612e3ca2", []}, + "pg_saslprep": {:git, "https://github.com/v0idpwn/pg_saslprep.git", "455e2e50757e8e5e091c00be4c1b9818612e3ca2", [ref: "455e2e50757e8e5e091c00be4c1b9818612e3ca2"]}, "pg_types": {:hex, :pg_types, "0.4.0", "3ce365c92903c5bb59c0d56382d842c8c610c1b6f165e20c4b652c96fa7e9c14", [:rebar3], [], "hexpm", "b02efa785caececf9702c681c80a9ca12a39f9161a846ce17b01fb20aeeed7eb"}, "pgo": {:hex, :pgo, "0.14.0", "f53711d103d7565db6fc6061fcf4ff1007ab39892439be1bb02d9f686d7e6663", [:rebar3], [{:backoff, "~> 1.1.6", [hex: :backoff, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:pg_types, "~> 0.4.0", [hex: :pg_types, repo: "hexpm", optional: false]}], "hexpm", "71016c22599936e042dc0012ee4589d24c71427d266292f775ebf201d97df9c9"}, "phoenix": {:hex, :phoenix, "1.7.20", "6bababaf27d59f5628f9b608de902a021be2cecefb8231e1dbdc0a2e2e480e9b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "6be2ab98302e8784a31829e0d50d8bdfa81a23cd912c395bafd8b8bfb5a086c2"}, From 7b1ac8d84e962f17dd5f11a5340bf7dd50dbc906 Mon Sep 17 00:00:00 2001 From: felipe stival Date: Sun, 3 May 2026 21:54:12 -0300 Subject: [PATCH 3/4] fmt --- lib/supavisor/helpers.ex | 1 + mix.exs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/supavisor/helpers.ex b/lib/supavisor/helpers.ex index 09391090c..4b6d696a4 100644 --- a/lib/supavisor/helpers.ex +++ b/lib/supavisor/helpers.ex @@ -239,6 +239,7 @@ defmodule Supavisor.Helpers do salted_password = :pgo_scram.hi(PgSASLprep.scram_normalize(IO.iodata_to_binary(secrets.password)), salt, i) + client_key = :pgo_scram.hmac(salted_password, "Client Key") stored_key = :pgo_scram.h(client_key) client_first_bare = [<<"n=">>, user_name, <<",r=">>, client_nonce] diff --git a/mix.exs b/mix.exs index ba6b2e9b2..3feb89b8b 100644 --- a/mix.exs +++ b/mix.exs @@ -75,7 +75,8 @@ defmodule Supavisor.MixProject do {:poolboy, git: "https://github.com/supabase/poolboy", tag: "v0.0.3"}, {:syn, "~> 3.3"}, {:pgo, "~> 0.13"}, - {:pg_saslprep, github: "v0idpwn/pg_saslprep", ref: "455e2e50757e8e5e091c00be4c1b9818612e3ca2"}, + {:pg_saslprep, + github: "v0idpwn/pg_saslprep", ref: "455e2e50757e8e5e091c00be4c1b9818612e3ca2"}, {:rustler, "~> 0.36.1"}, {:ranch, "~> 2.0", override: true}, From f2ff26342eac3ac28d4c40c88759111eac38aabd Mon Sep 17 00:00:00 2001 From: felipe stival Date: Wed, 10 Jun 2026 16:43:01 -0300 Subject: [PATCH 4/4] fix: correct authentication method --- lib/supavisor/client_handler/auth_methods.ex | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/supavisor/client_handler/auth_methods.ex b/lib/supavisor/client_handler/auth_methods.ex index 2b77f3f25..d132988ce 100644 --- a/lib/supavisor/client_handler/auth_methods.ex +++ b/lib/supavisor/client_handler/auth_methods.ex @@ -24,9 +24,8 @@ defmodule Supavisor.ClientHandler.AuthMethods do {:ok, :jit | :password | :scram_sha_256} | {:error, SslRequiredError.t()} def fetch_authentication_method(tenant, client_jit, ssl?, user) do case {tenant.use_jit, client_jit, ssl?} do - {false, _, _} -> {:ok, :scram_sha_256} - {true, false, false} -> {:ok, :scram_sha_256} - {true, false, true} -> {:ok, :password} + {_, false, false} -> {:ok, :scram_sha_256} + {_, false, true} -> {:ok, :password} {true, true, false} -> {:error, %SslRequiredError{user: user}} {true, true, true} -> {:ok, :jit} end