It was almost midnight and I was thirty seconds from closing the laptop.

I’d been poking at $COMPANY’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 — october-proof-6f4d9, 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.

The homepage loaded. My nonsense string was sitting in the middle of it.

My first thought wasn’t I found something. 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.

So I checked. Different browser: still there. Then I dropped to curl, because curl doesn’t have opinions:

GET / HTTP/1.1
Host: www.$COMPANY.example
User-Agent: curl/8.4.0
Accept: text/html

No cookies. No session. Nothing that could possibly identify me. And there it was in the server-rendered HTML:

<section data-module="creator-spotlight">
  <h2>october-proof-6f4d9</h2>
</section>

I scrolled up to the response headers, and that’s the point where I actually sat back in the chair.

Cache-Control: public, s-maxage=300, stale-while-revalidate=60
Age: 19
X-Cache: HIT

My text wasn’t just on the homepage. It had been cached there. Some CDN node had taken a copy and was handing it out to whoever asked.

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.

I ended up proving the two halves separately, and never together:

Test A — global placement
  audience: ALL
  content: plain text only
  result: marker appeared on anonymous homepage and CDN cache

Test B — script execution
  audience: two accounts I controlled
  content: redacted execution canary
  result: JavaScript ran in the real homepage origin

Never performed
  audience: ALL
  content: executable payload

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 $COMPANY’s origin.

The session cookie was HttpOnly. I’ll come back to how little that helped.

It started with a card

Some context on the product. $COMPANY 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.

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:

Top stories
Continue where you left off
Popular near you
Creator spotlight
Recommended collections

The contents shift by locale, experiment bucket, and whether you’re signed in. The HTML shell around them is cached hard.

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.

┌──────────────────────────────────────────────┐
│ Notes from building small language models   │
│ A short collection of experiments.          │
│                                  Read more   │
└──────────────────────────────────────────────┘

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.

So I made a draft and watched the request go by:

POST /api/creator/v2/campaigns HTTP/1.1
Host: www.$COMPANY.example
Content-Type: application/json
Cookie: __Host-session=<redacted>
X-CSRF-Token: <redacted>

{
  "headline": "October systems notes",
  "description": "A small collection of experiments.",
  "image_id": "img_test_17",
  "cta": {
    "label": "Read more",
    "url": "/u/researcher/notes"
  },
  "surface": "CREATOR_PROFILE",
  "audience": {
    "type": "FOLLOWERS"
  },
  "status": "DRAFT"
}

Two fields didn’t belong:

"surface": "CREATOR_PROFILE"
"audience": { "type": "FOLLOWERS" }

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.

That’s the moment where a boring evening turns into a late one.

I pulled down the JavaScript bundle and searched for CREATOR_PROFILE. It had company:

const CampaignSurface = {
  CREATOR_PROFILE: "CREATOR_PROFILE",
  FOLLOWING_FEED: "FOLLOWING_FEED",
  HOME_DISCOVERY: "HOME_DISCOVERY",
  HOME_HERO: "HOME_HERO",
  SEARCH_EMPTY_STATE: "SEARCH_EMPTY_STATE"
};

And a second enum right below it:

const CampaignAudience = {
  FOLLOWERS: "FOLLOWERS",
  USER_IDS: "USER_IDS",
  LOCALE: "LOCALE",
  ALL: "ALL"
};

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.

Which left one question, and it’s the question I now ask about basically every product with more than one client: does the server know the difference between a creator and an editor, or does it only know the difference between the UIs?

The field the UI never sent

I kept this deliberately small. One field changed, campaign left in draft, audience restricted to two accounts I owned:

{
  "headline": "october-proof-6f4d9",
  "description": "plain text placement test",
  "image_id": null,
  "cta": null,
  "surface": "HOME_DISCOVERY",
  "audience": {
    "type": "USER_IDS",
    "ids": ["user_test_a", "user_test_b"]
  },
  "status": "DRAFT"
}

I genuinely expected a 403. Instead:

{
  "id": "cmp_01HCG4Q5DAJ2",
  "owner_id": "user_test_a",
  "surface": "HOME_DISCOVERY",
  "audience": {
    "type": "USER_IDS",
    "ids": ["user_test_a", "user_test_b"]
  },
  "status": "DRAFT",
  "review_required": false
}

I read review_required: false three or four times.

There was 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:

async function createCampaign(user, input) {
  requireCreatorAccount(user);

  return db.campaigns.insert({
    ownerId: user.id,
    ...input,
  });
}

