Send TLS decode_error alert on malformed ClientHello and missing SNI

Scanners probe for fake-TLS proxies by sending structurally malformed
ClientHellos (e.g. ExtensionsLen=0 with trailing extension bytes). A
real TLS server responds with a fatal decode_error alert; previously
the proxy crashed the handler process silently, making it detectable.

Changes:
- mtp_fake_tls: add TLS_REC_ALERT, TLS_ALERT_FATAL, TLS_ALERT_DECODE_ERROR
  macros; export tls_decode_error_alert/0 which builds the 7-byte alert
  frame from macros
- mtp_fake_tls: add second clause to parse_client_hello/1 that throws
  {protocol_error, tls_bad_client_hello, bad_client_hello} instead of
  letting a bare function_clause propagate
- mtp_fake_tls: tighten parse_sni/1 catch to match the specific tagged
  error rather than a catch-all error:_
- mtp_handler: add attempt_fronting clauses for tls_bad_client_hello and
  tls_no_sni — both send the decode_error alert before closing
- mtp_handler: effective_secret/2 now raises tls_bad_client_hello (not
  tls_invalid_digest) when per_sni_secrets=on and the ClientHello has
  no SNI, so it also gets the alert treatment
- single_dc_SUITE: new malformed_tls_hello_decode_error_case/1 verifies
  the alert bytes are sent and the metric is incremented
- AGENTS.md: document test organisation, process architecture diagram,
  and upstream/downstream naming note

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Sergey Prokhorov 2026-04-07 13:46:39 +02:00
parent 36b30e3f5f
commit dfe8ebf034
No known key found for this signature in database
GPG key ID: 1C570244E4EF3337
4 changed files with 158 additions and 4 deletions

View file

@ -26,7 +26,8 @@
domain_fronting_fragmented_case/1,
domain_fronting_replay_case/1,
per_sni_secrets_on_case/1,
per_sni_secrets_wrong_secret_case/1
per_sni_secrets_wrong_secret_case/1,
malformed_tls_hello_decode_error_case/1
]).
-export([set_env/2,
@ -735,6 +736,51 @@ per_sni_secrets_wrong_secret_case(Cfg) when is_list(Cfg) ->
1, mtp_test_metric:get_tags(
count, [?APP, protocol_error, total], [?FUNCTION_NAME, tls_invalid_digest])).
%% @doc A structurally malformed ClientHello (ExtensionsLen=0 but data follows) must cause
%% the proxy to send a TLS fatal decode_error alert and then close the connection,
%% rather than crashing silently.
malformed_tls_hello_decode_error_case({pre, Cfg}) ->
setup_single(?FUNCTION_NAME, 10000 + ?LINE, #{}, Cfg);
malformed_tls_hello_decode_error_case({post, Cfg}) ->
stop_single(Cfg);
malformed_tls_hello_decode_error_case(Cfg) when is_list(Cfg) ->
Host = ?config(mtp_host, Cfg),
Port = ?config(mtp_port, Cfg),
%% Build a ClientHello that is structurally valid at the TLS record layer
%% (correct lengths, version bytes) but lies about ExtensionsLen=0 while
%% trailing bytes follow this is the exact pattern seen from real scanners.
TlsPacketLen = 512,
HelloLen = 508, % TlsPacketLen - 4 (hello type + hello len field)
Random = crypto:strong_rand_bytes(32),
SessId = crypto:strong_rand_bytes(32),
CipherSuites = <<19, 1>>, % TLS_AES_128_GCM_SHA256, 2 bytes
%% Padding fills the rest of the TLS frame after ExtensionsLen to hit TlsPacketLen exactly.
%% Consumed so far inside the frame: hello_type(1)+hello_len(3)+version(2)+random(32)
%% +sessid_len(1)+sessid(32)+cs_len(2)+cs(2)+comp_len(1)+comp(1)+ext_len(2) = 79
PaddingLen = TlsPacketLen - 79,
Padding = binary:copy(<<0>>, PaddingLen),
MalformedHello = <<22, 3, 1, TlsPacketLen:16, % TLS record header (handshake, TLS1.0)
1, HelloLen:24, % ClientHello type + length
3, 3, % legacy version (TLS1.2)
Random/binary, % 32-byte random
32, SessId/binary, % session ID
2:16, CipherSuites/binary, % cipher suites
1, 0, % compression methods
0:16, % ExtensionsLen = 0 (lie)
Padding/binary>>, % trailing bytes that should be extensions
{ok, Sock} = gen_tcp:connect(Host, Port, [binary, {active, false}], 2000),
ok = gen_tcp:send(Sock, MalformedHello),
%% Proxy must send back a TLS fatal decode_error alert (21, 3, 3, 0, 2, 2, 50)
ExpectedAlert = mtp_fake_tls:tls_decode_error_alert(),
{ok, Response} = gen_tcp:recv(Sock, byte_size(ExpectedAlert), 5000),
?assertEqual(ExpectedAlert, Response),
%% Then close the connection
?assertEqual({error, closed}, gen_tcp:recv(Sock, 0, 2000)),
gen_tcp:close(Sock),
?assertEqual(
1, mtp_test_metric:get_tags(
count, [?APP, protocol_error, total], [?FUNCTION_NAME, tls_bad_client_hello])).
setup_single(Name, MtpPort, DcCfg0, Cfg) ->
setup_single(Name, "127.0.0.1", MtpPort, DcCfg0, Cfg).