VYPR
Medium severityNVD Advisory· Published Mar 23, 2026· Updated Apr 6, 2026

CVE-2026-28809

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.

PackageAffected versionsPatched versions
esamlHex
<= 4.6.0

Affected products

1

Patches

1
bab85efde7c1

Fix CVE 2026 28809 (#5)

https://github.com/Jump-App/esamlPeaceful JamesMar 26, 2026via ghsa
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

5

News mentions

0

No linked articles in our index yet.