Ownership enforced. Field authority not enforced at all.

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 surface, audience, reviewRequired, and the scheduling behaviour weren’t content. They were distribution policy. They decided who the object was aimed at, not what it said.

The UI hid them. The API trusted them.

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.

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.

The HTML shell

On the creator profile, the campaign rendered the way you’d expect:

<h2>{campaign.headline}</h2>

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.

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:

{
  "type": "creator_spotlight",
  "campaign_id": "cmp_01HCG4Q5DAJ2",
  "html": "<div class=\"spotlight-copy\"><h2>october-proof-6f4d9</h2></div>",
  "tracking": {
    "surface": "home_discovery",
    "experiment": "spotlight_v3"
  }
}

And the React side was, near enough:

function ServerDrivenModule({ module }) {
  return (
    <section
      data-module={module.type}
      dangerouslySetInnerHTML={{ __html: module.html }}
    />
  );
}

The React team named that property well. It’s hard to type it by accident.

But here’s the part that made this interesting rather than merely bad: the team had not blindly trusted creator input. There was a sanitizer. Someone had thought about this. The problem was where they’d put it.

const template = DOMPurify.sanitize(`
  <div class="spotlight-copy">
    <h2>{{{headline_html}}}</h2>
    <p>{{description}}</p>
  </div>
`);

const html = Mustache.render(template, campaign);

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.

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.

sanitize(template)
        ↓
insert(untrusted_data)
        ↓
innerHTML

Which needed to be, at absolute minimum:

insert(data)
        ↓
sanitize(final_html)
        ↓
innerHTML

Or better, and this is the version I argued for later:

structured data
        ↓
React elements
        ↓
no raw HTML sink

I tested the mildest thing I could think of first:

October <em data-proof="6f4d9">systems</em> notes

On the creator profile it displayed as literal text with the tags visible. On the homepage, “systems” came out in italics, and the data-proof attribute was sitting there in the DOM when I inspected it.

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.

Then I noticed the CSP:

Content-Security-Policy-Report-Only:
  default-src 'self' https: data: blob:;
  script-src 'self' 'unsafe-inline' https:;
  report-uri /csp/report

Report-only. It would file a complaint. It would not stop anything.

The part where I had to decide something

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.

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 USER_IDS to ALL, hit activate, and I’d have the most complete proof-of-concept anyone could ask for.

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.

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.

So I split it, and did each half in the smallest way I could think of.

Execution first, audience of two. 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 data-xss-proof 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 $COMPANY privately and nowhere else.

I opened the homepage as user_test_b and looked at the root element:

<html data-xss-proof="9b7e2c1a">

And my server log:

00:18:43  GET /xss-proof/9b7e2c1a.gif HTTP/1.1
           Referer: https://www.$COMPANY.example/
           User-Agent: Mozilla/5.0 ... Chrome/118.0 ...

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.

Placement second, inert content. New campaign, plain text only:

october-global-placement-14c8

Audience ALL. 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.

The marker showed up in:

  • a logged-out Chrome profile
  • a private Firefox window
  • a cookie-less curl request
  • a request from a second network location
  • the server-rendered homepage HTML
  • a cached response with a non-zero Age

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.

ordinary creator account
        │
        │ client-controlled surface + audience
        ▼
global homepage campaign
        │
        │ raw HTML inserted after sanitization
        ▼
stored XSS in www origin
        │
        │ public CDN cache
        ▼
every browser receiving that homepage object

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.

So I stopped, and started writing.

While I was drafting, I checked the session cookie, half-hoping for something that would soften the impact.

Set-Cookie: __Host-session=<redacted>;
  Path=/;
  Secure;
  HttpOnly;
  SameSite=Lax

That’s a well-configured cookie. __Host- prefix, Secure, HttpOnly, SameSite. Somebody read the guidance and followed it. My canary had confirmed the session value simply wasn’t present in the script-visible cookie string.

It helped much less than it looks like it should.

The injected script wasn’t running near https://www.$COMPANY.example/. It was running as part of 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.

The cookie stayed secret. Its authority was available on request.

In deliberately non-exfiltrating pseudocode:

// Blocked by HttpOnly:
const rawSession = document.cookie;

// Still fully authenticated by the browser:
const response = await fetch("/api/account/me");
const myOwnTestAccount = await response.json();

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:

XSS doesn’t need to steal an HttpOnly cookie when it can make the victim’s browser use it.

SameSite=Lax was similarly beside the point. SameSite governs whether cookies ride along with cross-site 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.

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.

