CVE-2026-28809
Description
XML External Entity (XXE) vulnerability in esaml (and its forks) allows an attacker to cause the system to read local files and incorporate their contents into processed SAML documents, and potentially perform SSRF via crafted SAML messages.
esaml parses attacker-controlled SAML messages using xmerl_scan:string/2 before signature verification without disabling XML entity expansion. On Erlang/OTP versions before 27, Xmerl allows entities by default, enabling pre-signature XXE attacks. An attacker can cause the host to read local files (e.g., Kubernetes-mounted secrets) into the SAML document. If the attacker is not a trusted SAML SP, signature verification will fail and the document is discarded, but file contents may still be exposed through logs or error messages.
This issue affects all versions of esaml, including forks by arekinath, handnot2, and dropbox. Users running on Erlang/OTP 27 or later are not affected due to Xmerl defaulting to entities disabled.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
esamlHex | <= 4.6.0 | — |
Affected products
1Patches
1bab85efde7c1Fix CVE 2026 28809 (#5)
7 files changed · +150 −12
.gitignore+0 −1 modified@@ -4,6 +4,5 @@ _build doc ebin erl_crash.dump -rebar.lock rebar3.crashdump test/*.beam
mise.toml+2 −0 added@@ -0,0 +1,2 @@ +[tools] +erlang = "26"
rebar.lock+14 −0 added@@ -0,0 +1,14 @@ +{"1.2.0", +[{<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.14.2">>},0}, + {<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.16.0">>},1}, + {<<"ranch">>,{pkg,<<"ranch">>,<<"2.2.0">>},1}]}. +[ +{pkg_hash,[ + {<<"cowboy">>, <<"4008BE1DF6ADE45E4F2A4E9E2D22B36D0B5ABA4E20B0A0D7049E28D124E34847">>}, + {<<"cowlib">>, <<"54592074EBBBB92EE4746C8A8846E5605052F29309D3A873468D76CDF932076F">>}, + {<<"ranch">>, <<"25528F82BC8D7C6152C57666CA99EC716510FE0925CB188172F41CE93117B1B0">>}]}, +{pkg_hash_ext,[ + {<<"cowboy">>, <<"569081DA046E7B41B5DF36AA359BE71A0C8874E5B9CFF6F747073FC57BAF1AB9">>}, + {<<"cowlib">>, <<"7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51">>}, + {<<"ranch">>, <<"FA0B99A1780C80218A4197A59EA8D3BDAE32FBFF7E88527D7D8A4787EFF4F8E7">>}]} +].
src/esaml_binding.erl+2 −2 modified@@ -39,15 +39,15 @@ xml_payload_type(Xml) -> -spec decode_response(SAMLEncoding :: binary(), SAMLResponse :: binary()) -> #xmlDocument{}. decode_response(?deflate, SAMLResponse) -> XmlData = binary_to_list(zlib:unzip(base64:decode(SAMLResponse))), - {Xml, _} = xmerl_scan:string(XmlData, [{namespace_conformant, true}]), + {Xml, _} = xmerl_scan:string(XmlData, [{namespace_conformant, true}, {allow_entities, false}]), Xml; decode_response(_, SAMLResponse) -> Data = base64:decode(SAMLResponse), XmlData = case (catch zlib:unzip(Data)) of {'EXIT', _} -> binary_to_list(Data); Bin -> binary_to_list(Bin) end, - {Xml, _} = xmerl_scan:string(XmlData, [{namespace_conformant, true}]), + {Xml, _} = xmerl_scan:string(XmlData, [{namespace_conformant, true}, {allow_entities, false}]), Xml. %% @doc Encode a SAMLRequest (or SAMLResponse) as an HTTP-Redirect binding
src/esaml_sp.erl+1 −1 modified@@ -328,7 +328,7 @@ decrypt_assertion(Xml, #esaml_sp{key = PrivateKey}) -> SymmetricKey = decrypt_key_info(EncryptedData, Xml, PrivateKey), [#xmlAttribute{value = Algorithm}] = xmerl_xpath:string("./xenc:EncryptionMethod/@Algorithm", EncryptedData, [{namespace, XencNs}]), AssertionXml = block_decrypt(Algorithm, SymmetricKey, CipherValue), - {Assertion, _} = xmerl_scan:string(AssertionXml, [{namespace_conformant, true}]), + {Assertion, _} = xmerl_scan:string(AssertionXml, [{namespace_conformant, true}, {allow_entities, false}]), Assertion.
src/esaml_util.erl+8 −8 modified@@ -234,7 +234,7 @@ load_metadata(Url, FPs) -> [{Url, Meta}] -> Meta; _ -> {ok, {{_Ver, 200, _}, _Headers, Body}} = httpc:request(get, {Url, []}, [{autoredirect, true}, {timeout, 3000}], []), - {Xml, _} = xmerl_scan:string(Body, [{namespace_conformant, true}]), + {Xml, _} = xmerl_scan:string(Body, [{namespace_conformant, true}, {allow_entities, false}]), case xmerl_dsig:verify(Xml, Fingerprints) of ok -> ok; Err -> error(Err) @@ -252,7 +252,7 @@ load_metadata(Url) -> _ -> Timeout = application:get_env(esaml, load_metadata_timeout, 15000), {ok, {{_Ver, 200, _}, _Headers, Body}} = httpc:request(get, {Url, []}, [{autoredirect, true}, {timeout, Timeout}], []), - {Xml, _} = xmerl_scan:string(Body, [{namespace_conformant, true}]), + {Xml, _} = xmerl_scan:string(Body, [{namespace_conformant, true}, {allow_entities, false}]), {ok, Meta = #esaml_idp_metadata{}} = esaml:decode_idp_metadata(Xml), ets:insert(esaml_idp_meta_cache, {Url, Meta}), Meta @@ -345,37 +345,37 @@ build_nsinfo_test() -> key_load_test() -> start_ets(), - KeyPath = "../test/selfsigned_key.pem", + KeyPath = "test/selfsigned_key.pem", Key = load_private_key(KeyPath), ?assertEqual([{KeyPath, Key}], ets:lookup(esaml_privkey_cache, KeyPath)). key_import_test() -> start_ets(), - {ok, EncodedKey} = file:read_file("../test/selfsigned_key.pem"), + {ok, EncodedKey} = file:read_file("test/selfsigned_key.pem"), Key = import_private_key(EncodedKey, my_key), ?assertEqual([{my_key, Key}], ets:lookup(esaml_privkey_cache, my_key)). bad_key_load_test() -> start_ets(), - KeyPath = "../test/bad_data.pem", + KeyPath = "test/bad_data.pem", ?assertException(error, {badmatch, []}, load_private_key(KeyPath)), ?assertEqual([], ets:lookup(esaml_privkey_cache, KeyPath)). cert_load_test() -> start_ets(), - CertPath = "../test/selfsigned.pem", + CertPath = "test/selfsigned.pem", Cert = load_certificate(CertPath), ?assertEqual([{CertPath, [Cert]}], ets:lookup(esaml_certbin_cache, CertPath)). cert_import_test() -> start_ets(), - {ok, EncodedCert} = file:read_file("../test/selfsigned.pem"), + {ok, EncodedCert} = file:read_file("test/selfsigned.pem"), Cert = import_certificate(EncodedCert, my_cert), ?assertEqual([{my_cert, [Cert]}], ets:lookup(esaml_certbin_cache, my_cert)). bad_cert_load_test() -> start_ets(), - CertPath = "../test/bad_data.pem", + CertPath = "test/bad_data.pem", ?assertException(error, {badmatch, []}, load_certificate(CertPath)), ?assertEqual([{CertPath, []}], ets:lookup(esaml_certbin_cache, CertPath)).
test/xxe_SUITE.erl+123 −0 added@@ -0,0 +1,123 @@ +%% CVE-2026-28809: XXE (XML External Entity) vulnerability tests for esaml. +%% +%% esaml parses untrusted SAML XML via xmerl_scan:string/2 with only +%% [{namespace_conformant, true}] -- no entity restriction. This allows +%% attackers to include <!DOCTYPE> declarations with external entity +%% references that expand during parsing, leaking local file contents +%% into the SAML document. Parsing happens BEFORE signature verification, +%% so the attack works even against unsigned/invalid responses. +%% +%% OTP 27+ mitigates this by rejecting entity definitions by default. +%% {allow_entities, true} re-enables the old behavior, which we use +%% to demonstrate the vulnerability on modern OTP. +-module(xxe_SUITE). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("xmerl/include/xmerl.hrl"). + +%%==================================================================== +%% Helpers +%%==================================================================== + +xxe_saml_response(EntityDecl, EntityRef) -> + "<?xml version=\"1.0\"?>" + "<!DOCTYPE foo [" ++ EntityDecl ++ "]>" + "<samlp:Response xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" " + "xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" " + "Version=\"2.0\" IssueInstant=\"2013-01-01T01:01:01Z\">" + "<saml:Issuer>" ++ EntityRef ++ "</saml:Issuer>" + "</samlp:Response>". + +xxe_saml_assertion(EntityDecl, EntityRef) -> + "<?xml version=\"1.0\"?>" + "<!DOCTYPE foo [" ++ EntityDecl ++ "]>" + "<saml:Assertion xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" " + "Version=\"2.0\" IssueInstant=\"2013-01-01T01:01:01Z\">" + "<saml:Issuer>" ++ EntityRef ++ "</saml:Issuer>" + "</saml:Assertion>". + +encode_for_post(XmlStr) -> + base64:encode(list_to_binary(XmlStr)). + +encode_for_deflate(XmlStr) -> + base64:encode(zlib:zip(list_to_binary(XmlStr))). + +extract_issuer(Doc) -> + Ns = [{"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}], + [#xmlElement{content = [#xmlText{value = Value}]}] = + xmerl_xpath:string("//saml:Issuer", Doc, [{namespace, Ns}]), + Value. + +%%==================================================================== +%% Tests: OTP 27+ rejects entities by default in esaml code paths +%%==================================================================== + +%% CVE-2026-28809: XXE via POST binding path (esaml_binding:decode_response/2) +xxe_post_binding_rejects_entities_test() -> + XmlStr = xxe_saml_response( + "<!ENTITY xxe SYSTEM \"file:///etc/hostname\">", "&xxe;"), + Payload = encode_for_post(XmlStr), + ?assertExit( + {fatal, {{error, entities_not_allowed}, _, _, _}}, + esaml_binding:decode_response(<<>>, Payload)). + +%% CVE-2026-28809: XXE via DEFLATE binding path (esaml_binding:decode_response/2) +xxe_deflate_binding_rejects_entities_test() -> + XmlStr = xxe_saml_response( + "<!ENTITY xxe SYSTEM \"file:///etc/hostname\">", "&xxe;"), + Payload = encode_for_deflate(XmlStr), + Deflate = <<"urn:oasis:names:tc:SAML:2.0:bindings:URL-Encoding:DEFLATE">>, + ?assertExit( + {fatal, {{error, entities_not_allowed}, _, _, _}}, + esaml_binding:decode_response(Deflate, Payload)). + +%% CVE-2026-28809: Even internal entities (no SYSTEM) are rejected on OTP 27+. +xxe_internal_entity_rejected_test() -> + XmlStr = xxe_saml_response( + "<!ENTITY xxe \"INJECTED\">", "&xxe;"), + Payload = encode_for_post(XmlStr), + ?assertExit( + {fatal, {{error, entities_not_allowed}, _, _, _}}, + esaml_binding:decode_response(<<>>, Payload)). + +%% CVE-2026-28809: decrypt_assertion/2 calls xmerl_scan:string with +%% [{namespace_conformant, true}, {allow_entities, false}]. This test +%% confirms entities are rejected on that code path too. +xxe_decrypt_assertion_path_rejects_entities_test() -> + XxeAssertion = xxe_saml_assertion( + "<!ENTITY xxe SYSTEM \"file:///etc/hostname\">", "&xxe;"), + ?assertExit( + {fatal, {{error, entities_not_allowed}, _, _, _}}, + xmerl_scan:string(XxeAssertion, [{namespace_conformant, true}, {allow_entities, false}])). + +%%==================================================================== +%% Tests: Demonstrate the vulnerability (pre-OTP-27 behavior) +%% +%% {allow_entities, true} re-enables entity expansion, simulating +%% what happens on OTP < 27 where xmerl expanded entities by default. +%%==================================================================== + +%% Demonstrates the actual file read: an attacker embeds an external +%% entity referencing a local file, and its contents appear in the +%% parsed SAML document's Issuer element. +xxe_demonstrates_file_read_test() -> + XmlStr = xxe_saml_response( + "<!ENTITY xxe SYSTEM \"file:///etc/hostname\">", "&xxe;"), + {Doc, _} = xmerl_scan:string(XmlStr, + [{namespace_conformant, true}, {allow_entities, true}]), + IssuerValue = extract_issuer(Doc), + %% The Issuer element now contains /etc/hostname contents, + %% not the literal entity reference. + ?assertNotEqual("&xxe;", IssuerValue), + ?assert(length(IssuerValue) > 0). + +%% Entity expansion occurs during XML parsing in decode_response/2, +%% which runs BEFORE signature verification in validate_assertion/2. +%% A SAML response with no signature still has its entities fully +%% expanded, leaking file contents into the parsed XML tree. +xxe_expansion_before_signature_verification_test() -> + XmlStr = xxe_saml_response( + "<!ENTITY xxe \"INJECTED_BY_ATTACKER\">", "&xxe;"), + {Doc, _} = xmerl_scan:string(XmlStr, + [{namespace_conformant, true}, {allow_entities, true}]), + ?assertEqual("INJECTED_BY_ATTACKER", extract_issuer(Doc)).
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5News mentions
0No linked articles in our index yet.