Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion lib/supavisor/client_handler/auth_methods/password.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
10 changes: 8 additions & 2 deletions lib/supavisor/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,9 @@ 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]
Expand Down Expand Up @@ -291,7 +293,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)
Expand Down
2 changes: 2 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +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"},
{:rustler, "~> 0.36.1"},
{:ranch, "~> 2.0", override: true},

Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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", [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.23", "2a86f055b50f3ca2e692f8bc0e757b7bde6a44182476ec9193e337ccb7cf5492", [: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", "98b551a267cbcd0ca4a2bfe05ff2fb3cd68699197a2a3e14504f6b7be758ca9d"},
Expand Down
142 changes: 138 additions & 4 deletions test/integration/protocol_integration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -215,14 +215,148 @@
# 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

Check failure on line 264 in test/integration/protocol_integration_test.exs

View workflow job for this annotation

GitHub Actions / Run tests

test fullwidth password (SASLprep private-use bug workaround) authenticates over SSL (cleartext password) (Supavisor.Integration.ProtocolIntegrationTest)
{: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, <<?R, _::32, 3::32>>} = :ssl.recv(ssl, 0, 5000)

pw = @password <> <<0>>
:ok = :ssl.send(ssl, [<<?p, byte_size(pw) + 4::32>>, 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, <<?R, _::32, 10::32, methods_bin::binary>>} = 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_size::32>>, client_first]
:ok = transport.send(sock, :pgo_protocol.encode_scram_response_message(sasl_initial))

{:ok, <<?R, _::32, 11::32, server_first::binary>>} = 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 == ""
[<<?R, _::32, 12::32, server_final::binary>> | 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?(<<?D, _::binary>>, &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?(<<?Z, _::binary>>, &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
Loading