The cookie flags eliminated one category of theft. They did nothing about the authority of code that’s already in the page.

What it could have reached

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.

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:

Confirmed
- Any ordinary creator account could choose a homepage surface.
- The same account could choose an ALL audience.
- Active homepage campaigns entered a public CDN cache.
- Campaign headline HTML was inserted after sanitization.
- JavaScript executed in the production www origin.
- Same-origin authenticated requests succeeded as an owned test user.

Not tested
- Reading private messages or saved content
- Changing email, password, or recovery settings
- Creating access tokens
- Accessing payment information
- Impersonating users in comments or messages
- Persistence through service workers or other browser storage
- Targeting staff or administrator accounts

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-HttpOnly 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.

That last point is why placement mattered more than the injection itself.

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 use the product normally. There’s no suspicious link to squint at, no attachment, no click to regret. The delivery mechanism is the page they trust most.

The cache made it bigger

The campaign service fired an event on activation:

campaign.activated
  → home-composer consumes event
  → locale-specific home document regenerated
  → CDN caches rendered document

And the homepage cache key looked roughly like:

home:v4:en-US:anonymous
home:v4:en-US:signed-in

Locale plus a coarse auth state. No campaign owner in the key — and that’s correct, because the content was supposed to be shared. A public homepage should be cacheable. This wasn’t a cache bug.

What the cache did was change the shape of the vulnerability:

  1. The attacker didn’t need to stick around after publishing.
  2. Deleting the source campaign didn’t remove the copies already at the edge.
  3. Anonymous users got it before a session existed.
  4. Signed-in users got it with a live session.
  5. Edge nodes replicated one stored object across regions.

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.

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:

A creator-owned campaign may not become trusted homepage HTML.

Sending it

I filed at 01:07 UTC, which explains some of the sentence construction in the original draft.

Title:

Critical: ordinary creator can publish stored XSS to globally cached homepage

I’ve learned to make the summary fit on one screen, because the first person to read it is triaging on a phone:

Precondition
  Any account eligible to create a creator campaign.

Authorization failure
  POST /api/creator/v2/campaigns accepts internal-only `surface` and
  `audience` values from the creator client without a server-side allowlist.

XSS sink
  Homepage renderer sanitizes the Mustache template before inserting
  `headline_html` through triple braces, then returns the result to a React
  `dangerouslySetInnerHTML` sink.

Distribution
  HOME_DISCOVERY + audience ALL is rendered into anonymous and signed-in
  homepage documents and cached publicly at the CDN.

Impact
  Attacker-controlled JavaScript can execute for homepage visitors under
  the primary application origin. HttpOnly prevents direct cookie reads,
  but the script can still issue and read same-origin authenticated API
  requests with the victim's session.

Safety
  Global distribution was tested with plain text only. Script execution
  was tested only against two owned accounts. The two conditions were never
  combined. No user data was accessed.

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.

Acknowledged at 01:19. Then at 01:31, an engineer asked the question I’d been expecting and slightly dreading:

Did you ever make executable content visible to audience ALL?

I appreciated that they asked directly instead of dancing around it. I answered:

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.

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.

01:46, marked critical. 02:03, creator campaigns vanished from the homepage. I went to bed around three.

The kill switch

The first fix didn’t go anywhere near the sanitizer, which I thought was the right instinct.

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.

The API hotfix landed alongside it — the server simply stopped listening to the client on anything that counted:

const campaign = await createCreatorCampaign({
  ownerId: user.id,
  headline: input.headline,
  description: input.description,
  imageId: input.image_id,
  cta: input.cta,

  // Server-owned policy:
  surface: "CREATOR_PROFILE",
  audience: { type: "FOLLOWERS" },
  status: "DRAFT",
  reviewRequired: true,
});

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.

By 03:12 my old request was dead:

{
  "error": "invalid_campaign_surface",
  "message": "Creators may publish campaigns only to CREATOR_PROFILE"
}

The CDN had stopped serving my inert marker. The global delivery path was closed.

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.

Why the sanitizer wasn’t lying

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.

It didn’t. It never saw the dangerous value. It was handed this:

<h2>{{{headline_html}}}</h2>

There was nothing to remove. DOMPurify answered the question it was asked, correctly and completely. The question was:

Is this template safe right now?

The application needed the answer to a different one:

Is the final document safe after every transformation has finished?

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.

The DOMPurify docs 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 sanitize was right there on the screen.

The ordering made the sanitizer decorative. It was doing real work on a string that didn’t need it.

The permanent fix

