• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

supabase / supavisor / 17802639047

17 Sep 2025 03:26PM UTC coverage: 59.631% (+0.1%) from 59.516%
17802639047

push

github

web-flow
test: add test for DB SSL negotiation rejection ('N' response) (#743)

## What kind of change does this PR introduce?

Test coverage

## What is the new behavior?

Add new test to verify the connection is properly rejected when the DB
denies SSL negotiation with an `'N'` response.

1551 of 2601 relevant lines covered (59.63%)

5277.12 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

46.55
/lib/supavisor/helpers.ex
1
defmodule Supavisor.Helpers do
2
  @moduledoc false
3
  require Logger
4

5
  @spec check_creds_get_ver(map) :: {:ok, String.t() | nil} | {:error, String.t()}
6

7
  def check_creds_get_ver(%{"require_user" => false} = params) do
8
    cond do
×
9
      length(params["users"]) != 1 ->
×
10
        {:error, "Can't use 'require_user' and 'auth_query' with multiple users"}
11

12
      !hd(params["users"])["is_manager"] ->
×
13
        {:error, "Can't use 'require_user' and 'auth_query' with non-manager user"}
14

15
      true ->
×
16
        do_check_creds_get_ver(params)
×
17
    end
18
  end
19

20
  def check_creds_get_ver(%{"users" => _} = params) do
21
    do_check_creds_get_ver(params)
×
22
  end
23

24
  def check_creds_get_ver(_), do: {:ok, nil}
×
25

26
  def do_check_creds_get_ver(params) do
27
    Enum.reduce_while(params["users"], {nil, nil}, fn user, _ ->
28
      upstream_ssl? = !!params["upstream_ssl"]
×
29

30
      ssl_opts =
×
31
        if upstream_ssl? and params["upstream_verify"] == "peer" do
×
32
          [
33
            verify: :verify_peer,
34
            cacerts: [upstream_cert(params["upstream_tls_ca"])],
35
            server_name_indication: String.to_charlist(params["db_host"]),
36
            customize_hostname_check: [{:match_fun, fn _, _ -> true end}]
×
37
          ]
38
        else
39
          [
40
            verify: :verify_none
41
          ]
42
        end
43

44
      {:ok, conn} =
×
45
        Postgrex.start_link(
46
          hostname: params["db_host"],
47
          port: params["db_port"],
48
          database: params["db_database"],
49
          password: user["db_password"],
50
          username: user["db_user"],
51
          ssl: upstream_ssl?,
52
          socket_options: [
53
            ip_version(params["ip_version"], params["db_host"])
54
          ],
55
          queue_target: 1_000,
56
          queue_interval: 5_000,
57
          ssl_opts: ssl_opts
58
        )
59

60
      check =
×
61
        Postgrex.query(conn, "select version()", [])
62
        |> case do
63
          {:ok, %{rows: [[version]]}} ->
64
            if params["require_user"] do
×
65
              {:cont, {:ok, version}}
66
            else
67
              case get_user_secret(conn, params["auth_query"], user["db_user"]) do
×
68
                {:ok, _} ->
×
69
                  {:halt, {:ok, version}}
70

71
                {:error, reason} ->
×
72
                  {:halt, {:error, reason}}
73
              end
74
            end
75

76
          {:error, reason} ->
×
77
            {:halt, {:error, "Can't connect the user #{user["db_user"]}: #{inspect(reason)}"}}
×
78
        end
79

80
      GenServer.stop(conn)
×
81
      check
×
82
    end)
83
    |> case do
×
84
      {:ok, version} ->
85
        parse_pg_version(version)
×
86

87
      other ->
88
        other
×
89
    end
90
  end
91

92
  @spec get_user_secret(pid(), String.t() | nil, String.t()) ::
93
          {:ok, map()} | {:error, String.t()}
94
  def get_user_secret(_conn, nil, _user), do: {:error, "No auth_query specified"}
×
95

96
  def get_user_secret(conn, auth_query, user) when is_binary(auth_query) do
24✔
97
    Postgrex.query!(conn, auth_query, [user])
24✔
98
  catch
99
    _error, reason ->
×
100
      {:error, "Authentication query failed: #{inspect(reason)}"}
101
  else
102
    %{columns: [_, _], rows: [[^user, secret]]} ->
103
      parse_secret(secret, user)
24✔
104

105
    %{columns: [_, _], rows: []} ->
×
106
      {:error,
107
       "There is no user '#{user}' in the database. Please create it or change the user in the config"}
×
108

109
    %{columns: columns} ->
×
110
      {:error,
111
       "Authentication query returned wrong format. Should be two columns: user and secret, but got: #{inspect(columns)}"}
112
  end
113

114
  @spec parse_secret(String.t(), String.t()) :: {:ok, map()} | {:error, String.t()}
115
  def parse_secret("SCRAM-SHA-256" <> _ = secret, user) do
116
    # <digest>$<iteration>:<salt>$<stored_key>:<server_key>
117
    case Regex.run(~r/^(.+)\$(\d+):(.+)\$(.+):(.+)$/, secret) do
25✔
118
      [_, digest, iterations, salt, stored_key, server_key] ->
25✔
119
        {:ok,
120
         %{
121
           digest: digest,
122
           iterations: String.to_integer(iterations),
123
           salt: salt,
124
           stored_key: Base.decode64!(stored_key),
125
           server_key: Base.decode64!(server_key),
126
           user: user
127
         }}
128

129
      _ ->
×
130
        {:error, "Can't parse secret"}
131
    end
132
  end
133

134
  def parse_secret("md5" <> secret, user) do
1✔
135
    {:ok, %{digest: :md5, secret: secret, user: user}}
136
  end
137

138
  def parse_secret(_secret, _user) do
1✔
139
    {:error, "Unsupported or invalid secret format"}
140
  end
141

142
  def parse_postgres_secret(_), do: {:error, "Digest not supported"}
×
143

144
  ## Internal functions
145

146
  @doc """
147
  Parses a PostgreSQL version string and returns the version number and platform.
148

149
  ## Examples
150

151
      iex> Supavisor.Helpers.parse_pg_version("PostgreSQL 14.6 (Debian 14.6-1.pgdg110+1) some string")
152
      {:ok, "14.6 (Debian 14.6-1.pgdg110+1)"}
153

154
      iex> Supavisor.Helpers.parse_pg_version("PostgreSQL 15.1 on aarch64-unknown-linux-gnu, compiled by gcc (Ubuntu 10.3.0-1ubuntu1~20.04) 10.3.0, 64-bit")
155
      {:ok, "15.1"}
156

157
      iex> Supavisor.Helpers.parse_pg_version("PostgreSQL on x86_64-pc-linux-gnu")
158
      {:error, "Can't parse version in PostgreSQL on x86_64-pc-linux-gnu"}
159
  """
160
  def parse_pg_version(version) do
161
    case Regex.run(~r/PostgreSQL\s(\d+\.\d+)(?:\s\(([^)]+)\))?.*/, version) do
