Files
upstream-rocketchat/apps/meteor/server/lib/saml/lib/parsers/Response.ts
upstream-mirror 30810eecd7
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

532 lines
16 KiB
TypeScript

import xmldom from '@xmldom/xmldom';
import { SignedXml } from 'xml-crypto';
import type { Reference } from 'xml-crypto';
import xmlenc from 'xml-encryption';
import type { ISAMLAssertion } from '../../definition/ISAMLAssertion';
import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions';
import type { SAMLPOSTEnvelope } from '../../definition/SAMLEnvelope';
import type { IResponseValidateCallback } from '../../definition/callbacks';
import { SAMLUtils } from '../Utils';
import { StatusCode } from '../constants';
type XmlParent = Element | Document;
export class ResponseParser {
serviceProviderOptions: IServiceProviderOptions;
constructor(serviceProviderOptions: IServiceProviderOptions) {
this.serviceProviderOptions = serviceProviderOptions;
}
public validate(envelope: SAMLPOSTEnvelope<'SAMLResponse'>, callback: IResponseValidateCallback): void {
const { decodedDocument: xml } = envelope;
// We currently use RelayState to save SAML provider
SAMLUtils.log({ msg: 'Validating SAML Response', xml });
let error: Error | null = null;
const doc = new xmldom.DOMParser({
errorHandler: {
fatalError: (e: any) => {
if (e instanceof Error) {
error = e;
return;
}
if (typeof e === 'string') {
error = new Error(e);
return;
}
error = new Error();
},
error: (e: Error) => {
if (e instanceof Error) {
error = e;
return;
}
if (typeof e === 'string') {
error = new Error(e);
return;
}
error = new Error();
},
},
}).parseFromString(xml, 'text/xml');
if (!doc) {
return callback('No Doc Found');
}
if (error) {
return callback(error, null, false);
}
const allResponses = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'Response');
if (allResponses.length === 0) {
return this._checkLogoutResponse(doc, callback);
}
if (allResponses.length !== 1) {
return callback(new Error('Too many SAML responses'), null, false);
}
const response = allResponses[0];
if (response !== doc.documentElement) {
return callback(new Error('SAML Response must be the document root'), null, false);
}
SAMLUtils.log('Got response');
SAMLUtils.log('Verify status');
const statusValidateObj = SAMLUtils.validateStatus(doc);
if (!statusValidateObj.success) {
if (!statusValidateObj.statusCode) {
return callback(new Error('Missing StatusCode'), null, false);
}
if (statusValidateObj.statusCode === StatusCode.responder && statusValidateObj.message) {
return callback(new Error(statusValidateObj.message), null, false);
}
return callback(new Error(`Status is: ${statusValidateObj.statusCode}`), null, false);
}
SAMLUtils.log('Status ok');
let assertion: Element;
let assertionData: ISAMLAssertion;
let issuer;
try {
assertionData = this.getAssertion(response, xml);
assertion = assertionData.assertion;
this.verifySignatures(response, assertionData, xml);
} catch (e) {
return callback(e instanceof Error ? e : String(e), null, false);
}
const profile: Record<string, any> = {};
if (response.hasAttribute('InResponseTo')) {
profile.inResponseToId = response.getAttribute('InResponseTo');
}
try {
issuer = this.getIssuer(assertion);
} catch (e) {
return callback(e instanceof Error ? e : String(e), null, false);
}
if (issuer) {
profile.issuer = issuer.textContent;
}
if (assertion.hasAttribute('ID')) {
profile.assertionId = assertion.getAttribute('ID');
}
const conditions = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Conditions')[0];
if (conditions?.hasAttribute('NotOnOrAfter')) {
const notOnOrAfter = conditions.getAttribute('NotOnOrAfter');
if (notOnOrAfter) {
profile.expireAt = new Date(notOnOrAfter);
}
}
const subject = this.getSubject(assertion);
if (subject) {
const nameID = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'NameID')[0];
if (nameID) {
profile.nameID = nameID.textContent;
if (nameID.hasAttribute('Format')) {
profile.nameIDFormat = nameID.getAttribute('Format');
}
}
try {
this.validateSubjectConditions(subject);
} catch (e) {
return callback(e instanceof Error ? e : String(e), null, false);
}
}
try {
this.validateAssertionConditions(assertion);
} catch (e) {
return callback(e instanceof Error ? e : String(e), null, false);
}
const authnStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AuthnStatement')[0];
if (authnStatement) {
if (authnStatement.hasAttribute('SessionIndex')) {
profile.sessionIndex = authnStatement.getAttribute('SessionIndex');
SAMLUtils.log({ msg: 'Session Index Found', sessionIndex: profile.sessionIndex });
} else {
SAMLUtils.log('No Session Index Found');
}
} else {
SAMLUtils.log('No AuthN Statement found');
}
const attributeStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeStatement')[0];
if (attributeStatement) {
this.mapAttributes(attributeStatement, profile);
} else {
SAMLUtils.log('No Attribute Statement found in SAML response.');
}
if (!profile.email && profile.nameID && profile.nameIDFormat && profile.nameIDFormat.indexOf('emailAddress') >= 0) {
profile.email = profile.nameID;
}
const profileKeys = Object.keys(profile);
for (let i = 0; i < profileKeys.length; i++) {
const key = profileKeys[i];
if (key.match(/\./)) {
profile[key.replace(/\./g, '-')] = profile[key];
delete profile[key];
}
}
SAMLUtils.log({ msg: 'NameID', profile });
return callback(null, profile, false);
}
private _checkLogoutResponse(doc: Document, callback: IResponseValidateCallback): void {
const logoutResponse = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutResponse');
if (!logoutResponse.length) {
return callback(new Error('Unknown SAML response message'), null, false);
}
SAMLUtils.log('Verify status');
const statusValidateObj = SAMLUtils.validateStatus(doc);
if (!statusValidateObj.success) {
return callback(new Error(`Status is: ${statusValidateObj.statusCode}`), null, false);
}
SAMLUtils.log('Status ok');
// @ToDo: Check if this situation is still used
return callback(null, null, true);
}
private getAssertion(response: Element, xml: string): ISAMLAssertion {
const allAssertions = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion');
const allEncrypedAssertions = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedAssertion');
if (allAssertions.length + allEncrypedAssertions.length > 1) {
throw new Error('Too many SAML assertions');
}
let assertion: Element = allAssertions[0];
const encAssertion = allEncrypedAssertions[0];
let newXml = null;
if (typeof encAssertion !== 'undefined') {
// disallowDecryptionWithInsecureAlgorithm defaults to true in xml-encryption v4, but AES-CBC/3DES
// are still widely used by SAML IdPs in practice, so we keep the pre-v4 behaviour here.
const options = { key: this.serviceProviderOptions.privateKey, disallowDecryptionWithInsecureAlgorithm: false };
const encData = encAssertion.getElementsByTagNameNS('*', 'EncryptedData')[0];
xmlenc.decrypt(encData, options, (err, result) => {
if (err) {
SAMLUtils.error(err);
}
const document = new xmldom.DOMParser().parseFromString(result, 'text/xml');
if (!document) {
throw new Error('Failed to decrypt SAML assertion');
}
const decryptedAssertions = document.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion');
if (decryptedAssertions.length) {
assertion = decryptedAssertions[0];
}
newXml = result;
});
}
if (!assertion) {
throw new Error('Missing SAML assertion');
}
return {
assertion,
xml: newXml || xml,
};
}
private verifySignatures(response: Element, assertionData: ISAMLAssertion, xml: string): void {
const signatureType = this.serviceProviderOptions.signatureValidationType;
const checkEither = signatureType === 'Either';
const checkResponse = signatureType === 'Response' || signatureType === 'All' || checkEither;
const checkAssertion = signatureType === 'Assertion' || signatureType === 'All' || checkEither;
let anyValidSignature = false;
if (!this.serviceProviderOptions.cert) {
if (checkResponse || checkAssertion) {
SAMLUtils.log('Missing Signature validation params');
throw new Error('Unable to validate signature');
}
return;
}
if (checkResponse) {
SAMLUtils.log('Verify Document Signature');
if (!this.validateResponseSignature(xml, this.serviceProviderOptions.cert, response)) {
if (!checkEither) {
SAMLUtils.log('Document Signature WRONG');
throw new Error('Invalid Signature');
}
} else {
anyValidSignature = true;
}
SAMLUtils.log('Document Signature OK');
}
if (checkAssertion) {
SAMLUtils.log('Verify Assertion Signature');
if (!this.validateAssertionSignature(assertionData.xml, this.serviceProviderOptions.cert, assertionData.assertion)) {
if (!checkEither) {
SAMLUtils.log('Assertion Signature WRONG');
throw new Error('Invalid Assertion signature');
}
} else {
anyValidSignature = true;
}
SAMLUtils.log('Assertion Signature OK');
}
if (checkEither && !anyValidSignature) {
SAMLUtils.log('No Valid Signature');
throw new Error('No valid SAML Signature found');
}
}
private validateResponseSignature(xml: string, cert: string, response: Element): boolean {
return this.validateSignatureChildren(xml, cert, response);
}
private validateAssertionSignature(xml: string, cert: string, assertion: Element): boolean {
return this.validateSignatureChildren(xml, cert, assertion);
}
private validateSignatureChildren(xml: string, cert: string, parent: Element): boolean {
const signatures = parent.getElementsByTagNameNS('http://www.w3.org/2000/09/xmldsig#', 'Signature');
let signature = null;
for (let i = 0; i < signatures.length; i++) {
const sign = signatures[i];
if (sign.parentNode !== parent) {
continue;
}
// Too many signatures
if (signature) {
SAMLUtils.log('Failed to validate SAML signature: Too Many Signatures');
return false;
}
signature = sign;
}
if (!signature) {
SAMLUtils.log('Failed to validate SAML signature: Signature not found');
return false;
}
return this.validateSignature(xml, cert, signature, this.getElementId(parent));
}
private getElementId(element: Element): string | null {
return element.getAttribute('ID') || element.getAttribute('Id') || element.getAttribute('id');
}
private signatureCoversElement(sig: SignedXml, expectedId: string | null): boolean {
if (!expectedId) {
return false;
}
const references: Reference[] = sig.getReferences() || [];
if (references.length === 0) {
return false;
}
return references.every((reference: Reference) => {
// `signedReference` is only populated for references that were part of the successfully
// validated signature, so its presence proves this reference was actually covered.
if (reference.signedReference == null) {
return false;
}
const uri = reference.uri || '';
const referencedId = uri.charAt(0) === '#' ? uri.substring(1) : uri;
return referencedId === expectedId;
});
}
private validateSignature(xml: string, cert: string, signature: Element, expectedId: string | null): boolean {
const sig = new SignedXml({ publicCert: Buffer.from(SAMLUtils.certToPEM(cert)) });
sig.loadSignature(signature);
try {
if (!sig.checkSignature(xml)) {
SAMLUtils.log(sig.getReferences().map((reference) => reference.validationError?.message));
return false;
}
} catch (e) {
SAMLUtils.log(e instanceof Error ? e.message : String(e));
return false;
}
if (!this.signatureCoversElement(sig, expectedId)) {
SAMLUtils.log('Failed to validate SAML signature: signed reference does not match the processed element');
return false;
}
return true;
}
private getIssuer(assertion: Element): any {
const issuers = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer');
if (issuers.length > 1) {
throw new Error('Too many Issuers');
}
return issuers[0];
}
private getSubject(assertion: Element): XmlParent {
let subject: XmlParent = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Subject')[0];
const encSubject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedID')[0];
if (typeof encSubject !== 'undefined') {
const options = { key: this.serviceProviderOptions.privateKey, disallowDecryptionWithInsecureAlgorithm: false };
xmlenc.decrypt(encSubject.getElementsByTagNameNS('*', 'EncryptedData')[0], options, (err, result) => {
if (err) {
SAMLUtils.error({ err });
}
subject = new xmldom.DOMParser().parseFromString(result, 'text/xml');
});
}
return subject;
}
private validateSubjectConditions(subject: XmlParent): void {
const subjectConfirmation = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'SubjectConfirmation')[0];
if (subjectConfirmation) {
const subjectConfirmationData = subjectConfirmation.getElementsByTagNameNS(
'urn:oasis:names:tc:SAML:2.0:assertion',
'SubjectConfirmationData',
)[0];
if (subjectConfirmationData && !this.validateNotBeforeNotOnOrAfterAssertions(subjectConfirmationData)) {
throw new Error('NotBefore / NotOnOrAfter assertion failed');
}
}
}
private validateNotBeforeNotOnOrAfterAssertions(element: Element): boolean {
const sysnow = new Date();
const allowedclockdrift = this.serviceProviderOptions.allowedClockDrift || 0;
const now = new Date(sysnow.getTime() + allowedclockdrift);
if (element.hasAttribute('NotBefore')) {
const notBefore: string | null = element.getAttribute('NotBefore');
if (!notBefore) {
return false;
}
const date = new Date(notBefore);
if (now < date) {
return false;
}
}
if (element.hasAttribute('NotOnOrAfter')) {
const notOnOrAfter: string | null = element.getAttribute('NotOnOrAfter');
if (!notOnOrAfter) {
return false;
}
const date = new Date(notOnOrAfter);
if (now >= date) {
return false;
}
}
return true;
}
private validateAssertionConditions(assertion: Element): void {
const conditions = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Conditions')[0];
if (conditions && !this.validateNotBeforeNotOnOrAfterAssertions(conditions)) {
throw new Error('NotBefore / NotOnOrAfter assertion failed');
}
}
private mapAttributes(attributeStatement: Element, profile: Record<string, any>): void {
SAMLUtils.log({ msg: 'Attribute Statement found, mapping attributes to profile.', attributeStatement });
const attributes = attributeStatement.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Attribute');
SAMLUtils.log({ msg: 'Attributes will be processed', count: attributes.length });
if (attributes) {
for (let i = 0; i < attributes.length; i++) {
const values = attributes[i].getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeValue');
let value;
if (values.length === 1) {
value = values[0].textContent;
} else {
value = [];
for (let j = 0; j < values.length; j++) {
value.push(values[j].textContent);
}
}
const key = attributes[i].getAttribute('Name');
if (key) {
SAMLUtils.log({ msg: 'Mapping attribute to profile', attribute: key, value });
profile[key] = value;
}
}
} else {
SAMLUtils.log('No Attributes found in SAML attribute statement.');
}
if (!profile.mail && profile['urn:oid:0.9.2342.19200300.100.1.3']) {
// See http://www.incommonfederation.org/attributesummary.html for definition of attribute OIDs
profile.mail = profile['urn:oid:0.9.2342.19200300.100.1.3'];
}
if (!profile.email && profile['urn:oid:1.2.840.113549.1.9.1']) {
profile.email = profile['urn:oid:1.2.840.113549.1.9.1'];
}
if (!profile.displayName && profile['urn:oid:2.16.840.1.113730.3.1.241']) {
profile.displayName = profile['urn:oid:2.16.840.1.113730.3.1.241'];
}
if (!profile.eppn && profile['urn:oid:1.3.6.1.4.1.5923.1.1.1.6']) {
profile.eppn = profile['urn:oid:1.3.6.1.4.1.5923.1.1.1.6'];
}
if (!profile.email && profile.mail) {
profile.email = profile.mail;
}
if (!profile.cn && profile['urn:oid:2.5.4.3']) {
profile.cn = profile['urn:oid:2.5.4.3'];
}
}
}