Skip to content

Commit bab85ef

Browse files
Fix CVE 2026 28809 (#5)
* update cowboy, stop gitignoring rebar.lock * work with rebar3 * write tests capturing the CVE * force otp 26 to see le problem * fix cve in otp 26
1 parent 51676dd commit bab85ef

7 files changed

Lines changed: 150 additions & 12 deletions

File tree

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,5 @@ _build
44
doc
55
ebin
66
erl_crash.dump
7-
rebar.lock
87
rebar3.crashdump
98
test/*.beam

mise.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[tools]
2+
erlang = "26"

rebar.lock

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{"1.2.0",
2+
[{<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.14.2">>},0},
3+
{<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.16.0">>},1},
4+
{<<"ranch">>,{pkg,<<"ranch">>,<<"2.2.0">>},1}]}.
5+
[
6+
{pkg_hash,[
7+
{<<"cowboy">>, <<"4008BE1DF6ADE45E4F2A4E9E2D22B36D0B5ABA4E20B0A0D7049E28D124E34847">>},
8+
{<<"cowlib">>, <<"54592074EBBBB92EE4746C8A8846E5605052F29309D3A873468D76CDF932076F">>},
9+
{<<"ranch">>, <<"25528F82BC8D7C6152C57666CA99EC716510FE0925CB188172F41CE93117B1B0">>}]},
10+
{pkg_hash_ext,[
11+
{<<"cowboy">>, <<"569081DA046E7B41B5DF36AA359BE71A0C8874E5B9CFF6F747073FC57BAF1AB9">>},
12+
{<<"cowlib">>, <<"7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51">>},
13+
{<<"ranch">>, <<"FA0B99A1780C80218A4197A59EA8D3BDAE32FBFF7E88527D7D8A4787EFF4F8E7">>}]}
14+
].

src/esaml_binding.erl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,15 @@ xml_payload_type(Xml) ->
3939
-spec decode_response(SAMLEncoding :: binary(), SAMLResponse :: binary()) -> #xmlDocument{}.
4040
decode_response(?deflate, SAMLResponse) ->
4141
XmlData = binary_to_list(zlib:unzip(base64:decode(SAMLResponse))),
42-
{Xml, _} = xmerl_scan:string(XmlData, [{namespace_conformant, true}]),
42+
{Xml, _} = xmerl_scan:string(XmlData, [{namespace_conformant, true}, {allow_entities, false}]),
4343
Xml;
4444
decode_response(_, SAMLResponse) ->
4545
Data = base64:decode(SAMLResponse),
4646
XmlData = case (catch zlib:unzip(Data)) of
4747
{'EXIT', _} -> binary_to_list(Data);
4848
Bin -> binary_to_list(Bin)
4949
end,
50-
{Xml, _} = xmerl_scan:string(XmlData, [{namespace_conformant, true}]),
50+
{Xml, _} = xmerl_scan:string(XmlData, [{namespace_conformant, true}, {allow_entities, false}]),
5151
Xml.
5252

5353
%% @doc Encode a SAMLRequest (or SAMLResponse) as an HTTP-Redirect binding

src/esaml_sp.erl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ decrypt_assertion(Xml, #esaml_sp{key = PrivateKey}) ->
328328
SymmetricKey = decrypt_key_info(EncryptedData, Xml, PrivateKey),
329329
[#xmlAttribute{value = Algorithm}] = xmerl_xpath:string("./xenc:EncryptionMethod/@Algorithm", EncryptedData, [{namespace, XencNs}]),
330330
AssertionXml = block_decrypt(Algorithm, SymmetricKey, CipherValue),
331-
{Assertion, _} = xmerl_scan:string(AssertionXml, [{namespace_conformant, true}]),
331+
{Assertion, _} = xmerl_scan:string(AssertionXml, [{namespace_conformant, true}, {allow_entities, false}]),
332332
Assertion.
333333

334334

src/esaml_util.erl

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ load_metadata(Url, FPs) ->
234234
[{Url, Meta}] -> Meta;
235235
_ ->
236236
{ok, {{_Ver, 200, _}, _Headers, Body}} = httpc:request(get, {Url, []}, [{autoredirect, true}, {timeout, 3000}], []),
237-
{Xml, _} = xmerl_scan:string(Body, [{namespace_conformant, true}]),
237+
{Xml, _} = xmerl_scan:string(Body, [{namespace_conformant, true}, {allow_entities, false}]),
238238
case xmerl_dsig:verify(Xml, Fingerprints) of
239239
ok -> ok;
240240
Err -> error(Err)
@@ -252,7 +252,7 @@ load_metadata(Url) ->
252252
_ ->
253253
Timeout = application:get_env(esaml, load_metadata_timeout, 15000),
254254
{ok, {{_Ver, 200, _}, _Headers, Body}} = httpc:request(get, {Url, []}, [{autoredirect, true}, {timeout, Timeout}], []),
255-
{Xml, _} = xmerl_scan:string(Body, [{namespace_conformant, true}]),
255+
{Xml, _} = xmerl_scan:string(Body, [{namespace_conformant, true}, {allow_entities, false}]),
256256
{ok, Meta = #esaml_idp_metadata{}} = esaml:decode_idp_metadata(Xml),
257257
ets:insert(esaml_idp_meta_cache, {Url, Meta}),
258258
Meta
@@ -345,37 +345,37 @@ build_nsinfo_test() ->
345345

346346
key_load_test() ->
347347
start_ets(),
348-
KeyPath = "../test/selfsigned_key.pem",
348+
KeyPath = "test/selfsigned_key.pem",
349349
Key = load_private_key(KeyPath),
350350
?assertEqual([{KeyPath, Key}], ets:lookup(esaml_privkey_cache, KeyPath)).
351351

352352
key_import_test() ->
353353
start_ets(),
354-
{ok, EncodedKey} = file:read_file("../test/selfsigned_key.pem"),
354+
{ok, EncodedKey} = file:read_file("test/selfsigned_key.pem"),
355355
Key = import_private_key(EncodedKey, my_key),
356356
?assertEqual([{my_key, Key}], ets:lookup(esaml_privkey_cache, my_key)).
357357

358358
bad_key_load_test() ->
359359
start_ets(),
360-
KeyPath = "../test/bad_data.pem",
360+
KeyPath = "test/bad_data.pem",
361361
?assertException(error, {badmatch, []}, load_private_key(KeyPath)),
362362
?assertEqual([], ets:lookup(esaml_privkey_cache, KeyPath)).
363363

364364
cert_load_test() ->
365365
start_ets(),
366-
CertPath = "../test/selfsigned.pem",
366+
CertPath = "test/selfsigned.pem",
367367
Cert = load_certificate(CertPath),
368368
?assertEqual([{CertPath, [Cert]}], ets:lookup(esaml_certbin_cache, CertPath)).
369369

370370
cert_import_test() ->
371371
start_ets(),
372-
{ok, EncodedCert} = file:read_file("../test/selfsigned.pem"),
372+
{ok, EncodedCert} = file:read_file("test/selfsigned.pem"),
373373
Cert = import_certificate(EncodedCert, my_cert),
374374
?assertEqual([{my_cert, [Cert]}], ets:lookup(esaml_certbin_cache, my_cert)).
375375

376376
bad_cert_load_test() ->
377377
start_ets(),
378-
CertPath = "../test/bad_data.pem",
378+
CertPath = "test/bad_data.pem",
379379
?assertException(error, {badmatch, []}, load_certificate(CertPath)),
380380
?assertEqual([{CertPath, []}], ets:lookup(esaml_certbin_cache, CertPath)).
381381

test/xxe_SUITE.erl

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
%% CVE-2026-28809: XXE (XML External Entity) vulnerability tests for esaml.
2+
%%
3+
%% esaml parses untrusted SAML XML via xmerl_scan:string/2 with only
4+
%% [{namespace_conformant, true}] -- no entity restriction. This allows
5+
%% attackers to include <!DOCTYPE> declarations with external entity
6+
%% references that expand during parsing, leaking local file contents
7+
%% into the SAML document. Parsing happens BEFORE signature verification,
8+
%% so the attack works even against unsigned/invalid responses.
9+
%%
10+
%% OTP 27+ mitigates this by rejecting entity definitions by default.
11+
%% {allow_entities, true} re-enables the old behavior, which we use
12+
%% to demonstrate the vulnerability on modern OTP.
13+
-module(xxe_SUITE).
14+
15+
-include_lib("eunit/include/eunit.hrl").
16+
-include_lib("xmerl/include/xmerl.hrl").
17+
18+
%%====================================================================
19+
%% Helpers
20+
%%====================================================================
21+
22+
xxe_saml_response(EntityDecl, EntityRef) ->
23+
"<?xml version=\"1.0\"?>"
24+
"<!DOCTYPE foo [" ++ EntityDecl ++ "]>"
25+
"<samlp:Response xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" "
26+
"xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" "
27+
"Version=\"2.0\" IssueInstant=\"2013-01-01T01:01:01Z\">"
28+
"<saml:Issuer>" ++ EntityRef ++ "</saml:Issuer>"
29+
"</samlp:Response>".
30+
31+
xxe_saml_assertion(EntityDecl, EntityRef) ->
32+
"<?xml version=\"1.0\"?>"
33+
"<!DOCTYPE foo [" ++ EntityDecl ++ "]>"
34+
"<saml:Assertion xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" "
35+
"Version=\"2.0\" IssueInstant=\"2013-01-01T01:01:01Z\">"
36+
"<saml:Issuer>" ++ EntityRef ++ "</saml:Issuer>"
37+
"</saml:Assertion>".
38+
39+
encode_for_post(XmlStr) ->
40+
base64:encode(list_to_binary(XmlStr)).
41+
42+
encode_for_deflate(XmlStr) ->
43+
base64:encode(zlib:zip(list_to_binary(XmlStr))).
44+
45+
extract_issuer(Doc) ->
46+
Ns = [{"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}],
47+
[#xmlElement{content = [#xmlText{value = Value}]}] =
48+
xmerl_xpath:string("//saml:Issuer", Doc, [{namespace, Ns}]),
49+
Value.
50+
51+
%%====================================================================
52+
%% Tests: OTP 27+ rejects entities by default in esaml code paths
53+
%%====================================================================
54+
55+
%% CVE-2026-28809: XXE via POST binding path (esaml_binding:decode_response/2)
56+
xxe_post_binding_rejects_entities_test() ->
57+
XmlStr = xxe_saml_response(
58+
"<!ENTITY xxe SYSTEM \"file:///etc/hostname\">", "&xxe;"),
59+
Payload = encode_for_post(XmlStr),
60+
?assertExit(
61+
{fatal, {{error, entities_not_allowed}, _, _, _}},
62+
esaml_binding:decode_response(<<>>, Payload)).
63+
64+
%% CVE-2026-28809: XXE via DEFLATE binding path (esaml_binding:decode_response/2)
65+
xxe_deflate_binding_rejects_entities_test() ->
66+
XmlStr = xxe_saml_response(
67+
"<!ENTITY xxe SYSTEM \"file:///etc/hostname\">", "&xxe;"),
68+
Payload = encode_for_deflate(XmlStr),
69+
Deflate = <<"urn:oasis:names:tc:SAML:2.0:bindings:URL-Encoding:DEFLATE">>,
70+
?assertExit(
71+
{fatal, {{error, entities_not_allowed}, _, _, _}},
72+
esaml_binding:decode_response(Deflate, Payload)).
73+
74+
%% CVE-2026-28809: Even internal entities (no SYSTEM) are rejected on OTP 27+.
75+
xxe_internal_entity_rejected_test() ->
76+
XmlStr = xxe_saml_response(
77+
"<!ENTITY xxe \"INJECTED\">", "&xxe;"),
78+
Payload = encode_for_post(XmlStr),
79+
?assertExit(
80+
{fatal, {{error, entities_not_allowed}, _, _, _}},
81+
esaml_binding:decode_response(<<>>, Payload)).
82+
83+
%% CVE-2026-28809: decrypt_assertion/2 calls xmerl_scan:string with
84+
%% [{namespace_conformant, true}, {allow_entities, false}]. This test
85+
%% confirms entities are rejected on that code path too.
86+
xxe_decrypt_assertion_path_rejects_entities_test() ->
87+
XxeAssertion = xxe_saml_assertion(
88+
"<!ENTITY xxe SYSTEM \"file:///etc/hostname\">", "&xxe;"),
89+
?assertExit(
90+
{fatal, {{error, entities_not_allowed}, _, _, _}},
91+
xmerl_scan:string(XxeAssertion, [{namespace_conformant, true}, {allow_entities, false}])).
92+
93+
%%====================================================================
94+
%% Tests: Demonstrate the vulnerability (pre-OTP-27 behavior)
95+
%%
96+
%% {allow_entities, true} re-enables entity expansion, simulating
97+
%% what happens on OTP < 27 where xmerl expanded entities by default.
98+
%%====================================================================
99+
100+
%% Demonstrates the actual file read: an attacker embeds an external
101+
%% entity referencing a local file, and its contents appear in the
102+
%% parsed SAML document's Issuer element.
103+
xxe_demonstrates_file_read_test() ->
104+
XmlStr = xxe_saml_response(
105+
"<!ENTITY xxe SYSTEM \"file:///etc/hostname\">", "&xxe;"),
106+
{Doc, _} = xmerl_scan:string(XmlStr,
107+
[{namespace_conformant, true}, {allow_entities, true}]),
108+
IssuerValue = extract_issuer(Doc),
109+
%% The Issuer element now contains /etc/hostname contents,
110+
%% not the literal entity reference.
111+
?assertNotEqual("&xxe;", IssuerValue),
112+
?assert(length(IssuerValue) > 0).
113+
114+
%% Entity expansion occurs during XML parsing in decode_response/2,
115+
%% which runs BEFORE signature verification in validate_assertion/2.
116+
%% A SAML response with no signature still has its entities fully
117+
%% expanded, leaking file contents into the parsed XML tree.
118+
xxe_expansion_before_signature_verification_test() ->
119+
XmlStr = xxe_saml_response(
120+
"<!ENTITY xxe \"INJECTED_BY_ATTACKER\">", "&xxe;"),
121+
{Doc, _} = xmerl_scan:string(XmlStr,
122+
[{namespace_conformant, true}, {allow_entities, true}]),
123+
?assertEqual("INJECTED_BY_ATTACKER", extract_issuer(Doc)).

0 commit comments

Comments
 (0)