{
    "componentChunkName": "component---src-templates-blog-post-js",
    "path": "/the-cookie-was-httponly-the-session-was-not/",
    "result": {"data":{"markdownRemark":{"id":"d77b122c-9c83-5b54-b2f8-b83a3ca4db1a","excerpt":"It was almost midnight and I was thirty seconds from closing the laptop. I’d been poking at ’s creator dashboard for about an hour, not because I expected to…","html":"<p>It was almost midnight and I was thirty seconds from closing the laptop.</p>\n<p>I’d been poking at <code class=\"language-text\">$COMPANY</code>’s creator dashboard for about an hour, not because I expected to find anything, but because it was the kind of tired where you keep clicking things instead of going to bed. I’d made a test campaign with a nonsense headline — <code class=\"language-text\">october-proof-6f4d9</code>, the sort of string you generate when you want to be able to grep for it later — and I opened a private window to check something completely unrelated.</p>\n<p>The homepage loaded. My nonsense string was sitting in the middle of it.</p>\n<p>My first thought wasn’t <em>I found something</em>. My first thought was that I’d screwed up my own test setup. That’s usually what it is. You think you’re in a clean browser profile and you’re not, or you’ve got a service worker caching something stale, or you left a devtools override on from two hours ago. I’ve embarrassed myself this way before.</p>\n<p>So I checked. Different browser: still there. Then I dropped to curl, because curl doesn’t have opinions:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token request-line\"><span class=\"token method property\">GET</span> <span class=\"token request-target url\">/</span> <span class=\"token http-version property\">HTTP/1.1</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Host</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">www.$COMPANY.example</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">User-Agent</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">curl/8.4.0</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Accept</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">text/html</span></span></code></pre></div>\n<p>No cookies. No session. Nothing that could possibly identify me. And there it was in the server-rendered HTML:</p>\n<div class=\"gatsby-highlight\" data-language=\"html\"><pre class=\"language-html\"><code class=\"language-html\"><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>section</span> <span class=\"token attr-name\">data-module</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>creator-spotlight<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span>\n  <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>h2</span><span class=\"token punctuation\">></span></span>october-proof-6f4d9<span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>h2</span><span class=\"token punctuation\">></span></span>\n<span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>section</span><span class=\"token punctuation\">></span></span></code></pre></div>\n<p>I scrolled up to the response headers, and that’s the point where I actually sat back in the chair.</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token header\"><span class=\"token header-name keyword\">Cache-Control</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">public, s-maxage=300, stale-while-revalidate=60</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Age</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">19</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">X-Cache</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">HIT</span></span></code></pre></div>\n<p>My text wasn’t just on the homepage. It had been <em>cached</em> there. Some CDN node had taken a copy and was handing it out to whoever asked.</p>\n<p>The one thing keeping me calm was that the string was inert. Plain text, nothing else. I hadn’t put a script in a globally visible campaign, and by that point in the night I understood enough about what I was looking at to know that I never would. Because the other half of what I’d found — the half I’d stumbled into twenty minutes earlier and hadn’t fully processed yet — was that the homepage rendered campaign headlines as raw HTML.</p>\n<p>I ended up proving the two halves separately, and never together:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Test A — global placement\n  audience: ALL\n  content: plain text only\n  result: marker appeared on anonymous homepage and CDN cache\n\nTest B — script execution\n  audience: two accounts I controlled\n  content: redacted execution canary\n  result: JavaScript ran in the real homepage origin\n\nNever performed\n  audience: ALL\n  content: executable payload</code></pre></div>\n<p>A global placement primitive and a stored XSS primitive lived inside the same object. I didn’t need to combine them to know what the combination meant: anyone who loaded the homepage could have run attacker-controlled JavaScript under <code class=\"language-text\">$COMPANY</code>’s origin.</p>\n<p>The session cookie was <code class=\"language-text\">HttpOnly</code>. I’ll come back to how little that helped.</p>\n<h2 id=\"it-started-with-a-card\" style=\"position:relative;\"><a href=\"#it-started-with-a-card\" aria-label=\"it started with a card 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>It started with a card</h2>\n<p>Some context on the product. <code class=\"language-text\">$COMPANY</code> is a consumer platform — profiles, messages, public collections, the usual shape — and it has enough traffic that its homepage has quietly become a product in its own right rather than just a door.</p>\n<p>Most signed-in users never actually type the URL. They arrive from a notification or a shared link or the mobile app. But anonymous visitors do, and search traffic does, and logged-out users do, and anyone opening the site on a desktop after not using it for a week does. All of that funnels through the same discovery page, which is assembled from a stack of server-driven modules:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Top stories\nContinue where you left off\nPopular near you\nCreator spotlight\nRecommended collections</code></pre></div>\n<p>The contents shift by locale, experiment bucket, and whether you’re signed in. The HTML shell around them is cached hard.</p>\n<p>I hadn’t gone looking at the creator dashboard for any of this. I was there for something else entirely — an unrelated idea about image uploads that went nowhere. But the campaign editor caught my eye on the way past. It let a creator build a little promotional card for their own profile: headline, short description, image, button.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">┌──────────────────────────────────────────────┐\n│ Notes from building small language models   │\n│ A short collection of experiments.          │\n│                                  Read more   │\n└──────────────────────────────────────────────┘</code></pre></div>\n<p>Nothing about it looked dangerous. I typed some HTML into the headline field out of habit, the way you do, and it came out the other side as visible text. React escaping it, exactly as you’d hope.</p>\n<p>So I made a draft and watched the request go by:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token request-line\"><span class=\"token method property\">POST</span> <span class=\"token request-target url\">/api/creator/v2/campaigns</span> <span class=\"token http-version property\">HTTP/1.1</span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">Host</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">www.$COMPANY.example</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 header\"><span class=\"token header-name keyword\">Cookie</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">__Host-session=&lt;redacted></span></span>\n<span class=\"token header\"><span class=\"token header-name keyword\">X-CSRF-Token</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">&lt;redacted></span></span>\n<span class=\"token application-json\">\n<span class=\"token punctuation\">{</span>\n  <span class=\"token string-property property\">\"headline\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"October systems notes\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"description\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"A small collection of experiments.\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"image_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"img_test_17\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"cta\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token string-property property\">\"label\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"Read more\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token string-property property\">\"url\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"/u/researcher/notes\"</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"surface\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"CREATOR_PROFILE\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"audience\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token string-property property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"FOLLOWERS\"</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n  <span class=\"token string-property property\">\"status\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"DRAFT\"</span>\n<span class=\"token punctuation\">}</span></span></code></pre></div>\n<p>Two fields didn’t belong:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token property\">\"surface\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"CREATOR_PROFILE\"</span>\n<span class=\"token property\">\"audience\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span> <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"FOLLOWERS\"</span> <span class=\"token punctuation\">}</span></code></pre></div>\n<p>The dashboard had never offered me a choice about either. There was no placement dropdown, no audience selector. And yet the client was dutifully sending both values on every request, which almost always means the same thing: the server is accepting a generic object shared by more than one frontend, and this frontend just happens to only fill in the boring parts.</p>\n<p>That’s the moment where a boring evening turns into a late one.</p>\n<p>I pulled down the JavaScript bundle and searched for <code class=\"language-text\">CREATOR_PROFILE</code>. It had company:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">const</span> CampaignSurface <span class=\"token operator\">=</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token constant\">CREATOR_PROFILE</span><span class=\"token operator\">:</span> <span class=\"token string\">\"CREATOR_PROFILE\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token constant\">FOLLOWING_FEED</span><span class=\"token operator\">:</span> <span class=\"token string\">\"FOLLOWING_FEED\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token constant\">HOME_DISCOVERY</span><span class=\"token operator\">:</span> <span class=\"token string\">\"HOME_DISCOVERY\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token constant\">HOME_HERO</span><span class=\"token operator\">:</span> <span class=\"token string\">\"HOME_HERO\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token constant\">SEARCH_EMPTY_STATE</span><span class=\"token operator\">:</span> <span class=\"token string\">\"SEARCH_EMPTY_STATE\"</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>And a second enum right below it:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">const</span> CampaignAudience <span class=\"token operator\">=</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token constant\">FOLLOWERS</span><span class=\"token operator\">:</span> <span class=\"token string\">\"FOLLOWERS\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token constant\">USER_IDS</span><span class=\"token operator\">:</span> <span class=\"token string\">\"USER_IDS\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token constant\">LOCALE</span><span class=\"token operator\">:</span> <span class=\"token string\">\"LOCALE\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token constant\">ALL</span><span class=\"token operator\">:</span> <span class=\"token string\">\"ALL\"</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>Reading between the lines, these looked like they existed for an internal marketing console and for QA previews. Somebody’s editorial team needs to put a card on the homepage; somebody’s QA needs to target two specific accounts. Reasonable features. The creator dashboard was built on the same API types and simply chose not to render the dangerous options.</p>\n<p>Which left one question, and it’s the question I now ask about basically every product with more than one client: <strong>does the server know the difference between a creator and an editor, or does it only know the difference between the UIs?</strong></p>\n<h2 id=\"the-field-the-ui-never-sent\" style=\"position:relative;\"><a href=\"#the-field-the-ui-never-sent\" aria-label=\"the field the ui never sent 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 field the UI never sent</h2>\n<p>I kept this deliberately small. One field changed, campaign left in draft, audience restricted to two accounts I owned:</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\">\"headline\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"october-proof-6f4d9\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"description\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"plain text placement test\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"image_id\"</span><span class=\"token operator\">:</span> <span class=\"token null keyword\">null</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"cta\"</span><span class=\"token operator\">:</span> <span class=\"token null keyword\">null</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"surface\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"HOME_DISCOVERY\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"audience\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"USER_IDS\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token property\">\"ids\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span><span class=\"token string\">\"user_test_a\"</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"user_test_b\"</span><span class=\"token punctuation\">]</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"status\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"DRAFT\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>I genuinely expected a 403. Instead:</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\">\"id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"cmp_01HCG4Q5DAJ2\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"owner_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"user_test_a\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"surface\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"HOME_DISCOVERY\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"audience\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"USER_IDS\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token property\">\"ids\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span><span class=\"token string\">\"user_test_a\"</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"user_test_b\"</span><span class=\"token punctuation\">]</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"status\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"DRAFT\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"review_required\"</span><span class=\"token operator\">:</span> <span class=\"token boolean\">false</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>I read <code class=\"language-text\">review_required: false</code> three or four times.</p>\n<p>There <em>was</em> an authorization check. It worked. It confirmed that I owned this campaign, which I did. What it never asked was whether I was allowed to point the campaign at the homepage. The backend was doing something along these lines:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token keyword\">async</span> <span class=\"token keyword\">function</span> <span class=\"token function\">createCampaign</span><span class=\"token punctuation\">(</span>user<span class=\"token punctuation\">,</span> input<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token function\">requireCreatorAccount</span><span class=\"token punctuation\">(</span>user<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">return</span> db<span class=\"token punctuation\">.</span>campaigns<span class=\"token punctuation\">.</span><span class=\"token function\">insert</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span>\n    ownerId<span class=\"token operator\">:</span> user<span class=\"token punctuation\">.</span>id<span class=\"token punctuation\">,</span>\n    <span class=\"token operator\">...</span>input<span class=\"token punctuation\">,</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Ownership enforced. Field authority not enforced at all.</p>\n<p>The logic underneath is seductive and wrong in a way I’ve now seen in a dozen codebases. A creator is allowed to create campaigns, therefore the campaign input is creator-controlled, therefore every field on it is creator-controlled. But <code class=\"language-text\">surface</code>, <code class=\"language-text\">audience</code>, <code class=\"language-text\">reviewRequired</code>, and the scheduling behaviour weren’t content. They were distribution policy. They decided who the object was aimed at, not what it said.</p>\n<p>The UI hid them. The API trusted them.</p>\n<p>I activated it for my two test accounts and opened the real homepage, signed in as each one. There was my marker, sitting in the Creator spotlight module on the actual production discovery page.</p>\n<p>At that point I thought I had a decent authorization bug with a limited blast radius. Worth reporting, not worth losing sleep over. Then I looked at how the module was rendering the headline.</p>\n<h2 id=\"the-html-shell\" style=\"position:relative;\"><a href=\"#the-html-shell\" aria-label=\"the html shell 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 HTML shell</h2>\n<p>On the creator profile, the campaign rendered the way you’d expect:</p>\n<div class=\"gatsby-highlight\" data-language=\"jsx\"><pre class=\"language-jsx\"><code class=\"language-jsx\"><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>h2</span><span class=\"token punctuation\">></span></span><span class=\"token punctuation\">{</span>campaign<span class=\"token punctuation\">.</span>headline<span class=\"token punctuation\">}</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>h2</span><span class=\"token punctuation\">></span></span></code></pre></div>\n<p>Text is text. Angle brackets stay angle brackets. This is why my earlier HTML-in-the-headline poke had come back inert — I’d tested the safe surface and drawn a conclusion about the whole system.</p>\n<p>The homepage used a completely different pipeline. Its API didn’t return a headline and a description. It returned a server-driven component with an HTML fragment inside it:</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\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"creator_spotlight\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"campaign_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"cmp_01HCG4Q5DAJ2\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"html\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"&lt;div class=\\\"spotlight-copy\\\">&lt;h2>october-proof-6f4d9&lt;/h2>&lt;/div>\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"tracking\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token property\">\"surface\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"home_discovery\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token property\">\"experiment\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"spotlight_v3\"</span>\n  <span class=\"token punctuation\">}</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>And the React side was, near enough:</p>\n<div class=\"gatsby-highlight\" data-language=\"jsx\"><pre class=\"language-jsx\"><code class=\"language-jsx\"><span class=\"token keyword\">function</span> <span class=\"token function\">ServerDrivenModule</span><span class=\"token punctuation\">(</span><span class=\"token parameter\"><span class=\"token punctuation\">{</span> module <span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">return</span> <span class=\"token punctuation\">(</span>\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>section</span>\n      <span class=\"token attr-name\">data-module</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span>module<span class=\"token punctuation\">.</span>type<span class=\"token punctuation\">}</span></span>\n      <span class=\"token attr-name\">dangerouslySetInnerHTML</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span> <span class=\"token literal-property property\">__html</span><span class=\"token operator\">:</span> module<span class=\"token punctuation\">.</span>html <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span>\n    <span class=\"token punctuation\">/></span></span>\n  <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The React team named that property well. It’s hard to type it by accident.</p>\n<p>But here’s the part that made this interesting rather than merely bad: <strong>the team had not blindly trusted creator input.</strong> There was a sanitizer. Someone had thought about this. The problem was where they’d put it.</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token keyword\">const</span> template <span class=\"token operator\">=</span> DOMPurify<span class=\"token punctuation\">.</span><span class=\"token function\">sanitize</span><span class=\"token punctuation\">(</span><span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">\n  &lt;div class=\"spotlight-copy\">\n    &lt;h2>{{{headline_html}}}&lt;/h2>\n    &lt;p>{{description}}&lt;/p>\n  &lt;/div>\n</span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n<span class=\"token keyword\">const</span> html <span class=\"token operator\">=</span> Mustache<span class=\"token punctuation\">.</span><span class=\"token function\">render</span><span class=\"token punctuation\">(</span>template<span class=\"token punctuation\">,</span> campaign<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>The template gets sanitized. The template is safe — of course it is, it’s a hardcoded string written by an employee. The untrusted data isn’t in it yet.</p>\n<p>Then Mustache runs. Triple braces mean “insert without escaping.” The sanitizer had already looked at a harmless placeholder, given it a thumbs up, and gone home. Mustache then swapped that placeholder for whatever a creator had typed.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">sanitize(template)\n        ↓\ninsert(untrusted_data)\n        ↓\ninnerHTML</code></pre></div>\n<p>Which needed to be, at absolute minimum:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">insert(data)\n        ↓\nsanitize(final_html)\n        ↓\ninnerHTML</code></pre></div>\n<p>Or better, and this is the version I argued for later:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">structured data\n        ↓\nReact elements\n        ↓\nno raw HTML sink</code></pre></div>\n<p>I tested the mildest thing I could think of first:</p>\n<div class=\"gatsby-highlight\" data-language=\"html\"><pre class=\"language-html\"><code class=\"language-html\">October <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>em</span> <span class=\"token attr-name\">data-proof</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>6f4d9<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span>systems<span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>em</span><span class=\"token punctuation\">></span></span> notes</code></pre></div>\n<p>On the creator profile it displayed as literal text with the tags visible. On the homepage, “systems” came out in italics, and the <code class=\"language-text\">data-proof</code> attribute was sitting there in the DOM when I inspected it.</p>\n<p>My data had crossed the line from text into markup. That’s not the same as script execution — HTML injection and XSS are cousins, not twins, and I’ve seen people conflate them in reports and get downgraded for it. But it meant the boundary was gone, and everything after it was a question of degree.</p>\n<p>Then I noticed the CSP:</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\">Content-Security-Policy-Report-Only:\n  default-src 'self' https: data: blob:;\n  script-src 'self' 'unsafe-inline' https:;\n  report-uri /csp/report</code></pre></div>\n<p>Report-only. It would file a complaint. It would not stop anything.</p>\n<h2 id=\"the-part-where-i-had-to-decide-something\" style=\"position:relative;\"><a href=\"#the-part-where-i-had-to-decide-something\" aria-label=\"the part where i had to decide something 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 part where I had to decide something</h2>\n<p>This is the section I’ve rewritten the most times, because it’s the part that actually mattered and it’s the part that’s easiest to make sound self-congratulatory.</p>\n<p>I had two things. I could put text on a globally cached homepage. I could get HTML into a sink that would execute it. Combining them was a single JSON field away — change <code class=\"language-text\">USER_IDS</code> to <code class=\"language-text\">ALL</code>, hit activate, and I’d have the most complete proof-of-concept anyone could ask for.</p>\n<p>I want to be honest that the thought was appealing for about four seconds. Not because I wanted to hurt anyone, but because an airtight demo is easier to report than an argument. You don’t have to convince a triager of anything if you can just show them.</p>\n<p>But the demo would have run in strangers’ browsers. Real people, on a real homepage, on a Tuesday night, having done nothing but open a website. There is no version of “it was only a harmless canary” that survives contact with the fact that I would have been executing code in other people’s sessions without their knowledge. And the cache meant I couldn’t take it back — I’d have had to wait for a purge I didn’t control.</p>\n<p>So I split it, and did each half in the smallest way I could think of.</p>\n<p><strong>Execution first, audience of two.</strong> I kept the campaign restricted to the accounts I owned and replaced the italics with a redacted browser-execution canary. It did exactly two things: wrote a random token into a <code class=\"language-text\">data-xss-proof</code> attribute on the page, and requested a one-time image URL from a server I control containing the same token. It didn’t touch cookies, local storage, messages, profile data, or any API response. It didn’t persist. It didn’t modify an account. The working markup isn’t in this post; it went to <code class=\"language-text\">$COMPANY</code> privately and nowhere else.</p>\n<p>I opened the homepage as <code class=\"language-text\">user_test_b</code> and looked at the root element:</p>\n<div class=\"gatsby-highlight\" data-language=\"html\"><pre class=\"language-html\"><code class=\"language-html\"><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>html</span> <span class=\"token attr-name\">data-xss-proof</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>9b7e2c1a<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span></code></pre></div>\n<p>And my server log:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">00:18:43  GET /xss-proof/9b7e2c1a.gif HTTP/1.1\n           Referer: https://www.$COMPANY.example/\n           User-Agent: Mozilla/5.0 ... Chrome/118.0 ...</code></pre></div>\n<p>I did it again in Firefox with a fresh token, because one browser is an anecdote. Then I deleted the campaign and confirmed both test accounts were getting clean homepage HTML again.</p>\n<p><strong>Placement second, inert content.</strong> New campaign, plain text only:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">october-global-placement-14c8</code></pre></div>\n<p>Audience <code class=\"language-text\">ALL</code>. Activate, load the anonymous homepage once, deactivate. The whole window was under thirty seconds and I had the deactivation request queued up before I sent the activation.</p>\n<p>The marker showed up in:</p>\n<ul>\n<li>a logged-out Chrome profile</li>\n<li>a private Firefox window</li>\n<li>a cookie-less curl request</li>\n<li>a request from a second network location</li>\n<li>the server-rendered homepage HTML</li>\n<li>a cached response with a non-zero <code class=\"language-text\">Age</code></li>\n</ul>\n<p>And then — this is the bit that made my stomach turn slightly — it kept showing up after I’d deleted the campaign. The source object was gone. The rendered copies weren’t. They stayed until the CDN objects expired or got purged, and neither of those was something I could trigger.</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">ordinary creator account\n        │\n        │ client-controlled surface + audience\n        ▼\nglobal homepage campaign\n        │\n        │ raw HTML inserted after sanitization\n        ▼\nstored XSS in www origin\n        │\n        │ public CDN cache\n        ▼\nevery browser receiving that homepage object</code></pre></div>\n<p>Global placement: proven with inert text. Script execution: proven with a private audience. The combination was no longer a technical question, and turning it into one would have been an unsafe act rather than a research step.</p>\n<p>So I stopped, and started writing.</p>\n<h2 id=\"the-cookie-was-doing-its-job-perfectly\" style=\"position:relative;\"><a href=\"#the-cookie-was-doing-its-job-perfectly\" aria-label=\"the cookie was doing its job perfectly 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 cookie was doing its job perfectly</h2>\n<p>While I was drafting, I checked the session cookie, half-hoping for something that would soften the impact.</p>\n<div class=\"gatsby-highlight\" data-language=\"http\"><pre class=\"language-http\"><code class=\"language-http\"><span class=\"token header\"><span class=\"token header-name keyword\">Set-Cookie</span><span class=\"token punctuation\">:</span> <span class=\"token header-value\">__Host-session=&lt;redacted>;\n  Path=/;\n  Secure;\n  HttpOnly;\n  SameSite=Lax</span></span></code></pre></div>\n<p>That’s a well-configured cookie. <code class=\"language-text\">__Host-</code> prefix, <code class=\"language-text\">Secure</code>, <code class=\"language-text\">HttpOnly</code>, <code class=\"language-text\">SameSite</code>. Somebody read the guidance and followed it. My canary had confirmed the session value simply wasn’t present in the script-visible cookie string.</p>\n<p>It helped much less than it looks like it should.</p>\n<p>The injected script wasn’t running <em>near</em> <code class=\"language-text\">https://www.$COMPANY.example/</code>. It was running <em>as part of</em> it. Which means when it made a request to any other path on that origin, the browser attached the session cookie by itself, without asking anyone, exactly as designed. And because the response was same-origin, the script could read it.</p>\n<p>The cookie stayed secret. Its authority was available on request.</p>\n<p>In deliberately non-exfiltrating pseudocode:</p>\n<div class=\"gatsby-highlight\" data-language=\"javascript\"><pre class=\"language-javascript\"><code class=\"language-javascript\"><span class=\"token comment\">// Blocked by HttpOnly:</span>\n<span class=\"token keyword\">const</span> rawSession <span class=\"token operator\">=</span> document<span class=\"token punctuation\">.</span>cookie<span class=\"token punctuation\">;</span>\n\n<span class=\"token comment\">// Still fully authenticated by the browser:</span>\n<span class=\"token keyword\">const</span> response <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> <span class=\"token function\">fetch</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/api/account/me\"</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">const</span> myOwnTestAccount <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> response<span class=\"token punctuation\">.</span><span class=\"token function\">json</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>I ran the second one exactly once, against my own account, and the response contained a user ID that was already rendered on the page in front of me. I logged it to the console and it went nowhere. That was enough to establish the model:</p>\n<blockquote>\n<p>XSS doesn’t need to steal an HttpOnly cookie when it can make the victim’s browser use it.</p>\n</blockquote>\n<p><code class=\"language-text\">SameSite=Lax</code> was similarly beside the point. SameSite governs whether cookies ride along with <em>cross-site</em> requests. This script wasn’t cross-site — it was executing on the application’s own origin, which is the one place SameSite has nothing to say about.</p>\n<p>CSRF tokens weren’t a boundary either. A script inside the origin can read tokens out of the DOM or out of an API response and then submit requests in exactly the shape the real application uses. There’s nothing to forge when you’re already inside.</p>\n<p>The cookie flags eliminated one category of theft. They did nothing about the authority of code that’s already in the page.</p>\n<h2 id=\"what-it-could-have-reached\" style=\"position:relative;\"><a href=\"#what-it-could-have-reached\" aria-label=\"what it could have reached 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>What it could have reached</h2>\n<p>I want to be precise here, because this is where reports get sloppy and where I think researchers do real damage — both to their credibility and occasionally to users.</p>\n<p>I tested one read-only request against one account I owned, and then stopped. I did not go endpoint-hunting. So the report separated what I’d actually confirmed from what a reasonable person should assume follows:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Confirmed\n- Any ordinary creator account could choose a homepage surface.\n- The same account could choose an ALL audience.\n- Active homepage campaigns entered a public CDN cache.\n- Campaign headline HTML was inserted after sanitization.\n- JavaScript executed in the production www origin.\n- Same-origin authenticated requests succeeded as an owned test user.\n\nNot tested\n- Reading private messages or saved content\n- Changing email, password, or recovery settings\n- Creating access tokens\n- Accessing payment information\n- Impersonating users in comments or messages\n- Persistence through service workers or other browser storage\n- Targeting staff or administrator accounts</code></pre></div>\n<p>The plausible impact, subject to whatever authorization and reauthentication each endpoint enforces, is the standard same-origin script list: read what the current user can read, do what the current session can do, read any non-<code class=\"language-text\">HttpOnly</code> browser storage for the origin, rewrite the page to harvest credentials or anything else typed into it, post as the user, and — the one that matters most — catch whichever privileged employee happened to open the homepage that morning.</p>\n<p>That last point is why placement mattered more than the injection itself.</p>\n<p>Stored XSS on a creator profile needs the victim to visit that profile. Stored XSS in a DM needs them to open the conversation. Stored XSS in a homepage module needs them to <em>use the product normally</em>. There’s no suspicious link to squint at, no attachment, no click to regret. The delivery mechanism is the page they trust most.</p>\n<h2 id=\"the-cache-made-it-bigger\" style=\"position:relative;\"><a href=\"#the-cache-made-it-bigger\" aria-label=\"the cache made it bigger 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 cache made it bigger</h2>\n<p>The campaign service fired an event on activation:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">campaign.activated\n  → home-composer consumes event\n  → locale-specific home document regenerated\n  → CDN caches rendered document</code></pre></div>\n<p>And the homepage cache key looked roughly like:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">home:v4:en-US:anonymous\nhome:v4:en-US:signed-in</code></pre></div>\n<p>Locale plus a coarse auth state. No campaign owner in the key — and that’s correct, because the content was <em>supposed</em> to be shared. A public homepage should be cacheable. This wasn’t a cache bug.</p>\n<p>What the cache did was change the shape of the vulnerability:</p>\n<ol>\n<li>The attacker didn’t need to stick around after publishing.</li>\n<li>Deleting the source campaign didn’t remove the copies already at the edge.</li>\n<li>Anonymous users got it before a session existed.</li>\n<li>Signed-in users got it with a live session.</li>\n<li>Edge nodes replicated one stored object across regions.</li>\n</ol>\n<p>The browser had no way to know the HTML started life in a creator dashboard. The CDN had no way to know it contained user-controlled markup. The home renderer had no way to know the creator should never have been able to select its surface.</p>\n<p>Every component received something that looked completely valid coming from the component directly upstream. The bug lived in a sentence nobody had written down anywhere:</p>\n<blockquote>\n<p>A creator-owned campaign may not become trusted homepage HTML.</p>\n</blockquote>\n<h2 id=\"sending-it\" style=\"position:relative;\"><a href=\"#sending-it\" aria-label=\"sending 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>Sending it</h2>\n<p>I filed at 01:07 UTC, which explains some of the sentence construction in the original draft.</p>\n<p>Title:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Critical: ordinary creator can publish stored XSS to globally cached homepage</code></pre></div>\n<p>I’ve learned to make the summary fit on one screen, because the first person to read it is triaging on a phone:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Precondition\n  Any account eligible to create a creator campaign.\n\nAuthorization failure\n  POST /api/creator/v2/campaigns accepts internal-only `surface` and\n  `audience` values from the creator client without a server-side allowlist.\n\nXSS sink\n  Homepage renderer sanitizes the Mustache template before inserting\n  `headline_html` through triple braces, then returns the result to a React\n  `dangerouslySetInnerHTML` sink.\n\nDistribution\n  HOME_DISCOVERY + audience ALL is rendered into anonymous and signed-in\n  homepage documents and cached publicly at the CDN.\n\nImpact\n  Attacker-controlled JavaScript can execute for homepage visitors under\n  the primary application origin. HttpOnly prevents direct cookie reads,\n  but the script can still issue and read same-origin authenticated API\n  requests with the victim's session.\n\nSafety\n  Global distribution was tested with plain text only. Script execution\n  was tested only against two owned accounts. The two conditions were never\n  combined. No user data was accessed.</code></pre></div>\n<p>Attached: account IDs, campaign IDs for both proofs, full request and response pairs, the redacted payload in a private file, screen recordings, CDN headers from two regions, timestamps for every create/activate/deactivate and how long the cache held on afterwards, a suggested emergency kill switch, and remediation notes.</p>\n<p>Acknowledged at 01:19. Then at 01:31, an engineer asked the question I’d been expecting and slightly dreading:</p>\n<blockquote>\n<p>Did you ever make executable content visible to audience ALL?</p>\n</blockquote>\n<p>I appreciated that they asked directly instead of dancing around it. I answered:</p>\n<blockquote>\n<p>No. ALL received a random plain-text marker only. Executable content was limited to two user IDs I control. The report demonstrates each required property independently.</p>\n</blockquote>\n<p>That was the whole exchange. No follow-up, no suspicion. But it’s the reason I structured the testing the way I did — so that when someone eventually asked, the answer could be one paragraph instead of a negotiation.</p>\n<p>01:46, marked critical. 02:03, creator campaigns vanished from the homepage. I went to bed around three.</p>\n<h2 id=\"the-kill-switch\" style=\"position:relative;\"><a href=\"#the-kill-switch\" aria-label=\"the kill switch 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 kill switch</h2>\n<p>The first fix didn’t go anywhere near the sanitizer, which I thought was the right instinct.</p>\n<p>They killed the Creator spotlight module at the home-composer layer and purged the associated CDN keys. The vulnerable campaign objects still existed in the database, but the highest-impact sink stopped consuming them. You cut the delivery path first and argue about root cause in the morning.</p>\n<p>The API hotfix landed alongside it — the server simply stopped listening to the client on anything that counted:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token keyword\">const</span> campaign <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> <span class=\"token function\">createCreatorCampaign</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span>\n  ownerId<span class=\"token operator\">:</span> user<span class=\"token punctuation\">.</span>id<span class=\"token punctuation\">,</span>\n  headline<span class=\"token operator\">:</span> input<span class=\"token punctuation\">.</span>headline<span class=\"token punctuation\">,</span>\n  description<span class=\"token operator\">:</span> input<span class=\"token punctuation\">.</span>description<span class=\"token punctuation\">,</span>\n  imageId<span class=\"token operator\">:</span> input<span class=\"token punctuation\">.</span>image_id<span class=\"token punctuation\">,</span>\n  cta<span class=\"token operator\">:</span> input<span class=\"token punctuation\">.</span>cta<span class=\"token punctuation\">,</span>\n\n  <span class=\"token comment\">// Server-owned policy:</span>\n  surface<span class=\"token operator\">:</span> <span class=\"token string\">\"CREATOR_PROFILE\"</span><span class=\"token punctuation\">,</span>\n  audience<span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span> type<span class=\"token operator\">:</span> <span class=\"token string\">\"FOLLOWERS\"</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n  status<span class=\"token operator\">:</span> <span class=\"token string\">\"DRAFT\"</span><span class=\"token punctuation\">,</span>\n  reviewRequired<span class=\"token operator\">:</span> <span class=\"token boolean\">true</span><span class=\"token punctuation\">,</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>Existing creator-owned campaigns pointing at non-profile surfaces were suspended pending review, which I’d suggested and was glad to see done without argument.</p>\n<p>By 03:12 my old request was dead:</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_campaign_surface\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"message\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"Creators may publish campaigns only to CREATOR_PROFILE\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The CDN had stopped serving my inert marker. The global delivery path was closed.</p>\n<p>The sink still needed fixing, obviously — internal editors, a compromised staff account, a bulk import, or the next authorization mistake could all reach it again. But the emergency was over.</p>\n<h2 id=\"why-the-sanitizer-wasnt-lying\" style=\"position:relative;\"><a href=\"#why-the-sanitizer-wasnt-lying\" aria-label=\"why the sanitizer wasnt lying 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 sanitizer wasn’t lying</h2>\n<p>The renderer team’s first read was that this was a DOMPurify bypass. I get why — there’s a sanitizer, and something got through, so the sanitizer must have failed.</p>\n<p>It didn’t. It never saw the dangerous value. It was handed this:</p>\n<div class=\"gatsby-highlight\" data-language=\"html\"><pre class=\"language-html\"><code class=\"language-html\"><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>h2</span><span class=\"token punctuation\">></span></span>{{{headline_html}}}<span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>h2</span><span class=\"token punctuation\">></span></span></code></pre></div>\n<p>There was nothing to remove. DOMPurify answered the question it was asked, correctly and completely. The question was:</p>\n<blockquote>\n<p>Is this template safe right now?</p>\n</blockquote>\n<p>The application needed the answer to a different one:</p>\n<blockquote>\n<p>Is the final document safe after every transformation has finished?</p>\n</blockquote>\n<p>I keep coming back to this because it generalizes well past XSS. Validation can be quietly undone by anything that happens afterwards: decoding, interpolation, re-parsing, concatenation, decompression, redirect following, type coercion. A check is only meaningful at the boundary immediately before interpretation. For HTML, that boundary is the DOM sink and nowhere else.</p>\n<p>The <a href=\"https://github.com/cure53/DOMPurify\">DOMPurify docs</a> say this outright — modifying sanitized markup afterwards can void the protection. The renderer had done exactly that, just indirectly, through a template engine, in a way that looked completely fine in code review because the word <code class=\"language-text\">sanitize</code> was right there on the screen.</p>\n<p>The ordering made the sanitizer decorative. It was doing real work on a string that didn’t need it.</p>\n<h2 id=\"the-permanent-fix\" style=\"position:relative;\"><a href=\"#the-permanent-fix\" aria-label=\"the permanent 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>The permanent fix</h2>\n<p>Because the finding crossed several trust boundaries, the remediation had to as well. Six pieces:</p>\n<h3 id=\"1-the-client-stopped-choosing-distribution-policy\" style=\"position:relative;\"><a href=\"#1-the-client-stopped-choosing-distribution-policy\" aria-label=\"1 the client stopped choosing distribution policy 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>1. The client stopped choosing distribution policy</h3>\n<p>The creator API got its own input type, separate from the internal editorial one.</p>\n<p>Before:</p>\n<div class=\"gatsby-highlight\" data-language=\"graphql\"><pre class=\"language-graphql\"><code class=\"language-graphql\"><span class=\"token keyword\">input</span> <span class=\"token atom-input class-name\">CampaignInput</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token attr-name\">headline</span><span class=\"token punctuation\">:</span> <span class=\"token scalar\">String</span><span class=\"token operator\">!</span>\n  <span class=\"token attr-name\">description</span><span class=\"token punctuation\">:</span> <span class=\"token scalar\">String</span>\n  <span class=\"token attr-name\">imageId</span><span class=\"token punctuation\">:</span> <span class=\"token scalar\">ID</span>\n  <span class=\"token attr-name\">cta</span><span class=\"token punctuation\">:</span> <span class=\"token atom-input class-name\">CampaignCTAInput</span>\n  <span class=\"token attr-name\">surface</span><span class=\"token punctuation\">:</span> <span class=\"token class-name\">CampaignSurface</span><span class=\"token operator\">!</span>\n  <span class=\"token attr-name\">audience</span><span class=\"token punctuation\">:</span> <span class=\"token atom-input class-name\">CampaignAudienceInput</span><span class=\"token operator\">!</span>\n  <span class=\"token attr-name\">status</span><span class=\"token punctuation\">:</span> <span class=\"token class-name\">CampaignStatus</span><span class=\"token operator\">!</span>\n  <span class=\"token attr-name\">reviewRequired</span><span class=\"token punctuation\">:</span> <span class=\"token scalar\">Boolean</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>After:</p>\n<div class=\"gatsby-highlight\" data-language=\"graphql\"><pre class=\"language-graphql\"><code class=\"language-graphql\"><span class=\"token keyword\">input</span> <span class=\"token atom-input class-name\">CreatorCampaignInput</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token attr-name\">headline</span><span class=\"token punctuation\">:</span> <span class=\"token scalar\">String</span><span class=\"token operator\">!</span>\n  <span class=\"token attr-name\">description</span><span class=\"token punctuation\">:</span> <span class=\"token scalar\">String</span>\n  <span class=\"token attr-name\">imageId</span><span class=\"token punctuation\">:</span> <span class=\"token scalar\">ID</span>\n  <span class=\"token attr-name\">cta</span><span class=\"token punctuation\">:</span> <span class=\"token atom-input class-name\">CampaignCTAInput</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Every policy field derived server-side. Editorial tools moved to a different mutation behind role checks and an approval workflow. Hiding a field in the UI stopped being the authorization model.</p>\n<h3 id=\"2-html-stopped-being-the-content-model\" style=\"position:relative;\"><a href=\"#2-html-stopped-being-the-content-model\" aria-label=\"2 html stopped being the content model 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>2. HTML stopped being the content model</h3>\n<p>The homepage no longer accepts an opaque <code class=\"language-text\">html</code> blob for creator content. The API returns data:</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\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"creator_spotlight\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"headline\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"October systems notes\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"description\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"A small collection of experiments.\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"cta\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token property\">\"label\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"Read more\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token property\">\"href\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"/u/researcher/notes\"</span>\n  <span class=\"token punctuation\">}</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>And React renders it as text:</p>\n<div class=\"gatsby-highlight\" data-language=\"jsx\"><pre class=\"language-jsx\"><code class=\"language-jsx\"><span class=\"token keyword\">function</span> <span class=\"token function\">CreatorSpotlight</span><span class=\"token punctuation\">(</span><span class=\"token parameter\"><span class=\"token punctuation\">{</span> module <span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">return</span> <span class=\"token punctuation\">(</span>\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>section</span> <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>creator-spotlight<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>h2</span><span class=\"token punctuation\">></span></span><span class=\"token punctuation\">{</span>module<span class=\"token punctuation\">.</span>headline<span class=\"token punctuation\">}</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>h2</span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>p</span><span class=\"token punctuation\">></span></span><span class=\"token punctuation\">{</span>module<span class=\"token punctuation\">.</span>description<span class=\"token punctuation\">}</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>p</span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\n      </span><span class=\"token punctuation\">{</span>module<span class=\"token punctuation\">.</span>cta <span class=\"token operator\">&amp;&amp;</span> <span class=\"token punctuation\">(</span>\n        <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>a</span> <span class=\"token attr-name\">href</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token function\">validateInternalHref</span><span class=\"token punctuation\">(</span>module<span class=\"token punctuation\">.</span>cta<span class=\"token punctuation\">.</span>href<span class=\"token punctuation\">)</span><span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\n          </span><span class=\"token punctuation\">{</span>module<span class=\"token punctuation\">.</span>cta<span class=\"token punctuation\">.</span>label<span class=\"token punctuation\">}</span><span class=\"token plain-text\">\n        </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>a</span><span class=\"token punctuation\">></span></span>\n      <span class=\"token punctuation\">)</span><span class=\"token punctuation\">}</span><span class=\"token plain-text\">\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>section</span><span class=\"token punctuation\">></span></span>\n  <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The feature lost the ability to put arbitrary HTML in a headline. As far as I know, not one person has complained.</p>\n<h3 id=\"3-rich-text-became-structured-instead-of-trusted\" style=\"position:relative;\"><a href=\"#3-rich-text-became-structured-instead-of-trusted\" aria-label=\"3 rich text became structured instead of trusted 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>3. Rich text became structured instead of trusted</h3>\n<p>A handful of editorial modules did legitimately need emphasis, links, and line breaks. Those moved to a restricted document format rather than raw HTML:</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\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"heading\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"children\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span>\n    <span class=\"token punctuation\">{</span> <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"text\"</span><span class=\"token punctuation\">,</span> <span class=\"token property\">\"value\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"Build \"</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">{</span> <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"emphasis\"</span><span class=\"token punctuation\">,</span> <span class=\"token property\">\"value\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"smaller\"</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">{</span> <span class=\"token property\">\"type\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"text\"</span><span class=\"token punctuation\">,</span> <span class=\"token property\">\"value\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\" systems\"</span> <span class=\"token punctuation\">}</span>\n  <span class=\"token punctuation\">]</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Known node types map to known React components; unknown nodes are rejected. No generic string-to-HTML conversion, no triple-brace escape hatch. Where legacy HTML still exists, it’s sanitized after all interpolation and immediately before the sink, and nothing is permitted to touch it afterwards.</p>\n<h3 id=\"4-the-policy-started-blocking\" style=\"position:relative;\"><a href=\"#4-the-policy-started-blocking\" aria-label=\"4 the policy started blocking 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>4. The policy started blocking</h3>\n<p>The homepage CSP moved from report-only to enforced, nonce-based. The production policy is more involved than this, but the relevant properties:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">script-src used nonces for application scripts\ninline event handlers were blocked\nobject-src was none\nbase-uri was restricted\nthird-party script origins were reduced\nreport-only remained as a second policy for future tightening</code></pre></div>\n<p>I made a point of framing CSP as defense in depth in the report, not as the fix. A weak CSP wouldn’t have excused unsafe rendering, and a strong one wouldn’t have made client-controlled homepage placement acceptable. But after the change, an equivalent injection has to beat both the rendering boundary and the browser.</p>\n<h3 id=\"5-sensitive-actions-got-a-second-boundary\" style=\"position:relative;\"><a href=\"#5-sensitive-actions-got-a-second-boundary\" aria-label=\"5 sensitive actions got a second boundary 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>5. Sensitive actions got a second boundary</h3>\n<p>The account team went through the high-impact operations a same-origin script could invoke — email changes, recovery settings, API token creation, session management — and put them behind recent reauthentication or a step-up challenge.</p>\n<p>This does nothing to stop XSS reading ordinary session-visible data. What it does is reduce the odds that a single compromised page view turns into permanent account takeover.</p>\n<p><code class=\"language-text\">HttpOnly</code> protects the cookie value. Reauthentication protects selected consequences of the session. Different controls, different jobs, and it’s worth being clear about which one you actually have.</p>\n<h3 id=\"6-cache-invalidation-became-part-of-incident-response\" style=\"position:relative;\"><a href=\"#6-cache-invalidation-became-part-of-incident-response\" aria-label=\"6 cache invalidation became part of incident response 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>6. Cache invalidation became part of incident response</h3>\n<p>The thirty seconds where my deleted campaign kept serving from the edge turned into its own workstream:</p>\n<ul>\n<li>a campaign-to-cache-key index</li>\n<li>immediate purge on suspension or deletion</li>\n<li>shorter emergency TTLs for server-driven modules</li>\n<li>a global module kill switch</li>\n<li>audit logging for every transition into a public surface</li>\n<li>a scanner looking for executable markup in cached homepage fragments</li>\n</ul>\n<p>The cache wasn’t the root cause. It was most of the blast radius, which made it part of the fix.</p>\n<h2 id=\"retest\" style=\"position:relative;\"><a href=\"#retest\" aria-label=\"retest 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>Retest</h2>\n<p>The patched build arrived nine days later. Nine days is fast for something touching this many services, and I said so.</p>\n<p>The original request now behaves like this:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">surface = HOME_DISCOVERY\n  → rejected\n\naudience = ALL\n  → rejected\n\nheadline contains HTML\n  → stored as text\n\ninternal editorial rich text\n  → parsed into allowlisted nodes\n\nlegacy HTML fragment\n  → sanitized after interpolation\n\ninline execution canary\n  → absent from DOM; CSP blocks execution path</code></pre></div>\n<p>I also walked the whole lifecycle rather than just re-firing the original payload, because the original payload is the one thing you can be certain they tested:</p>\n<ol>\n<li>Create an ordinary creator campaign.</li>\n<li>Confirm it appears only on the creator profile.</li>\n<li>Try to change its surface through both create <em>and</em> update mutations.</li>\n<li>Confirm the server ignores or rejects policy fields in both.</li>\n<li>Insert harmless HTML; verify it renders as text.</li>\n<li>Use a company-provided internal test campaign carrying the old redacted canary.</li>\n<li>Confirm the sanitizer strips the executable portion.</li>\n<li>Confirm the enforced CSP logs and blocks the attempt.</li>\n<li>Suspend the campaign and verify edge caches purge immediately.</li>\n</ol>\n<p>Resolved on October 26. The whole thing, start to finish:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">October 14  23:52  Hidden homepage surface accepted\nOctober 15  00:18  XSS confirmed for owned test audience\nOctober 15  00:41  Global placement confirmed with plain text\nOctober 15  01:07  Report submitted\nOctober 15  01:46  Marked critical\nOctober 15  02:03  Homepage module disabled and purge started\nOctober 15  03:12  Creator API hotfix verified\nOctober 20          Rendering and CSP fixes deployed\nOctober 24          Retest completed\nOctober 26          Report resolved; $BOUNTY awarded</code></pre></div>\n<h2 id=\"the-part-i-keep-thinking-about\" style=\"position:relative;\"><a href=\"#the-part-i-keep-thinking-about\" aria-label=\"the part i keep thinking about 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 part I keep thinking about</h2>\n<p>Months later, the thing that stays with me isn’t the payload or the bounty. It’s how ordinary every individual decision was.</p>\n<p>The bug didn’t start with a missing sanitizer — there was one, and it was a good one. It didn’t start with a session cookie exposed to JavaScript — the cookie was configured better than most I see. It didn’t start with a recklessly public publishing endpoint — the creator UI only ever showed profile placement. It didn’t start with a misbehaving cache — the CDN cached precisely what the homepage told it to.</p>\n<p>Read these one at a time and every single one is fine:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Creators may create campaigns.\nThe homepage may render campaigns.\nMarketing templates may contain formatting.\nSanitized HTML may be inserted into the DOM.\nPublic homepage HTML may be cached.\nHttpOnly cookies may not be read by JavaScript.</code></pre></div>\n<p>The vulnerability was in the joins. Each sentence was true in isolation and none of them carried the information about who was trusted when. A creator picked an editorial surface. A sanitized template got unsanitized data poured into it afterwards. A React app accepted an opaque HTML string from its own backend. A report-only policy watched the browser run it and wrote it down. A CDN made copies. A well-configured cookie authenticated the requests.</p>\n<p>The campaign was attacker-controlled. The homepage was trusted. The browser had no way to tell them apart, because by the time the HTML reached it, they were the same string.</p>\n<p>The cookie stayed secret the entire time. Its authority didn’t.</p>\n<p>The cookie was <code class=\"language-text\">HttpOnly</code>. The session wasn’t.</p>","timeToRead":24,"frontmatter":{"title":"The cookie was HttpOnly. The session was not.","date":"October 28, 2023","description":"A creator campaign escaped into a globally cached homepage module. The session cookie could not be read, but every authenticated request the injected script made still carried it."}}},"pageContext":{"slug":"/the-cookie-was-httponly-the-session-was-not/","previous":{"fields":{"slug":"/the-preview-failed-the-command-did-not/"},"frontmatter":{"title":"The preview failed. The command did not."}},"next":null}},
    "staticQueryHashes": ["3037286347","63159454"]}