SecurityStored XSSSocial engineeringIncident mapping

The Ghost CMS / ClickFix Campaign, Mapped to DaloyJS — Plus the One Default We Just Tightened

A pre-auth SQL injection in Ghost CMS (CVE-2026-26980) is being exploited at scale to hijack 700+ sites — including Harvard, Oxford, and DuckDuckGo — and serve a fake Cloudflare "verify you are human" prompt that silently stuffs a PowerShell one-liner into the visitor's clipboard. Most of the chain was already blocked by DaloyJS defaults; the last mile (the clipboard write) wasn't. Here's the stage-by-stage mapping and the one-line default we changed in response.

Devlin DuldulaoFullstack cloud engineer8 min read

Same question, different week. A reader sent over BleepingComputer's write-up of the Ghost CMS / ClickFix campaign and asked: are we doing anything about this — and if not, can we?I love that question because it forces me to actually look at the framework instead of telling myself nice stories about it. So I looked. The answer was "mostly yes, but there was one default I hadn't closed" — which is a polite way of saying I missed it. This post is the stage-by-stage mapping plus the one-line change I shipped to secureHeaders() after I stopped feeling embarrassed.

Short version of the incident, for context: XLab researchers found a campaign exploiting CVE-2026-26980 — a pre-auth SQL injection in Ghost CMS 3.24.0 → 6.19.0 — across more than 700 domains, including Harvard, Oxford, Auburn, and DuckDuckGo. Ghost shipped the fix in 6.19.1 back on February 19, 2026. Three months later, plenty of sites were still on the vulnerable version. I've been the person who didn't patch in time before. It happens to everyone exactly once before they automate it.

The attack chain has five distinct stages:

  1. SQLi — read arbitrary rows from the Ghost database, including the admin API keys.
  2. Privilege escalation via stolen API key — use the admin key to log into the admin API as a manager.
  3. Stored XSS — inject <script> tags into published articles.
  4. Fake Cloudflare iframe— overlay a "Verify you are human" prompt loaded from attacker infrastructure.
  5. ClickFix clipboard stuffing — when the visitor clicks the fake checkbox, silently call navigator.clipboard.writeText() with a PowerShell payload and instruct the victim to paste it into Win+R.

DaloyJS isn't a CMS — it's an HTTP framework — so stages 1 and 2 only matter for users who build a Ghost-shaped app on top of Daloy. Stages 3, 4, and 5 matter for anyHTML surface Daloy serves. Here's how each stage maps to what was already in the box, and the one default we tightened in response to stage 5.

Stage 1 — Pre-auth SQL injection

Database read via injection
What happened in Ghost
A query parameter on a public Ghost endpoint was concatenated into a SQL string. Unauthenticated attackers could dump arbitrary tables, including 'mobiledoc_revisions' and the row holding admin API keys.
DaloyJS posture
DaloyJS doesn't ship an ORM (zero runtime deps in @daloyjs/core), but every docs example uses parameterised queries — pg tagged templates, postgres.js, better-sqlite3 prepared statements, or Prisma. 'website/app/docs/security/sql-injection' walks through each, and explicitly calls out the 'knex.raw(`${input}`)' template-literal footgun. The Standard Schema .strict() validator on params/query/body rejects unknown shapes before the handler runs, which removes the 'unexpected JSON in a query param' attack surface that often leads to SQLi in the first place.
typescript
// What CVE-2026-26980 looked like: a query parameter on Ghost's
// admin API was concatenated straight into a SQL string. DaloyJS
// doesn't ship its own ORM (zero runtime deps, by design), but every
// docs example uses parameterized queries. The 'website/app/docs/
// security/sql-injection' page walks through pg, postgres, better-
// sqlite3, and Prisma.
import { App } from "@daloyjs/core";
import postgres from "postgres";
import { z } from "zod";

const sql = postgres(process.env.DATABASE_URL!);
const app = new App();

app.route({
  method: "GET",
  path: "/posts/:slug",
  operationId: "getPost",
  // .strict() rejects unknown query params before the handler ever sees them.
  params: z.object({ slug: z.string().min(1).max(120).strict() }),
  responses: { 200: { description: "ok" } },
  handler: async ({ params }) => {
    // Tagged template = parameterised. The slug is bound, not interpolated.
    const rows = await sql`select id, title from posts where slug = ${params.slug}`;
    return { status: 200 as const, body: rows[0] ?? null };
  },
});

Stage 2 — Stolen admin API key

