Net-SAML2
view release on metacpan or search on metacpan
t/32-xsw-defenses.t view on Meta::CPAN
my $aud = $p{audience} // 'http://sp.test';
my $recip = $p{recipient} // 'http://sp.test/saml/post';
return <<"XML";
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="$id" Version="2.0" IssueInstant="$now">
<saml:Issuer>$issuer</saml:Issuer>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">$nameid</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData InResponseTo="_req_$$" NotOnOrAfter="$exp" Recipient="$recip"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="$now" NotOnOrAfter="$exp">
<saml:AudienceRestriction>
<saml:Audience>$aud</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="$now" SessionIndex="_sess_$$">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="role">
<saml:AttributeValue>user</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
XML
}
# Wrap a (possibly-signed) Assertion XML in a Response wrapper.
sub wrap_in_response {
my %p = @_;
my $inner = $p{inner};
my $resp_id = "_resp_$$" . "_" . int(rand(1e9));
my $now = strftime("%Y-%m-%dT%H:%M:%SZ", gmtime());
my $dest = $p{destination} // 'http://sp.test/saml/post';
return <<"XML";
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="$resp_id" Version="2.0" IssueInstant="$now"
Destination="$dest">
<saml:Issuer>http://idp.test/idp</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
$inner
</samlp:Response>
XML
}
my $signer = XML::Sig->new({
x509 => 1,
key => $idp_key,
cert => $idp_crt,
no_xml_declaration => 1,
});
# Helper: sign and post-process so the <dsig:Signature> sits right
# after <saml:Issuer> within the signed element - the position the
# SAML 2.0 schema (AssertionType, StatusResponseType) requires.
# XML::Sig appends the signature as the last child, which is valid
# per W3C XML-DSig but rejected by strict schema-validating SPs.
#
# This is regex-based and only moves the Signature element verbatim
# (no parsing/reserialization). Internal whitespace of SignedInfo
# is untouched, so digest and signature math both remain valid.
sub sign_schema_compliant {
my ($s, $xml) = @_;
my $signed = $s->sign($xml);
my ($sig) = $signed =~ m{(<(?:ds|dsig):Signature\b.*?</(?:ds|dsig):Signature>)}s;
return $signed unless $sig;
# Move the Signature element ONLY - no surrounding whitespace -
# so the canonical form of the signed element (after enveloped-
# signature transform strips the Signature) is byte-identical to
# what XML::Sig digested. Any added whitespace would change the
# text-node structure and break digest validation.
$signed =~ s{\Q$sig\E}{}s;
$signed =~ s{(</saml:Issuer>)}{$1$sig}s;
return $signed;
}
# ============================================================
# Sanity: a legitimately-signed Response + Assertion parses
# ============================================================
{
my $assertion_xml = make_assertion(nameid => 'lowuser@victim.com');
my $signed = sign_schema_compliant($signer, $assertion_xml);
my $response = wrap_in_response(inner => $signed);
my $a = eval {
Net::SAML2::Protocol::Assertion->new_from_xml(
xml => $response,
cacert => $idp_crt,
);
};
ok($a, 'sanity: legit signed assertion parses') or diag $@;
is($a && $a->nameid, 'lowuser@victim.com', 'sanity: nameid extracted');
}
# ============================================================
# Test 1: math gate catches a corrupted SignatureValue
# ============================================================
{
my $assertion_xml = make_assertion(nameid => 'lowuser@victim.com');
my $signed = sign_schema_compliant($signer, $assertion_xml);
my $response = wrap_in_response(inner => $signed);
# Corrupt the first base64 char of the SignatureValue so the signature
# math must fail. Deterministic: flip the captured char to a
# guaranteed-different value (A<->B), regardless of what it is, and
# tolerate optional whitespace/newline after the opening tag. The
# previous version set a fixed char ('A') which was a no-op when the
# original char already equalled it, making the test flaky.
(my $corrupted = $response) =~ s{
(<(?:ds|dsig)?:?SignatureValue[^>]*>\s*) ([A-Za-z0-9+/])
}{
$1 . ($2 eq 'A' ? 'B' : 'A')
}ex;
isnt($corrupted, $response, 'corruption regex made a change');
throws_ok(
sub {
Net::SAML2::Protocol::Assertion->new_from_xml(
xml => $corrupted,
cacert => $idp_crt,
);
},
qr/XML signature verification failed/,
'XML signature verification failed croaks on corrupted SignatureValue'
);
}
# ============================================================
# Test 2: defensive croak on XSW1 duplicate-ID
# ============================================================
{
my $legit_id = "_legit_id_dup_test";
my $assertion_xml = make_assertion(id => $legit_id, nameid => 'lowuser@victim.com');
my $signed = sign_schema_compliant($signer, $assertion_xml);
# Inject a SECOND <saml:Assertion> with the SAME ID, AFTER the signed
# one (so XML::Sig's _get_node still picks the legit one for digest).
my $duplicate = qq{<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="$legit_id"><saml:Issuer>x</saml:Issuer></saml:Assertion>};
my $payload = $signed . $duplicate;
my $response = wrap_in_response(inner => $payload);
throws_ok(
sub {
Net::SAML2::Protocol::Assertion->new_from_xml(
xml => $response,
cacert => $idp_crt,
);
},
qr/XSW guard|ambiguous|XML signature verification failed/,
'duplicate-ID document is rejected (either XSW guard or upstream XML::Sig fail)'
);
}
# ============================================================
# Test 3: XSW different-ID wrapping is defended
#
# Attacker constructs a Response containing a fresh attacker
# Assertion (different ID, NameID="admin@victim.com") prepended
# in document order, followed by the legitimately-signed Assertion
# (NameID="lowuser@victim.com"). Without the XSW anchor, document-
# order extraction would pick the attacker's element. With it,
# extraction is anchored at the signed Assertion's subtree.
# ============================================================
{
my $legit_id = "_legit_" . int(rand(1e9));
my $legit_xml = make_assertion(id => $legit_id, nameid => 'lowuser@victim.com');
my $signed_legit = sign_schema_compliant($signer, $legit_xml);
# Attacker's modified Assertion - no signature, NameID escalated.
my $atk_id = "_attacker_" . int(rand(1e9));
( run in 1.021 second using v1.01-cache-2.11-cpan-5fbc6bb55f2 )