Because the finding crossed several trust boundaries, the remediation had to as well. Six pieces:

1. The client stopped choosing distribution policy

The creator API got its own input type, separate from the internal editorial one.

Before:

input CampaignInput {
  headline: String!
  description: String
  imageId: ID
  cta: CampaignCTAInput
  surface: CampaignSurface!
  audience: CampaignAudienceInput!
  status: CampaignStatus!
  reviewRequired: Boolean
}

After:

input CreatorCampaignInput {
  headline: String!
  description: String
  imageId: ID
  cta: CampaignCTAInput
}

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.

2. HTML stopped being the content model

The homepage no longer accepts an opaque html blob for creator content. The API returns data:

{
  "type": "creator_spotlight",
  "headline": "October systems notes",
  "description": "A small collection of experiments.",
  "cta": {
    "label": "Read more",
    "href": "/u/researcher/notes"
  }
}

And React renders it as text:

function CreatorSpotlight({ module }) {
  return (
    <section className="creator-spotlight">
      <h2>{module.headline}</h2>
      <p>{module.description}</p>
      {module.cta && (
        <a href={validateInternalHref(module.cta.href)}>
          {module.cta.label}
        </a>
      )}
    </section>
  );
}

The feature lost the ability to put arbitrary HTML in a headline. As far as I know, not one person has complained.

3. Rich text became structured instead of trusted

A handful of editorial modules did legitimately need emphasis, links, and line breaks. Those moved to a restricted document format rather than raw HTML:

{
  "type": "heading",
  "children": [
    { "type": "text", "value": "Build " },
    { "type": "emphasis", "value": "smaller" },
    { "type": "text", "value": " systems" }
  ]
}

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.

4. The policy started blocking

The homepage CSP moved from report-only to enforced, nonce-based. The production policy is more involved than this, but the relevant properties:

script-src used nonces for application scripts
inline event handlers were blocked
object-src was none
base-uri was restricted
third-party script origins were reduced
report-only remained as a second policy for future tightening

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.

5. Sensitive actions got a second boundary

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.

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.

HttpOnly 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.

6. Cache invalidation became part of incident response

The thirty seconds where my deleted campaign kept serving from the edge turned into its own workstream:

  • a campaign-to-cache-key index
  • immediate purge on suspension or deletion
  • shorter emergency TTLs for server-driven modules
  • a global module kill switch
  • audit logging for every transition into a public surface
  • a scanner looking for executable markup in cached homepage fragments

The cache wasn’t the root cause. It was most of the blast radius, which made it part of the fix.

Retest

The patched build arrived nine days later. Nine days is fast for something touching this many services, and I said so.

The original request now behaves like this:

surface = HOME_DISCOVERY
  → rejected

audience = ALL
  → rejected

headline contains HTML
  → stored as text

internal editorial rich text
  → parsed into allowlisted nodes

legacy HTML fragment
  → sanitized after interpolation

inline execution canary
  → absent from DOM; CSP blocks execution path

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:

  1. Create an ordinary creator campaign.
  2. Confirm it appears only on the creator profile.
  3. Try to change its surface through both create and update mutations.
  4. Confirm the server ignores or rejects policy fields in both.
  5. Insert harmless HTML; verify it renders as text.
  6. Use a company-provided internal test campaign carrying the old redacted canary.
  7. Confirm the sanitizer strips the executable portion.
  8. Confirm the enforced CSP logs and blocks the attempt.
  9. Suspend the campaign and verify edge caches purge immediately.

Resolved on October 26. The whole thing, start to finish:

October 14  23:52  Hidden homepage surface accepted
October 15  00:18  XSS confirmed for owned test audience
October 15  00:41  Global placement confirmed with plain text
October 15  01:07  Report submitted
October 15  01:46  Marked critical
October 15  02:03  Homepage module disabled and purge started
October 15  03:12  Creator API hotfix verified
October 20          Rendering and CSP fixes deployed
October 24          Retest completed
October 26          Report resolved; $BOUNTY awarded

The part I keep thinking about

Months later, the thing that stays with me isn’t the payload or the bounty. It’s how ordinary every individual decision was.

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.

Read these one at a time and every single one is fine:

Creators may create campaigns.
The homepage may render campaigns.
Marketing templates may contain formatting.
Sanitized HTML may be inserted into the DOM.
Public homepage HTML may be cached.
HttpOnly cookies may not be read by JavaScript.

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.

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.

The cookie stayed secret the entire time. Its authority didn’t.

The cookie was HttpOnly. The session wasn’t.