Privilege escalation via leaked credential
What happened in Ghost
Ghost's admin API keys were sitting in the table the SQLi could read, and the API verified them with a non-constant-time string compare. Once stolen, the keys gave full management access — create users, edit themes, publish posts.
DaloyJS posture
Every bytes-vs-bytes comparison in the framework goes through 'timingSafeEqual' — enforced by 'verify:secret-comparisons' at publish time, so we structurally can't reintroduce the bug. JWT verification ships an algorithm allowlist (no 'alg: none', no HS256-vs-RS256 confusion), a 2048-bit RSA floor ('weak_rsa_key'), JWKS rotation with kid-pinning, and an 'isRevoked' hook that runs LAST so forged tokens never enumerate the blocklist. The 'assertStrongSecret' guard refuses weak HMAC secrets at boot.
typescript
// What lets Ghost-style "steal the admin API key, then publish posts
// with malicious JS" not work against a DaloyJS admin route.
import { createJwtVerifier } from "@daloyjs/core";

const verifier = createJwtVerifier({
  // 1. Alg allowlist — no 'alg: none', no HS256-from-RS256 confusion.
  algorithms: ["RS256"],
  // 2. JWKS rotation with kid-pinning (src/jwk.ts).
  jwksUrl: "https://idp.example.com/.well-known/jwks.json",
  // 3. RSA keys < 2048 bits refused at construction (weak_rsa_key).
  // 4. Revocation hook — point at Redis/DB; runs LAST so forged tokens
  //    never get to enumerate the blocklist.
  isRevoked: async (verified) => redis.sIsMember("revoked:jti", verified.jti),
  issuer: "https://idp.example.com/",
  audience: "https://api.example.com",
});

// 5. All bytes-vs-bytes comparisons go through timingSafeEqual (gated
//    by verify:secret-comparisons), so leaked-key timing oracles don't
//    work either.

Stage 3 — Stored XSS via admin API

Injected <script> on every article view
What happened in Ghost
With the admin key in hand, attackers edited live articles and embedded a lightweight loader script. Because Ghost renders post bodies as HTML, the script ran in the site's own origin on every page view.
DaloyJS posture
The default 'content-security-policy: default-src \\'self\\'; frame-ancestors \\'none\\'' refuses inline scripts and cross-origin script sources out of the box. The CSP nonce + Trusted Types path ('csp-nonces-and-trusted-types-without-tears' blog post) lets you serve necessary inline scripts without 'unsafe-inline'. 'frame-ancestors \\'none\\'' blocks the page from being embedded by an attacker, and the dual-knob refuse-to-boot guard in secureHeaders() refuses to construct if you disable BOTH X-Frame-Options AND frame-ancestors at once. For user-generated HTML specifically, the response-body schema validator + .strict() on body params + 'isForbiddenObjectKey' parser guard reduce the surface where unsanitised HTML can sneak in.

Stage 4 — Fake Cloudflare iframe overlay

Cross-origin iframe loaded over the article
What happened in Ghost
The injected loader fetched a second-stage script that built an iframe pointing at the attacker's 'verify-you-are-human' page and overlaid it on top of the article.
DaloyJS posture
The default CSP 'frame-ancestors none' stops attacker pages from embedding YOUR Daloy app. The mirror — stopping YOUR Daloy app from embedding attacker pages — is a one-line opt-in: pass a 'frame-src' allowlist (e.g. just 'self') in secureHeaders() contentSecurityPolicy directives. Combined with COOP: same-origin and CORP: same-origin (both defaults), this neutralises the cross-window communication channel the overlay needs.

Stage 5 — ClickFix clipboard stuffing

navigator.clipboard.writeText() called silently
What happened in Ghost
When the visitor clicks the fake 'Verify you are human' checkbox (which counts as user activation), the page silently calls navigator.clipboard.writeText() with a base64-encoded PowerShell one-liner, then displays 'Press Win+R, paste, hit Enter'. Most victims paste without reading. I would probably paste without reading on a bad day too.
DaloyJS posture
This is the gap I missed. CSP doesn't cover the Clipboard API — it controls WHERE script can come from, not WHAT script can do once it's running. The right defence is the Permissions-Policy header, and 'clipboard-write' has been in the spec for years. I just hadn't put it in the default string. Now I have: secureHeaders() ships 'clipboard-write=()' alongside the existing camera/microphone/geolocation denials. Override only if your HTML surface legitimately needs 'Copy' buttons.
javascript
// In the Ghost CMS / ClickFix attack chain, this is the line that does
// the damage. After the visitor clicks the fake "Verify you are human"
// checkbox, the injected script silently runs:
await navigator.clipboard.writeText(
  "powershell -nop -w hidden -e <base64 blob>"
);
// The victim is then told to press Win+R, paste, and hit Enter — and
// they run the attacker's command without ever seeing a prompt.
//
// With 'permissions-policy: clipboard-write=()' set on the parent
// document, the browser rejects the call with a SecurityError before a
// single byte hits the clipboard. The same rule applies inside any
// iframe loaded into that page, because iframes can never escalate
// past the parent's Permissions-Policy.

