diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 04bdcb1..123ddc4 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -7,11 +7,38 @@ Features: support the URI fragment) - Change selected XMLSecurityDSig private properties and methods to protected so the class can be extended (gdespirito). refs #152 +- Add XMLSecurityDSig::ENVELOPED constant for the enveloped-signature Transform + URI; deprecate empty appendCert() stub (use add509Cert()) Security Improvements: - Harden add509Cert() URL fetching against SSRF: only http/https by default (file:// requires options['allow_file_scheme']); resolve the host and reject loopback/private/link-local/reserved/CGNAT addresses; disable HTTP redirects +- Reject IPv4-mapped / IPv4-compatible IPv6 certificate URL hosts (e.g. + ::ffff:127.0.0.1, ::ffff:169.254.169.254): unwrap the embedded IPv4 and apply + the same private/reserved/CGNAT checks (PHP's FILTER_FLAG_NO_* treats mapped + addresses as public) +- Fail closed on unknown Reference Transform algorithms; recognize + enveloped-signature explicitly. validateReference() no longer always detaches + the Signature from the caller DOM (that broke C14N of ds:Object targets and + left half-mutated trees on failure); enveloped-signature removes Signature + only when it is a proper descendant of the node being digested +- Tighten same-document Reference URIs to empty URI and "#id" only (reject + "?query", bare "#", and external URIs); share parsing between processRefNode() + and getRefNodeID(). Same-document refs always omit comments +- Reject hostile idKeys attribute names (no whitespace / XPath operators) via + Utils\XPath::filterAttrName; invalid names throw +- Reject duplicate CanonicalizationMethod / SignatureMethod under SignedInfo and + duplicate DigestMethod / DigestValue / SignatureValue during verify +- Reset the cached DOMXPath in locateSignature() so instance reuse across + documents cannot fatal with "Node from wrong document" +- verifyDocument() no longer permanently overwrites instance algorithm + allowlists (restored in finally) +- addReference() / addReferenceList() / sign() / add509Cert() throw instead of + silently no-oping when SignedInfo / signature context is missing; sign() + requires setCanonicalMethod() and a SignatureMethod element +- Require correct namespace URIs for InclusiveNamespaces (exc-c14n) and XPath + transform children (not localName alone) - Reject a DOCTYPE in decrypted XML (defense against entity-expansion / XXE in attacker-crafted encrypted content) - Reject a DOCTYPE in documents being signature-verified (locateSignature). Closes @@ -97,6 +124,10 @@ Improvements: - Update parameter type in XMLSecurityDSig::addReference() and addRefInternal() - Tighten XMLSecEnc PHPDoc types for algorithm allowlists, references, and encrypt/decrypt return values +- Harden XMLSecurityDSig DOM handling for static analysis: require DOMElement + before attribute access on XPath results, correct locateSignature / + reference / staticAdd509Cert PHPDocs, guard failed XPath queries and missing + SignatureMethod nodes, and remove dead null comparisons Bug Fixes: - Compact signature template (XMLSecurityDSig 'stripWhitespace' option) removes diff --git a/src/Utils/XPath.php b/src/Utils/XPath.php index b50975b..df1f80f 100644 --- a/src/Utils/XPath.php +++ b/src/Utils/XPath.php @@ -9,6 +9,9 @@ class XPath const LETTERS = '\w'; const EXTENDED_ALPHANUMERIC = '-\w\d\s_:\.'; + /* Attribute names only — no whitespace or XPath operators. */ + const EXTENDED_ALPHANUMERIC_STRICT = '-\w\d_:\.'; + const SINGLE_QUOTE = '\''; const DOUBLE_QUOTE = '"'; const ALL_QUOTES = '[\'"]'; @@ -37,7 +40,7 @@ public static function filterAttrValue($value, $quotes = self::ALL_QUOTES) * * @return string The filtered attribute name. */ - public static function filterAttrName($name, $allow = self::EXTENDED_ALPHANUMERIC) + public static function filterAttrName($name, $allow = self::EXTENDED_ALPHANUMERIC_STRICT) { return preg_replace('#[^'.$allow.']#', '', $name); } diff --git a/src/XMLSecurityDSig.php b/src/XMLSecurityDSig.php index c66e461..0f0d79d 100644 --- a/src/XMLSecurityDSig.php +++ b/src/XMLSecurityDSig.php @@ -62,6 +62,7 @@ class XMLSecurityDSig const C14N_COMMENTS = 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments'; const EXC_C14N = 'http://www.w3.org/2001/10/xml-exc-c14n#'; const EXC_C14N_COMMENTS = 'http://www.w3.org/2001/10/xml-exc-c14n#WithComments'; + const ENVELOPED = 'http://www.w3.org/2000/09/xmldsig#enveloped-signature'; /** Default maximum XPath transforms allowed per Reference (DoS protection). */ const MAX_XPATH_TRANSFORMS = 5; @@ -185,7 +186,7 @@ class XMLSecurityDSig /** @var string|null */ protected $signedInfo = null; - /** @var DomXPath|null */ + /** @var DOMXPath|null */ protected $xPathCtx = null; /** @var string|null */ @@ -270,7 +271,7 @@ protected function resetXPathObj() } /** - * Returns the XPathObj or null if xPathCtx is set and sigNode is empty. + * Returns the cached DOMXPath for sigNode's owner document, creating it if needed. * * @return DOMXPath|null */ @@ -317,18 +318,21 @@ public static function generate_GUID($prefix='pfx') } /** - * @param DOMDocument $objDoc + * @param DOMNode $objDoc * @param int $pos - * @return DOMNode|null + * @return DOMElement|null */ public function locateSignature($objDoc, $pos=0) { + /* Drop any XPath context bound to a previous document. */ + $this->resetXPathObj(); + if ($objDoc instanceof DOMDocument) { $doc = $objDoc; } else { $doc = $objDoc->ownerDocument; } - if ($doc) { + if ($doc instanceof DOMDocument) { if ($this->forbidDoctype && $doc->doctype !== null) { throw new Exception('A DOCTYPE is not allowed in a document being verified'); } @@ -336,10 +340,19 @@ public function locateSignature($objDoc, $pos=0) $xpath->registerNamespace('secdsig', self::XMLDSIGNS); $query = ".//secdsig:Signature"; $nodeset = $xpath->query($query, $objDoc); - $this->sigNode = $nodeset->item($pos); + if ($nodeset === false) { + $this->sigNode = null; + return null; + } + $sigNode = $nodeset->item($pos); + if (! $sigNode instanceof DOMElement) { + $this->sigNode = null; + return null; + } + $this->sigNode = $sigNode; $query = "./secdsig:SignedInfo"; $nodeset = $xpath->query($query, $this->sigNode); - if ($nodeset->length > 1) { + if ($nodeset !== false && $nodeset->length > 1) { throw new Exception("Invalid structure - Too many SignedInfo elements found"); } return $this->sigNode; @@ -382,10 +395,12 @@ public function setCanonicalMethod($method) if ($xpath = $this->getXPathObj()) { $query = './'.$this->searchpfx.':SignedInfo'; $nodeset = $xpath->query($query, $this->sigNode); - if ($sinfo = $nodeset->item(0)) { + $sinfo = ($nodeset !== false) ? $nodeset->item(0) : null; + if ($sinfo instanceof DOMElement) { $query = './'.$this->searchpfx.':CanonicalizationMethod'; $nodeset = $xpath->query($query, $sinfo); - if (! ($canonNode = $nodeset->item(0))) { + $canonNode = ($nodeset !== false) ? $nodeset->item(0) : null; + if (! $canonNode instanceof DOMElement) { $canonNode = $this->createNewSignNode('CanonicalizationMethod'); $sinfo->insertBefore($canonNode, $sinfo->firstChild); } @@ -424,7 +439,10 @@ protected function canonicalizeData($node, $canonicalmethod, $arXPath=null, $pre throw new Exception('Invalid CanonicalizationMethod: '.$canonicalmethod); } - if (is_null($arXPath) && ($node instanceof DOMNode) && ($node->ownerDocument !== null) && $node->isSameNode($node->ownerDocument->documentElement)) { + if (is_null($arXPath) + && ($node->ownerDocument !== null) + && ($node->ownerDocument->documentElement !== null) + && $node->isSameNode($node->ownerDocument->documentElement)) { /* Check for any PI or comments as they would have been excluded */ $element = $node; while ($refnode = $element->previousSibling) { @@ -463,12 +481,18 @@ public function canonicalizeSignedInfo() if ($signInfoNode = $nodeset->item(0)) { $query = "./secdsig:CanonicalizationMethod"; $nodeset = $xpath->query($query, $signInfoNode); + if ($nodeset->length > 1) { + throw new Exception("Invalid structure - Too many CanonicalizationMethod elements found"); + } $prefixList = null; - if ($canonNode = $nodeset->item(0)) { + $canonNode = $nodeset->item(0); + if ($canonNode instanceof DOMElement) { $canonicalmethod = $canonNode->getAttribute('Algorithm'); foreach ($canonNode->childNodes as $node) { - if ($node->localName == 'InclusiveNamespaces') { + if ($node instanceof DOMElement + && $node->localName == 'InclusiveNamespaces' + && $node->namespaceURI === self::EXC_C14N) { if ($pfx = $node->getAttribute('PrefixList')) { $arpfx = array_filter(explode(' ', $pfx)); if (count($arpfx) > 0) { @@ -478,6 +502,11 @@ public function canonicalizeSignedInfo() } } } + $query = "./secdsig:SignatureMethod"; + $nodeset = $xpath->query($query, $signInfoNode); + if ($nodeset->length > 1) { + throw new Exception("Invalid structure - Too many SignatureMethod elements found"); + } $this->signedInfo = $this->canonicalizeData($signInfoNode, $canonicalmethod, null, $prefixList); return $this->signedInfo; } @@ -523,7 +552,7 @@ public function calculateDigest($digestAlgorithm, $data, $encode = true) } /** - * @param $refNode + * @param DOMElement $refNode * @param string $data * @return bool */ @@ -531,22 +560,39 @@ public function validateDigest($refNode, $data) { $xpath = new DOMXPath($refNode->ownerDocument); $xpath->registerNamespace('secdsig', self::XMLDSIGNS); - $query = 'string(./secdsig:DigestMethod/@Algorithm)'; - $digestAlgorithm = $xpath->evaluate($query, $refNode); + $query = './secdsig:DigestMethod'; + $nodeset = $xpath->query($query, $refNode); + if ($nodeset === false || $nodeset->length !== 1) { + throw new Exception('Invalid structure - Expected exactly one DigestMethod element'); + } + $digestMethod = $nodeset->item(0); + if (! $digestMethod instanceof DOMElement) { + throw new Exception('Invalid structure - Expected exactly one DigestMethod element'); + } + $digestAlgorithm = $digestMethod->getAttribute('Algorithm'); if ($this->allowedDigestAlgorithms !== null && ! in_array($digestAlgorithm, $this->allowedDigestAlgorithms, true)) { throw new Exception("DigestMethod algorithm is not allowed: '$digestAlgorithm'"); } $digValue = $this->calculateDigest($digestAlgorithm, $data, false); - $query = 'string(./secdsig:DigestValue)'; - $digestValue = $xpath->evaluate($query, $refNode); - return ($digValue !== false && hash_equals($digValue, base64_decode($digestValue))); + $query = './secdsig:DigestValue'; + $nodeset = $xpath->query($query, $refNode); + if ($nodeset === false || $nodeset->length !== 1) { + throw new Exception('Invalid structure - Expected exactly one DigestValue element'); + } + $digestValue = $nodeset->item(0)->textContent; + $decoded = base64_decode($digestValue, true); + if ($decoded === false) { + return false; + } + return hash_equals($digValue, $decoded); } /** - * @param $refNode + * @param DOMElement $refNode * @param DOMNode $objData * @param bool $includeCommentNodes + * @param bool $signing * @return string * @throws Exception */ @@ -561,148 +607,224 @@ public function processTransforms($refNode, $objData, $includeCommentNodes = tru $arXPath = null; $prefixList = null; $xpathTransformCount = 0; - foreach ($nodelist AS $transform) { - $algorithm = $transform->getAttribute("Algorithm"); - switch ($algorithm) { - case 'http://www.w3.org/2001/10/xml-exc-c14n#': - case 'http://www.w3.org/2001/10/xml-exc-c14n#WithComments': - - if (!$includeCommentNodes) { - /* We remove comment nodes by forcing it to use a canonicalization - * without comments. - */ - $canonicalMethod = 'http://www.w3.org/2001/10/xml-exc-c14n#'; - } else { - $canonicalMethod = $algorithm; - } + $enveloped = false; + if ($nodelist !== false) { + foreach ($nodelist AS $transform) { + if (! $transform instanceof DOMElement) { + continue; + } + $algorithm = $transform->getAttribute("Algorithm"); + switch ($algorithm) { + case 'http://www.w3.org/2001/10/xml-exc-c14n#': + case 'http://www.w3.org/2001/10/xml-exc-c14n#WithComments': + + if (!$includeCommentNodes) { + /* We remove comment nodes by forcing it to use a canonicalization + * without comments. + */ + $canonicalMethod = 'http://www.w3.org/2001/10/xml-exc-c14n#'; + } else { + $canonicalMethod = $algorithm; + } - $node = $transform->firstChild; - while ($node) { - if ($node->localName == 'InclusiveNamespaces') { - if ($pfx = $node->getAttribute('PrefixList')) { - $arpfx = array(); - $pfxlist = explode(" ", $pfx); - foreach ($pfxlist AS $pfx) { - $val = trim($pfx); - if (! empty($val)) { - $arpfx[] = $val; + $node = $transform->firstChild; + while ($node) { + if ($node instanceof DOMElement + && $node->localName == 'InclusiveNamespaces' + && $node->namespaceURI === self::EXC_C14N) { + if ($pfx = $node->getAttribute('PrefixList')) { + $arpfx = array(); + $pfxlist = explode(" ", $pfx); + foreach ($pfxlist AS $pfx) { + $val = trim($pfx); + if (! empty($val)) { + $arpfx[] = $val; + } + } + if (count($arpfx) > 0) { + $prefixList = $arpfx; } } - if (count($arpfx) > 0) { - $prefixList = $arpfx; - } + break; } - break; + $node = $node->nextSibling; + } + break; + case 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315': + case 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments': + if (!$includeCommentNodes) { + /* We remove comment nodes by forcing it to use a canonicalization + * without comments. + */ + $canonicalMethod = 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315'; + } else { + $canonicalMethod = $algorithm; } - $node = $node->nextSibling; - } - break; - case 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315': - case 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments': - if (!$includeCommentNodes) { - /* We remove comment nodes by forcing it to use a canonicalization - * without comments. - */ - $canonicalMethod = 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315'; - } else { - $canonicalMethod = $algorithm; - } - break; - case 'http://www.w3.org/TR/1999/REC-xpath-19991116': - /* - * Reject attacker-controlled XPath transforms on the - * verification path unless explicitly allowed. Signing uses - * caller-supplied transforms and is always permitted. - */ - if (! $signing && ! $this->allowXPathTransforms) { - throw new Exception( - 'XPath Transforms are not allowed during verification; set ' - . 'XMLSecurityDSig::$allowXPathTransforms = true to enable them' - ); - } - $xpathTransformCount++; - if ($xpathTransformCount > $this->maxXPathTransforms) { - throw new Exception( - 'Too many XPath Transformations found ('.$nodelist->length.') with a max allowed of '.$this->maxXPathTransforms - ); - } - $node = $transform->firstChild; - while ($node) { - if ($node->localName == 'XPath') { - $arXPath = array(); - $arXPath['query'] = '(.//. | .//@* | .//namespace::*)['.$node->nodeValue.']'; - $arXPath['namespaces'] = array(); - $nslist = $xpath->query('./namespace::*', $node); - foreach ($nslist AS $nsnode) { - /* Exclude xml and the default xmlns (empty prefix). */ - if ($nsnode->localName != "xml" && $nsnode->localName !== '' && $nsnode->localName !== 'xmlns') { - $arXPath['namespaces'][$nsnode->localName] = $nsnode->nodeValue; + break; + case self::ENVELOPED: + $enveloped = true; + break; + case 'http://www.w3.org/TR/1999/REC-xpath-19991116': + /* + * Reject attacker-controlled XPath transforms on the + * verification path unless explicitly allowed. Signing uses + * caller-supplied transforms and is always permitted. + */ + if (! $signing && ! $this->allowXPathTransforms) { + throw new Exception( + 'XPath Transforms are not allowed during verification; set ' + . 'XMLSecurityDSig::$allowXPathTransforms = true to enable them' + ); + } + $xpathTransformCount++; + if ($xpathTransformCount > $this->maxXPathTransforms) { + throw new Exception( + 'Too many XPath Transformations found ('.$nodelist->length.') with a max allowed of '.$this->maxXPathTransforms + ); + } + $node = $transform->firstChild; + while ($node) { + if ($node->localName == 'XPath' + && ($node->namespaceURI === self::XMLDSIGNS || $node->namespaceURI === null || $node->namespaceURI === '')) { + $arXPath = array(); + $arXPath['query'] = '(.//. | .//@* | .//namespace::*)['.$node->nodeValue.']'; + $arXPath['namespaces'] = array(); + $nslist = $xpath->query('./namespace::*', $node); + foreach ($nslist AS $nsnode) { + /* Exclude xml and the default xmlns (empty prefix). */ + if ($nsnode->localName != "xml" && $nsnode->localName !== '' && $nsnode->localName !== 'xmlns') { + $arXPath['namespaces'][$nsnode->localName] = $nsnode->nodeValue; + } } + $nsCount = count($arXPath['namespaces']); + if ($nsCount > $this->maxXPathNamespaces) { + throw new Exception( + 'Too many namespaces in XPath Transformation found ('.$nsCount.') with a max allowed of '.$this->maxXPathNamespaces + ); + } + break; } - $nsCount = count($arXPath['namespaces']); - if ($nsCount > $this->maxXPathNamespaces) { - throw new Exception( - 'Too many namespaces in XPath Transformation found ('.$nsCount.') with a max allowed of '.$this->maxXPathNamespaces - ); - } - break; + $node = $node->nextSibling; } - $node = $node->nextSibling; + break; + default: + throw new Exception("Transform algorithm is not supported: '$algorithm'"); + } + } + } + $sig = null; + $sigParent = null; + $sigNextSibling = null; + + /* + * Temporarily detach an enveloped Signature for canonicalization, + * then restore it so validation does not mutate the caller's DOM. + */ + if ($enveloped) { + $candidate = $this->sigNode; + $ancestor = $data instanceof DOMDocument ? $data->documentElement : $data; + if ($candidate instanceof DOMElement + && $candidate->parentNode !== null + && $ancestor !== null + && ! $candidate->isSameNode($ancestor)) { + $walk = $candidate->parentNode; + while ($walk !== null) { + if ($walk->isSameNode($ancestor)) { + $sig = $candidate; + $sigParent = $sig->parentNode; + $sigNextSibling = $sig->nextSibling; + $sigParent->removeChild($sig); + break; } - break; + $walk = $walk->parentNode; + } } } - if ($data instanceof DOMNode) { + + try { $data = $this->canonicalizeData($objData, $canonicalMethod, $arXPath, $prefixList); + } finally { + if ($sig !== null && $sigParent !== null && $sig->parentNode === null) { + $sigParent->insertBefore($sig, $sigNextSibling); + } } return $data; } /** - * @param DOMNode $refNode - * @return bool + * Parse a Reference @URI as a same-document reference. + * + * Only the empty URI and "#id" (non-empty fragment, no scheme/host/path/query) + * are accepted. + * + * @param string $uri + * @return array{identifier: ?string} identifier is null for the empty URI * @throws Exception */ - public function processRefNode($refNode) + protected function parseSameDocumentURI($uri) { - $dataObject = null; - $identifier = null; + if ($uri === '') { + return array('identifier' => null); + } + + $arUrl = parse_url($uri); + if ($arUrl === false + || ! empty($arUrl['scheme']) + || ! empty($arUrl['host']) + || ! empty($arUrl['path']) + || ! empty($arUrl['query']) + || ! empty($arUrl['user']) + || ! empty($arUrl['pass']) + || isset($arUrl['port']) + || ! array_key_exists('fragment', $arUrl) + || $arUrl['fragment'] === '') { + throw new Exception('Reference URI must be a same-document reference'); + } + + return array('identifier' => $arUrl['fragment']); + } + /** + * Resolve the data object for a Reference before digests/transforms run. + * + * @param DOMElement $refNode + * @return array{dataObject: DOMNode, includeCommentNodes: bool, identifier: ?string} + * @throws Exception + */ + protected function resolveReferenceData($refNode) + { /* - * Depending on the URI, we may not want to include comments in the result + * Same-document references must omit comments from the digest. * See: http://www.w3.org/TR/xmldsig-core/#sec-ReferenceProcessingModel */ - $includeCommentNodes = true; - - if ($uri = $refNode->getAttribute("URI")) { - $arUrl = parse_url($uri); - if (! empty($arUrl['path']) || ! empty($arUrl['host']) || ! empty($arUrl['scheme'])) { - throw new Exception('Reference URI must be a same-document reference'); - } - if ($identifier = $arUrl['fragment'] ?? null) { + $includeCommentNodes = false; + $identifier = null; - /* This reference identifies a node with the given id by using - * a URI on the form "#identifier". This should not include comments. - */ - $includeCommentNodes = false; + if ($refNode->hasAttribute("URI")) { + $uri = $refNode->getAttribute("URI"); + $parsed = $this->parseSameDocumentURI($uri); + $identifier = $parsed['identifier']; + if ($identifier !== null) { $xPath = new DOMXPath($refNode->ownerDocument); - if ($this->idNS && is_array($this->idNS)) { + if ($this->idNS) { foreach ($this->idNS as $nspf => $ns) { $xPath->registerNamespace($nspf, $ns); } } $iDlist = '@Id="'.XPath::filterAttrValue($identifier, XPath::DOUBLE_QUOTE).'"'; - if (is_array($this->idKeys)) { - foreach ($this->idKeys as $idKey) { - $iDlist .= " or @".XPath::filterAttrName($idKey).'="'. - XPath::filterAttrValue($identifier, XPath::DOUBLE_QUOTE).'"'; + foreach ($this->idKeys as $idKey) { + $attrName = XPath::filterAttrName($idKey); + if ($attrName === '' || $attrName !== $idKey) { + throw new Exception('Invalid idKeys attribute name'); } + $iDlist .= " or @".$attrName.'="'. + XPath::filterAttrValue($identifier, XPath::DOUBLE_QUOTE).'"'; } $query = '//*['.$iDlist.']'; $nodeset = $xPath->query($query); - if ($nodeset->length === 0) { + if ($nodeset === false || $nodeset->length === 0) { throw new Exception('Reference URI does not identify a node'); } if ($nodeset->length > 1) { @@ -713,11 +835,7 @@ public function processRefNode($refNode) $dataObject = $refNode->ownerDocument; } } else { - /* This reference identifies the root node with an empty URI. This should - * not include comments. - */ - $includeCommentNodes = false; - + /* No URI attribute — whole document, comments omitted. */ $dataObject = $refNode->ownerDocument; } @@ -725,34 +843,54 @@ public function processRefNode($refNode) throw new Exception('Reference URI could not be resolved'); } - $data = $this->processTransforms($refNode, $dataObject, $includeCommentNodes); + return array( + 'dataObject' => $dataObject, + 'includeCommentNodes' => $includeCommentNodes, + 'identifier' => $identifier, + ); + } + + /** + * @param DOMElement $refNode + * @return bool + * @throws Exception + */ + public function processRefNode($refNode) + { + $resolved = $this->resolveReferenceData($refNode); + $data = $this->processTransforms( + $refNode, + $resolved['dataObject'], + $resolved['includeCommentNodes'] + ); if (!$this->validateDigest($refNode, $data)) { return false; } /* Add this node to the list of validated nodes. */ - if (! empty($identifier)) { - $this->validatedNodes[$identifier] = $dataObject; + if (! empty($resolved['identifier'])) { + $this->validatedNodes[$resolved['identifier']] = $resolved['dataObject']; } else { - $this->validatedNodes[] = $dataObject; + $this->validatedNodes[] = $resolved['dataObject']; } return true; } /** - * @param DOMNode $refNode - * @return null + * @param DOMElement $refNode + * @return string|null */ public function getRefNodeID($refNode) { - if ($uri = $refNode->getAttribute("URI")) { - $arUrl = parse_url($uri); - if (is_array($arUrl) && empty($arUrl['path'])) { - if ($identifier = $arUrl['fragment'] ?? null) { - return $identifier; - } + if ($refNode->hasAttribute("URI")) { + $uri = $refNode->getAttribute("URI"); + try { + $parsed = $this->parseSameDocumentURI($uri); + } catch (Exception $e) { + return null; } + return $parsed['identifier']; } return null; } @@ -772,6 +910,9 @@ public function getRefIDs() throw new Exception("Reference nodes not found"); } foreach ($nodeset AS $refNode) { + if (! $refNode instanceof DOMElement) { + throw new Exception("Reference nodes not found"); + } $refids[] = $this->getRefNodeID($refNode); } return $refids; @@ -783,12 +924,6 @@ public function getRefIDs() */ public function validateReference() { - $docElem = $this->sigNode->ownerDocument->documentElement; - if (! $docElem->isSameNode($this->sigNode)) { - if ($this->sigNode->parentNode != null) { - $this->sigNode->parentNode->removeChild($this->sigNode); - } - } $xpath = $this->getXPathObj(); $query = "./secdsig:SignedInfo[1]/secdsig:Reference"; $nodeset = $xpath->query($query, $this->sigNode); @@ -799,7 +934,17 @@ public function validateReference() /* Initialize/reset the list of validated nodes. */ $this->validatedNodes = array(); + /* + * Do not detach the Signature here. PHP's C14N returns an empty string + * for nodes that live under a detached subtree (e.g. ds:Object), which + * would break same-document refs into the Signature. The + * enveloped-signature Transform removes sigNode only when it is a + * proper descendant of the node being digested. + */ foreach ($nodeset AS $refNode) { + if (! $refNode instanceof DOMElement) { + throw new Exception("Reference validation failed"); + } if (! $this->processRefNode($refNode)) { /* Clear the list of validated nodes. */ $this->validatedNodes = null; @@ -909,13 +1054,17 @@ protected function addRefInternal($sinfoNode, $node, $algorithm, $arTransforms=n */ public function addReference($node, $algorithm, $arTransforms=null, $options=null) { - if ($xpath = $this->getXPathObj()) { - $query = "./secdsig:SignedInfo"; - $nodeset = $xpath->query($query, $this->sigNode); - if ($sInfo = $nodeset->item(0)) { - $this->addRefInternal($sInfo, $node, $algorithm, $arTransforms, $options); - } + $xpath = $this->getXPathObj(); + if (! $xpath) { + throw new Exception('Cannot locate signature context for addReference'); } + $query = "./secdsig:SignedInfo"; + $nodeset = $xpath->query($query, $this->sigNode); + $sInfo = $nodeset->item(0); + if (! $sInfo) { + throw new Exception('Cannot locate SignedInfo for addReference'); + } + $this->addRefInternal($sInfo, $node, $algorithm, $arTransforms, $options); } /** @@ -926,14 +1075,18 @@ public function addReference($node, $algorithm, $arTransforms=null, $options=nul */ public function addReferenceList($arNodes, $algorithm, $arTransforms=null, $options=null) { - if ($xpath = $this->getXPathObj()) { - $query = "./secdsig:SignedInfo"; - $nodeset = $xpath->query($query, $this->sigNode); - if ($sInfo = $nodeset->item(0)) { - foreach ($arNodes AS $node) { - $this->addRefInternal($sInfo, $node, $algorithm, $arTransforms, $options); - } - } + $xpath = $this->getXPathObj(); + if (! $xpath) { + throw new Exception('Cannot locate signature context for addReferenceList'); + } + $query = "./secdsig:SignedInfo"; + $nodeset = $xpath->query($query, $this->sigNode); + $sInfo = $nodeset->item(0); + if (! $sInfo) { + throw new Exception('Cannot locate SignedInfo for addReferenceList'); + } + foreach ($arNodes AS $node) { + $this->addRefInternal($sInfo, $node, $algorithm, $arTransforms, $options); } } @@ -1013,8 +1166,16 @@ public function verify($objKey) $xpath = new DOMXPath($doc); $xpath->registerNamespace('secdsig', self::XMLDSIGNS); - $query = "string(./secdsig:SignedInfo/secdsig:SignatureMethod/@Algorithm)"; - $sigMethod = $xpath->evaluate($query, $this->sigNode); + $query = "./secdsig:SignedInfo/secdsig:SignatureMethod"; + $nodeset = $xpath->query($query, $this->sigNode); + if ($nodeset === false || $nodeset->length !== 1) { + throw new Exception('Invalid structure - Expected exactly one SignatureMethod element'); + } + $sigMethodNode = $nodeset->item(0); + if (! $sigMethodNode instanceof DOMElement) { + throw new Exception('Invalid structure - Expected exactly one SignatureMethod element'); + } + $sigMethod = $sigMethodNode->getAttribute('Algorithm'); /* * Always bind the document's declared SignatureMethod to the algorithm @@ -1031,12 +1192,20 @@ public function verify($objKey) throw new Exception("SignatureMethod algorithm is not allowed: '$sigMethod'"); } - $query = "string(./secdsig:SignatureValue)"; - $sigValue = $xpath->evaluate($query, $this->sigNode); - if (empty($sigValue)) { + $query = "./secdsig:SignatureValue"; + $nodeset = $xpath->query($query, $this->sigNode); + if ($nodeset === false || $nodeset->length !== 1) { + throw new Exception('Invalid structure - Expected exactly one SignatureValue element'); + } + $sigValue = $nodeset->item(0)->textContent; + if ($sigValue === '') { throw new Exception("Unable to locate SignatureValue"); } - return $objKey->verifySignature($this->signedInfo, base64_decode($sigValue)); + $decoded = base64_decode($sigValue, true); + if ($decoded === false) { + return -1; + } + return $objKey->verifySignature($this->signedInfo, $decoded); } /** @@ -1060,7 +1229,7 @@ public function verify($objKey) * returned so callers never have to re-query the document (which would * reintroduce XML Signature Wrapping). * - * @param XMLSecurityKey $objKey Trusted/pinned verification key. + * @param XMLSecurityKey|null $objKey Trusted/pinned verification key. * @param DOMDocument|DOMNode $objDoc Document (or node) to verify. * @param int $pos Which Signature element to verify (default: first). * @return array Associative array of validated nodes (id => node). Never empty on success. @@ -1072,6 +1241,8 @@ public function verifyDocument($objKey, $objDoc, $pos = 0) throw new Exception('A trusted key must be supplied to verifyDocument()'); } + $prevSigAlgs = $this->allowedSignatureAlgorithms; + $prevDigAlgs = $this->allowedDigestAlgorithms; if ($this->allowedSignatureAlgorithms === null) { $this->allowedSignatureAlgorithms = self::DEFAULT_SIGNATURE_ALGORITHMS; } @@ -1079,26 +1250,31 @@ public function verifyDocument($objKey, $objDoc, $pos = 0) $this->allowedDigestAlgorithms = self::DEFAULT_DIGEST_ALGORITHMS; } - $this->resetXPathObj(); - if (! $this->locateSignature($objDoc, $pos)) { - throw new Exception('Cannot locate Signature Node'); - } - if ($this->canonicalizeSignedInfo() === null) { - throw new Exception('Cannot canonicalize SignedInfo'); - } + try { + $this->resetXPathObj(); + if (! $this->locateSignature($objDoc, $pos)) { + throw new Exception('Cannot locate Signature Node'); + } + if ($this->canonicalizeSignedInfo() === null) { + throw new Exception('Cannot canonicalize SignedInfo'); + } - /* Throws on any unresolved/failed/duplicate reference (fail closed). */ - $this->validateReference(); + /* Throws on any unresolved/failed/duplicate reference (fail closed). */ + $this->validateReference(); - if ($this->verify($objKey) !== 1) { - throw new Exception('Signature validation failed'); - } + if ($this->verify($objKey) !== 1) { + throw new Exception('Signature validation failed'); + } - $validatedNodes = $this->getValidatedNodes(); - if (empty($validatedNodes)) { - throw new Exception('Signature verified but no signed nodes were validated'); + $validatedNodes = $this->getValidatedNodes(); + if (empty($validatedNodes)) { + throw new Exception('Signature verified but no signed nodes were validated'); + } + return $validatedNodes; + } finally { + $this->allowedSignatureAlgorithms = $prevSigAlgs; + $this->allowedDigestAlgorithms = $prevDigAlgs; } - return $validatedNodes; } /** @@ -1121,28 +1297,48 @@ public function sign($objKey, $appendToNode = null) if ($appendToNode != null) { $this->resetXPathObj(); $this->appendSignature($appendToNode); - $this->sigNode = $appendToNode->lastChild; - } - if ($xpath = $this->getXPathObj()) { - $query = "./secdsig:SignedInfo"; - $nodeset = $xpath->query($query, $this->sigNode); - if ($sInfo = $nodeset->item(0)) { - $query = "./secdsig:SignatureMethod"; - $nodeset = $xpath->query($query, $sInfo); - $sMethod = $nodeset->item(0); - $sMethod->setAttribute('Algorithm', $objKey->type); - $data = $this->canonicalizeData($sInfo, $this->canonicalMethod); - $sigValue = base64_encode($this->signData($objKey, $data)); - $sigValueNode = $this->createNewSignNode('SignatureValue', $sigValue); - if ($infoSibling = $sInfo->nextSibling) { - $infoSibling->parentNode->insertBefore($sigValueNode, $infoSibling); - } else { - $this->sigNode->appendChild($sigValueNode); - } + $lastChild = $appendToNode->lastChild; + if (! $lastChild instanceof DOMElement) { + throw new Exception('Cannot locate signature node after append'); } + $this->sigNode = $lastChild; + } + if ($this->canonicalMethod === null) { + throw new Exception('Canonicalization method has not been set'); + } + $xpath = $this->getXPathObj(); + if (! $xpath) { + throw new Exception('Cannot locate signature context for sign'); + } + $query = "./secdsig:SignedInfo"; + $nodeset = $xpath->query($query, $this->sigNode); + $sInfo = ($nodeset !== false) ? $nodeset->item(0) : null; + if (! $sInfo) { + throw new Exception('Cannot locate SignedInfo for sign'); + } + $query = "./secdsig:SignatureMethod"; + $nodeset = $xpath->query($query, $sInfo); + if ($nodeset === false || $nodeset->length !== 1) { + throw new Exception('Invalid structure - Expected exactly one SignatureMethod element'); + } + $sMethod = $nodeset->item(0); + if (! $sMethod instanceof DOMElement) { + throw new Exception('Invalid structure - Expected exactly one SignatureMethod element'); + } + $sMethod->setAttribute('Algorithm', $objKey->type); + $data = $this->canonicalizeData($sInfo, $this->canonicalMethod); + $sigValue = base64_encode($this->signData($objKey, $data)); + $sigValueNode = $this->createNewSignNode('SignatureValue', $sigValue); + if ($infoSibling = $sInfo->nextSibling) { + $infoSibling->parentNode->insertBefore($sigValueNode, $infoSibling); + } else { + $this->sigNode->appendChild($sigValueNode); } } + /** + * @deprecated Empty stub retained for API compatibility; use add509Cert(). + */ public function appendCert() { @@ -1367,23 +1563,58 @@ private static function assertPublicHost($host) } foreach ($ips as $ip) { - if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + if (! self::isPublicIpAddress($ip)) { throw new Exception('Certificate URL host is not allowed'); } - /* PHP's reserved-range flag misses CGNAT (100.64.0.0/10). */ - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $long = ip2long($ip); - if ($long !== false && ($long & 0xffc00000) === (ip2long('100.64.0.0') & 0xffc00000)) { - throw new Exception('Certificate URL host is not allowed'); + } + + return array_values(array_unique($ips)); + } + + /** + * Return true when $ip is a public unicast address safe to fetch. + * + * Unwraps IPv4-mapped / IPv4-compatible IPv6 so FILTER_FLAG_NO_* checks + * apply to the embedded IPv4 (PHP treats ::ffff:127.0.0.1 as "public"). + * + * @param string $ip + * @return bool + */ + private static function isPublicIpAddress($ip) + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $binary = inet_pton($ip); + if ($binary !== false && strlen($binary) === 16) { + $mappedPrefix = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff"; + $compatPrefix = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"; + if (substr($binary, 0, 12) === $mappedPrefix) { + $ip = inet_ntop(substr($binary, 12)); + } elseif (substr($binary, 0, 12) === $compatPrefix) { + $v4 = substr($binary, 12); + /* Leave :: and ::1 as IPv6 (handled by reserved-range flags). */ + if ($v4 !== "\x00\x00\x00\x00" && $v4 !== "\x00\x00\x00\x01") { + $ip = inet_ntop($v4); + } } } } - return array_values(array_unique($ips)); + if ($ip === false + || ! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return false; + } + /* PHP's reserved-range flag misses CGNAT (100.64.0.0/10). */ + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $long = ip2long($ip); + if ($long !== false && ($long & 0xffc00000) === (ip2long('100.64.0.0') & 0xffc00000)) { + return false; + } + } + return true; } /** - * @param DOMElement $parentRef + * @param DOMNode $parentRef * @param string $cert * @param bool $isPEMFormat * @param bool $isURL @@ -1538,9 +1769,11 @@ private static function getX509SerialNumber(array $certData) */ public function add509Cert($cert, $isPEMFormat=true, $isURL=false, $options=null) { - if ($xpath = $this->getXPathObj()) { - static::staticAdd509Cert($this->sigNode, $cert, $isPEMFormat, $isURL, $xpath, $options); + $xpath = $this->getXPathObj(); + if (! $xpath) { + throw new Exception('Cannot locate signature context for add509Cert'); } + static::staticAdd509Cert($this->sigNode, $cert, $isPEMFormat, $isURL, $xpath, $options); } /** @@ -1596,12 +1829,12 @@ public function appendToKeyInfo($node) * This function retrieves an associative array of the validated nodes. * * The array will contain the id of the referenced node as the key and the node itself - * as the value. + * as the value. Empty-URI references use numeric keys. * * Returns: * An associative array of validated nodes or null if no nodes have been validated. * - * @return array Associative array of validated nodes + * @return array|null */ public function getValidatedNodes() { diff --git a/tests/cert-url-ssrf.phpt b/tests/cert-url-ssrf.phpt index 18976d4..635c9cc 100644 --- a/tests/cert-url-ssrf.phpt +++ b/tests/cert-url-ssrf.phpt @@ -17,6 +17,9 @@ $cases = array( 'PRIVATE' => 'http://10.0.0.5/cert.pem', 'IPV6LOOP' => 'http://[::1]/cert.pem', 'CGNAT' => 'http://100.64.1.1/cert.pem', + 'MAPPEDLOOP' => 'http://[::ffff:127.0.0.1]/cert.pem', + 'MAPPEDPRIV' => 'http://[::ffff:10.0.0.1]/cert.pem', + 'MAPPEDMETA' => 'http://[::ffff:169.254.169.254]/cert.pem', ); foreach ($cases as $label => $url) { @@ -47,4 +50,7 @@ LINKLOCAL: Certificate URL host is not allowed PRIVATE: Certificate URL host is not allowed IPV6LOOP: Certificate URL host is not allowed CGNAT: Certificate URL host is not allowed +MAPPEDLOOP: Certificate URL host is not allowed +MAPPEDPRIV: Certificate URL host is not allowed +MAPPEDMETA: Certificate URL host is not allowed FILE_OPTIN: OK diff --git a/tests/enveloped-signature-restored.phpt b/tests/enveloped-signature-restored.phpt new file mode 100644 index 0000000..4c932d3 --- /dev/null +++ b/tests/enveloped-signature-restored.phpt @@ -0,0 +1,53 @@ +--TEST-- +Enveloped transform restores Signature before validating later references +--FILE-- +loadXML('data'); + +$signer = new XMLSecurityDSig(); +$signer->setCanonicalMethod(XMLSecurityDSig::EXC_C14N); +$signer->addReference( + $doc, + XMLSecurityDSig::SHA256, + array(XMLSecurityDSig::ENVELOPED, XMLSecurityDSig::EXC_C14N), + array('force_uri' => true) +); + +$object = $signer->addObject('inside-object'); +$object->setAttribute('Id', 'obj1'); +$signer->addReference( + $object, + XMLSecurityDSig::SHA256, + array(XMLSecurityDSig::EXC_C14N), + array('overwrite' => false) +); + +$privateKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, array('type' => 'private')); +$privateKey->loadKey(dirname(__FILE__) . '/privkey.pem', true); +$signer->sign($privateKey); +$signer->appendSignature($doc->documentElement); + +$verifier = new XMLSecurityDSig(); +$publicKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, array('type' => 'public')); +$publicKey->loadKey(dirname(__FILE__) . '/mycert.pem', true); +$verifier->allowedSignatureAlgorithms = array(XMLSecurityKey::RSA_SHA256); +$verifier->allowedDigestAlgorithms = array(XMLSecurityDSig::SHA256); + +try { + $nodes = $verifier->verifyDocument($publicKey, $doc); + echo "VERIFY: OK\n"; + echo "SIGNATURES: ".$doc->getElementsByTagNameNS(XMLSecurityDSig::XMLDSIGNS, 'Signature')->length."\n"; + echo isset($nodes['obj1']) ? "OBJECT: OK\n" : "OBJECT: missing\n"; +} catch (Exception $e) { + echo "VERIFY: ".$e->getMessage()."\n"; +} +?> +--EXPECTF-- +VERIFY: OK +SIGNATURES: 1 +OBJECT: OK diff --git a/tests/get-ref-node-id-external.phpt b/tests/get-ref-node-id-external.phpt new file mode 100644 index 0000000..b2d3b75 --- /dev/null +++ b/tests/get-ref-node-id-external.phpt @@ -0,0 +1,31 @@ +--TEST-- +getRefNodeID rejects external URIs consistently with processRefNode +--FILE-- + 'http://example.com/data.xml#abc', + 'QUERY' => '?x=1', + 'BAREHASH' => '#', + 'FRAGMENT' => '#abc', + 'EMPTY' => '', +); + +foreach ($cases as $label => $uri) { + $doc = new DOMDocument(); + $doc->loadXML(''); + $id = $objDSig->getRefNodeID($doc->documentElement); + echo "$label: ".var_export($id, true)."\n"; +} +?> +--EXPECTF-- +EXTERNAL: NULL +QUERY: NULL +BAREHASH: NULL +FRAGMENT: 'abc' +EMPTY: NULL diff --git a/tests/idkeys-attr-name.phpt b/tests/idkeys-attr-name.phpt new file mode 100644 index 0000000..d8af95c --- /dev/null +++ b/tests/idkeys-attr-name.phpt @@ -0,0 +1,40 @@ +--TEST-- +idKeys attribute names cannot inject XPath operators +--FILE-- + + + one + + + + + + + 2jmj7l5rSw0yVb/vlWAYkK/YBwk= + + + AA== + + +XML; + +$doc = new DOMDocument(); +$doc->loadXML($xml); +$objXMLSecDSig = new XMLSecurityDSig(); +$objXMLSecDSig->idKeys = array('foo or bar'); +$objXMLSecDSig->locateSignature($doc); +$objXMLSecDSig->canonicalizeSignedInfo(); +try { + $objXMLSecDSig->validateReference(); + print "INJECT: unexpected success\n"; +} catch (Exception $e) { + print "INJECT: ".$e->getMessage()."\n"; +} +?> +--EXPECTF-- +INJECT: Invalid idKeys attribute name diff --git a/tests/locate-signature-reuse.phpt b/tests/locate-signature-reuse.phpt new file mode 100644 index 0000000..884672a --- /dev/null +++ b/tests/locate-signature-reuse.phpt @@ -0,0 +1,31 @@ +--TEST-- +locateSignature resets XPath context across documents +--FILE-- +'; + +$doc1 = new DOMDocument(); +$doc1->loadXML($sigXml); + +$doc2 = new DOMDocument(); +$doc2->loadXML(str_replace('', '', str_replace('', '', $sigXml))); + +$objDSig = new XMLSecurityDSig(); +/* Prime an XPath context against the constructor template document. */ +$objDSig->setCanonicalMethod(XMLSecurityDSig::EXC_C14N); +$objDSig->locateSignature($doc1); +$objDSig->canonicalizeSignedInfo(); + +try { + $objDSig->locateSignature($doc2); + $objDSig->canonicalizeSignedInfo(); + echo "REUSE: OK\n"; +} catch (Throwable $e) { + echo "REUSE: ".$e->getMessage()."\n"; +} +?> +--EXPECTF-- +REUSE: OK diff --git a/tests/object-ref-nested-signature.phpt b/tests/object-ref-nested-signature.phpt new file mode 100644 index 0000000..1b257b5 --- /dev/null +++ b/tests/object-ref-nested-signature.phpt @@ -0,0 +1,53 @@ +--TEST-- +Object Id under nested Signature resolves before enveloped strip +--FILE-- +loadXML('data'); + +$objDSig = new XMLSecurityDSig(); +$objDSig->setCanonicalMethod(XMLSecurityDSig::EXC_C14N); +$objDSig->idKeys = array('xml:id'); + +$wrapped = $objDSig->sigNode->ownerDocument->createElement('Wrapped'); +$wrapped->setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:id', 'obj1'); +$wrapped->appendChild($objDSig->sigNode->ownerDocument->createTextNode('inside-object')); +$objDSig->addObject($wrapped); + +$objDSig->addReference( + $wrapped, + XMLSecurityDSig::SHA256, + array(XMLSecurityDSig::ENVELOPED, XMLSecurityDSig::EXC_C14N), + array( + 'id_name' => 'id', + 'overwrite' => false, + 'prefix' => 'xml', + 'prefix_ns' => 'http://www.w3.org/XML/1998/namespace', + ) +); + +$key = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, array('type' => 'private')); +$key->loadKey(dirname(__FILE__) . '/privkey.pem', true); +$objDSig->sign($key); +$objDSig->appendSignature($doc->documentElement); + +$verify = new XMLSecurityDSig(); +$verify->idKeys = array('xml:id'); +$pub = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, array('type' => 'public')); +$pub->loadKey(dirname(__FILE__) . '/mycert.pem', true); +$verify->allowedSignatureAlgorithms = array(XMLSecurityKey::RSA_SHA256); +$verify->allowedDigestAlgorithms = array(XMLSecurityDSig::SHA256); + +try { + $nodes = $verify->verifyDocument($pub, $doc); + echo isset($nodes['obj1']) ? "OBJECT_REF: OK\n" : "OBJECT_REF: missing node\n"; +} catch (Exception $e) { + echo "OBJECT_REF: ".$e->getMessage()."\n"; +} +?> +--EXPECTF-- +OBJECT_REF: OK diff --git a/tests/signedinfo-cardinality.phpt b/tests/signedinfo-cardinality.phpt new file mode 100644 index 0000000..4548ecc --- /dev/null +++ b/tests/signedinfo-cardinality.phpt @@ -0,0 +1,53 @@ +--TEST-- +Duplicate SignedInfo children are rejected +--FILE-- +loadXML($xml); + $objXMLSecDSig = new XMLSecurityDSig(); + $objXMLSecDSig->locateSignature($doc); + try { + $objXMLSecDSig->canonicalizeSignedInfo(); + print "$label: unexpected success\n"; + } catch (Exception $e) { + print "$label: ".$e->getMessage()."\n"; + } +} + +$dupCanon = << + + + + + + + + AA== + + +XML; +checkStruct($dupCanon, 'DUP_CANON'); + +$dupSigMethod = << + + + + + + + + AA== + + +XML; +checkStruct($dupSigMethod, 'DUP_SIGMETHOD'); +?> +--EXPECTF-- +DUP_CANON: Invalid structure - Too many CanonicalizationMethod elements found +DUP_SIGMETHOD: Invalid structure - Too many SignatureMethod elements found diff --git a/tests/xml-ref-fail-closed.phpt b/tests/xml-ref-fail-closed.phpt index 8bb4501..68a2df4 100644 --- a/tests/xml-ref-fail-closed.phpt +++ b/tests/xml-ref-fail-closed.phpt @@ -77,8 +77,71 @@ $dup = << XML; checkRef($dup, 'DUPLICATE'); + +/* Query-only URI must be rejected */ +$queryOnly = << + + + + + + + + 2jmj7l5rSw0yVb/vlWAYkK/YBwk= + + + AA== + + +XML; +checkRef($queryOnly, 'QUERY'); + +/* Bare "#" fragment must be rejected */ +$bareHash = << + + + + + + + + 2jmj7l5rSw0yVb/vlWAYkK/YBwk= + + + AA== + + +XML; +checkRef($bareHash, 'BAREHASH'); + +/* Unknown Transform Algorithm must fail closed */ +$unknownXform = << + + + + + + + + + + + 2jmj7l5rSw0yVb/vlWAYkK/YBwk= + + + AA== + + +XML; +checkRef($unknownXform, 'UNKNOWN_TRANSFORM'); ?> --EXPECTF-- EXTERNAL: Reference URI must be a same-document reference MISSING: Reference URI does not identify a node DUPLICATE: Reference URI identifies multiple nodes +QUERY: Reference URI must be a same-document reference +BAREHASH: Reference URI must be a same-document reference +UNKNOWN_TRANSFORM: Transform algorithm is not supported: 'http://example.com/unknown-transform'