SEC.CFG.TLS_MIN_VERSION — The server still negotiates a withdrawn TLS version
- Category: security
- Severity: high
- Level: 0
- Confidence: deterministic
- Downtime class: none — the finding is about a server parameter, not about a statement
- Stability: stable
- Suites: audit
- Applies to: PostgreSQL 18
The connection is encrypted, and that is the problem
SEC.CFG.TLS_DISABLED is at least visible: nothing is encrypted, and anything that checks says so.
This finding is the quiet one. TLS is on, every connection reports as encrypted, and a monitoring
dashboard agrees. What ssl_min_protocol_version below TLSv1.2 adds is a version an active
attacker on the path can negotiate down to. TLS 1.0 and 1.1 are withdrawn (RFC
8996), and their weaknesses are reachable exactly when both
ends will still agree to them.
It is almost never wrong by accident
PostgreSQL has shipped TLSv1.2 as the default for as long as this parameter has existed. So a
server below it was moved there deliberately — nearly always years ago, for one client that no
longer connects.
That is the shape of the finding: not a bad default nobody fixed, but an exception nobody removed.
Why high and not critical
Reaching the weakness needs an attacker between the client and the server. That is a precondition,
not a given, and critical here is reserved for findings that need nothing else to be true.
It sits at the same severity as its disabled-TLS neighbor on purpose. One says the door is open, the other says the lock is from 2006, and ranking them without knowing the network would be a guess.
What it looks like
# postgresql.conf: TLS is on, and TLS 1.0 is still on the table.
ssl = on
ssl_min_protocol_version = 'TLSv1'
What to do about it
# The vendor default, and the floor this rule checks against.
ssl = on
ssl_min_protocol_version = 'TLSv1.2'
The parameter has context sighup: SELECT pg_reload_conf(); is enough, and there is no downtime to
plan. Before reloading, check that nothing still connects with an old client library — that is the
only thing this change can break, and it breaks loudly rather than silently.
The comparison is by order, never by name
'TLSv1.10' < 'TLSv1.2' is true in every string comparison there is, because 1 sorts before 2
one character at a time. A check that compared names would report a server stricter than
required and tell you to weaken it.
So the versions are held in a measured order and compared by position. A version name the package
has not measured is reported as undetermined with that reason, never guessed into a position.
When TLS is off entirely
You get one finding, not two. ssl = off is reported by
SEC.CFG.TLS_DISABLED, and this check then passes with a sentence saying
why: the minimum describes a capability that is not running. It becomes worth acting on the moment
TLS is switched on.
The cases this cannot see
-
It reads the running value, not the file. A change on disk that has not been reloaded is invisible here; one made only for the reading session would be read as the server's.
-
It says what the server will accept, not what anybody negotiates. A deployment whose clients all insist on TLS 1.3 is never exposed to the older version this reports, and nothing about the clients is visible from the catalog.
-
It cannot read the TLS policy of anything standing in front of the server. Whether a pooler is in the path is established — the run reports that verdict beside this finding, from measurement rather than from your hostname — but what a proxy negotiates with a client is not visible from the catalog. So a managed provider that terminates or re-negotiates TLS at a proxy may put this parameter out of reach, and the parameter reads the same either way.
The finding is still reported in that topology, deliberately. A proxy in the path does not prove the server's own floor is irrelevant: a pooler may pass TLS straight through, and the server accepts what it accepts from anything reaching it directly. Suppressing the finding on the strength of a detected proxy would hide a real one, so the limitation is named instead.