×
162
      [_, version, platform] ->
×
163
        {:ok, "#{version} (#{platform})"}
×
164

165
      [_, version] ->
×
166
        {:ok, version}
167

168
      _ ->
×
169
        {:error, "Can't parse version in #{version}"}
×
170
    end
171
  end
172

173
  @doc """
174
  Returns the IP version for a given host.
175

176
  ## Examples
177

178
      iex> Supavisor.Helpers.ip_version(:v4, "example.com")
179
      :inet
180
      iex> Supavisor.Helpers.ip_version(:v6, "example.com")
181
      :inet6
182
      iex> Supavisor.Helpers.ip_version(nil, "example.com")
183
      :inet
184
  """
185
  @spec ip_version(any(), String.t()) :: :inet | :inet6
186
  def ip_version(:v4, _), do: :inet
×
187
  def ip_version(:v6, _), do: :inet6
×
188

189
  def ip_version(_, host) do
190
    detect_ip_version(host)
53✔
191
  end
192

193
  @doc """
194
  Detects the IP version for a given host.
195

196
  ## Examples
197

198
      iex> Supavisor.Helpers.detect_ip_version("example.com")
199
      :inet
200
      iex> Supavisor.Helpers.detect_ip_version("ipv6.example.com")
201
      :inet6
202
  """
203
  @spec detect_ip_version(String.t()) :: :inet | :inet6
204
  def detect_ip_version(host) when is_binary(host) do
205
    Logger.info("Detecting IP version for #{host}")
53✔
206
    host = String.to_charlist(host)
53✔
207

208
    case :inet.gethostbyname(host) do
53✔
209
      {:ok, _} -> :inet
53✔
210
      _ -> :inet6
