Skip to content

Commit caced0a

Browse files
committed
Add enableLegacyMode() for pre-4.0 interoperability
Provide a single migration aid on XMLSecurityDSig and XMLSecEnc that restores document/peer acceptance defaults (DOCTYPE, XPath transforms without count caps, RSA-1.5 key transport) without undoing always-on cryptographic hardening.
1 parent b792ca1 commit caced0a

5 files changed

Lines changed: 178 additions & 53 deletions

File tree

CHANGELOG.txt

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,8 @@ Security Improvements:
77
loopback/private/link-local/reserved/CGNAT addresses; disable HTTP redirects
88
- Reject a DOCTYPE in decrypted XML (defense against entity-expansion / XXE in
99
attacker-crafted encrypted content)
10-
- Reject a DOCTYPE in documents being signature-verified (locateSignature), the
11-
same defense onelogin/php-saml and simplesamlphp ship. Closes the
12-
entity-reference signature bypass where an Id="&e;" attribute is resolved by
10+
- Reject a DOCTYPE in documents being signature-verified (locateSignature). Closes
11+
the entity-reference signature bypass where an Id="&e;" attribute is resolved by
1312
getAttribute() but invisible to the XPath reference lookup (libxml2
1413
xmlXPathNodeValHash bug, the CVE-2025-23369 root cause), letting verify()
1514
validate a different node than the application reads. Opt out with
@@ -36,16 +35,15 @@ Security Improvements:
3635
the supplied key (previously only when $allowedSignatureAlgorithms was set),
3736
so a mismatch is rejected even on the low-level path. Blocks algorithm
3837
substitution / key confusion (e.g. downgrading an RSA signature to hmac-sha1
39-
so a public key is used as the HMAC secret) (GHSA-m5mw-mr39-66vp)
38+
so a public key is used as the HMAC secret)
4039
- Bound EncryptedKey / RetrievalMethod resolution depth (prevents recursive
4140
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
41+
- Reject XPath (REC-xpath-19991116) Transforms during verification by default.
42+
The transform evaluates a document-supplied XPath expression in
43+
validateReference() before any crypto runs, so a crafted expression is a
44+
pre-auth CPU denial-of-service; the expression is arbitrary XPath by design
45+
and cannot be sanitized. SAML / WS-Security do not use XPath transforms. Opt in
46+
with XMLSecurityDSig::$allowXPathTransforms = true. Signing is unaffected
4947
- Cap XPath transforms (max 5) and namespaces per XPath transform (max 20);
5048
overridable via $maxXPathTransforms / $maxXPathNamespaces
5149
- Fail closed on unresolved, external, and duplicate-Id Reference URIs
@@ -62,12 +60,18 @@ Features:
6260
(Julius Türich and joonlabs)
6361
- use phpseclib for all crypto (symmetric, RSA-1.5, RSA signatures,
6462
X.509); OpenSSL extension is now optional
63+
- Add enableLegacyMode() on XMLSecurityDSig and XMLSecEnc: one call restores
64+
pre-4.0 interoperability defaults (accept DOCTYPE, allow XPath transforms
65+
without count caps, allow RSA-1.5 key transport). Does not undo always-on
66+
cryptographic hardening (algorithm/key binding, uniform decrypt errors,
67+
hash_equals, decrypted-XML DOCTYPE rejection, etc.). Temporary migration aid.
6568

6669
Notes:
6770
- Prefer RSA-OAEP and AES-GCM for new deployments; RSA-1.5 and CBC remain
6871
for legacy XML Encryption interoperability
6972
- Always check verify() === 1; use getValidatedNodes() for SAML/WS-Security
7073
- Do not trust KeyInfo certificates alone — pin or validate against a trust store
74+
- Prefer secure defaults; use enableLegacyMode() only while migrating peers
7175

7276
12, Dec 2026, 3.1.5
7377
Security:

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ xmlseclibs requires PHP version 8.0 or greater. OpenSSL is optional (phpseclib i
3232

3333
* Documents carrying a `DOCTYPE` are rejected during signature verification (`locateSignature()` / `verifyDocument()`). This closes the entity-reference bypass in which an `Id="&e;"` attribute is resolved by `getAttribute()` but is invisible to the XPath reference lookup (a libxml2 hashing bug, the same root cause as CVE-2025-23369), causing `verify()` to validate a different node than the one your application reads. Set `$objDSig->forbidDoctype = false` only if you fully trust the document source and require DTD support.
3434

35+
* **Legacy interoperability (temporary):** if you must accept documents/peers that rely on pre-4.0 behaviour, call `$objDSig->enableLegacyMode()` and/or `$objenc->enableLegacyMode()` once after construction. That restores the interoperability settings in one place (DOCTYPE allowed on verify, XPath transforms allowed without count caps, RSA-1.5 key transport allowed). It does **not** undo always-on hardening such as SignatureMethod/key binding, uniform decryption errors, or DOCTYPE rejection in *decrypted* XML. Prefer migrating peers and removing the call.
36+
3537
### Verifying a signature (recommended)
3638

3739
```php

src/XMLSecEnc.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,25 @@ public function __construct()
132132
$this->_resetTemplate();
133133
}
134134

135+
/**
136+
* Restore pre-4.0 interoperability defaults for XML Encryption decryption.
137+
*
138+
* Use this only when you must decrypt payloads that use RSA-1.5 key
139+
* transport. Prefer migrating senders to RSA-OAEP (and AES-GCM) and then
140+
* removing the call.
141+
*
142+
* This does NOT undo 4.0 oracle / XXE hardening that is always enforced
143+
* (uniform decryption error messages, DOCTYPE rejection in decrypted XML,
144+
* EncryptedKey recursion depth bound, ISO 10126 pad-length validation).
145+
*
146+
* @return $this
147+
*/
148+
public function enableLegacyMode()
149+
{
150+
$this->allowRSA15KeyTransport = true;
151+
return $this;
152+
}
153+
135154
private function _resetTemplate()
136155
{
137156
$this->encdoc = new DOMDocument();

src/XMLSecurityDSig.php

Lines changed: 53 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,9 @@ class XMLSecurityDSig
132132
*
133133
* The XPath Filtering Transform evaluates an arbitrary, document-supplied
134134
* 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.
135+
* cryptography runs. The expression is an arbitrary XPath by design, so it
136+
* cannot be sanitized without breaking the feature.
137+
* The maxXPath* caps only bound the count, not the cost of a single expression.
141138
*
142139
* SAML and WS-Security do not use XPath transforms, so this defaults to
143140
* false (reject them on the verification path). Set to true only if you
@@ -227,6 +224,29 @@ public function __construct($prefix='ds')
227224
$this->sigNode = $sigdoc->documentElement;
228225
}
229226

227+
/**
228+
* Restore pre-4.0 interoperability defaults for signature verification.
229+
*
230+
* Use this only when you must accept documents/peers that rely on
231+
* behaviours 4.0 rejects by default (DOCTYPE, XPath Filtering Transforms,
232+
* uncapped XPath transform counts). Prefer migrating peers and then
233+
* removing the call.
234+
*
235+
* This does NOT weaken cryptographic checks that are always enforced in
236+
* 4.0 (SignatureMethod/key algorithm binding, hash_equals compares,
237+
* fail-closed Reference handling, unknown C14N rejection).
238+
*
239+
* @return $this
240+
*/
241+
public function enableLegacyMode()
242+
{
243+
$this->forbidDoctype = false;
244+
$this->allowXPathTransforms = true;
245+
$this->maxXPathTransforms = PHP_INT_MAX;
246+
$this->maxXPathNamespaces = PHP_INT_MAX;
247+
return $this;
248+
}
249+
230250
/**
231251
* Reset the XPathObj to null
232252
*/
@@ -646,39 +666,37 @@ public function processRefNode($refNode)
646666
if (! empty($arUrl['path']) || ! empty($arUrl['host']) || ! empty($arUrl['scheme'])) {
647667
throw new Exception('Reference URI must be a same-document reference');
648668
}
649-
if (empty($arUrl['path'])) {
650-
if ($identifier = $arUrl['fragment'] ?? null) {
669+
if ($identifier = $arUrl['fragment'] ?? null) {
651670

652-
/* This reference identifies a node with the given id by using
653-
* a URI on the form "#identifier". This should not include comments.
654-
*/
655-
$includeCommentNodes = false;
671+
/* This reference identifies a node with the given id by using
672+
* a URI on the form "#identifier". This should not include comments.
673+
*/
674+
$includeCommentNodes = false;
656675

657-
$xPath = new DOMXPath($refNode->ownerDocument);
658-
if ($this->idNS && is_array($this->idNS)) {
659-
foreach ($this->idNS as $nspf => $ns) {
660-
$xPath->registerNamespace($nspf, $ns);
661-
}
662-
}
663-
$iDlist = '@Id="'.XPath::filterAttrValue($identifier, XPath::DOUBLE_QUOTE).'"';
664-
if (is_array($this->idKeys)) {
665-
foreach ($this->idKeys as $idKey) {
666-
$iDlist .= " or @".XPath::filterAttrName($idKey).'="'.
667-
XPath::filterAttrValue($identifier, XPath::DOUBLE_QUOTE).'"';
668-
}
676+
$xPath = new DOMXPath($refNode->ownerDocument);
677+
if ($this->idNS && is_array($this->idNS)) {
678+
foreach ($this->idNS as $nspf => $ns) {
679+
$xPath->registerNamespace($nspf, $ns);
669680
}
670-
$query = '//*['.$iDlist.']';
671-
$nodeset = $xPath->query($query);
672-
if ($nodeset->length === 0) {
673-
throw new Exception('Reference URI does not identify a node');
674-
}
675-
if ($nodeset->length > 1) {
676-
throw new Exception('Reference URI identifies multiple nodes');
681+
}
682+
$iDlist = '@Id="'.XPath::filterAttrValue($identifier, XPath::DOUBLE_QUOTE).'"';
683+
if (is_array($this->idKeys)) {
684+
foreach ($this->idKeys as $idKey) {
685+
$iDlist .= " or @".XPath::filterAttrName($idKey).'="'.
686+
XPath::filterAttrValue($identifier, XPath::DOUBLE_QUOTE).'"';
677687
}
678-
$dataObject = $nodeset->item(0);
679-
} else {
680-
$dataObject = $refNode->ownerDocument;
681688
}
689+
$query = '//*['.$iDlist.']';
690+
$nodeset = $xPath->query($query);
691+
if ($nodeset->length === 0) {
692+
throw new Exception('Reference URI does not identify a node');
693+
}
694+
if ($nodeset->length > 1) {
695+
throw new Exception('Reference URI identifies multiple nodes');
696+
}
697+
$dataObject = $nodeset->item(0);
698+
} else {
699+
$dataObject = $refNode->ownerDocument;
682700
}
683701
} else {
684702
/* This reference identifies the root node with an empty URI. This should
@@ -980,11 +998,7 @@ public function verify($objKey)
980998

981999
/*
9821000
* Always bind the document's declared SignatureMethod to the algorithm
983-
* of the caller-supplied key. Without this, an attacker who controls
984-
* the XML can substitute the algorithm (e.g. downgrade an RSA signature
985-
* to hmac-sha1) so that the relying party's *public* key material is
986-
* used as the HMAC secret, enabling signature forgery (key/algorithm
987-
* confusion, GHSA-m5mw-mr39-66vp). The key's algorithm is fixed by the
1001+
* of the caller-supplied key. The key's algorithm is fixed by the
9881002
* caller -- or, in the locateKey() flow, derived from the same document
9891003
* -- so a mismatch always signals tampering.
9901004
*/
@@ -1216,9 +1230,6 @@ public static function staticGet509XCerts($certs, $isPEMFormat=true)
12161230
* and HTTP redirects are disabled so a public URL cannot bounce to an
12171231
* internal one.
12181232
*
1219-
* NOTE: a small DNS-rebinding TOCTOU window remains because the transport
1220-
* re-resolves the host; only enable URL fetching with trusted input.
1221-
*
12221233
* @param string $url
12231234
* @param null|array $options
12241235
* @return string

tests/legacy-mode.phpt

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
--TEST--
2+
enableLegacyMode() restores pre-4.0 interoperability defaults
3+
--FILE--
4+
<?php
5+
require(dirname(__FILE__) . '/../xmlseclibs.php');
6+
use RobRichards\XMLSecLibs\XMLSecurityDSig;
7+
use RobRichards\XMLSecLibs\XMLSecurityKey;
8+
use RobRichards\XMLSecLibs\XMLSecEnc;
9+
10+
/* --- Signature side --- */
11+
$dsig = new XMLSecurityDSig();
12+
print "DSIG_DEFAULT: forbidDoctype=".($dsig->forbidDoctype ? '1' : '0')
13+
." allowXPath=".($dsig->allowXPathTransforms ? '1' : '0')
14+
." maxXF=".$dsig->maxXPathTransforms
15+
." maxXN=".$dsig->maxXPathNamespaces."\n";
16+
17+
$dsig->enableLegacyMode();
18+
print "DSIG_LEGACY: forbidDoctype=".($dsig->forbidDoctype ? '1' : '0')
19+
." allowXPath=".($dsig->allowXPathTransforms ? '1' : '0')
20+
." maxXF=".($dsig->maxXPathTransforms === PHP_INT_MAX ? 'MAX' : $dsig->maxXPathTransforms)
21+
." maxXN=".($dsig->maxXPathNamespaces === PHP_INT_MAX ? 'MAX' : $dsig->maxXPathNamespaces)."\n";
22+
23+
/* DOCTYPE is accepted after enableLegacyMode(). */
24+
$signed = file_get_contents(dirname(__FILE__) . '/sign-sha256-rsa-sha256-test.xml');
25+
$withDoctype = preg_replace(
26+
'/(<\?xml[^>]*\?>\n)/',
27+
"$1<!DOCTYPE Root [ <!ENTITY e \"anything\"> ]>\n",
28+
$signed,
29+
1
30+
);
31+
$doc = new DOMDocument();
32+
$doc->loadXML($withDoctype, LIBXML_NOENT);
33+
$dsig = new XMLSecurityDSig();
34+
$dsig->enableLegacyMode();
35+
try {
36+
$dsig->locateSignature($doc);
37+
print "DSIG_DOCTYPE: accepted\n";
38+
} catch (Exception $e) {
39+
print "DSIG_DOCTYPE: ".$e->getMessage()."\n";
40+
}
41+
42+
/* --- Encryption side --- */
43+
$enc = new XMLSecEnc();
44+
print "ENC_DEFAULT: allowRSA15=".($enc->allowRSA15KeyTransport ? '1' : '0')."\n";
45+
$enc->enableLegacyMode();
46+
print "ENC_LEGACY: allowRSA15=".($enc->allowRSA15KeyTransport ? '1' : '0')."\n";
47+
48+
$xml = <<<XML
49+
<?xml version="1.0"?>
50+
<Root xmlns="urn:envelope">
51+
<xenc:EncryptedData xmlns:xenc="http://www.w3.org/2001/04/xmlenc#" xmlns:dsig="http://www.w3.org/2000/09/xmldsig#" Type="http://www.w3.org/2001/04/xmlenc#Content">
52+
<xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"/>
53+
<dsig:KeyInfo>
54+
<xenc:EncryptedKey>
55+
<xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-1_5"/>
56+
<xenc:CipherData><xenc:CipherValue>QUJD</xenc:CipherValue></xenc:CipherData>
57+
</xenc:EncryptedKey>
58+
</dsig:KeyInfo>
59+
<xenc:CipherData><xenc:CipherValue>QUJD</xenc:CipherValue></xenc:CipherData>
60+
</xenc:EncryptedData>
61+
</Root>
62+
XML;
63+
$doc = new DOMDocument();
64+
$doc->loadXML($xml);
65+
$objenc = new XMLSecEnc();
66+
$objenc->enableLegacyMode();
67+
$encData = $objenc->locateEncryptedData($doc);
68+
$objenc->setNode($encData);
69+
$objenc->type = $encData->getAttribute("Type");
70+
try {
71+
$objKey = $objenc->locateKey();
72+
$objenc->locateKeyInfo($objKey);
73+
print "ENC_RSA15: accepted\n";
74+
} catch (Exception $e) {
75+
print "ENC_RSA15: ".$e->getMessage()."\n";
76+
}
77+
78+
/* enableLegacyMode() returns $this for chaining. */
79+
$chained = (new XMLSecurityDSig())->enableLegacyMode();
80+
print "CHAIN: ".(($chained instanceof XMLSecurityDSig && $chained->allowXPathTransforms) ? 'ok' : 'fail')."\n";
81+
?>
82+
--EXPECTF--
83+
DSIG_DEFAULT: forbidDoctype=1 allowXPath=0 maxXF=5 maxXN=20
84+
DSIG_LEGACY: forbidDoctype=0 allowXPath=1 maxXF=MAX maxXN=MAX
85+
DSIG_DOCTYPE: accepted
86+
ENC_DEFAULT: allowRSA15=0
87+
ENC_LEGACY: allowRSA15=1
88+
ENC_RSA15: accepted
89+
CHAIN: ok

0 commit comments

Comments
 (0)