The new default, in full

text
# What 'app.use(secureHeaders())' actually sends — auto-installed on
# every App() unless you pass 'secureDefaults: false'.
content-security-policy: default-src 'self'; frame-ancestors 'none'
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: no-referrer
permissions-policy: camera=(), microphone=(), geolocation=(), clipboard-write=()
cross-origin-opener-policy: same-origin
cross-origin-resource-policy: same-origin

The only changed line is the permissions-policy one; everything else has been the default for releases. With clipboard-write=() in place, even if attacker JS slips past your CSP and runs in your origin, calling navigator.clipboard.writeText() throws a SecurityErrorbefore a single byte hits the clipboard. Iframes inherit the parent's Permissions-Policy and can never escalate past it, so the "fake Cloudflare iframe" from stage 4 also can't fall back to writing its own clipboard if it ever managed to load.

If your app is a CMS, an admin UI, or anything else where users click "Copy" buttons, opt back in explicitly — the override fully replaces the default (no merging), so be deliberate:

typescript
import { secureHeaders } from "@daloyjs/core";

app.use(secureHeaders({
  // Keep the camera/mic/geo denials, allow clipboard-write to self.
  permissionsPolicy:
    "camera=(), microphone=(), geolocation=(), clipboard-write=(self)",
}));

What this attack would have needed to do on a DaloyJS app

  1. Hand-roll a SQL string with user input — the docs example uses parameterised queries, and .strict() schemas reject the unexpected-shape inputs that often start SQLi.
  2. Compare the stolen admin key with === instead of timingSafeEqual — blocked by verify:secret-comparisons at publish time.
  3. Render attacker HTML without CSP — the default default-src 'self' + Trusted Types path refuses inline + cross-origin scripts.
  4. Let attacker iframes load — opt in to frame-src allowlist; frame-ancestors 'none' already stops the inverse (your page being embedded).
  5. Allow navigator.clipboard.writeText() from injected JS — blocked by the new permissions-policy: clipboard-write=() default.

What you should do in your own DaloyJS app

  • Update to the release that ships this default and don't override permissionsPolicy unless you genuinely need clipboard write. The override fully replaces the default, so re-list the camera/mic/geo denials if you set your own string.
  • For HTML routes that render user-generated content (comments, wikis, articles), turn on the CSP nonce + Trusted Types path (see the CSP nonces post) and serve user HTML with a sanitiser like DOMPurify on the server.
  • Use parameterised queries everywhere. The SQL injection page shows the four common drivers and the one template-literal footgun to avoid.
  • If you mint admin API keys, run them through timingSafeEqual on every check and put a short-lived JWT on top with the algorithm allowlist + revocation hook. Long-lived shared secrets that compare with === are exactly what made the Ghost compromise so destructive.

The honest answer to the original question

Are we doing anything to protect ourselves and the users of our framework against the Ghost CMS / ClickFix campaign? Stages 1 through 4 were already covered — parameterised queries in every docs example, timingSafeEqual + JWT alg allowlist + revocation hook, CSP default-src 'self' with the Trusted-Types path, and frame-ancestors 'none'. Stage 5 — the silent clipboard write that makes the whole social engineering trick land — wasn't. So I changed the default. Add clipboard-write=() to the Permissions-Policy string, write the regression test, document the override pattern, ship it.

That's the whole job of secure-by-default in my head: when the threat model moves, the default moves with it, and apps that already trust the framework inherit the fix on the next dependency bump without reading a CVE or running a migration script. If you have to read a security blog before your app is safe, the framework already failed you. I'd rather feel a little dumb for missing this one line than ship a framework that quietly leaves it to you.

Related reading on this blog: CSP nonces and Trusted Types without tears, Aikido top 10 mapped to DaloyJS, LiteLLM / TeamPCP mapped to DaloyJS, Secure by default. Relevant docs: /docs/security, SQL injection.