Skip to content

findNSPrefix returns only the first xmlns declaration → duplicate default xmlns in non-exclusive C14N when subset declares any xmlns:* #538

Description

@msheby

Summary

findNSPrefix in src/utils.ts returns only the first xmlns-like attribute on the referenced subset root. findAncestorNs then filters ancestor namespaces by that single prefix. If the subset root declares any prefixed namespace (xmlns:enc="...", xmlns:xsi="...", etc.) and inherits a default namespace from an ancestor, the inherited default (prefix "") is not filtered from ancestorNamespaces. During non-exclusive C14N (http://www.w3.org/TR/2001/REC-xml-c14n-20010315[#WithComments]), the default namespace is then emitted twice — once via renderNs's defaultNs !== currNs branch, once via the ancestorNamespaces merge — producing a duplicate xmlns="..." and a digest that no spec-compliant implementation will match.

Impact

Any signature whose referenced subset declares a prefixed namespace and inherits a default namespace fails to interoperate. We hit this in a SMPTE standard where the referenced <AuthenticatedPrivate> element carries xmlns:enc="http://www.w3.org/2001/04/xmlenc#" — signatures produced by other conformant signers verify against each other but not against xml-crypto, and vice versa.

Repro (Node 20, xml-crypto 6.1.2)

import { DOMParser } from '@xmldom/xmldom'
import xmlCrypto from 'xml-crypto'
const { C14nCanonicalization, SignedXml } = xmlCrypto

const xml = `<Root xmlns="http://example.com/root">
  <Body Id="B" xmlns:enc="http://www.w3.org/2001/04/xmlenc#">
    <enc:CipherValue>x</enc:CipherValue>
  </Body>
</Root>`

const doc = new DOMParser().parseFromString(xml, 'text/xml')
const body = doc.getElementsByTagName('Body')[0]
const ancestors = SignedXml.findAncestorNs(doc, "//*[local-name()='Body']")

console.log('ancestorNamespaces:', ancestors)
// Actual:   [{ prefix: '', namespaceURI: 'http://example.com/root' }]
// Expected: []  (Body already inherits the default; nothing to hoist)

const c14n = new C14nCanonicalization().process(body, {
  ancestorNamespaces: ancestors
})
console.log(c14n)
// Actual:   <Body xmlns="http://example.com/root" xmlns:enc="..." xmlns="http://example.com/root" Id="B">…
// Expected: <Body xmlns="http://example.com/root" xmlns:enc="..." Id="B">…

Root cause

src/utils.ts, function findNSPrefix (returns on the first match):

function findNSPrefix(subset) {
  const subsetAttributes = subset.attributes;
  for (let k = 0; k < subsetAttributes.length; k++) {
    const nodeName = subsetAttributes[k].nodeName;
    if (nodeName.search(/^xmlns:?/) !== -1) {
      return nodeName.replace(/^xmlns:?/, "");   // <-- first match wins
    }
  }
  return subset.prefix || "";
}

And findAncestorNs (compares against that single prefix):

const subsetNsPrefix = findNSPrefix(docSubset[0]);
for (const ancestorNs of ancestorNsWithoutDuplicate) {
  if (ancestorNs.prefix !== subsetNsPrefix) {
    returningNs.push(ancestorNs);
  }
}

If the subset has both xmlns:enc and would need xmlns (default) filtered, only one is handled.

Proposed fix

Collect all xmlns declarations on the subset root into a Set<string> (including "" for the default), then filter any ancestor whose prefix is in that set. Happy to open a PR — sketch:

function findSubsetNSPrefixes(subset: Element): Set<string> {
  const prefixes = new Set<string>();
  const subsetAttributes = subset.attributes;
  for (let k = 0; k < subsetAttributes.length; k++) {
    const nodeName = subsetAttributes[k].nodeName;
    if (nodeName.search(/^xmlns:?/) !== -1) {
      prefixes.add(nodeName.replace(/^xmlns:?/, ""));
    }
  }
  if (prefixes.size === 0 && subset.prefix) {
    prefixes.add(subset.prefix);
  }
  return prefixes;
}

// in findAncestorNs, replace the trailing loop with:
const subsetPrefixes = findSubsetNSPrefixes(docSubset[0]);
return ancestorNsWithoutDuplicate.filter((ns) => !subsetPrefixes.has(ns.prefix));

Behavioral compatibility: the previous code stripped a single prefix; the new code strips every prefix declared on the subset root. That's a strict superset — no ancestor previously stripped will start being retained; only ancestors that were incorrectly retained (like the duplicate default) will now be filtered out.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions