From 03e528737f60851bc6686732af56fe93fa7c4af1 Mon Sep 17 00:00:00 2001 From: "Luis Gustavo S. Barreto" Date: Fri, 16 Oct 2026 14:49:03 -0300 Subject: [PATCH] test(ui): check a signed SAML redirect request by its query signature shellhub-io/cloud#2667 moved the signature of an HTTP-Redirect AuthnRequest from inside the XML to the query string, as the binding requires (saml-bindings-2.0, 3.4.4.1): the request carries no XML signature, and SigAlg and Signature sign SAMLRequest, RelayState and SigAlg as sent. The spec that turns request signing on still looked for an XML signature and has failed on every run since. The spec now asserts the request went to the redirect endpoint and verifies its query signature against the SP certificate, over the values still encoded as they came in the URL, rebuilt in the binding's order; decoding them first would hide an escaping mismatch between what was signed and what was sent. Only RSA-SHA256 is accepted, the one algorithm the SP signs with, so a change to the algorithm fails the spec instead of passing it. --- ui/apps/console/e2e/saml.spec.ts | 46 +++++++++++++++++++++++--------- ui/apps/console/e2e/saml.ts | 2 -- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/ui/apps/console/e2e/saml.spec.ts b/ui/apps/console/e2e/saml.spec.ts index 14638c429cc..bbe436db3a5 100644 --- a/ui/apps/console/e2e/saml.spec.ts +++ b/ui/apps/console/e2e/saml.spec.ts @@ -1,5 +1,5 @@ import { type Locator, type Page, expect, test } from "@playwright/test"; -import { SignedXml } from "xml-crypto"; +import { createPublicKey, verify } from "node:crypto"; import { adminUpdateUser, createNamespace, @@ -20,7 +20,6 @@ import { signOut, } from "./helpers"; import { - type SignOnRequest, adminContext, answerSignOn, buildSamlUser, @@ -108,15 +107,37 @@ async function publishIdpMetadata(entityId: string, bindings?: Binding[]) { return `http://ui:8080/${file}`; } -function isSignedBy(request: SignOnRequest, certificate: string) { - const signature = request.document.getElementsByTagNameNS( - "http://www.w3.org/2000/09/xmldsig#", - "Signature", - )[0]; - if (!signature) throw new Error("expected a signed SAML request"); - const verifier = new SignedXml({ publicCert: certificate }); - verifier.loadSignature(signature); - return verifier.checkSignature(request.xml); +const rsaSha256 = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; + +function isSignedBy(url: URL, certificate: string) { + const encoded = new Map( + url.search + .slice(1) + .split("&") + .map((pair) => { + const at = pair.indexOf("="); + return [pair.slice(0, at), pair.slice(at + 1)] as const; + }), + ); + const signature = required(encoded.get("Signature"), `a Signature in ${url}`); + const algorithm = decodeURIComponent( + required(encoded.get("SigAlg"), `a SigAlg in ${url}`), + ); + if (algorithm !== rsaSha256) { + throw new Error( + `expected the request signed with RSA-SHA256, got ${algorithm}`, + ); + } + const signed = ["SAMLRequest", "RelayState", "SigAlg"] + .filter((name) => encoded.has(name)) + .map((name) => `${name}=${encoded.get(name)}`) + .join("&"); + return verify( + "sha256", + Buffer.from(signed), + createPublicKey(certificate), + Buffer.from(decodeURIComponent(signature), "base64"), + ); } const buildEntityId = () => `${identityProvider}/${buildShortId()}`; @@ -256,7 +277,8 @@ test.describe("Admin configuration", () => { const token = await signInWithSso(page); expect(requests).toHaveLength(1); - expect(isSignedBy(requests[0], certificate)).toBe(true); + expect(endpointOf(requests[0].url)).toBe(signOnURLs.redirect); + expect(isSignedBy(requests[0].url, certificate)).toBe(true); expect((await readSessionUser(token)).email).toBe(user.email); }); diff --git a/ui/apps/console/e2e/saml.ts b/ui/apps/console/e2e/saml.ts index 35e8a268d91..f1f18d24d69 100644 --- a/ui/apps/console/e2e/saml.ts +++ b/ui/apps/console/e2e/saml.ts @@ -37,7 +37,6 @@ export function buildSamlUser(): SamlUser { export type SignOnRequest = { url: URL; document: Element; - xml: string; relayState: string; }; @@ -200,7 +199,6 @@ function buildSignOnRequest( return { url, document: new DOMParser().parseFromString(xml, "text/xml").documentElement, - xml, relayState: relayState ?? "", }; }