// 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 = / { 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, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }; // Reconstruct the exact octet string the SP signed for a redirect-binding // request: SAMLRequest=&RelayState=&SigAlg= (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 '' + "" + constants.SAML_AUTHN_CONTEXT + ""; }; // Expand the single Groups {attrGroups} // (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 = /]*)>\{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 = ""; } else { for (const g of list) { values += "" + escapeXmlAttr(g) + ""; } } 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 ? `` : ""; return `
${rs}
`; }; // 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=&acs=). 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 };