Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce new .signedReferences property of signature to help prevent signature wrapping attacks. #489

Open
wants to merge 7 commits into
base: 6.x
Choose a base branch
from
Open
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 6.1.0 (...)

# Changelog

- Introduced new .signedReferences property of signature to help prevent signature wrapping attacks.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changelog is automatically generated from the title of the PR. Any additional notes should go in the README.

- After calling .checkSignature() with your public certificate, obtain .signedReferences to use. Array of signed strings by the certificate


## 6.0.0 (2024-01-26)
=======

## 6.0.1 (2025-03-14)

- Address CVEs: CVE-2025-29774 and CVE-2025-29775
Expand Down
61 changes: 32 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# xml-crypto


# Upgrading

The `.getReferences() AND the .references` API is deprecated.
Please do not attempt to access it. The content in there should be treated as unsigned.

Instead, we strongly encourage users to migrate to the .signedReferences API. See the `Verifying XML document` section
We understand that this may take a lot of efforts to migrate, feel free to ask for help.
This will help prevent future XML signature wrapping attacks in the future.

``


![Build](https://github.com/node-saml/xml-crypto/actions/workflows/ci.yml/badge.svg)
[![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/from-referrer/)

Expand Down Expand Up @@ -159,8 +172,13 @@ var select = require("xml-crypto").xpath,
fs = require("fs");

var xml = fs.readFileSync("signed.xml").toString();

var doc = new dom().parseFromString(xml);

// DO NOT attempt to parse whatever data object you have here
// i.e. BAD: parseAssertion(doc),
// good: see below

var signature = select(
doc,
"//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']",
Expand All @@ -172,44 +190,29 @@ try {
} catch (ex) {
console.log(ex);
}


```

In order to protect from some attacks we must check the content we want to use is the one that has been signed:

```javascript
// Roll your own
const elem = xpath.select("/xpath_to_interesting_element", doc);
const uri = sig.getReferences()[0].uri; // might not be 0; it depends on the document
const id = uri[0] === "#" ? uri.substring(1) : uri;
if (
elem.getAttribute("ID") != id &&
elem.getAttribute("Id") != id &&
elem.getAttribute("id") != id
) {
throw new Error("The interesting element was not the one verified by the signature");
if (!res) {
throw "Invalid Signature"
}
// good: The XML Signature has been verified, meaning some subset of XML is verified.
var signedBytes = sig.signedReferences;

// Get the validated element directly from a reference
const elem = sig.references[0].getValidatedElement(); // might not be 0; it depends on the document
const matchingReference = xpath.select1("/xpath_to_interesting_element", elem);
if (!isDomNode.isNodeLike(matchingReference)) {
throw new Error("The interesting element was not the one verified by the signature");
}
var authenticatedDoc = new dom().parseFromString(signedBytes[0]); // take the first of the signed references
// load SAML or whatever from now
// obtain the assertion XML from here
// use only authenticated data
let signedAssertionNode = extractAssertion(authenticatedDoc);
let parsedAssertion = parseAssertion(signedAssertionNode)
return parsedAssertion; // now return the client, the signed Assertion

// Use the built-in method
const elem = xpath.select1("/xpath_to_interesting_element", doc);
try {
const matchingReference = sig.validateElementAgainstReferences(elem, doc);
} catch {
throw new Error("The interesting element was not the one verified by the signature");
}

// Use the built-in method with a an xpath expression
try {
const matchingReference = sig.validateReferenceWithXPath("/xpath_to_interesting_element", doc);
} catch {
throw new Error("The interesting element was not the one verified by the signature");
}
// BAD example: DO not use the .getReferences() API.
```

Note:
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export { SignedXml } from "./signed-xml";
export { C14nCanonicalization, C14nCanonicalizationWithComments } from "./c14n-canonicalization";
export {
ExclusiveCanonicalization,
ExclusiveCanonicalizationWithComments,
} from "./exclusive-canonicalization";
export * from "./utils";
export * from "./types";
51 changes: 44 additions & 7 deletions src/signed-xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import * as signatureAlgorithms from "./signature-algorithms";
import * as crypto from "crypto";
import * as isDomNode from "@xmldom/is-dom-node";


export class SignedXml {
idMode?: "wssecurity";
idAttributes: string[];
Expand Down Expand Up @@ -87,6 +88,8 @@ export class SignedXml {
"http://www.w3.org/2000/09/xmldsig#enveloped-signature": envelopedSignatures.EnvelopedSignature,
};

// TODO: In V7.x we may consider deprecating sha1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm all for putting in a deprecation warning now and removing it later on. Either way.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's only temporary for some SAML libraries to use.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. I was thinking there is no reason not to throw a deprecation notice now if someone uses sha1, no need to wait for 7.x to throw the notice.


/**
* To add a new hash algorithm create a new class that implements the {@link HashAlgorithm} interface, and register it here. More info: {@link https://github.com/node-saml/xml-crypto#customizing-algorithms|Customizing Algorithms}
*/
Expand All @@ -96,6 +99,8 @@ export class SignedXml {
"http://www.w3.org/2001/04/xmlenc#sha512": hashAlgorithms.Sha512,
};

// TODO: In V7.x we may consider deprecating sha1

/**
* To add a new signature algorithm create a new class that implements the {@link SignatureAlgorithm} interface, and register it here. More info: {@link https://github.com/node-saml/xml-crypto#customizing-algorithms|Customizing Algorithms}
*/
Expand All @@ -112,6 +117,7 @@ export class SignedXml {
};

static noop = () => null;
public signedReferences: string[] = [];

/**
* The SignedXml constructor provides an abstraction for sign and verify xml documents. The object is constructed using
Expand Down Expand Up @@ -154,6 +160,9 @@ export class SignedXml {
this.CanonicalizationAlgorithms;
this.HashAlgorithms;
this.SignatureAlgorithms;
// this populates only after verifying the signature
// array of bytes that are cryptographically authenticated
this.signedReferences = []; // TODO: should we rename this to something better.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems like a descriptive name to me. What other thoughts do you have?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea, only problem is that people might confuse it to be a array of Reference objects, when it's an array of bytes.
Something like protectedBytes/protectedData could be better.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chatty suggested options like authenticatedData and signatureVerifiedBytes. I have no problems with log descriptive names. I think, at a minimum, we should make sure the type is correct; right now it is untyped.

}

/**
Expand Down Expand Up @@ -252,6 +261,7 @@ export class SignedXml {
this.signedXml = xml;

const doc = new xmldom.DOMParser().parseFromString(xml);

// Reset the references as only references from our re-parsed signedInfo node can be trusted
this.references = [];

Expand Down Expand Up @@ -307,25 +317,45 @@ export class SignedXml {
return false;
}

// (Stage B authentication step, show that the signedInfoCanon is signed)

// first find the key & signature algorithm, this should match
// Stage B: Take the signature algorithm and key and verify the SignatureValue against the canonicalized SignedInfo
const signer = this.findSignatureAlgorithm(this.signatureAlgorithm);
const key = this.getCertFromKeyInfo(this.keyInfo) || this.publicCert || this.privateKey;
if (key == null) {
throw new Error("KeyInfo or publicCert or privateKey is required to validate signature");
}

if (callback) {
signer.verifySignature(unverifiedSignedInfoCanon, key, this.signatureValue, callback);

// let's clear the callback up a little bit, so we can access it's results,
// and decide whether to reset signature value or not
const sigRes = signer.verifySignature(unverifiedSignedInfoCanon, key, this.signatureValue);
// true case
if (sigRes === true) {
if (callback) {
callback(null, true);
} else {
return true;
}
} else {
const verified = signer.verifySignature(unverifiedSignedInfoCanon, key, this.signatureValue);
// false case
// reset the signedReferences back to empty array
// I would have preferred to start by verifying the signedInfoCanon first, if that's OK
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like our tests would catch if this causes breaking changes. Did one of the existing tests fail?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all test cases passed

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If all tests past when verifying signedInfoCanon first, I'm ok if you wanted to make that change now. If you still think it is too risky, I understand, though I'd be interested in what test might give us reassurance.

// but that may cause some breaking changes?
this.signedReferences = [];


if (verified === false) {
if (callback) {
callback(new Error(
`invalid signature: the signature value ${this.signatureValue} is incorrect`,
));
return; // return early
} else {
throw new Error(
`invalid signature: the signature value ${this.signatureValue} is incorrect`,
);
}

return true;
}
}

Expand Down Expand Up @@ -525,6 +555,11 @@ export class SignedXml {

return false;
}
// This step can only be done after we have verified the signedInfo
// we verified that they have same hash
// so, the canonXml and only the canonXml can be trusted
// append this to signedReferences
this.signedReferences.push(canonXml);

return true;
}
Expand Down Expand Up @@ -575,6 +610,7 @@ export class SignedXml {

const signedInfoNodes = utils.findChildren(this.signatureNode, "SignedInfo");
if (!utils.isArrayHasLength(signedInfoNodes)) {

throw new Error("no signed info node found");
}
if (signedInfoNodes.length > 1) {
Expand Down Expand Up @@ -655,6 +691,7 @@ export class SignedXml {
if (nodes.length === 0) {
throw new Error(`could not find DigestValue node in reference ${refNode.toString()}`);
}

if (nodes.length > 1) {
throw new Error(
`could not load reference for a node that contains multiple DigestValue nodes: ${refNode.toString()}`,
Expand Down Expand Up @@ -713,7 +750,7 @@ export class SignedXml {
) {
transforms.push("http://www.w3.org/TR/2001/REC-xml-c14n-20010315");
}
const refUri = isDomNode.isElementNode(refNode)
const refUri = isDomNode.isElementNode(refNode)
? refNode.getAttribute("URI") || undefined
: undefined;

Expand Down