Skip to content

Commit b792ca1

Browse files
committed
Reject XPath Transforms on the verify path by default (GHSA-7mf5-fjj8-mvjc)
The XPath Filtering Transform evaluates a document-supplied XPath expression in validateReference() before any signature cryptography runs, so a crafted expression (e.g. deeply nested predicates) is a pre-authentication CPU denial-of-service. The expression is arbitrary XPath by design and cannot be sanitized, and the maxXPath* caps only bound the transform/namespace count, not the cost of a single expression. processTransforms() now refuses REC-xpath-19991116 transforms during verification unless XMLSecurityDSig::$allowXPathTransforms is set to true. SAML and WS-Security do not use XPath transforms, so the default breaks essentially no one. Signing is unaffected (a new $signing flag keeps caller-supplied transforms working). Updated xml-max-checks to opt in so it still exercises the count/namespace caps.
1 parent e95275a commit b792ca1

5 files changed

Lines changed: 120 additions & 3 deletions

File tree

CHANGELOG.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ Security Improvements:
3939
so a public key is used as the HMAC secret) (GHSA-m5mw-mr39-66vp)
4040
- Bound EncryptedKey / RetrievalMethod resolution depth (prevents recursive
4141
key-reference DoS / memory exhaustion)
42+
- Reject XPath (REC-xpath-19991116) Transforms during verification by default
43+
(GHSA-7mf5-fjj8-mvjc). The transform evaluates a document-supplied XPath
44+
expression in validateReference() before any crypto runs, so a crafted
45+
expression is a pre-auth CPU denial-of-service; the expression is arbitrary
46+
XPath by design and cannot be sanitized. SAML / WS-Security do not use XPath
47+
transforms. Opt in with XMLSecurityDSig::$allowXPathTransforms = true. Signing
48+
is unaffected
4249
- Cap XPath transforms (max 5) and namespaces per XPath transform (max 20);
4350
overridable via $maxXPathTransforms / $maxXPathNamespaces
4451
- Fail closed on unresolved, external, and duplicate-Id Reference URIs

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ xmlseclibs requires PHP version 8.0 or greater. OpenSSL is optional (phpseclib i
2424
* Do not trust a signing certificate from `KeyInfo` alone. Load and pin trusted keys yourself.
2525
* By default `verifyDocument()` accepts only SHA-256/384/512 digests and RSA-SHA-256/384/512 (and RSA-PSS) signatures. To interoperate with legacy peers, widen the sets explicitly, e.g. `$objDSig->allowedSignatureAlgorithms[] = XMLSecurityKey::RSA_SHA1;`.
2626
* Prefer RSA-OAEP and AES-GCM for encryption. RSA-1.5 key transport is **denied by default** on decryption (Bleichenbacher risk); opt in with `$objenc->allowRSA15KeyTransport = true;` only for legacy interop. You can additionally pin exact algorithms via `$objenc->allowedKeyAlgorithms` / `$objenc->allowedDataAlgorithms` (presets: `XMLSecEnc::DEFAULT_KEY_ALGORITHMS` and `XMLSecEnc::DEFAULT_DATA_ALGORITHMS`, both authenticated/OAEP-only). Unauthenticated CBC modes remain available for interop but are malleable — prefer AES-GCM.
27-
* XPath transforms are capped by default (`maxXPathTransforms` / `maxXPathNamespaces`, defaults 5 and 20). Raise or lower these on the `XMLSecurityDSig` instance if your use case needs different limits.
27+
* XPath (`REC-xpath-19991116`) transforms are **rejected during verification by default**. They evaluate a document-supplied XPath expression in `validateReference()` before any signature crypto runs, so a crafted expression is a pre-authentication CPU denial-of-service — and the expression is arbitrary XPath by design, so it cannot be sanitized. SAML and WS-Security do not use them. Set `$objDSig->allowXPathTransforms = true` only if you must verify signatures that legitimately rely on XPath transforms and you trust the source. Signing is unaffected. When enabled, the count/namespace caps below still apply.
28+
29+
* XPath transforms (once enabled) are capped by default (`maxXPathTransforms` / `maxXPathNamespaces`, defaults 5 and 20). Raise or lower these on the `XMLSecurityDSig` instance if your use case needs different limits.
2830
* `add509Cert(..., $isURL = true)` fetches over http/https only and rejects hosts that resolve to loopback/private/link-local/reserved/CGNAT addresses, with redirects disabled (SSRF hardening). `file://` is disabled unless you pass `array('allow_file_scheme' => true)` in `$options`. Only fetch certificates from trusted URLs — a small DNS-rebinding window remains.
2931
* Decrypted XML containing a `DOCTYPE` is rejected to guard against entity-expansion / XXE. Always load untrusted *input* documents yourself with DTD/entity processing disabled.
3032

src/XMLSecurityDSig.php

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,28 @@ class XMLSecurityDSig
127127
*/
128128
public $maxXPathNamespaces = self::MAX_XPATH_NAMESPACES;
129129

130+
/**
131+
* Allow XPath (REC-xpath-19991116) Transforms while verifying references.
132+
*
133+
* The XPath Filtering Transform evaluates an arbitrary, document-supplied
134+
* XPath expression during validateReference() -- before any signature
135+
* cryptography runs. A crafted expression (e.g. deeply nested predicates)
136+
* can be evaluated at super-linear cost, giving an unauthenticated attacker
137+
* a pre-auth CPU denial-of-service (GHSA-7mf5-fjj8-mvjc). The expression is
138+
* an arbitrary XPath by design, so it cannot be sanitized without breaking
139+
* the feature; the maxXPath* caps only bound the count, not the cost of a
140+
* single expression.
141+
*
142+
* SAML and WS-Security do not use XPath transforms, so this defaults to
143+
* false (reject them on the verification path). Set to true only if you
144+
* must verify signatures that legitimately rely on XPath transforms and you
145+
* trust the document source. Signing is unaffected (the transforms are
146+
* caller-supplied, not attacker-controlled).
147+
*
148+
* @var bool
149+
*/
150+
public $allowXPathTransforms = false;
151+
130152
/**
131153
* Allowlist of acceptable SignatureMethod algorithm URIs.
132154
*
@@ -494,7 +516,7 @@ public function validateDigest($refNode, $data)
494516
* @return string
495517
* @throws Exception
496518
*/
497-
public function processTransforms($refNode, $objData, $includeCommentNodes = true)
519+
public function processTransforms($refNode, $objData, $includeCommentNodes = true, $signing = false)
498520
{
499521
$data = $objData;
500522
$xpath = new DOMXPath($refNode->ownerDocument);
@@ -554,6 +576,17 @@ public function processTransforms($refNode, $objData, $includeCommentNodes = tru
554576

555577
break;
556578
case 'http://www.w3.org/TR/1999/REC-xpath-19991116':
579+
/*
580+
* Reject attacker-controlled XPath transforms on the
581+
* verification path unless explicitly allowed. Signing uses
582+
* caller-supplied transforms and is always permitted.
583+
*/
584+
if (! $signing && ! $this->allowXPathTransforms) {
585+
throw new Exception(
586+
'XPath Transforms are not allowed during verification; set '
587+
. 'XMLSecurityDSig::$allowXPathTransforms = true to enable them'
588+
);
589+
}
557590
$xpathTransformCount++;
558591
if ($xpathTransformCount > $this->maxXPathTransforms) {
559592
throw new Exception(
@@ -817,7 +850,7 @@ private function addRefInternal($sinfoNode, $node, $algorithm, $arTransforms=nul
817850
$transNode->setAttribute('Algorithm', $this->canonicalMethod);
818851
}
819852

820-
$canonicalData = $this->processTransforms($refNode, $node);
853+
$canonicalData = $this->processTransforms($refNode, $node, true, true);
821854
$digValue = $this->calculateDigest($algorithm, $canonicalData);
822855

823856
$digestMethod = $this->createNewSignNode('DigestMethod');
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
--TEST--
2+
XPath Transforms are rejected on the verify path by default (GHSA-7mf5-fjj8-mvjc)
3+
--FILE--
4+
<?php
5+
require(dirname(__FILE__) . '/../xmlseclibs.php');
6+
use RobRichards\XMLSecLibs\XMLSecurityDSig;
7+
use RobRichards\XMLSecLibs\XMLSecurityKey;
8+
9+
/* Build a document that is legitimately signed with an XPath Transform. */
10+
function signWithXPathTransform() {
11+
$doc = new DOMDocument();
12+
$doc->load(dirname(__FILE__) . '/basic-doc.xml');
13+
$objDSig = new XMLSecurityDSig();
14+
$objDSig->setCanonicalMethod(XMLSecurityDSig::EXC_C14N);
15+
$objDSig->addReference(
16+
$doc,
17+
XMLSecurityDSig::SHA256,
18+
array(
19+
'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
20+
array('http://www.w3.org/TR/1999/REC-xpath-19991116' => array('query' => 'true()')),
21+
)
22+
);
23+
$objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, array('type' => 'private'));
24+
$objKey->loadKey(dirname(__FILE__) . '/privkey.pem', true);
25+
$objDSig->sign($objKey);
26+
$objDSig->appendSignature($doc->documentElement);
27+
return $doc->saveXML();
28+
}
29+
30+
function pubKey() {
31+
$objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, array('type' => 'public'));
32+
$objKey->loadKey(dirname(__FILE__) . '/mycert.pem', true, true);
33+
return $objKey;
34+
}
35+
36+
$signedXml = signWithXPathTransform();
37+
38+
/* 1. Signing with an XPath transform is unaffected. */
39+
print "SIGN: ".(strpos($signedXml, 'REC-xpath-19991116') !== false ? "ok" : "missing transform")."\n";
40+
41+
/* 2. Default verification refuses the XPath transform (pre-auth DoS surface). */
42+
$doc = new DOMDocument();
43+
$doc->loadXML($signedXml);
44+
$objDSig = new XMLSecurityDSig();
45+
$objDSig->locateSignature($doc);
46+
$objDSig->canonicalizeSignedInfo();
47+
try {
48+
$objDSig->validateReference();
49+
print "DENY: not rejected\n";
50+
} catch (Exception $e) {
51+
print "DENY: ".$e->getMessage()."\n";
52+
}
53+
54+
/* 3. Opt-in allows it and the signature still validates. */
55+
$doc = new DOMDocument();
56+
$doc->loadXML($signedXml);
57+
$objDSig = new XMLSecurityDSig();
58+
$objDSig->allowXPathTransforms = true;
59+
$objDSig->locateSignature($doc);
60+
$objDSig->canonicalizeSignedInfo();
61+
try {
62+
$objDSig->validateReference();
63+
print "ALLOW: ".($objDSig->verify(pubKey()) === 1 ? "valid" : "invalid")."\n";
64+
} catch (Exception $e) {
65+
print "ALLOW: ".$e->getMessage()."\n";
66+
}
67+
?>
68+
--EXPECTF--
69+
SIGN: ok
70+
DENY: XPath Transforms are not allowed during verification; set XMLSecurityDSig::$allowXPathTransforms = true to enable them
71+
ALLOW: valid

tests/xml-max-checks.phpt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ foreach ($arTests AS $testName=>$testFile) {
1414
try {
1515
$doc->load(dirname(__FILE__) . "/$testFile");
1616
$objXMLSecDSig = new XMLSecurityDSig();
17+
$objXMLSecDSig->allowXPathTransforms = true;
1718

1819
$objDSig = $objXMLSecDSig->locateSignature($doc);
1920
if (! $objDSig) {
@@ -57,6 +58,7 @@ foreach ($arTests AS $testName=>$testFile) {
5758
try {
5859
$doc->load(dirname(__FILE__) . '/xml-max-transforms.xml');
5960
$objXMLSecDSig = new XMLSecurityDSig();
61+
$objXMLSecDSig->allowXPathTransforms = true;
6062
$objXMLSecDSig->maxXPathTransforms = 2;
6163
$objDSig = $objXMLSecDSig->locateSignature($doc);
6264
$objXMLSecDSig->canonicalizeSignedInfo();
@@ -72,6 +74,7 @@ try {
7274
try {
7375
$doc->load(dirname(__FILE__) . '/xml-max-transforms.xml');
7476
$objXMLSecDSig = new XMLSecurityDSig();
77+
$objXMLSecDSig->allowXPathTransforms = true;
7578
$objXMLSecDSig->maxXPathTransforms = 100;
7679
$objDSig = $objXMLSecDSig->locateSignature($doc);
7780
$objXMLSecDSig->canonicalizeSignedInfo();
@@ -91,6 +94,7 @@ try {
9194
try {
9295
$doc->load(dirname(__FILE__) . '/xml-max-namespaces.xml');
9396
$objXMLSecDSig = new XMLSecurityDSig();
97+
$objXMLSecDSig->allowXPathTransforms = true;
9498
$objXMLSecDSig->maxXPathNamespaces = 100;
9599
$objDSig = $objXMLSecDSig->locateSignature($doc);
96100
$objXMLSecDSig->canonicalizeSignedInfo();

0 commit comments

Comments
 (0)