Skip to content

feat(server): set the lowest TLS version the listener negotiates with --min-tls - #56

Merged
mhenrixon merged 1 commit into
dashfrom
feature/tls-min-version
Jul 29, 2026
Merged

feat(server): set the lowest TLS version the listener negotiates with --min-tls#56
mhenrixon merged 1 commit into
dashfrom
feature/tls-min-version

Conversation

@mhenrixon

Copy link
Copy Markdown
Collaborator

Summary

Adds --min-tls (env MIN_TLS), the lowest TLS version the HTTPS listener will negotiate. Ports basecamp#199, which has been open upstream since before April 2026.

kamal-proxy run --min-tls 1.3

Accepted values are 1.2 and 1.3. Upstream's tls1_2 / tls1_3 spellings are accepted too, so a config written against basecamp#199 keeps working here.

Wiring: --min-tls (internal/cmd/run.go, validated in a new preRun) → Config.MinTLS (internal/server/config.go) → ParseMinTLSVersionMinVersion on the HTTPS listener's tls.Config (internal/server/server.go). New internal/server/tls_version.go holds the parsing.

Closes #12
Closes #13
Closes #14

(#12 and #13 were delivered by merged PRs #53 and #50, which said Refs rather than Closes and so left them open. Nothing further is needed for either — see the note at the bottom.)

Three deliberate departures from upstream basecamp#199

1. The flag cannot weaken the proxy. basecamp#199 accepts tls1_0 and tls1_1. Go's server-side default minimum is already TLS 1.2, so those two values are pure new downgrade capability — the only thing in that PR that makes a proxy less secure than it is today. Both are deprecated by RFC 8996. Here they are refused by name, with an error that says why, and the proxy declines to start rather than pretend the setting took effect. --min-tls can only ever narrow what the listener accepts.

2. Validated at flag-parse time as well as at bind time. run had no PreRunE. Without one, a typo'd --min-tls costs a state restore and an ACME account registration before startHTTPServers rejects it. preRun fails in a millisecond instead. The listener parses the same string again, so a Config built programmatically cannot start a listener that silently ignores the setting.

3. testServer's signature is untouched. basecamp#199 threads minTLS through it and rewrites every call site. This fork already had testServerWithConfig(t, config), so the new tests use that and no existing test changes.

Cipher suites are deliberately out

The issue title says "Min-TLS version / cipher config". Cipher configuration is consciously excluded, not forgotten, for reasons verified against the Go 1.26.5 source in this toolchain:

  • Go documents that Config.CipherSuites is ignored for TLS 1.3 — so the flag would be inert on exactly the configuration a hardening baseline asks for.
  • PreferServerCipherSuites is documented as "a legacy field and has no effect".
  • Go's default offer set already excludes everything in tls.InsecureCipherSuites() (RC4, 3DES, static RSA), so the flag's realistic use is to weaken.
  • --min-tls 1.3 already delivers what compliance scanners actually ask for: TLS 1.3 has no CBC suites at all, which is what removes the ECDHE_*_CBC_SHA entries a scanner flags.

This is documented in the README so the decision is visible to the next person, rather than looking like an oversight.

Test plan

  • TestParseMinTLSVersion — table over accepted spellings ("", 1.2, 1.3, " 1.3 ", TLSv1.3, tls1_2, tls1_3), refusals (1.0, 1.1, tls1_0, TLSv1.1) and non-versions (1.4, tls13, yes, tlsv)
  • TestServer_MinTLSVersionIsEnforcedOnTheHTTPSListener — a real listener refuses a TLS 1.1 client by default, serves 1.2; under 1.3 it refuses a 1.2 client and serves 1.3
  • TestServer_MinTLSVersionAppliesToMutualTLSHosts — the tripwire for the mTLS interaction: hosts requiring a client certificate get a replacement tls.Config, and Go negotiates the version against that. Only because clientCertificateConfig clones does the minimum survive; a fresh config there would downgrade every mTLS host back to 1.2 with no error anywhere
  • TestServer_InvalidMinTLSFailsToStartServer.Start() refuses a bad value
  • TestRunCommand_MinTLSFlag / TestRunCommand_MinTLSPreRun — flag registration, and preRun over both accepted and rejected values
  • make test green; go vet ./... and gofmt -l internal/ cmd/ clean; -race clean

The version-refusal tests pin both bounds on the client. Go's own client minimum is TLS 1.2, so a MaxVersion: TLS1.1 alone makes the client config self-contradictory and the request fails locally, before a byte reaches the listener — which would pass a refusal test for entirely the wrong reason.

Deviations & judgment calls

  • This PR was adversarially reviewed by a 21-agent workflow before it was committed — four attack lenses (parser, protocol, operational, completeness) producing 17 raw findings, each then independently verified by an agent instructed to refute it and to execute rather than reason. 15 were refuted with executed evidence. Two survived and are fixed here. The refutations that mattered:
    • tls-alpn-01 does not break under --min-tls 1.3. Reproduced against a live listener using boulder's actual va/tlsalpn.go dial config: peerCerts=1 proto="acme-tls/1" version=0x0304, under both 1.2 and 1.3, including on mTLS hosts.
    • HTTP/3 is genuinely unaffected. quic-go v0.60.0 clones the server config and unconditionally sets conf.MinVersion = tls.VersionTLS13 (internal/handshake/tls_config.go:14), confirmed on the wire.
    • SO_REUSEPORT handoff with two generations on different minimums — the concrete scenario did not reproduce (0 accepted / 40 refused).
  • Fixed (was real): the accept path through preRun had no test. Mutation testing proved it: replacing preRun's ParseMinTLSVersion delegation with a literal switch on "", "1.2", "1.3" left the entire suite green while breaking --min-tls tls1_2 at boot — the exact upstream-compatibility contract the README in this same diff promises. TestRunCommand_MinTLSPreRun now carries accept rows and catches that mutation.
  • Fixed (was judged out-of-scope, and I overrode that): TestRunCommand_MinTLSFlag failed whenever MIN_TLS was exported. The verifier correctly noted this is a file-wide pre-existing pattern — nine flag.DefValue assertions in run_test.go behave the same way, and CI sets no env: block so exposure there is zero. I fixed only the new test anyway, with one t.Setenv, because this feature is what teaches operators to export MIN_TLS — a developer with it set is far likelier than one with REUSE_PORT set. Follow-up worth filing: the other eight assertions remain ambient-sensitive; hardening them belongs in its own PR, not a TLS one.
  • Known limitation, not fixed here: a client locked out by --min-tls 1.3 is invisible. It fails during the handshake, so no access-log line, no metric, no status code. Nothing in this repo sets http.Server.ErrorLog, so Go's http: TLS handshake error lines bypass slog. Verified to be pre-existingnewHTTPServer is untouched by this diff and loses the same lines today with MinVersion: 0 — so routing handshake errors into slog is its own change. The README warns operators to check what their clients negotiate before setting 1.3.
  • Known limitation: DefaultMinTLSVersion pins "1.2" as a string. If a future Go raises its own server default to 1.3, this fork stays at 1.2 while presenting itself as hardened. The alternative — letting unset mean "inherit Go's default" — tracks forward for free but makes the --help default a lie and the behavior untestable. I kept the pin; it is the value Go documents today, and a fleet whose TLS floor moves without a deploy is its own hazard.
  • Global run flag, not per-service. Worth recording why, because the usual reason is wrong. "TLS version is negotiated before SNI so it cannot be per-vhost" is true of OpenSSL but not of Go: GetConfigForClient runs before mutualVersion, so a per-SNI minimum would genuinely be enforced — the mTLS test in this PR proves the mechanism. It is still the wrong scope, because a compliance scanner connects to the IP with no SNI or an unknown one, lands on the listener's own config, and would report an unchanged result against a per-service setting.
  • On closing R3: mTLS client certs (--tls-client-ca-path) #12 and R4: On-demand TLS with ask endpoint #13: both shipped in merged PRs (feat(service): require client certificates with --tls-client-ca-path #53 mTLS, fix(router): let on-demand TLS gate issuance ahead of the cert registry #50 on-demand-TLS registry gating) that used Refs. Neither has outstanding proxy-side work. Two things I flagged at the time remain genuinely open and are not closed by this: gem-side --tls-client-ca-path plumbing in ../kamal, and the question of whether an mTLS catch-all should be rejected at validation. Say the word and I will file those as their own issues rather than let them disappear with R3: mTLS client certs (--tls-client-ca-path) #12.

… --min-tls

Ports basecamp#199, open upstream since before April. The HTTPS
listener sets no MinVersion today, so it inherits Go's default of TLS 1.2 with
no way to require 1.3.

Three deliberate departures from basecamp#199:

It refuses TLS 1.0 and 1.1 rather than offering them. Go's server default is
already 1.2, so those two values in basecamp#199 are pure new downgrade capability --
the only part of that PR that makes a proxy less secure than it is today, and
both are deprecated by RFC 8996. --min-tls can only narrow what the listener
accepts; a request for 1.0 or 1.1 fails the boot by name instead of quietly
serving them.

It validates in run's PreRunE as well as at bind time. run had no PreRunE, so a
typo previously cost a state restore and an ACME account registration before
startHTTPServers rejected it. The listener still parses the same string, so a
Config built in code cannot start a listener that ignores the setting.

It leaves testServer's signature alone. basecamp#199 threads minTLS through it and
rewrites every call site; this fork already had testServerWithConfig.

Cipher suites are consciously out of scope, not overlooked: Go ignores
CipherSuites for TLS 1.3 and documents PreferServerCipherSuites as having no
effect, its default offer set already excludes the insecure suites, and
--min-tls 1.3 is what actually removes the CBC suites a scanner flags. The
README says so, so the next person sees a decision rather than a gap.

Upstream's tls1_2/tls1_3 spellings are accepted, so a config written against
basecamp#199 keeps working here.

Refs #14
@mhenrixon
mhenrixon merged commit cad460e into dash Jul 29, 2026
2 checks passed
@mhenrixon
mhenrixon deleted the feature/tls-min-version branch July 29, 2026 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

R4: Min-TLS version / cipher config R4: On-demand TLS with ask endpoint R3: mTLS client certs (--tls-client-ca-path)

1 participant