×
211
    end
212
  end
213

214
  @spec cert_to_bin(binary()) :: {:ok, binary()} | {:error, atom()}
215
  def cert_to_bin(cert) do
216
    case :public_key.pem_decode(cert) do
×
217
      [] ->
×
218
        {:error, :cant_decode_certificate}
219

220
      pem_entries ->
221
        cert = for {:Certificate, cert, :not_encrypted} <- pem_entries, do: cert
×
222

223
        case cert do
×
224
          [cert] -> {:ok, cert}
×
225
          _ -> {:error, :invalid_certificate}
×
226
        end
227
    end
228
  end
229

230
  @spec upstream_cert(binary() | nil) :: binary() | nil
231
  def upstream_cert(default) do
232
    Application.get_env(:supavisor, :global_upstream_ca) || default
39✔
233
  end
234

235
  @spec downstream_cert() :: Path.t() | nil
236
  def downstream_cert do
237
    Application.get_env(:supavisor, :global_downstream_cert)
×
238
  end
239

240
  @spec downstream_key() :: Path.t() | nil
241
  def downstream_key do
242
    Application.get_env(:supavisor, :global_downstream_key)
×
243
  end
244

245
  @spec get_client_final(:password | :auth_query, map(), map(), binary(), binary(), binary()) ::
246
          {iolist(), binary()}
247
  def get_client_final(
248
        :password,
249
        secrets,
250
        srv_first,
251
        client_nonce,
252
        user_name,
253
        channel
254
      ) do
255
    channel_binding = "c=#{channel}"
114✔
256
    nonce = ["r=", srv_first.nonce]
114✔
257

258
    salt = srv_first.salt
114✔
259
    i = srv_first.i
114✔
260

261
    salted_password = :pgo_scram.hi(:pgo_sasl_prep_profile.validate(secrets), salt, i)
114✔
262
    client_key = :pgo_scram.hmac(salted_password, "Client Key")
114✔
263
    stored_key = :pgo_scram.h(client_key)
114✔
264
    client_first_bare = [<<"n=">>, user_name, <<",r=">>, client_nonce]
114✔
265
    server_first = srv_first.raw
114✔
266
    client_final_without_proof = [channel_binding, ",", nonce]
114✔
267
    auth_message = [client_first_bare, ",", server_first, ",", client_final_without_proof]
114✔
268
    client_signature = :pgo_scram.hmac(stored_key, auth_message)
114✔
269
    client_proof = :pgo_scram.bin_xor(client_key, client_signature)
114✔
270

271
    server_key = :pgo_scram.hmac(salted_password, "Server Key")
114✔
272
    server_signature = :pgo_scram.hmac(server_key, auth_message)
114✔
273

274
    {[client_final_without_proof, ",p=", Base.encode64(client_proof)], server_signature}
275
  end
276

277
  def get_client_final(
278
        :auth_query,
279
        secrets,
280
        srv_first,
281
        client_nonce,
282
        user_name,
283
        channel
284
      ) do
285
    channel_binding = "c=#{channel}"
268✔
286
    nonce = ["r=", srv_first.nonce]
268✔
287

288
    client_first_bare = [<<"n=">>, user_name, <<",r=">>, client_nonce]
268✔
289
    server_first = srv_first.raw
268✔
290
    client_final_without_proof = [channel_binding, ",", nonce]
268✔
291
    auth_message = [client_first_bare, ",", server_first, ",", client_final_without_proof]
268✔
292
    client_signature = :pgo_scram.hmac(secrets.stored_key, auth_message)
268✔
293
    client_proof = :pgo_scram.bin_xor(secrets.client_key, client_signature)
268✔
294

295
    server_signature = :pgo_scram.hmac(secrets.server_key, auth_message)
268✔
296

297
    {[client_final_without_proof, ",p=", Base.encode64(client_proof)], server_signature}
298
  end
299

300
  def signatures(stored_key, server_key, srv_first, client_nonce, user_name, channel) do
301
    channel_binding = "c=#{channel}"
449✔
302
    nonce = ["r=", srv_first.nonce]
449✔
303
    client_first_bare = [<<"n=">>, user_name, <<",r=">>, client_nonce]
449✔
304
    server_first = srv_first.raw
449✔
305
    client_final_without_proof = [channel_binding, ",", nonce]
449✔
306
    auth_message = [client_first_bare, ",", server_first, ",", client_final_without_proof]
