From b50c5bf7b2375eefd4972bae2f4e434c5a0020c6 Mon Sep 17 00:00:00 2001 From: PingoLee Date: Thu, 5 Nov 2026 17:57:22 -0300 Subject: [PATCH] Validate sslmode and gssencmode at parse time and in the constructor An invalid sslmode or gssencmode passed parse_dsn and was only rejected when connecting, and the sslmode check ran in _connect_tls!, after the GSS branch had already returned: gssencmode=require with an invalid sslmode connected over GSS encryption. The accepted values now live in one place, Postgres.SSLMODES and Postgres.GSSENCMODES. parse_dsn and validate_dsn check them, after environment defaults are applied, in the slot the malformed-UTF-8 check already used, with the same message. The ConnectionParams and Connection keyword constructors check them too and throw the same ArgumentError. The connect path keeps its checks as a backstop, both before any dial, and cancel_request gets the same backstop. Values stay case-insensitive and no value is added. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/src/index.md | 2 +- src/Postgres.jl | 15 ++++ src/api/API.jl | 15 +++- src/connection_string.jl | 35 +++++++--- test/connection_string_errors.jl | 70 ++++++++++++++++++- test/gssapi.jl | 114 ++++++++++++++++++++++++++++++- test/runtests.jl | 2 +- 8 files changed, 238 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 06c0f86..f07e7ca 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Connection options support: - Environment defaults: `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, `PGAPPNAME`, `PGCONNECT_TIMEOUT`, `PGOPTIONS`, TLS-related `PGSSL*` variables, and `PGGSSENCMODE`, `PGKRBSRVNAME`, `PGGSSDELEGATION`. - `sslmode` values: `disable`, `prefer` (the default), `require`, `verify-full`. Only `verify-full` verifies the server's certificate; `require` encrypts without authenticating the server, and the default `prefer` falls back to an unencrypted connection if the server declines TLS. Use `verify-full` with `sslrootcert` when the connection needs to be authenticated. - TLS files: `sslrootcert`, `sslcert`, `sslkey`, and `sslcapath` (`sslcapath` is a fallback CA bundle or directory, used only when `sslrootcert` is unset and ignored otherwise). `sslservername` overrides the TLS server name when connecting to a pre-resolved address; under `verify-full` it is also the name the certificate is verified against, so it must name the server you intend to authenticate. -- `gssencmode` values: `disable` (the default), `prefer`, `require`. GSSAPI (Kerberos) encryption is negotiated before TLS, as in libpq, through the operating system's Kerberos library and ticket cache (`kinit`); `prefer` only tries it when a ticket is available and otherwise follows `sslmode`. `krbsrvname` sets the Kerberos service name (default `postgres`; Active Directory servers often need `POSTGRES`) and `gssdelegation` forwards the ticket to the server. GSSAPI authentication (`gss` in `pg_hba.conf`) is answered automatically with the same library. +- `gssencmode` values: `disable` (the default), `prefer`, `require`. GSSAPI (Kerberos) encryption is negotiated before TLS, as in libpq, through the operating system's Kerberos library and ticket cache (`kinit`); `prefer` only tries it when a ticket is available and otherwise follows `sslmode`. `krbsrvname` sets the Kerberos service name (default `postgres`; Active Directory servers often need `POSTGRES`) and `gssdelegation` forwards the ticket to the server. GSSAPI authentication (`gss` in `pg_hba.conf`) is answered automatically with the same library. Both `sslmode` and `gssencmode` are case-insensitive, and an invalid value of either is rejected with an `ArgumentError` when the connection string is parsed or the connection is constructed, not when connecting. - `connect_timeout` (seconds) and `statement_timeout` (milliseconds). - `application_name` and `statement_cache_maxsize`. - `options`: server command-line options applied when the session starts, as in libpq (`PGOPTIONS`). For example `options='-c search_path=myschema'` sets the default schema. The value is sent in the startup packet, so it also applies after an automatic reconnect. diff --git a/docs/src/index.md b/docs/src/index.md index 68c5f0e..cc5aed7 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -20,7 +20,7 @@ Postgres.jl accepts DSN strings or PostgreSQL URIs and supports: - Environment defaults from `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, `PGAPPNAME`, `PGCONNECT_TIMEOUT`, `PGOPTIONS`, TLS-related `PGSSL*` variables, and `PGGSSENCMODE`, `PGKRBSRVNAME`, `PGGSSDELEGATION`. - `sslmode` values: `disable`, `prefer` (the default), `require`, `verify-full`. Only `verify-full` verifies the server's certificate; `require` encrypts without authenticating the server, and the default `prefer` falls back to an unencrypted connection if the server declines TLS. Use `verify-full` with `sslrootcert` when the connection needs to be authenticated. - TLS files: `sslrootcert`, `sslcert`, `sslkey`, and `sslcapath` (`sslcapath` is a fallback CA bundle or directory, used only when `sslrootcert` is unset and ignored otherwise). `sslservername` overrides the TLS server name when connecting to a pre-resolved address; under `verify-full` it is also the name the certificate is verified against, so it must name the server you intend to authenticate. -- `gssencmode` values: `disable` (the default), `prefer`, `require`. GSSAPI (Kerberos) encryption is negotiated before TLS, as in libpq, through the operating system's Kerberos library and ticket cache (`kinit`); `prefer` only tries it when a ticket is available and otherwise follows `sslmode`. `krbsrvname` sets the Kerberos service name (default `postgres`; Active Directory servers often need `POSTGRES`) and `gssdelegation` forwards the ticket to the server. GSSAPI authentication (`gss` in `pg_hba.conf`) is answered automatically with the same library. +- `gssencmode` values: `disable` (the default), `prefer`, `require`. GSSAPI (Kerberos) encryption is negotiated before TLS, as in libpq, through the operating system's Kerberos library and ticket cache (`kinit`); `prefer` only tries it when a ticket is available and otherwise follows `sslmode`. `krbsrvname` sets the Kerberos service name (default `postgres`; Active Directory servers often need `POSTGRES`) and `gssdelegation` forwards the ticket to the server. GSSAPI authentication (`gss` in `pg_hba.conf`) is answered automatically with the same library. Both `sslmode` and `gssencmode` are case-insensitive, and an invalid value of either is rejected with an `ArgumentError` when the connection string is parsed or the connection is constructed, not when connecting. - `connect_timeout` (seconds), `statement_timeout` (milliseconds). - `application_name` and `statement_cache_maxsize`. - `options`: server command-line options applied when the session starts, as in libpq (`PGOPTIONS`). For example `options='-c search_path=myschema'` sets the default schema. The value is sent in the startup packet, so it also applies after an automatic reconnect. diff --git a/src/Postgres.jl b/src/Postgres.jl index a5ba7e5..76b2628 100644 --- a/src/Postgres.jl +++ b/src/Postgres.jl @@ -21,6 +21,12 @@ Base.showerror(io::IO, e::PostgresInterfaceError) = print(io, e.msg) @noinline _reject_nul(what::String) = throw(PostgresInterfaceError("$what cannot contain a NUL byte")) +# The values accepted for sslmode and gssencmode, compared case-insensitively. +# The connection-string parser, the constructors, and the connect and cancel +# paths all read these, so the lists cannot drift apart. +const SSLMODES = ("disable", "prefer", "require", "verify-full") +const GSSENCMODES = ("disable", "prefer", "require") + include("api/API.jl") using .API include("connection_string.jl") @@ -82,6 +88,9 @@ Supported keyword arguments. All are also available as DSN/URI options except forwards the ticket to the server (default `false`). Kerberos comes from the operating system library (`kinit` tickets, `krb5.conf`), with no bundled Kerberos; see the support policy. +- `sslmode` and `gssencmode` values are case-insensitive. Any other value + throws an `ArgumentError` when the connection is constructed, before + connecting, with the message `Postgres.parse_dsn` gives for it. - `statement_cache_maxsize`: LRU backend cache size for explicit named prepared statements (default 100; `0` disables) - `reconnect`: automatically reconnect and re-prepare statements if the @@ -180,6 +189,12 @@ mutable struct Connection{T, S <: API.AbstractPostgresStyle} <: DBInterface.Conn krbsrvname_val !== nothing && occursin('\0', krbsrvname_val) && _reject_nul("krbsrvname") xor(sslcert_val === nothing, sslkey_val === nothing) && throw(PostgresInterfaceError("sslcert and sslkey must be provided together")) + # checked here, not only when connecting, so that no negotiation path + # can skip the check and the error matches parse_dsn's + err = ConnectionString.check_mode("sslmode", sslmode_val, SSLMODES) + err === nothing || throw(ArgumentError(err.msg)) + err = ConnectionString.check_mode("gssencmode", gssencmode_val, GSSENCMODES) + err === nothing || throw(ArgumentError(err.msg)) maxsize = max(0, Int(statement_cache_maxsize)) socket, pid, skey, server_params = API.connect(host, port, dbname, user, password, debug, app_name, options_val, timeout, sslmode_val, sslrootcert_val, sslcert_val, sslkey_val, sslcapath_val, sslservername_val, statement_timeout_val, gssencmode_val, krbsrvname_val, gssdelegation, style) registry = Dict(API.DEFAULT_TYPE_REGISTRY) diff --git a/src/api/API.jl b/src/api/API.jl index 1ebf287..3c0d43b 100644 --- a/src/api/API.jl +++ b/src/api/API.jl @@ -1,7 +1,7 @@ module API using UUIDs, Dates, Reseau, SASLAuth, MD5, Parsers, StructUtils, JSON, Random -import ..PostgresInterfaceError +import ..PostgresInterfaceError, ..SSLMODES, ..GSSENCMODES import Durations, DataDecimals @@ -758,8 +758,13 @@ function connect(host::String, port::Integer, dbname::String, user::String, @nos sslcapath_v = sslcapath::Union{String, Nothing} sslservername_v = sslservername::Union{String, Nothing} statement_timeout_v = statement_timeout::Union{Int, Nothing} + # The public constructors reject invalid modes with an ArgumentError before + # getting here, so these checks are a backstop for direct callers. They run + # before any dial, so no negotiation path (GSS included) skips either one. gssencmode_str = gssencmode === nothing ? "disable" : lowercase(gssencmode::String) - gssencmode_str == "disable" || gssencmode_str == "prefer" || gssencmode_str == "require" || throw(Error("invalid gssencmode: $gssencmode_str")) + gssencmode_str in GSSENCMODES || throw(Error("invalid gssencmode: $gssencmode_str")) + sslmode_str = sslmode_v === nothing ? "prefer" : lowercase(sslmode_v::String) + sslmode_str in SSLMODES || throw(Error("invalid sslmode: $sslmode_str")) krbsrvname_str = krbsrvname === nothing ? "postgres" : krbsrvname::String # GSS encryption is only attempted when initiator credentials (a ticket) # can be acquired; `require` without them fails before any dial, as in libpq @@ -813,8 +818,8 @@ function _connect_tls!(socket::Reseau.TCP.Conn, debug::Bool, user::String, dbnam sslcapath_v = sslcapath::Union{String, Nothing} sslservername_v = sslservername::Union{String, Nothing} statement_timeout_v = statement_timeout::Union{Int, Nothing} + # validated by connect sslmode_str = sslmode_v === nothing ? "prefer" : lowercase(String(sslmode_v)) - sslmode_str == "disable" || sslmode_str == "prefer" || sslmode_str == "require" || sslmode_str == "verify-full" || throw(Error("invalid sslmode: $sslmode_str")) if sslmode_str != "disable" # send SSLRequest writemessage(socket, debug, '\0', Int32(80877103)) @@ -1649,6 +1654,10 @@ function cancel_request(host::String, port::Int, pid::Int32, skey::Int32, debug: tls_required = sslmode_str == "require" || sslmode_str == "verify-full" gssencmode_str = gssencmode === nothing ? "disable" : lowercase(gssencmode::String) gss_required = gssencmode_str == "require" + # a backstop, as in connect: an unknown mode would fall through to the + # branches that may send the cancel key in cleartext + gssencmode_str in GSSENCMODES || throw(Error("invalid gssencmode: $gssencmode_str")) + sslmode_str in SSLMODES || throw(Error("invalid sslmode: $sslmode_str")) krbsrvname_str = krbsrvname === nothing ? "postgres" : krbsrvname::String socket = connectsocket(host, port, connect_timeout_v) stream = socket diff --git a/src/connection_string.jl b/src/connection_string.jl index 58a2cb2..db27db2 100644 --- a/src/connection_string.jl +++ b/src/connection_string.jl @@ -1,6 +1,7 @@ module ConnectionString using URIs +import ..SSLMODES, ..GSSENCMODES """ Postgres.ConnectionParams(; host="localhost", port=5432, user="", password=nothing, @@ -17,7 +18,10 @@ arguments mirror the connection keywords: `application_name`, `options`, `sslservername`, `gssencmode`, `krbsrvname`, `gssdelegation`, `statement_timeout`, `statement_cache_maxsize`, `debug`, and `reconnect`. `numeric_overflow` accepts `:warn` (return out-of-range numeric values as text with a warning) or `:error` (throw). DSN strings use `warn` or -`error` without the colon. +`error` without the colon. `sslmode` accepts `disable`, `prefer`, `require`, or +`verify-full`, and `gssencmode` accepts `disable`, `prefer`, or `require`, in +any letter case; any other value throws an `ArgumentError` here rather than +when connecting. """ struct ConnectionParams host::String @@ -46,6 +50,10 @@ end function ConnectionParams(; host::String="localhost", port::Int=5432, user::String="", password::Union{String, Nothing}=nothing, dbname::String="", application_name::Union{String, Nothing}=nothing, options::Union{String, Nothing}=nothing, connect_timeout::Union{Int, Nothing}=nothing, sslmode::Union{String, Nothing}=nothing, sslrootcert::Union{String, Nothing}=nothing, sslcert::Union{String, Nothing}=nothing, sslkey::Union{String, Nothing}=nothing, sslcapath::Union{String, Nothing}=nothing, sslservername::Union{String, Nothing}=nothing, gssencmode::Union{String, Nothing}=nothing, krbsrvname::Union{String, Nothing}=nothing, gssdelegation::Bool=false, statement_timeout::Union{Int, Nothing}=nothing, statement_cache_maxsize::Int=100, debug::Bool=false, reconnect::Bool=false, numeric_overflow::Symbol=:warn) numeric_overflow in (:warn, :error) || throw(ArgumentError("numeric_overflow must be :warn or :error")) + err = check_mode("sslmode", sslmode, SSLMODES) + err === nothing || throw(ArgumentError(err.msg)) + err = check_mode("gssencmode", gssencmode, GSSENCMODES) + err === nothing || throw(ArgumentError(err.msg)) return ConnectionParams(host, port, user, password, dbname, application_name, options, connect_timeout, sslmode, sslrootcert, sslcert, sslkey, sslcapath, sslservername, gssencmode, krbsrvname, gssdelegation, statement_timeout, statement_cache_maxsize, debug, reconnect, numeric_overflow) end @@ -152,6 +160,14 @@ const IGNORED_PARAMS = Set([ # valid UTF-8 either, but lowercase accepts them, so they parse as before. has_malformed_char(s::String) = any(c -> Base.ismalformed(c) || Base.isoverlong(c), s) +# sslmode and gssencmode are case-insensitive, as in libpq. A malformed value +# can never match and cannot be lowercased. +function check_mode(key::String, value::Union{String, Nothing}, accepted::Tuple{Vararg{String}}) + value === nothing && return nothing + !has_malformed_char(value) && lowercase(value) in accepted && return nothing + return ConnectionParameterError(key, :invalid_value, "invalid value \"$(escape_string(value))\" for connection parameter \"$key\"; expected one of $(join(accepted, ", "))") +end + function parse_bool_param(value::Union{String, Nothing}, default::Bool, key::String) value === nothing && return default # an empty value means "unset", as it does for the integer parameters @@ -230,13 +246,10 @@ function params_from_values(values::Dict{String, String}) port isa ConnectionParameterError && return port connect_timeout = parse_optional_int(get(merged, "connect_timeout", nothing), "connect_timeout") connect_timeout isa ConnectionParameterError && return connect_timeout - # other sslmode and gssencmode values are checked when connecting, but a - # malformed one can never match and cannot be lowercased - for (key, accepted) in (("sslmode", "disable, prefer, require, verify-full"), ("gssencmode", "disable, prefer, require")) - value = get(merged, key, nothing) - (value === nothing || !has_malformed_char(value)) || - return ConnectionParameterError(key, :invalid_value, "invalid value \"$(escape_string(value))\" for connection parameter \"$key\"; expected one of $accepted") - end + err = check_mode("sslmode", get(merged, "sslmode", nothing), SSLMODES) + err === nothing || return err + err = check_mode("gssencmode", get(merged, "gssencmode", nothing), GSSENCMODES) + err === nothing || return err gssdelegation = parse_bool_param(get(merged, "gssdelegation", nothing), false, "gssdelegation") gssdelegation isa ConnectionParameterError && return gssdelegation statement_timeout = parse_optional_int(get(merged, "statement_timeout", nothing), "statement_timeout") @@ -369,6 +382,12 @@ to the `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, `PGAPPNAME`, `PGCONNECT_TIMEOUT`, `PGSSL*`, `PGGSSENCMODE`, `PGKRBSRVNAME`, and `PGGSSDELEGATION` environment variables, then to defaults. +An invalid value throws an `ArgumentError`. This includes an `sslmode` or +`gssencmode` that is not one of the accepted values, compared +case-insensitively, also when it comes from `PGSSLMODE` or `PGGSSENCMODE`: +`invalid value "verfy-full" for connection parameter "sslmode"; expected one of +disable, prefer, require, verify-full`. + Invalid URI syntax is reported as `ArgumentError` without retaining the underlying parser's input-bearing exception. To find out which parameter is invalid without parsing the message, use `Postgres.validate_dsn`. diff --git a/test/connection_string_errors.jl b/test/connection_string_errors.jl index 6878165..dca37be 100644 --- a/test/connection_string_errors.jl +++ b/test/connection_string_errors.jl @@ -113,7 +113,7 @@ end @test Postgres.validate_dsn("host=h numeric_overflow=ignore").msg == thrown.msg # values are checked in the order parse_dsn reports them - invalid = ["port=abc", "connect_timeout=x", "gssdelegation=2", "statement_timeout=x", + invalid = ["port=abc", "connect_timeout=x", "sslmode=bogus", "gssencmode=bogus", "gssdelegation=2", "statement_timeout=x", "statement_cache_maxsize=x", "debug=maybe", "reconnect=ture", "numeric_overflow=ignore"] for i in eachindex(invalid) dsn = join(["host=h"; invalid[i:end]], " ") @@ -154,7 +154,6 @@ end @test Postgres.validate_dsn("host=h password=p\xed\xa0\x80") === nothing @test Postgres.parse_dsn("host=h password=p\xed\xa0\x80").password == "p\xed\xa0\x80" @test Postgres.parse_dsn("postgresql://u:%ff@h/db").password == "\xff" - @test Postgres.validate_dsn("postgresql://u@h/db?sslmode=%ed%a0%80") === nothing withenv("PGPORT" => nothing) do @test Postgres.validate_dsn(nothing) === nothing end @@ -181,3 +180,70 @@ end @test !occursin("top-secret", sprint(showerror, err)) end end + +@testset "sslmode and gssencmode values" begin + for (key, accepted, envkey) in (("sslmode", ("disable", "prefer", "require", "verify-full"), "PGSSLMODE"), + ("gssencmode", ("disable", "prefer", "require"), "PGGSSENCMODE")) + field = Symbol(key) + # every value, in any case, from every source; parse_dsn stores it lowercased + for mode in accepted, value in (mode, uppercase(mode), titlecase(mode)) + @test getfield(Postgres.parse_dsn("host=h $key=$value"), field) == mode + @test getfield(Postgres.parse_dsn("postgresql://u@h/db?$key=$value"), field) == mode + @test Postgres.validate_dsn("host=h $key=$value") === nothing + withenv(envkey => value) do + @test getfield(Postgres.parse_dsn("host=h"), field) == mode + end + @test getfield(Postgres.ConnectionParams(; field => value), field) == value + end + + # anything else is rejected when parsing or constructing, not when connecting + rejected = [("bogus", "bogus"), ("verfy-full", "verfy-full"), ("", ""), + ("require ", "require%20"), ("\xed\xa0\x80", "%ed%a0%80")] + for (value, encoded) in rejected + msg = "invalid value \"$(escape_string(value))\" for connection parameter \"$key\"; expected one of $(join(accepted, ", "))" + check = function (dsn) + err = Postgres.validate_dsn(dsn) + @test err isa Postgres.ConnectionParameterError + err isa Postgres.ConnectionParameterError || return + @test err.key == key + @test err.reason === :invalid_value + @test err.msg == msg + thrown = try + Postgres.parse_dsn(dsn) + catch e + e + end + @test thrown isa ArgumentError && thrown.msg == msg + end + check("host=h $key='$value'") + check("postgresql://u@h/db?$key=$encoded") + # an unexpanded ${PGSSLMODE} must fail loudly rather than fall back to the + # default. Windows keeps environment variables as UTF-16, so they cannot + # hold a lone surrogate. + if isvalid(value) || !Sys.iswindows() + withenv(() -> check("host=h"), envkey => value) + end + for construct in (() -> Postgres.ConnectionParams(; field => value), + # port 1: a dial would fail with a Postgres.Error instead + () -> Postgres.Connection(; host="127.0.0.1", port=1, field => value)) + thrown = try + construct() + catch e + e + end + @test thrown isa ArgumentError && thrown.msg == msg + end + end + end + + # the Connection constructor's other checks still come first + @test_throws Postgres.PostgresInterfaceError Postgres.Connection(host="a\0b", port=1, sslmode="bogus") + @test_throws Postgres.PostgresInterfaceError Postgres.Connection(host="127.0.0.1", port=1, sslcert="c.pem", gssencmode="bogus") + + # the parser, the constructors, and the connect and cancel paths read one + # list: no other site in src may spell the values out again + srcdir = joinpath(pkgdir(Postgres), "src") + code = join((read(joinpath(dir, f), String) for (dir, _, files) in walkdir(srcdir) for f in files if endswith(f, ".jl")), "\n") + @test count(r"\"disable\", \"prefer\", \"require\"", code) == 2 + @test !occursin(r"(== \"(disable|prefer|require|verify-full)\"\s*\|\|\s*\w+\s*){2}", code) +end diff --git a/test/gssapi.jl b/test/gssapi.jl index 85e7453..bfbf295 100644 --- a/test/gssapi.jl +++ b/test/gssapi.jl @@ -388,7 +388,119 @@ function test_gssapi_protocol() @test accepted[] == 1 @test isempty(style.contexts) end - @test_throws Postgres.Error Postgres.Connection(host="127.0.0.1", port=1, user="u", gssencmode="bogus", style=style) + @test_throws ArgumentError Postgres.Connection(host="127.0.0.1", port=1, user="u", gssencmode="bogus", style=style) + end + + @testset "every sslmode and gssencmode value is accepted, in any case" begin + # a server without TLS: disable and prefer connect, while require and + # verify-full get past validation and fail on its 'N' + handler = function (sock, n) + len = rd32(readn(sock, 4)) + body = readn(sock, len - 4) + if rd32(body) == SSL_REQUEST + send(sock, [UInt8('N')]) + read_startup(sock) + end + ready!(sock) + drain(sock) + end + style = FakeGSSStyle(false) + with_fake_server(handler) do port, accepted + for mode in ("disable", "prefer", "require", "verify-full"), sslmode in (mode, uppercase(mode), titlecase(mode)), + gssencmode in (nothing, "disable", "Prefer", "PREFER") + err = connect_err() do + close(Postgres.Connection(host="127.0.0.1", port=port, user="u", sslmode=sslmode, gssencmode=gssencmode, style=style)) + end + if mode in ("disable", "prefer") + @test err === nothing + else + @test err isa Postgres.Error && err.message == "server does not support SSL" + end + end + for gssencmode in ("require", "Require", "REQUIRE") + err = connect_err(() -> Postgres.Connection(host="127.0.0.1", port=port, user="u", gssencmode=gssencmode, style=style)) + @test err isa Postgres.Error && err.message == "GSSAPI encryption required but no credential cache" + end + end + end + + @testset "an invalid sslmode is refused before GSS encryption is negotiated" begin + # this server accepts GSS encryption, so before sslmode was checked up + # front, gssencmode=require with an invalid sslmode connected + style = FakeGSSStyle() + handler = function (sock, n) + code, _ = read_request(sock) + code == GSSENC_REQUEST || error("expected GSSENCRequest, got $code") + send(sock, [UInt8('G')]) + io = server_handshake(sock) + read_startup(io) + ready!(io) + drain(io) + end + with_fake_server(handler) do port, accepted + for (gssencmode, sslmode) in (("require", "bogus"), ("Prefer", "verify-ful")) + err = connect_err(() -> Postgres.Connection(host="127.0.0.1", port=port, user="u", gssencmode=gssencmode, sslmode=sslmode, style=style)) + @test err isa ArgumentError + @test err.msg == "invalid value \"$sslmode\" for connection parameter \"sslmode\"; expected one of disable, prefer, require, verify-full" + # the connect path checks too, before any dial, for direct callers + err = connect_err(() -> Postgres.API.connect("127.0.0.1", port, "db", "u", nothing, false, nothing, nothing, nothing, sslmode, + nothing, nothing, nothing, nothing, nothing, nothing, gssencmode, nothing, false, style)) + @test err isa Postgres.Error && err.message == "invalid sslmode: $(lowercase(sslmode))" + end + err = connect_err(() -> DBInterface.connect(Postgres.Connection, "postgresql://u@127.0.0.1:$port/db?gssencmode=require&sslmode=bogus"; style=style)) + @test err isa ArgumentError + @test accepted[] == 0 + @test isempty(style.contexts) + # with a valid sslmode, the same configuration connects over GSS + conn = Postgres.Connection(host="127.0.0.1", port=port, user="u", gssencmode="require", sslmode="Verify-Full", style=style) + @test conn.socket.transport isa Postgres.API.GSSConn + close(conn) + end + end + + @testset "cancel_request accepts every sslmode and gssencmode value, in any case" begin + cancels = Ref(0) + handler = function (sock, n) + code, _ = read_request(sock) + if code == SSL_REQUEST + send(sock, [UInt8('N')]) + code, _ = read_request(sock) + elseif code == GSSENC_REQUEST + send(sock, [UInt8('G')]) + code, _ = read_request(server_handshake(sock)) + end + code == CANCEL_REQUEST || error("expected CancelRequest, got $code") + cancels[] += 1 + end + style = FakeGSSStyle() + with_fake_server(handler) do port, accepted + expected = 0 + for mode in ("disable", "prefer", "require", "verify-full"), sslmode in (mode, uppercase(mode), titlecase(mode)) + if mode in ("disable", "prefer") + @test Postgres.API.cancel_request("127.0.0.1", port, Int32(1), Int32(1), false, sslmode) + expected += 1 + else + # a server without TLS: the cancel key is not sent in cleartext + err = connect_err(() -> Postgres.API.cancel_request("127.0.0.1", port, Int32(1), Int32(1), false, sslmode)) + @test err isa Postgres.PostgresInterfaceError && startswith(err.msg, "server refused TLS on the cancel connection") + end + end + for gssencmode in ("disable", "Disable", "prefer", "PREFER", "require", "Require") + @test Postgres.API.cancel_request("127.0.0.1", port, Int32(1), Int32(1), false, nothing, nothing, nothing, nothing, + nothing, nothing, nothing, gssencmode, nothing, false, style) + expected += 1 + end + @test timedwait(() -> cancels[] == expected, 5.0) === :ok + @test count(ctx -> ctx.encrypt, style.contexts) == 4 + # an invalid mode is refused before any dial + dialed = accepted[] + err = connect_err(() -> Postgres.API.cancel_request("127.0.0.1", port, Int32(1), Int32(1), false, "bogus")) + @test err isa Postgres.Error && err.message == "invalid sslmode: bogus" + err = connect_err(() -> Postgres.API.cancel_request("127.0.0.1", port, Int32(1), Int32(1), false, nothing, nothing, nothing, nothing, + nothing, nothing, nothing, "bogus", nothing, false, style)) + @test err isa Postgres.Error && err.message == "invalid gssencmode: bogus" + @test accepted[] == dialed + end end @testset "GSSAPI authentication (codes 7, 8, 9)" begin diff --git a/test/runtests.jl b/test/runtests.jl index c4193d3..a68f419 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -698,7 +698,7 @@ include("trim_compile_tests.jl") # an unexpanded ${PGSSLMODE} meant to be verify-full must fail loudly # rather than fall back to the unauthenticated default withenv("PGSSLMODE" => "") do - @test Postgres.parse_dsn("host=h").sslmode == "" + @test_throws ArgumentError Postgres.parse_dsn("host=h") end withenv(