|
| 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