From eac8bb7e8b9226f5f01aab38af3422700643498e Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 8 Dec 2025 20:47:10 +0000 Subject: [PATCH] Fix `cipher_suite: :strong` failing on OTP 28 OTP 28 added stricter validation for SSL options. The `secure_renegotiate` and `reuse_sessions` options are only valid for TLS 1.2 and earlier (renegotiation was removed in TLS 1.3, replaced by key updates). The current code flow in `Plug.SSL.configure/1`: 1. `set_secure_defaults/1` runs first - adds `secure_renegotiate: true` because `:ssl.versions()[:supported]` includes TLS 1.2 2. `set_strong_tls_defaults/1` runs second - sets `versions: [:"tlsv1.3"]` Result: Both `secure_renegotiate: true` AND `versions: [:"tlsv1.3"]` are set, which OTP 28 rejects as incompatible. To fix, after applying TLS version defaults in `set_strong_tls_defaults/1`, check if the final versions list contains only TLS 1.3. If so, remove `secure_renegotiate` and `reuse_sessions`. ref: https://github.com/phoenixframework/phoenix/issues/6557 --- lib/plug/ssl.ex | 23 +++++++++++++++++++---- test/plug/ssl_test.exs | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/lib/plug/ssl.ex b/lib/plug/ssl.ex index f48a3bf3..7dc97e9c 100644 --- a/lib/plug/ssl.ex +++ b/lib/plug/ssl.ex @@ -301,10 +301,25 @@ defmodule Plug.SSL do end defp set_strong_tls_defaults(options) do - options - |> set_managed_tls_defaults - |> keynew(:ciphers, 0, {:ciphers, @strong_tls_ciphers}) - |> keynew(:versions, 0, {:versions, [:"tlsv1.3"]}) + options = + options + |> set_managed_tls_defaults + |> keynew(:ciphers, 0, {:ciphers, @strong_tls_ciphers}) + |> keynew(:versions, 0, {:versions, [:"tlsv1.3"]}) + + # secure_renegotiate and reuse_sessions are TLS 1.2 and earlier options. + # They must be removed for TLS 1.3-only configurations because OTP 28+ + # validates that these options are not set when only TLS 1.3 is enabled. + # Only remove them if the final versions list has no pre-TLS 1.3 versions. + versions = options[:versions] + + if Enum.any?([:tlsv1, :"tlsv1.1", :"tlsv1.2"], &(&1 in versions)) do + options + else + options + |> List.keydelete(:secure_renegotiate, 0) + |> List.keydelete(:reuse_sessions, 0) + end end defp set_compatible_tls_defaults(options) do diff --git a/test/plug/ssl_test.exs b/test/plug/ssl_test.exs index 733e5f7b..ae6d60c9 100644 --- a/test/plug/ssl_test.exs +++ b/test/plug/ssl_test.exs @@ -42,6 +42,10 @@ defmodule Plug.SSLTest do assert opts[:honor_cipher_order] == true assert opts[:eccs] == [:x25519, :secp256r1, :secp384r1, :secp521r1] assert opts[:versions] == [:"tlsv1.3"] + # secure_renegotiate and reuse_sessions must NOT be set for TLS 1.3-only + # configurations, as OTP 28+ rejects these options with TLS 1.3 + assert opts[:secure_renegotiate] == nil + assert opts[:reuse_sessions] == nil assert opts[:ciphers] == [ ~c"TLS_AES_256_GCM_SHA384", @@ -50,6 +54,21 @@ defmodule Plug.SSLTest do ] end + test "sets cipher suite to strong but preserves secure_renegotiate when TLS 1.2 is included" do + # When user overrides versions to include TLS 1.2, secure_renegotiate should be preserved + assert {:ok, opts} = + configure( + key: "abcdef", + cert: "ghijkl", + cipher_suite: :strong, + versions: [:"tlsv1.3", :"tlsv1.2"] + ) + + assert opts[:versions] == [:"tlsv1.3", :"tlsv1.2"] + assert opts[:secure_renegotiate] == true + assert opts[:reuse_sessions] == true + end + test "sets cipher suite to compatible" do assert {:ok, opts} = configure(key: "abcdef", cert: "ghijkl", cipher_suite: :compatible) assert opts[:cipher_suite] == nil