449✔
307

308
    %{
449✔
309
      client: :pgo_scram.hmac(stored_key, auth_message),
310
      server: :pgo_scram.hmac(server_key, auth_message)
311
    }
312
  end
313

314
  def hash(bin) do
315
    :crypto.hash(:sha256, bin)
447✔
316
  end
317

318
  @spec parse_server_first(binary(), binary()) :: map()
319
  def parse_server_first(message, nonce) do
320
    :pgo_scram.parse_server_first(message, nonce) |> Map.new()
831✔
321
  end
322

323
  @spec md5([String.t()]) :: String.t()
324
  def md5(strings) do
325
    strings
326
    |> :erlang.md5()
327
    |> Base.encode16(case: :lower)
3✔
328
  end
329

330
  @spec rpc(Node.t(), module(), atom(), [any()], non_neg_integer()) :: {:error, any()} | any()
331
  def rpc(node, module, function, args, timeout \\ 15_000) do
332
    :erpc.call(node, module, function, args, timeout)
×
333
  catch
334
    kind, reason -> {:error, {:badrpc, {kind, reason}}}
×
335
  else
336
    {:EXIT, _} = badrpc -> {:error, {:badrpc, badrpc}}
×
337
    result -> result
×
338
  end
339

340
  @spec parse_integer_list(String.t()) :: [integer()]
341
  def parse_integer_list(numbers) when is_binary(numbers) do
342
    numbers
343
    |> String.split(",", trim: true)
344
    |> Enum.map(&String.to_integer/1)
×
345
  end
346

347
  @doc """
348
  Sets the maximum heap size for the current process. The `max_heap_size` parameter is in megabytes.
349
  """
350
  @spec set_max_heap_size(pos_integer()) :: map()
351
  def set_max_heap_size(max_heap_size) do
352
    max_heap_words = div(max_heap_size * 1024 * 1024, :erlang.system_info(:wordsize))
987✔
353
    Process.flag(:max_heap_size, %{size: max_heap_words})
987✔
354
  end
355

356
  @spec set_log_level(atom()) :: :ok | nil
357
  def set_log_level(level) when level in [:debug, :info, :notice, :warning, :error] do
358
    Logger.notice("Setting log level to #{inspect(level)}")
×
359
    Logger.put_process_level(self(), level)
×
360
  end
361

362
  def set_log_level(_), do: nil
327✔
363

364
  @spec peer_ip(:gen_tcp.socket()) :: String.t()
365
  def peer_ip(socket) do
366
    case :inet.peername(socket) do
688✔
367
      {:ok, {ip, _port}} -> List.to_string(:inet.ntoa(ip))
688✔
368
      _error -> "undefined"
×
369
    end
370
  end
371

372
  @spec controlling_process(Supavisor.sock(), pid) :: :ok | {:error, any()}
373
  def controlling_process({mod, socket}, pid),
374
    do: mod.controlling_process(socket, pid)
×
375

376
  # This is the value of `NAMEDATALEN` set when compiling PostgreSQL. By default
377
  # we use default Postgres value of `64`
378
  @max_length Application.compile_env(:supabase, :namedatalen, 64) - 1
379

380
  @spec validate_name(String.t()) :: boolean()
381
  def validate_name(name) do
382
    byte_size(name) in 1..@max_length and String.printable?(name)
2,034✔
383
  end
384

385
  @doc """
386
  Converts megabytes to Erlang words.
387

388
  ## Examples
389

390
      iex> Supavisor.Helpers.mb_to_words(1)
391
      131_072
392

393
      iex> Supavisor.Helpers.mb_to_words(1.5)
394
      196_608
395
  """
396
  @spec mb_to_words(number()) :: pos_integer()
397
  def mb_to_words(mb), do: round(mb * 1_048_576 / :erlang.system_info(:wordsize))
1✔
398

399
  @spec get_env_bool(String.t(), boolean()) :: boolean()
400
  def get_env_bool(env_var, default) do
401
    case System.get_env(env_var) do
×
402
      nil ->
403
        default
×
404

405
      value when value in ["true", "1"] ->
×
406
        true
407

408
      value when value in ["false", "0"] ->
×
409
        false
410

411
      value ->
412
        raise "Invalid boolean value for #{env_var}: #{inspect(value)}. Expected: true, false, 1, or 0"
×
413
    end
414
  end
415
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc