sc-idp/lib/saml/routes.js
2026-06-01 16:40:54 -05:00

545 lines
24 KiB
JavaScript

// SAML IdP HTTP endpoints: metadata, SP-initiated SSO, IdP-initiated SSO, and
// Single Logout (SLO). All SAML messages are validated against a registered-SP
// allow-list (lib/saml/sps.js): the IdP only issues an assertion to a registered
// SP and only to one of its allow-listed ACS URLs, so a forged AuthnRequest
// cannot redirect a signed assertion to an attacker-chosen Destination. Inbound
// XML is DTD/ENTITY-screened (defence-in-depth over samlify's XXE-safe parser)
// and, for SPs registered with a signing cert, the AuthnRequest signature is
// verified. Mounted under /idp/ (CSRF-exempt; SAML messages aren't Saltcorn forms).
const zlib = require("zlib");
const crypto = require("crypto");
const User = require("@saltcorn/data/models/user");
const constants = require("../constants");
const claims = require("../claims");
const web = require("../web");
const samlIdp = require("./idp");
const samlSps = require("./sps");
const { issuerForReq } = require("../oidc/discovery");
// A SAML protocol message never legitimately carries a DOCTYPE or ENTITY
// declaration; reject any that does before it reaches the XML parser.
const DTD_RE = /<!DOCTYPE|<!ENTITY/i;
const CONDITION_WINDOW_MS = 5 * 60 * 1000;
const STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success";
const NAMEID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
const newId = () => {
return "_" + crypto.randomBytes(16).toString("hex");
};
const decodeRequest = (samlReq, binding) => {
// These handlers are fully unauthenticated (noCsrf, no apiToken gate), so
// bound the work before any allocation: reject an oversized base64 blob, then
// cap the inflate output (DEFLATE can expand ~1000:1 -- an unbounded
// inflateRawSync is a memory bomb that crashes the worker).
if (typeof samlReq !== "string" || samlReq.length > constants.SAML_MAX_MSG_B64_BYTES) {
throw new Error("saltcorn-idp: SAML message too large");
}
const buf = Buffer.from(samlReq, "base64");
let xml;
if (binding === samlIdp.REDIRECT_BINDING) {
xml = zlib.inflateRawSync(buf, { maxOutputLength: constants.SAML_MAX_XML_BYTES }).toString("utf8");
} else {
if (buf.length > constants.SAML_MAX_XML_BYTES) {
throw new Error("saltcorn-idp: SAML message too large");
}
xml = buf.toString("utf8");
}
if (DTD_RE.test(xml)) {
throw new Error("saltcorn-idp: SAML message contains a DOCTYPE/ENTITY declaration");
}
return xml;
};
const matchIssuer = (xml) => {
const m = xml.match(/<(?:[\w]+:)?Issuer[^>]*>([^<]+)<\/(?:[\w]+:)?Issuer>/);
return m ? m[1].trim() : null;
};
const matchAcs = (xml) => {
const m = xml.match(/AssertionConsumerServiceURL="([^"]+)"/);
return m ? m[1] : null;
};
const escapeXmlAttr = (s) => {
return String(s === null || s === undefined ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
};
// Reconstruct the exact octet string the SP signed for a redirect-binding
// request: SAMLRequest=<v>&RelayState=<v>&SigAlg=<v> (RelayState omitted when
// absent), using the RAW (still percent-encoded) values from req.originalUrl so
// the bytes byte-match what the SP signed (req.query is already decoded).
const rawQueryOctetString = (req) => {
const url = req.originalUrl || "";
const qpos = url.indexOf("?");
const q = qpos >= 0 ? url.slice(qpos + 1) : "";
const raw = {};
for (const part of q.split("&")) {
const eq = part.indexOf("=");
if (eq > 0) {
raw[part.slice(0, eq)] = part.slice(eq + 1);
}
}
let s = "SAMLRequest=" + (raw.SAMLRequest || "");
if (raw.RelayState !== undefined) {
s += "&RelayState=" + raw.RelayState;
}
s += "&SigAlg=" + (raw.SigAlg || "");
return s;
};
// A real AuthnStatement fragment (raw XML markup). AuthnInstant + SessionIndex
// are server-generated (ISO time / hex), the class ref is a fixed URN, so none
// need XML escaping; this is injected verbatim, not through the value loop.
const buildAuthnStatement = (nowIso, sessionIndex) => {
return '<saml:AuthnStatement AuthnInstant="' + nowIso + '" SessionIndex="' + sessionIndex + '">'
+ "<saml:AuthnContext><saml:AuthnContextClassRef>"
+ constants.SAML_AUTHN_CONTEXT
+ "</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement>";
};
// Expand the single Groups <saml:AttributeValue>{attrGroups}</saml:AttributeValue>
// (samlify bakes ONE AttributeValue per attribute into the template) into ONE
// element per group, so SAML emits multi-valued group attributes matching the
// OIDC/LDAP array form. We capture the AttributeValue's own attributes from the
// template so the xmlns/xsi:type markup is reproduced verbatim (keeps the
// assertion schema-valid + signable); each group text is XML-escaped. An empty
// group list collapses to a single empty AttributeValue (valid, no membership).
const expandGroupValues = (template, groupsArr) => {
const re = /<saml:AttributeValue\b([^>]*)>\{attrGroups\}<\/saml:AttributeValue>/;
const m = template.match(re);
if (!m) {
return template;
}
const attrs = m[1];
const list = Array.isArray(groupsArr) ? groupsArr : (groupsArr === null || groupsArr === undefined ? [] : [groupsArr]);
let values = "";
if (list.length === 0) {
values = "<saml:AttributeValue" + attrs + "></saml:AttributeValue>";
} else {
for (const g of list) {
values += "<saml:AttributeValue" + attrs + ">" + escapeXmlAttr(g) + "</saml:AttributeValue>";
}
}
return template.replace(re, values);
};
// Builds the customTagReplacement callback for a login Response. samlify hands
// us the (already AttributeStatement-expanded) template and uses our returned
// context verbatim, so we fill the FULL token set ourselves: standard tokens +
// the attribute-value tokens ({attrEmail}) and the multi-valued group elements.
// The AuthnStatement is injected after the escaping loop because it is markup,
// not a text value.
const makeLoginResponseRenderer = (opts) => {
return (template) => {
const nowIso = new Date().toISOString();
const laterIso = new Date(Date.now() + CONDITION_WINDOW_MS).toISOString();
const respId = newId();
const sessionIndex = newId();
const tvalue = {
ID: respId,
AssertionID: newId(),
Destination: opts.acs,
Audience: opts.spEntityId,
SubjectRecipient: opts.acs,
Issuer: opts.issuer + "/saml",
IssueInstant: nowIso,
StatusCode: STATUS_SUCCESS,
ConditionsNotBefore: nowIso,
ConditionsNotOnOrAfter: laterIso,
SubjectConfirmationDataNotOnOrAfter: laterIso,
NameIDFormat: NAMEID_FORMAT_EMAIL,
NameID: opts.attrs.email,
attrEmail: opts.attrs.email
};
// Multi-value the Groups attribute BEFORE the token loop so the per-group
// text goes through the same XML escaping as every other value.
let work = expandGroupValues(template, opts.attrs.groups);
if (opts.idpInitiated) {
// An unsolicited Response must NOT carry InResponseTo (on the Response
// or the SubjectConfirmationData) -- drop both attributes.
work = work.replace(/\s+InResponseTo="\{InResponseTo\}"/g, "");
} else {
tvalue.InResponseTo = (opts.requestInfo && opts.requestInfo.extract && opts.requestInfo.extract.request && opts.requestInfo.extract.request.id) || "";
}
for (const k of Object.keys(tvalue)) {
work = work.replace(new RegExp("\\{" + k + "\\}", "g"), escapeXmlAttr(tvalue[k]));
}
work = work.replace(/\{AuthnStatement\}/g, buildAuthnStatement(nowIso, sessionIndex));
return { id: respId, context: work };
};
};
// customTagReplacement for a LogoutResponse (fills the default samlify logout
// template tokens: ID/IssueInstant/Destination/InResponseTo/Issuer/StatusCode).
const makeLogoutResponseRenderer = (opts) => {
return (template) => {
const respId = newId();
const tvalue = {
ID: respId,
IssueInstant: new Date().toISOString(),
Destination: opts.destination || "",
InResponseTo: opts.requestId || "",
Issuer: opts.issuer + "/saml",
StatusCode: STATUS_SUCCESS
};
let work = template;
for (const k of Object.keys(tvalue)) {
work = work.replace(new RegExp("\\{" + k + "\\}", "g"), escapeXmlAttr(tvalue[k]));
}
return { id: respId, context: work };
};
};
const autoPostForm = (acs, samlResponse, relayState) => {
const rs = relayState
? `<input type="hidden" name="RelayState" value="${web.escapeHtml(relayState)}">`
: "";
return `<!doctype html>
<html><body onload="document.forms[0].submit()">
<form method="post" action="${web.escapeHtml(acs)}">
<input type="hidden" name="SAMLResponse" value="${web.escapeHtml(samlResponse)}">
${rs}
<noscript><button type="submit">Continue</button></noscript>
</form></body></html>`;
};
// Builds + signs the login Response and auto-POSTs it to the (validated) ACS.
const sendLoginResponse = async (res, opts) => {
const customTagReplacement = makeLoginResponseRenderer({
issuer: opts.issuer,
spEntityId: opts.spEntityId,
acs: opts.acs,
requestInfo: opts.requestInfo,
idpInitiated: opts.idpInitiated,
attrs: opts.attrs
});
const response = await opts.idp.createLoginResponse(
opts.sp,
opts.requestInfo,
samlIdp.POST_BINDING,
{ email: opts.attrs.email, groups: opts.attrs.groups },
{ relayState: opts.relayState, customTagReplacement: customTagReplacement }
);
// Always deliver to the validated ACS (never response.entityEndpoint, which
// could echo an unvalidated request value).
res.type("text/html").send(autoPostForm(opts.acs, response.context, opts.relayState));
};
const metadataHandler = async (req, res) => {
try {
const xml = await samlIdp.getMetadata(issuerForReq(req));
res.set("Content-Type", "application/samlmetadata+xml").send(xml);
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml metadata failed:`, e);
res.status(503).type("text/plain").send("saml metadata unavailable");
}
};
// SP-initiated SSO. Validates the SP registration + ACS allow-list BEFORE any
// assertion is issued (and verifies the AuthnRequest signature for SPs that
// registered a signing cert).
const ssoHandler = async (req, res) => {
try {
const binding = req.method === "POST" ? samlIdp.POST_BINDING : samlIdp.REDIRECT_BINDING;
const samlReq = (req.body && req.body.SAMLRequest) || (req.query && req.query.SAMLRequest);
const relayState = (req.body && req.body.RelayState) || (req.query && req.query.RelayState) || "";
if (!samlReq) {
res.status(400).type("text/plain").send("missing SAMLRequest");
return;
}
let xml;
try {
xml = decodeRequest(samlReq, binding);
} catch (e) {
res.status(400).type("text/plain").send("malformed SAML request");
return;
}
const spEntityId = matchIssuer(xml);
if (!spEntityId) {
res.status(400).type("text/plain").send("could not parse SAML AuthnRequest");
return;
}
// Registry gate: only registered SPs get assertions. Checked before the
// login bounce so an attacker cannot use an unknown SP to drive a login.
const spRow = await samlSps.getSp(spEntityId);
if (!spRow) {
res.status(403).type("text/plain").send("unknown SAML SP");
return;
}
const allowed = samlSps.acsUrls(spRow);
if (allowed.length === 0) {
res.status(403).type("text/plain").send("SP has no registered ACS");
return;
}
if (!(req.user && req.user.id)) {
res.redirect("/auth/login?dest=" + encodeURIComponent(req.originalUrl));
return;
}
const issuer = issuerForReq(req);
const wantSigned = samlSps.wantsSignedRequests(spRow);
const idp = wantSigned ? await samlIdp.getSignedIdp(issuer) : await samlIdp.getIdp(issuer);
const sp = samlIdp.buildSp(spEntityId, allowed[0], { signingCert: wantSigned ? spRow.signing_cert : null });
const parseReq = { query: req.query, body: req.body };
if (wantSigned && binding === samlIdp.REDIRECT_BINDING) {
parseReq.octetString = rawQueryOctetString(req);
}
let requestInfo;
try {
requestInfo = await idp.parseLoginRequest(sp, binding, parseReq);
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml AuthnRequest rejected:`, e && e.message);
res.status(403).type("text/plain").send("SAML request rejected");
return;
}
// Parser-differential guard: the authoritative (parsed) issuer must match
// the entityID we looked the SP up by.
const parsedIssuer = (requestInfo.extract && requestInfo.extract.issuer) || spEntityId;
if (parsedIssuer !== spEntityId) {
res.status(403).type("text/plain").send("issuer mismatch");
return;
}
// ACS allow-list: prefer the parsed ACS, fall back to the regex; if the
// request named an ACS it MUST be allow-listed, else use the first one.
const reqAcs = (requestInfo.extract && requestInfo.extract.request && requestInfo.extract.request.assertionConsumerServiceUrl) || matchAcs(xml);
let acs;
if (reqAcs) {
if (!samlSps.acsAllowed(spRow, reqAcs)) {
res.status(403).type("text/plain").send("ACS not allowed");
return;
}
acs = reqAcs;
} else {
acs = allowed[0];
}
const user = await User.findOne({ id: req.user.id });
if (!user) {
res.status(403).type("text/plain").send("no such user");
return;
}
const attrs = await claims.samlAttributes(user);
await sendLoginResponse(res, { idp, sp, requestInfo, issuer, spEntityId, acs, attrs, relayState, idpInitiated: false });
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml sso failed:`, e);
if (!res.headersSent) {
res.status(500).type("text/plain").send("saml sso error");
}
}
};
// IdP-initiated SSO: an unsolicited Response for a registered SP named by query
// param (?sp=<entityID>&acs=<acsUrl>). The target SP + ACS are still validated
// against the registry/allow-list (no AuthnRequest to trust).
const initHandler = async (req, res) => {
try {
const spEntityId = String((req.query && req.query.sp) || "");
if (!spEntityId) {
res.status(400).type("text/plain").send("missing sp");
return;
}
const spRow = await samlSps.getSp(spEntityId);
if (!spRow) {
res.status(403).type("text/plain").send("unknown SAML SP");
return;
}
const allowed = samlSps.acsUrls(spRow);
if (allowed.length === 0) {
res.status(403).type("text/plain").send("SP has no registered ACS");
return;
}
if (!(req.user && req.user.id)) {
res.redirect("/auth/login?dest=" + encodeURIComponent(req.originalUrl));
return;
}
const reqAcs = String((req.query && req.query.acs) || "");
let acs;
if (reqAcs) {
if (!samlSps.acsAllowed(spRow, reqAcs)) {
res.status(403).type("text/plain").send("ACS not allowed");
return;
}
acs = reqAcs;
} else {
acs = allowed[0];
}
const issuer = issuerForReq(req);
const idp = await samlIdp.getIdp(issuer);
const sp = samlIdp.buildSp(spEntityId, acs);
const user = await User.findOne({ id: req.user.id });
if (!user) {
res.status(403).type("text/plain").send("no such user");
return;
}
const attrs = await claims.samlAttributes(user);
const relayState = String((req.query && req.query.RelayState) || "");
await sendLoginResponse(res, { idp, sp, requestInfo: {}, issuer, spEntityId, acs, attrs, relayState, idpInitiated: true });
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml idp-initiated sso failed:`, e);
if (!res.headersSent) {
res.status(500).type("text/plain").send("saml init error");
}
}
};
// Single Logout: parse a LogoutRequest from a registered SP, terminate the local
// Saltcorn session, and return a signed LogoutResponse.
const sloHandler = async (req, res) => {
try {
const binding = req.method === "POST" ? samlIdp.POST_BINDING : samlIdp.REDIRECT_BINDING;
const samlReq = (req.body && req.body.SAMLRequest) || (req.query && req.query.SAMLRequest);
const relayState = (req.body && req.body.RelayState) || (req.query && req.query.RelayState) || "";
if (!samlReq) {
res.status(400).type("text/plain").send("missing SAMLRequest");
return;
}
let xml;
try {
xml = decodeRequest(samlReq, binding);
} catch (e) {
res.status(400).type("text/plain").send("malformed SAML request");
return;
}
const spEntityId = matchIssuer(xml);
if (!spEntityId) {
res.status(400).type("text/plain").send("could not parse LogoutRequest");
return;
}
const spRow = await samlSps.getSp(spEntityId);
if (!spRow) {
res.status(403).type("text/plain").send("unknown SAML SP");
return;
}
// SLO terminates THE CURRENT user's session. An unauthenticated request
// has no session to end and must not reach the logout below -- this is
// what stops a forged LogoutRequest from destroying a session.
if (!(req.user && req.user.id)) {
res.status(403).type("text/plain").send("no authenticated session");
return;
}
const issuer = issuerForReq(req);
const allowed = samlSps.acsUrls(spRow);
// The LogoutResponse destination MUST be a registered URL. Mirror the SSO
// handler's empty-list rejection and NEVER fall back to a URL parsed from
// the request: matchAcs(xml) would let an SP registered with no ACS (or a
// crafted LogoutRequest) steer the signed LogoutResponse to an
// attacker-chosen endpoint (open redirect + assertion/RelayState leak).
if (allowed.length === 0) {
res.status(403).type("text/plain").send("SP has no registered ACS");
return;
}
// MVP: deliver the LogoutResponse to the SP's first registered endpoint.
const sloAcs = allowed[0];
// For an SP that registered a signing cert, REQUIRE + verify the
// LogoutRequest signature (getSignedIdp sets wantLogoutRequestSigned),
// mirroring the AuthnRequest path -- this blocks forged/replayed
// LogoutRequests for opted-in SPs. RESIDUAL: an SP WITHOUT a registered
// cert can't have its requests verified, so a cross-site GET could still
// force the CURRENT user to log out THEMSELVES (the req.user + NameID==
// session checks above bound it to self-logout). Register the SP's signing
// cert to close it fully; we don't reject unsigned SLO outright because
// that would break SPs that legitimately don't sign LogoutRequests.
// The signed redirect binding needs the
// raw octet string reconstructed exactly as the SP signed it.
const wantSigned = samlSps.wantsSignedRequests(spRow);
const idp = wantSigned ? await samlIdp.getSignedIdp(issuer) : await samlIdp.getIdp(issuer);
const sp = samlIdp.buildSpForLogout(spEntityId, sloAcs, { signingCert: spRow.signing_cert });
const parseReq = { query: req.query, body: req.body };
if (wantSigned && binding === samlIdp.REDIRECT_BINDING) {
parseReq.octetString = rawQueryOctetString(req);
}
let requestInfo;
try {
requestInfo = await idp.parseLogoutRequest(sp, binding, parseReq);
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml LogoutRequest rejected:`, e && e.message);
res.status(403).type("text/plain").send("SAML logout request rejected");
return;
}
// Parser-differential guard (mirror the SSO handler): the authoritative
// parsed issuer must equal the entityID we looked the SP up by.
const parsedIssuer = (requestInfo.extract && requestInfo.extract.issuer) || spEntityId;
if (parsedIssuer !== spEntityId) {
res.status(403).type("text/plain").send("issuer mismatch");
return;
}
// The LogoutRequest MUST name the currently authenticated user; never end
// a session the request does not pertain to.
const reqNameId = String((requestInfo.extract && requestInfo.extract.nameID) || "").trim().toLowerCase();
const sessionEmail = String((req.user && req.user.email) || "").trim().toLowerCase();
if (!reqNameId || !sessionEmail || reqNameId !== sessionEmail) {
res.status(403).type("text/plain").send("logout subject does not match the session");
return;
}
// The actual logout: end the local Saltcorn session.
if (typeof req.logout === "function") {
try {
req.logout(() => {});
} catch (e) {
// older passport: req.logout() is synchronous
}
}
if (req.session && typeof req.session.destroy === "function") {
try {
req.session.destroy(() => {});
} catch (e) {
// best effort
}
}
const requestId = (requestInfo.extract && requestInfo.extract.request && requestInfo.extract.request.id) || "";
const customTagReplacement = makeLogoutResponseRenderer({ issuer, destination: sloAcs, requestId });
const response = await idp.createLogoutResponse(sp, requestInfo, samlIdp.POST_BINDING, { relayState: relayState, customTagReplacement: customTagReplacement });
res.type("text/html").send(autoPostForm(sloAcs, response.context, relayState));
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml slo failed:`, e);
if (!res.headersSent) {
res.status(500).type("text/plain").send("saml slo error");
}
}
};
const samlRoutes = [
{ url: constants.SAML_METADATA_PATH, method: "get", callback: metadataHandler, noCsrf: true },
{ url: constants.SAML_SSO_PATH, method: "get", callback: ssoHandler, noCsrf: true },
{ url: constants.SAML_SSO_PATH, method: "post", callback: ssoHandler, noCsrf: true },
{ url: constants.SAML_INIT_PATH, method: "get", callback: initHandler, noCsrf: true },
{ url: constants.SAML_SLO_PATH, method: "get", callback: sloHandler, noCsrf: true },
{ url: constants.SAML_SLO_PATH, method: "post", callback: sloHandler, noCsrf: true }
];
module.exports = {
samlRoutes
};