{
    "componentChunkName": "component---src-templates-blog-post-js",
    "path": "/the-signature-was-valid-the-company-was-wrong/",
    "result": {"data":{"markdownRemark":{"id":"b8680e5a-5e9f-5938-a3c6-befc5bb4b743","excerpt":"Editorial note: This is a fictionalized composite security case study. The company, identifiers, correspondence, dates, and reward are invented; the technical…","html":"<blockquote>\n<p><strong>Editorial note:</strong> This is a fictionalized composite security case study. The company, identifiers, correspondence, dates, and reward are invented; the technical failure mode and remediation are designed to be realistic for June 2023.</p>\n</blockquote>\n<p>At 11:47 on a Saturday night, I logged into the wrong company.</p>\n<p>Not with a stolen password. Not with a leaked session cookie. Not with an expired reset link that somebody had forgotten to invalidate.</p>\n<p>I used a SAML assertion signed by a key I owned, for an identity provider I had configured, inside a workspace I had created fifteen minutes earlier.</p>\n<p>The assertion was valid.</p>\n<p>The signature was valid.</p>\n<p>The browser opened a different workspace.</p>\n<p>In the first workspace, my test account was a normal member. In the second, the same email address was an administrator. The avatar was the same, the email was the same, and the small badge beside it had changed from <strong>Member</strong> to <strong>Administrator</strong>.</p>\n<p>For about ten seconds I blamed the two browser tabs.</p>\n<p>Then I opened <code class=\"language-text\">/api/me</code>.</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"user_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"usr_01H3EJ9J9G5W6D1R7K0P\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"email\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"andrew+saml@example.test\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"workspace_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"ws_blue\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"workspace_name\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"BLUE LAB\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"role\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"administrator\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The SAML identity provider belonged to <code class=\"language-text\">RED LAB</code>.</p>\n<p>The session belonged to <code class=\"language-text\">BLUE LAB</code>.</p>\n<p>I logged out, cleared every cookie for the domain, opened a private window, and did it again.</p>\n<p>Same result.</p>\n<p>Then I renamed the workspaces because <code class=\"language-text\">lab-a</code> and <code class=\"language-text\">lab-b</code> had become psychologically dangerous.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">RED LAB   — SAML configured — member\nBLUE LAB  — password login  — administrator</code></pre></div>\n<p>I repeated the flow a third time, recorded the request, saved the response, and stopped.</p>\n<p>My notes from that minute contain only this:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">23:51 — cross-tenant session confirmed\n23:52 — do not touch anything else\n23:53 — write report before convincing yourself it is normal</code></pre></div>\n<p>The bug was not an XML parser trick. It was not signature wrapping. It was not a broken cryptographic primitive.</p>\n<p>It was one missing relationship.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">identity_provider.workspace_id == session.workspace_id</code></pre></div>\n<p>The distance between two companies was one <code class=\"language-text\">==</code>.</p>\n<h2 id=\"the-feature\" style=\"position:relative;\"><a href=\"#the-feature\" aria-label=\"the feature permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The feature</h2>\n<p>The company—I’ll call it <code class=\"language-text\">$COMPANY</code>—runs a multi-tenant business application. Customers live in workspaces. A person can belong to several workspaces, and each workspace can configure its own SAML identity provider.</p>\n<p>The intended flow is ordinary:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">employee\n   │\n   ▼\ncompany identity provider\n   │  signed SAML assertion\n   ▼\n$COMPANY assertion consumer service\n   │\n   ▼\ncompany workspace</code></pre></div>\n<p>A workspace administrator uploads identity-provider metadata: an issuer, a login URL, and a public signing certificate. When an employee signs in, <code class=\"language-text\">$COMPANY</code> verifies the assertion, resolves the employee, and creates a session in that workspace.</p>\n<p>Every customer used the same callback endpoint:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">https://app.$COMPANY.example/auth/saml/callback</code></pre></div>\n<p>That is normal. A shared assertion consumer service is not itself a vulnerability.</p>\n<p>It does mean the application must answer two different questions when a SAML response arrives:</p>\n<ol>\n<li>Is this assertion authentic?</li>\n<li>Which customer’s trust relationship makes it authentic here?</li>\n</ol>\n<p>The first answer came from the signature.</p>\n<p>The second came from a value carried through the user’s browser.</p>\n<p>That was the loose thread.</p>\n<h2 id=\"the-thing-that-looked-too-readable\" style=\"position:relative;\"><a href=\"#the-thing-that-looked-too-readable\" aria-label=\"the thing that looked too readable permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The thing that looked too readable</h2>\n<p>I was not hunting for a cross-tenant takeover. I was testing the SSO setup flow because it had a button labeled <strong>Test connection</strong>, and buttons that promise to test enterprise authentication tend to hide interesting state machines.</p>\n<p>I created <code class=\"language-text\">RED LAB</code>, enabled an enterprise trial, and configured a small local identity provider using a key pair generated for the test. Nothing unusual happened. The metadata imported correctly. The test user signed in correctly. The application redirected back to the SSO settings page with a green success banner.</p>\n<p>Then I looked at the browser request.</p>\n<p>The authentication request included a <code class=\"language-text\">RelayState</code> parameter. That is expected: SAML applications often use it to remember local state while the browser travels to the identity provider and back.</p>\n<p>What was less expected was how much the state wanted to introduce itself.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">RelayState=eyJ3b3Jrc3BhY2VfaWQiOiJ3c19yZWQiLCJuZXh0IjoiL3NldHRpbmdzL3NzbyJ9</code></pre></div>\n<p>Base64-decoded, it became:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"workspace_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"ws_red\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"next\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"/settings/sso\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>There was no MAC. No signature. No opaque server-side reference. Just JSON taking the scenic route through base64.</p>\n<p>Encoding is not integrity.</p>\n<p>Still, client-controlled <code class=\"language-text\">RelayState</code> does not automatically mean account takeover. The server might use it only for a harmless redirect after it has independently established the tenant from the authentication request. Plenty of applications put ugly things in state and then decline to trust them.</p>\n<p>So I changed only one value:</p>\n<div class=\"gatsby-highlight\" data-language=\"diff\"><pre class=\"language-diff\"><code class=\"language-diff\"><span class=\"token deleted-sign deleted\"><span class=\"token prefix deleted\">-</span> \"workspace_id\": \"ws_red\"\n</span><span class=\"token inserted-sign inserted\"><span class=\"token prefix inserted\">+</span> \"workspace_id\": \"ws_blue\"</span></code></pre></div>\n<p>I left the SAML response untouched.</p>\n<p>The assertion was still signed by <code class=\"language-text\">RED LAB</code>’s identity provider. Its <code class=\"language-text\">InResponseTo</code> still matched the request created for <code class=\"language-text\">RED LAB</code>. The destination still pointed to <code class=\"language-text\">$COMPANY</code>’s callback. The audience still named <code class=\"language-text\">$COMPANY</code>’s service provider. The assertion was fresh and inside its validity window.</p>\n<p>The callback returned:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token response-status\"><span class=\"token http-version property\">HTTP/1.1</span> <span class=\"token status-code number\">302</span> <span class=\"token reason-phrase string\">Found</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Location</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">/workspaces/ws_blue/dashboard</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Set-Cookie</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">session=eyJ...; Secure; HttpOnly; SameSite=Lax</span></span></code></pre></div>\n<p>I followed the redirect and saw the administrator badge.</p>\n<h2 id=\"three-explanations-i-wanted-to-be-true\" style=\"position:relative;\"><a href=\"#three-explanations-i-wanted-to-be-true\" aria-label=\"three explanations i wanted to be true permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Three explanations I wanted to be true</h2>\n<p>A good bug becomes dangerous around the moment you want it to be real. That is also the moment to become suspicious of yourself.</p>\n<p>My first theory was <strong>stale session state</strong>. Perhaps I had already authenticated to <code class=\"language-text\">BLUE LAB</code>, and the callback merely sent me to the most recent workspace.</p>\n<p>I deleted the session, cleared local storage, used a new private window, and verified that <code class=\"language-text\">/api/me</code> returned <code class=\"language-text\">401</code> before starting SAML. The new session still belonged to <code class=\"language-text\">BLUE LAB</code>.</p>\n<p>My second theory was <strong>a cosmetic workspace switch</strong>. Some applications keep one global user session and let the interface display whichever workspace appears in the URL. Maybe the page looked like <code class=\"language-text\">BLUE LAB</code>, but authorization still came from <code class=\"language-text\">RED LAB</code>.</p>\n<p>I requested an endpoint available only to workspace administrators:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\">GET /api/workspaces/ws_blue/sso/configuration</code></pre></div>\n<p>The response was <code class=\"language-text\">200</code>, not <code class=\"language-text\">403</code>.</p>\n<p>I did not inspect or modify any real configuration. Both workspaces were mine, and the endpoint’s status was enough.</p>\n<p>My third theory was <strong>an accidental role carried from the source workspace</strong>. Perhaps the application had copied my <code class=\"language-text\">RED LAB</code> role into the target instead of resolving the existing <code class=\"language-text\">BLUE LAB</code> membership.</p>\n<p>So I reversed the roles:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">RED LAB   — administrator\nBLUE LAB  — member</code></pre></div>\n<p>The same SAML assertion now produced a <strong>member</strong> session in <code class=\"language-text\">BLUE LAB</code>.</p>\n<p>That told me exactly where authorization came from. The application was using the assertion to choose an identity and the target workspace to choose the role.</p>\n<p>Authentication came from one tenant.</p>\n<p>Authorization came from another.</p>\n<p>The seam between them had no tenant check.</p>\n<h2 id=\"reconstructing-the-server-from-the-outside\" style=\"position:relative;\"><a href=\"#reconstructing-the-server-from-the-outside\" aria-label=\"reconstructing the server from the outside permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Reconstructing the server from the outside</h2>\n<p>I could not see <code class=\"language-text\">$COMPANY</code>’s source, but the behavior narrowed the implementation to something like this:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">consume_saml</span><span class=\"token punctuation\">(</span>saml_response<span class=\"token punctuation\">:</span> <span class=\"token builtin\">str</span><span class=\"token punctuation\">,</span> relay_state<span class=\"token punctuation\">:</span> <span class=\"token builtin\">str</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    assertion <span class=\"token operator\">=</span> parse_saml_response<span class=\"token punctuation\">(</span>saml_response<span class=\"token punctuation\">)</span>\n\n    pending_request <span class=\"token operator\">=</span> AuthnRequest<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">.</span>in_response_to<span class=\"token punctuation\">)</span>\n    idp <span class=\"token operator\">=</span> IdentityProvider<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>pending_request<span class=\"token punctuation\">.</span>identity_provider_id<span class=\"token punctuation\">)</span>\n\n    verify_signature<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">,</span> certificate<span class=\"token operator\">=</span>idp<span class=\"token punctuation\">.</span>certificate<span class=\"token punctuation\">)</span>\n    verify_issuer<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">,</span> expected<span class=\"token operator\">=</span>idp<span class=\"token punctuation\">.</span>issuer<span class=\"token punctuation\">)</span>\n    verify_audience<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">,</span> expected<span class=\"token operator\">=</span>GLOBAL_SP_ENTITY_ID<span class=\"token punctuation\">)</span>\n    verify_recipient<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">,</span> expected<span class=\"token operator\">=</span>GLOBAL_ACS_URL<span class=\"token punctuation\">)</span>\n    verify_time_window<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">)</span>\n\n    state <span class=\"token operator\">=</span> json<span class=\"token punctuation\">.</span>loads<span class=\"token punctuation\">(</span>base64_decode<span class=\"token punctuation\">(</span>relay_state<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span>\n    workspace <span class=\"token operator\">=</span> Workspace<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>state<span class=\"token punctuation\">[</span><span class=\"token string\">\"workspace_id\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span>\n\n    user <span class=\"token operator\">=</span> workspace<span class=\"token punctuation\">.</span>users<span class=\"token punctuation\">.</span>get_by_email<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">.</span>email<span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">return</span> create_session<span class=\"token punctuation\">(</span>user<span class=\"token operator\">=</span>user<span class=\"token punctuation\">,</span> workspace<span class=\"token operator\">=</span>workspace<span class=\"token punctuation\">)</span></code></pre></div>\n<p>Every line looks reasonable in isolation.</p>\n<p>The request ID selects the identity-provider configuration. The configured certificate verifies the response. The issuer, audience, recipient, and time window are checked. The state restores the destination. The user is looked up inside the selected workspace.</p>\n<p>The missing line is not glamorous:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">if</span> idp<span class=\"token punctuation\">.</span>workspace_id <span class=\"token operator\">!=</span> workspace<span class=\"token punctuation\">.</span><span class=\"token builtin\">id</span><span class=\"token punctuation\">:</span>\n    <span class=\"token keyword\">raise</span> InvalidSamlResponse<span class=\"token punctuation\">(</span><span class=\"token string\">\"identity provider does not belong to workspace\"</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>Without it, the system proved this:</p>\n<blockquote>\n<p>An identity provider trusted by some <code class=\"language-text\">$COMPANY</code> customer signed this email address.</p>\n</blockquote>\n<p>It needed to prove this:</p>\n<blockquote>\n<p>The identity provider trusted by this workspace signed this subject for this login transaction.</p>\n</blockquote>\n<p>Those sentences differ by a few words and an entire security boundary.</p>\n<h2 id=\"i-brought-my-own-valid-signature\" style=\"position:relative;\"><a href=\"#i-brought-my-own-valid-signature\" aria-label=\"i brought my own valid signature permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>I brought my own valid signature</h2>\n<p>The unsettling part was that none of the cryptography failed.</p>\n<p>I did not steal a private key. I did not alter the signed XML. I did not convince the parser to validate one assertion and consume another.</p>\n<p>I brought my own key.</p>\n<p>As the administrator of <code class=\"language-text\">RED LAB</code>, I was allowed to configure an identity provider. <code class=\"language-text\">$COMPANY</code> quite reasonably stored my public certificate as a trusted key for that workspace.</p>\n<p>The vulnerable flow turned that narrow trust relationship into something closer to this:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">trusted by one tenant\n        ↓\ntrusted somewhere in the platform\n        ↓\nusable wherever RelayState points</code></pre></div>\n<p>An attacker who controlled any SAML-enabled workspace could issue a valid assertion containing the email address of an existing user in another workspace. If the target account was an administrator, the resulting session inherited the target workspace’s administrator role.</p>\n<p>The attacker would not need the target’s password. Application-level MFA would not help if SAML was already treated as the completed authentication step. The feature existed specifically so the application could trust the identity provider.</p>\n<p>The platform remembered the key.</p>\n<p>It forgot who trusted it.</p>\n<h2 id=\"proving-severity-without-borrowing-a-victim\" style=\"position:relative;\"><a href=\"#proving-severity-without-borrowing-a-victim\" aria-label=\"proving severity without borrowing a victim permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Proving severity without borrowing a victim</h2>\n<p>Cross-tenant vulnerabilities create an awkward temptation. Once you can move between two tenants you own, the next dramatic screenshot would be a real customer’s workspace.</p>\n<p>That screenshot is unnecessary.</p>\n<p>Two controlled workspaces were enough to demonstrate the complete authorization failure:</p>\n<table>\n<thead>\n<tr>\n<th>Assertion signed by</th>\n<th>Target workspace</th>\n<th align=\"right\">Matching user in target</th>\n<th>Target role</th>\n<th>Result</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code class=\"language-text\">RED LAB</code> IdP</td>\n<td><code class=\"language-text\">RED LAB</code></td>\n<td align=\"right\">yes</td>\n<td>member</td>\n<td>expected member session</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">RED LAB</code> IdP</td>\n<td><code class=\"language-text\">BLUE LAB</code></td>\n<td align=\"right\">yes</td>\n<td>administrator</td>\n<td>unexpected administrator session</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">RED LAB</code> IdP</td>\n<td><code class=\"language-text\">BLUE LAB</code></td>\n<td align=\"right\">no</td>\n<td>—</td>\n<td>user-not-found error</td>\n</tr>\n<tr>\n<td>modified assertion</td>\n<td><code class=\"language-text\">BLUE LAB</code></td>\n<td align=\"right\">yes</td>\n<td>administrator</td>\n<td>signature rejected</td>\n</tr>\n<tr>\n<td>expired assertion</td>\n<td><code class=\"language-text\">BLUE LAB</code></td>\n<td align=\"right\">yes</td>\n<td>administrator</td>\n<td>time-window rejected</td>\n</tr>\n</tbody>\n</table>\n<p>The last two rows mattered. They showed that the security machinery was active. The application rejected a modified assertion and an expired assertion.</p>\n<p>Only the tenant relationship was missing.</p>\n<p>I saved a HAR file, recorded a short video using the two lab workspaces, copied the request IDs and timestamps, invalidated the sessions, and stopped testing.</p>\n<p>A proof of concept should make the bug undeniable while making customer impact unnecessary.</p>\n<p>Anything beyond that is not better evidence. It is damage with cleaner screenshots.</p>\n<h2 id=\"the-report\" style=\"position:relative;\"><a href=\"#the-report\" aria-label=\"the report permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The report</h2>\n<p>I used the least exciting title I could manage:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Cross-tenant account takeover through unbound SAML RelayState</code></pre></div>\n<p>The summary was more direct:</p>\n<blockquote>\n<p>A SAML assertion signed by an identity provider configured for workspace A can create a session for an existing user in workspace B. The response signature, issuer, audience, recipient, time window, and request correlation all validate. The target workspace is selected separately from client-controlled RelayState and is not checked against the identity-provider configuration. A tenant administrator can therefore authenticate as a known user—including an administrator—in another workspace.</p>\n</blockquote>\n<p>Then I included:</p>\n<ul>\n<li>The IDs of both workspaces, with a note that I controlled them.</li>\n<li>The identity-provider configuration used to sign the assertion.</li>\n<li>The original and modified <code class=\"language-text\">RelayState</code> values.</li>\n<li>The resulting session’s workspace and role.</li>\n<li>A five-row test matrix showing both positive and negative controls.</li>\n<li>The invariant I believed the callback needed to enforce.</li>\n</ul>\n<p>I also included the sentence I would want to see first if I were reading the report at <code class=\"language-text\">$COMPANY</code>:</p>\n<blockquote>\n<p>No customer workspace or customer data was accessed during testing.</p>\n</blockquote>\n<p>The report went out at 00:26.</p>\n<p>I closed the laptop, got into bed, and spent twenty minutes mentally inventing reasons the report would be marked informative.</p>\n<h2 id=\"the-reply-on-sunday-morning\" style=\"position:relative;\"><a href=\"#the-reply-on-sunday-morning\" aria-label=\"the reply on sunday morning permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The reply on Sunday morning</h2>\n<p>At 09:12, a human answered.</p>\n<p>Not an automated receipt. Not a request for a video I had already attached.</p>\n<blockquote>\n<p>We reproduced the behavior between two internal tenants and have escalated it to the identity team. We are temporarily restricting creation of new SAML configurations while we investigate. Existing SSO connections remain active. Please do not perform additional testing until we confirm the patch is ready.</p>\n</blockquote>\n<p>That was the best possible response.</p>\n<p>The exploit required an attacker-controlled identity provider to become trusted somewhere in the platform. Pausing new SAML configurations removed the easiest path to introducing a malicious key without locking existing enterprise customers out on Monday morning.</p>\n<p>An hour later they asked one question:</p>\n<blockquote>\n<p>Does the target workspace also need to have SAML enabled?</p>\n</blockquote>\n<p>It did not.</p>\n<p><code class=\"language-text\">BLUE LAB</code> used password login only. The callback selected <code class=\"language-text\">BLUE LAB</code> from <code class=\"language-text\">RelayState</code>, found an existing membership by email, and created the session. The target workspace did not need its own SAML configuration because the source workspace’s identity provider had already satisfied the authentication branch.</p>\n<p>That detail changed the shape of the incident. The bug was not a trust confusion between two SAML customers. It was a platform-wide tenant confusion reachable from any workspace allowed to configure SAML.</p>\n<p>I answered with the controlled retest and went back to not touching it.</p>\n<h2 id=\"the-patch-had-two-parts\" style=\"position:relative;\"><a href=\"#the-patch-had-two-parts\" aria-label=\"the patch had two parts permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The patch had two parts</h2>\n<p>The first fix changed <code class=\"language-text\">RelayState</code> from a container of authority into a random lookup key.</p>\n<p>Before:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"workspace_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"ws_red\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"next\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"/settings/sso\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>After:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">RelayState=8HsaQ6HtwQn8zQkHq13gR4eJr5m1uQwP</code></pre></div>\n<p>The useful state moved to a short-lived server-side transaction:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"state_hash\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"sha256:2fd0...\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"workspace_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"ws_red\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"identity_provider_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"idp_red\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"authn_request_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"_req_7c91\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"next\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"/settings/sso\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"expires_at\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"2023-06-04T00:02:00Z\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"consumed_at\"</span><span class=\"token operator\">:</span> <span class=\"token null keyword\">null</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The callback no longer asked the browser which workspace should receive the session. It loaded one immutable login transaction created before authentication began.</p>\n<p>Conceptually, the new path looked like this:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">consume_saml</span><span class=\"token punctuation\">(</span>saml_response<span class=\"token punctuation\">:</span> <span class=\"token builtin\">str</span><span class=\"token punctuation\">,</span> relay_state<span class=\"token punctuation\">:</span> <span class=\"token builtin\">str</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    transaction <span class=\"token operator\">=</span> consume_one_time_state<span class=\"token punctuation\">(</span>relay_state<span class=\"token punctuation\">)</span>\n\n    workspace <span class=\"token operator\">=</span> Workspace<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>transaction<span class=\"token punctuation\">.</span>workspace_id<span class=\"token punctuation\">)</span>\n    idp <span class=\"token operator\">=</span> IdentityProvider<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>transaction<span class=\"token punctuation\">.</span>identity_provider_id<span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">if</span> idp<span class=\"token punctuation\">.</span>workspace_id <span class=\"token operator\">!=</span> workspace<span class=\"token punctuation\">.</span><span class=\"token builtin\">id</span><span class=\"token punctuation\">:</span>\n        <span class=\"token keyword\">raise</span> InvalidSamlResponse<span class=\"token punctuation\">(</span><span class=\"token string\">\"tenant binding failed\"</span><span class=\"token punctuation\">)</span>\n\n    assertion <span class=\"token operator\">=</span> verify_saml_response<span class=\"token punctuation\">(</span>\n        saml_response<span class=\"token punctuation\">,</span>\n        certificate<span class=\"token operator\">=</span>idp<span class=\"token punctuation\">.</span>certificate<span class=\"token punctuation\">,</span>\n        expected_issuer<span class=\"token operator\">=</span>idp<span class=\"token punctuation\">.</span>issuer<span class=\"token punctuation\">,</span>\n        expected_audience<span class=\"token operator\">=</span>GLOBAL_SP_ENTITY_ID<span class=\"token punctuation\">,</span>\n        expected_recipient<span class=\"token operator\">=</span>GLOBAL_ACS_URL<span class=\"token punctuation\">,</span>\n        expected_request_id<span class=\"token operator\">=</span>transaction<span class=\"token punctuation\">.</span>authn_request_id<span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n\n    identity <span class=\"token operator\">=</span> SamlIdentity<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>\n        workspace_id<span class=\"token operator\">=</span>workspace<span class=\"token punctuation\">.</span><span class=\"token builtin\">id</span><span class=\"token punctuation\">,</span>\n        identity_provider_id<span class=\"token operator\">=</span>idp<span class=\"token punctuation\">.</span><span class=\"token builtin\">id</span><span class=\"token punctuation\">,</span>\n        subject<span class=\"token operator\">=</span>assertion<span class=\"token punctuation\">.</span>subject<span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">return</span> create_session<span class=\"token punctuation\">(</span>identity<span class=\"token punctuation\">.</span>user<span class=\"token punctuation\">,</span> workspace<span class=\"token punctuation\">)</span></code></pre></div>\n<p>The dangerous line disappeared:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">workspace <span class=\"token operator\">=</span> Workspace<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span>client_supplied_workspace_id<span class=\"token punctuation\">)</span></code></pre></div>\n<p>The browser still carried a token, but the token contained no tenant choice. It could identify only one server-side transaction, once, for a few minutes.</p>\n<p>The second fix changed how federated users were mapped.</p>\n<p>The vulnerable design treated this as an identity:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">andrew@example.com</code></pre></div>\n<p>The patched design treated identity as a relationship:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">workspace + identity_provider + subject</code></pre></div>\n<p>An email address is an attribute. A federated identity is a statement made by a particular issuer inside a particular trust boundary.</p>\n<p>Flatten those things into one string and the application eventually has to guess which organization supplied the truth.</p>\n<h2 id=\"why-the-standard-saml-checks-did-not-save-it\" style=\"position:relative;\"><a href=\"#why-the-standard-saml-checks-did-not-save-it\" aria-label=\"why the standard saml checks did not save it permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Why the standard SAML checks did not save it</h2>\n<p>SAML has enough nouns to make a code review feel secure by vocabulary alone: issuer, audience, recipient, destination, subject, conditions, signature, <code class=\"language-text\">InResponseTo</code>, <code class=\"language-text\">NotBefore</code>, <code class=\"language-text\">NotOnOrAfter</code>.</p>\n<p>Every one of them matters.</p>\n<p>None of them automatically models your application’s tenant boundary.</p>\n<h3 id=\"the-signature\" style=\"position:relative;\"><a href=\"#the-signature\" aria-label=\"the signature permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The signature</h3>\n<p>The assertion was genuinely signed by the configured identity provider. The flaw was that a provider configured by one workspace could supply identity for another workspace.</p>\n<p>Cryptography established who signed the message. It did not establish where that signer was authorized to speak.</p>\n<h3 id=\"the-audience\" style=\"position:relative;\"><a href=\"#the-audience\" aria-label=\"the audience permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The audience</h3>\n<p>All tenants shared the same service-provider entity ID. The assertion correctly said it was intended for <code class=\"language-text\">$COMPANY</code>.</p>\n<p>It did not say which <code class=\"language-text\">$COMPANY</code> customer should receive it.</p>\n<h3 id=\"the-recipient-and-destination\" style=\"position:relative;\"><a href=\"#the-recipient-and-destination\" aria-label=\"the recipient and destination permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The recipient and destination</h3>\n<p>Every tenant posted to the same callback URL. Those values were correct too.</p>\n<p>A global endpoint can prove that a response arrived at the right application while saying nothing about the right tenant inside that application.</p>\n<h3 id=\"inresponseto\" style=\"position:relative;\"><a href=\"#inresponseto\" aria-label=\"inresponseto permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a><code class=\"language-text\">InResponseTo</code></h3>\n<p>The response correctly referred to a request created for <code class=\"language-text\">RED LAB</code>.</p>\n<p>The server used that request to load <code class=\"language-text\">RED LAB</code>’s certificate, verified the assertion, and then later allowed <code class=\"language-text\">RelayState</code> to choose <code class=\"language-text\">BLUE LAB</code> for the session.</p>\n<p>Request correlation worked.</p>\n<p>Tenant correlation did not.</p>\n<h3 id=\"the-email-address\" style=\"position:relative;\"><a href=\"#the-email-address\" aria-label=\"the email address permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The email address</h3>\n<p>The same email can legitimately exist in several workspaces with different roles. That is normal for consultants, contractors, and people who create test accounts they later regret.</p>\n<p>Looking up a membership by <code class=\"language-text\">workspace + email</code> is not necessarily wrong.</p>\n<p>Allowing an identity provider from a different workspace to supply that email is.</p>\n<h2 id=\"retesting-the-fix\" style=\"position:relative;\"><a href=\"#retesting-the-fix\" aria-label=\"retesting the fix permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Retesting the fix</h2>\n<p>The patch was ready four days later.</p>\n<p>The old proof of concept now failed before the assertion was used:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token response-status\"><span class=\"token http-version property\">HTTP/1.1</span> <span class=\"token status-code number\">400</span> <span class=\"token reason-phrase string\">Bad Request</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Content-Type</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">application/json</span></span>\n<span class=\"token application-json\">\n<span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"error\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"invalid_saml_response\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"reason\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"tenant_binding_failed\"</span>\n<span class=\"token punctuation\">}</span></span></code></pre></div>\n<p>I tried changing the workspace, the return path, and the request identifier. The state token was single-use, expired quickly, and resolved to one workspace and one identity-provider configuration on the server.</p>\n<p>Then I replayed the original token.</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"error\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"invalid_relay_state\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"reason\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"already_consumed\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Finally, I ran the ordinary login flow for <code class=\"language-text\">RED LAB</code>. It still worked.</p>\n<p>That last test matters. Security patches that destroy the feature are technically effective and operationally temporary.</p>\n<p>I sent the retest results at 18:05.</p>\n<p>The report was closed at the highest severity tier the following week. The reward was generous. The useful part was a sentence from the engineer who owned the patch:</p>\n<blockquote>\n<p>We added this as a cross-tenant invariant in the shared authentication test suite, not only in the SAML tests.</p>\n</blockquote>\n<p>That is better than a screenshot of a severity label.</p>\n<h2 id=\"the-regression-test-i-hope-never-becomes-interesting-again\" style=\"position:relative;\"><a href=\"#the-regression-test-i-hope-never-becomes-interesting-again\" aria-label=\"the regression test i hope never becomes interesting again permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The regression test I hope never becomes interesting again</h2>\n<p>A fixed vulnerability is a code change. A dead vulnerability is a test.</p>\n<p>The test should construct the forbidden relationship directly, even if the public login route no longer knows how:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">def</span> <span class=\"token function\">test_saml_identity_provider_cannot_cross_workspace_boundary</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    idp_red <span class=\"token operator\">=</span> create_identity_provider<span class=\"token punctuation\">(</span>workspace<span class=\"token operator\">=</span>red_workspace<span class=\"token punctuation\">)</span>\n    admin_blue <span class=\"token operator\">=</span> create_user<span class=\"token punctuation\">(</span>\n        workspace<span class=\"token operator\">=</span>blue_workspace<span class=\"token punctuation\">,</span>\n        email<span class=\"token operator\">=</span><span class=\"token string\">\"andrew+saml@example.test\"</span><span class=\"token punctuation\">,</span>\n        role<span class=\"token operator\">=</span><span class=\"token string\">\"administrator\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n\n    transaction <span class=\"token operator\">=</span> create_authn_transaction_for_test<span class=\"token punctuation\">(</span>\n        workspace<span class=\"token operator\">=</span>blue_workspace<span class=\"token punctuation\">,</span>\n        identity_provider<span class=\"token operator\">=</span>idp_red<span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n\n    response <span class=\"token operator\">=</span> idp_red<span class=\"token punctuation\">.</span>sign_assertion<span class=\"token punctuation\">(</span>\n        subject<span class=\"token operator\">=</span>admin_blue<span class=\"token punctuation\">.</span>email<span class=\"token punctuation\">,</span>\n        in_response_to<span class=\"token operator\">=</span>transaction<span class=\"token punctuation\">.</span>request_id<span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n\n    result <span class=\"token operator\">=</span> consume_saml<span class=\"token punctuation\">(</span>\n        saml_response<span class=\"token operator\">=</span>response<span class=\"token punctuation\">,</span>\n        relay_state<span class=\"token operator\">=</span>transaction<span class=\"token punctuation\">.</span>relay_state<span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">assert</span> result<span class=\"token punctuation\">.</span>status <span class=\"token operator\">==</span> <span class=\"token number\">400</span>\n    <span class=\"token keyword\">assert</span> result<span class=\"token punctuation\">.</span>error <span class=\"token operator\">==</span> <span class=\"token string\">\"tenant_binding_failed\"</span>\n    <span class=\"token keyword\">assert</span> no_session_exists_for<span class=\"token punctuation\">(</span>admin_blue<span class=\"token punctuation\">)</span></code></pre></div>\n<p>The exact framework does not matter. The invariant does:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">login_request.workspace\n    == identity_provider.workspace\n    == identity_mapping.workspace\n    == session.workspace</code></pre></div>\n<p>Four objects.</p>\n<p>One tenant.</p>\n<p>Every path that creates a session should make that equality impossible to avoid.</p>\n<h2 id=\"the-lesson-hiding-between-correct-checks\" style=\"position:relative;\"><a href=\"#the-lesson-hiding-between-correct-checks\" aria-label=\"the lesson hiding between correct checks permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The lesson hiding between correct checks</h2>\n<p>A few months ago, a PDF button reminded me that reachability is not authorization. A server being able to contact an internal service does not mean the user who pressed the button should inherit that reach.</p>\n<p>SAML taught me the adjacent lesson:</p>\n<p><strong>Authentication is not tenancy.</strong></p>\n<p>A valid identity does not float above the application waiting to be dropped into whichever account has the same email address. It arrives through a specific issuer, configured by a specific organization, for a specific login transaction.</p>\n<p>Strip away that context and a valid identity becomes a very convincing counterfeit.</p>\n<p>The dangerous code did not look dangerous:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\">verify_signature<span class=\"token punctuation\">(</span>assertion<span class=\"token punctuation\">)</span>\nworkspace <span class=\"token operator\">=</span> load_workspace<span class=\"token punctuation\">(</span>relay_state<span class=\"token punctuation\">)</span>\ncreate_session<span class=\"token punctuation\">(</span>workspace<span class=\"token punctuation\">,</span> assertion<span class=\"token punctuation\">.</span>email<span class=\"token punctuation\">)</span></code></pre></div>\n<p>It had a signature verifier. It had issuer validation. It had request correlation. It had a workspace lookup. It had all the shapes security code is expected to have.</p>\n<p>The bug lived between the lines.</p>\n<p>That is what made it severe. Every local check succeeded. The application never checked the relationship between their results.</p>\n<p>Security failures often look like missing validation.</p>\n<p>This one had plenty of validation.</p>\n<p>It was missing a sentence:</p>\n<blockquote>\n<p>This identity provider belongs to this workspace.</p>\n</blockquote>\n<p>The signature was valid. The assertion was fresh. The audience matched. The certificate was exactly the certificate the platform had been told to trust.</p>\n<p>And it was still the wrong company.</p>\n<p>Cryptography can prove that a key signed a statement.</p>\n<p>It cannot prove that you asked the right key.</p>\n<p>The lock worked perfectly.</p>\n<p>It was bolted to the wrong door.</p>","timeToRead":16,"frontmatter":{"title":"The signature was valid. The company was wrong","date":"June 24, 2023","description":"I signed a SAML login for one test workspace and arrived as an administrator in another. Every cryptographic check passed. The application had forgotten to ask which company trusted the key"}}},"pageContext":{"slug":"/the-signature-was-valid-the-company-was-wrong/","previous":{"fields":{"slug":"/the-model-is-13-gb-my-opinion-of-it-is-8-mb/"},"frontmatter":{"title":"The model is 13 GB. My opinion of it is 8 MB"}},"next":null}},
    "staticQueryHashes": ["3037286347","63159454"]}