From e9e3cbbe9ea53b91e1727a6965677d6d5ec29427 Mon Sep 17 00:00:00 2001 From: Chris Barth Date: Wed, 9 Sep 2026 18:02:50 -0500 Subject: [PATCH] test: pin the hoisted ancestor namespace behaviour #541 fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three branches were open against defects in the ancestor-namespace path of non-exclusive C14N: a hoisted ancestor default namespace not becoming the default its descendants were canonicalized against, an ancestor declaration being appended alongside one the node made itself, and `process()` marking ancestor prefixes in scope before anything had been rendered so the apex's own redeclaration was skipped as redundant. #541 fixed all three by a different route โ€” `localDefaultNs`/`nodeDefaultNs` in `renderNs`, `newDefaultNs` seeded from the hoisted entry, and `findSubsetNSPrefixes` collecting every prefix the subset declares rather than the first. The fixes those branches carried are obsolete, so only their tests land here, parameterized over both non-exclusive canonicalizers. Each case fails against the implementation as it stood before #541 and passes now, so they are guards rather than documentation. Two spot checks confirm they bite: dropping the `newDefaultNs` assignment from the ancestor merge fails three of them, and reverting `findSubsetNSPrefixes` to the first declaration only fails another. The integration case is the one worth having. A hoisted default namespace leaking into a descendant leaves the signature verifying while `getSignedReferences()` reports an element in a namespace the sender never signed it in, and nothing else in the suite covers that. Refs #538, #541 Co-Authored-By: Claude Opus 5 --- test/c14n-non-exclusive-unit-tests.spec.ts | 64 ++++++++++++++++++++++ test/signature-integration-tests.spec.ts | 38 +++++++++++++ 2 files changed, 102 insertions(+) diff --git a/test/c14n-non-exclusive-unit-tests.spec.ts b/test/c14n-non-exclusive-unit-tests.spec.ts index a5d308ba..eae579ff 100644 --- a/test/c14n-non-exclusive-unit-tests.spec.ts +++ b/test/c14n-non-exclusive-unit-tests.spec.ts @@ -250,6 +250,70 @@ describe("C14N non-exclusive canonicalization tests", function () { }); for (const Canonicalization of [C14nCanonicalization, C14nCanonicalizationWithComments]) { + describe(`${Canonicalization.name}: hoisted ancestor namespaces`, function () { + // A hoisted ancestor default namespace becomes the default the subset's descendants are + // canonicalized against. The rationale is spelled out in ยง4.7: the empty default is kept on + // output "so that e3 does not take on the default namespace qualification of e1". + // https://www.w3.org/TR/xml-c14n/#ProcessingModel + // https://www.w3.org/TR/xml-c14n/#PropagateDefaultNSDecl + it('renders xmlns="" on a descendant when the apex hoists an ancestor default namespace', function () { + test_C14nCanonicalization( + '', + "//*[local-name()='x']", + '', + new Canonicalization(), + ); + }); + + it("omits a descendant declaration the hoisted ancestor default namespace makes redundant", function () { + test_C14nCanonicalization( + '', + "//*[local-name()='x']", + '', + new Canonicalization(), + ); + }); + + it("renders the hoisted ancestor default namespace once when the apex inherits it", function () { + test_C14nCanonicalization( + '', + "//*[local-name()='x']", + '', + new Canonicalization(), + ); + }); + + it("prefers the apex's own default namespace over the hoisted ancestor one", function () { + test_C14nCanonicalization( + '', + "//*[local-name()='x']", + '', + new Canonicalization(), + ); + }); + + // A declaration on the apex shadows the ancestor one binding the same prefix, so the + // element must not resolve against the outer binding โ€” in its descendants or its own name. + // https://www.w3.org/TR/REC-xml-names/#scoping + it("renders the apex's redeclaration of an ancestor prefix, not the ancestor binding", function () { + test_C14nCanonicalization( + '', + "//*[local-name()='x']", + '', + new Canonicalization(), + ); + }); + + it("renders the apex's redeclaration of the prefix in its own name", function () { + test_C14nCanonicalization( + '', + "//*[local-name()='x']", + '', + new Canonicalization(), + ); + }); + }); + describe(`${Canonicalization.name}: subset namespace declarations`, function () { it("does not duplicate the default namespace when the subset declares a prefixed namespace", function () { // Render the inherited default namespace exactly once on the subset root. diff --git a/test/signature-integration-tests.spec.ts b/test/signature-integration-tests.spec.ts index effc4e0b..578e2bf5 100644 --- a/test/signature-integration-tests.spec.ts +++ b/test/signature-integration-tests.spec.ts @@ -341,4 +341,42 @@ describe("Signature integration tests", function () { }); }); } + + // A signed reference must hand back the element in the namespace it was signed in. If a + // hoisted ancestor default namespace leaks into a descendant, the signature still verifies + // while `getSignedReferences()` reports an identity the sender never signed โ€” and a caller + // that dispatches on element namespace acts on it. + // https://www.w3.org/TR/xml-c14n/#ProcessingModel + it("does not move an element into a namespace it was not signed in", function () { + const c14n = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315"; + const xml = ''; + + const sig = new SignedXml(); + sig.privateKey = fs.readFileSync("./test/static/client.pem"); + sig.canonicalizationAlgorithm = c14n; + sig.signatureAlgorithm = "http://www.w3.org/2000/09/xmldsig#rsa-sha1"; + sig.addReference({ + xpath: "//*[local-name(.)='x']", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: [c14n], + }); + sig.computeSignature(xml); + const signed = sig.getSignedXml(); + + const signature = xpath.select1( + "//*[local-name(.)='Signature']", + new xmldom.DOMParser().parseFromString(signed), + ); + isDomNode.assertIsNodeLike(signature); + + const verify = new SignedXml(); + verify.publicCert = fs.readFileSync("./test/static/client_public.pem"); + verify.loadSignature(signature); + expect(verify.checkSignature(signed)).to.be.true; + + const trusted = new xmldom.DOMParser().parseFromString(verify.getSignedReferences()[0]); + const y = xpath.select1("//*[local-name(.)='y']", trusted); + isDomNode.assertIsElementNode(y); + expect(y.namespaceURI ?? "", " must stay in no namespace").to.equal(""); + }); });