Skip to content

Commit e4e76f5

Browse files
authored
fix(comments): verify Turnstile tokens server-side on comment submission (#1854)
* fix(comments): verify Turnstile tokens server-side on comment submission The CommentForm widget rendered Turnstile and submitted its token, but the public POST endpoint never verified it — bots POSTing directly to the API bypassed the challenge entirely (#1589). When a Turnstile secret key is configured (EMDASH_TURNSTILE_SECRET_KEY or TURNSTILE_SECRET_KEY), the route now verifies the token against Cloudflare's siteverify API before persisting and rejects submissions without a valid token (fails closed on siteverify errors too). Without a configured secret the behavior is unchanged. Also resets the Turnstile widget after failed submissions so a retry gets a fresh single-use token. * fix(comments): timeout siteverify, reset Turnstile on network errors, test IP forwarding Review follow-ups: abort the siteverify subrequest after 10s so a slow Cloudflare API fails closed instead of hanging the comment POST, move the widget reset into finally so network errors also produce a fresh single-use token, and add a test asserting the trusted remote IP is forwarded to siteverify.
1 parent 1526f96 commit e4e76f5

7 files changed

Lines changed: 304 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Fixes comment submissions bypassing Turnstile: when the `EMDASH_TURNSTILE_SECRET_KEY` (or `TURNSTILE_SECRET_KEY`) environment variable is set, the public comment endpoint now verifies the submitted Turnstile token server-side and rejects submissions without a valid one. Sites without a configured secret key are unaffected.

docs/src/content/docs/reference/configuration.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,7 @@ EmDash respects these environment variables:
646646
| `EMDASH_PREVIEW_SECRET` | Optional override for preview HMAC secret. When unset, a stable per-site value is generated and stored in the database. |
647647
| `EMDASH_IP_SALT` | Optional override for the commenter-IP hash salt. When unset, a stable per-site value is generated and stored in the database. |
648648
| `EMDASH_AUTH_SECRET` | Legacy. Used as the IP-salt source if set; existing installs should keep this to preserve stable commenter-IP hashes across upgrade. |
649+
| `EMDASH_TURNSTILE_SECRET_KEY` | Cloudflare Turnstile secret key (falls back to `TURNSTILE_SECRET_KEY`). When set, comment submissions must include a valid Turnstile token — pair it with the `turnstileSiteKey` prop on `<CommentForm>`. |
649650
| `EMDASH_URL` | Remote EmDash URL for schema sync |
650651

651652
Generate an encryption key with the following command:

packages/core/src/api/schemas/comments.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ export const createCommentBody = z
1212
parentId: z.string().optional(),
1313
/** Honeypot field — hidden in the form, filled only by bots */
1414
website_url: z.string().optional(),
15+
/**
16+
* Turnstile response token from the form widget. Verified
17+
* server-side when a Turnstile secret key is configured.
18+
* Turnstile tokens can be up to 2048 characters.
19+
*/
20+
turnstileToken: z.string().max(2048).optional(),
1521
})
1622
.meta({ id: "CreateCommentBody" });
1723

packages/core/src/astro/routes/api/comments/[collection]/[contentId]/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createCommentBody } from "#api/schemas.js";
1414
import { getSiteBaseUrl } from "#api/site-url.js";
1515
import { sendCommentNotification } from "#comments/notifications.js";
1616
import { createComment, type CommentHookRunner } from "#comments/service.js";
17+
import { getTurnstileSecretKey, verifyTurnstileToken } from "#comments/turnstile.js";
1718
import { resolveSecretsCached } from "#config/secrets.js";
1819
import { CommentRepository } from "#db/repositories/comment.js";
1920
import { validateIdentifier } from "#db/validate.js";
@@ -163,6 +164,18 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
163164
return apiError("RATE_LIMITED", "Too many comments. Please try again later.", 429);
164165
}
165166

167+
// Anti-spam: Turnstile — enforced only when the operator configured a
168+
// secret key. Runs after the free checks (honeypot, rate limit) so
169+
// obvious spam never triggers a siteverify subrequest. Fails closed:
170+
// missing token, failed verification, and siteverify errors all reject.
171+
const turnstileSecretKey = getTurnstileSecretKey();
172+
if (turnstileSecretKey) {
173+
const verified = await verifyTurnstileToken(body.turnstileToken, turnstileSecretKey, meta.ip);
174+
if (!verified) {
175+
return apiError("TURNSTILE_FAILED", "CAPTCHA verification failed", 403);
176+
}
177+
}
178+
166179
// Build collection settings
167180
const collectionSettings: CollectionCommentSettings = {
168181
commentsEnabled: collectionRow.comments_enabled === 1,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Server-side Turnstile verification for public comment submissions.
3+
*
4+
* The comment form widget (`CommentForm.astro`) submits a `turnstileToken`;
5+
* this verifies it against Cloudflare's siteverify API. Enforcement is
6+
* opt-in: it only runs when the operator configures the Turnstile secret
7+
* key (`EMDASH_TURNSTILE_SECRET_KEY` or `TURNSTILE_SECRET_KEY`), so
8+
* existing non-Turnstile sites are unaffected.
9+
*
10+
* Mirrors `verifyTurnstile` in `@emdash-cms/plugin-forms` (not imported —
11+
* core doesn't depend on plugin packages).
12+
*/
13+
14+
const SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
15+
16+
/**
17+
* Resolve the configured Turnstile secret key, or `""` when Turnstile
18+
* enforcement is not configured.
19+
*/
20+
export function getTurnstileSecretKey(): string {
21+
return import.meta.env.EMDASH_TURNSTILE_SECRET_KEY || import.meta.env.TURNSTILE_SECRET_KEY || "";
22+
}
23+
24+
/**
25+
* Verify a Turnstile response token via siteverify.
26+
*
27+
* Fails closed: a missing token, a failed verification, and a siteverify
28+
* transport error all return `false` — when the operator has configured a
29+
* secret, an unverifiable submission must not be persisted.
30+
*/
31+
export async function verifyTurnstileToken(
32+
token: string | undefined,
33+
secretKey: string,
34+
remoteIp?: string | null,
35+
): Promise<boolean> {
36+
if (!token) return false;
37+
38+
const body: Record<string, string> = { secret: secretKey, response: token };
39+
if (remoteIp) {
40+
body.remoteip = remoteIp;
41+
}
42+
43+
try {
44+
const res = await fetch(SITEVERIFY_URL, {
45+
method: "POST",
46+
headers: { "Content-Type": "application/json" },
47+
body: JSON.stringify(body),
48+
// Fail closed *quickly* if siteverify is slow — without a timeout
49+
// the comment POST would hang until the runtime kills it
50+
signal: AbortSignal.timeout(10_000),
51+
});
52+
const data: { success?: boolean; "error-codes"?: string[] } = await res.json();
53+
if (!data.success) {
54+
console.warn("[comments] Turnstile verification failed:", data["error-codes"] ?? []);
55+
}
56+
return data.success === true;
57+
} catch (error) {
58+
console.error(
59+
"[comments] Turnstile siteverify request failed:",
60+
error instanceof Error ? error.message : error,
61+
);
62+
return false;
63+
}
64+
}

packages/core/src/components/CommentForm.astro

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ interface Props {
2222
contentId: string;
2323
parentId?: string | null;
2424
class?: string;
25-
/** Turnstile site key. If provided, the widget is rendered. */
25+
/**
26+
* Turnstile site key. If provided, the widget is rendered and its
27+
* token is submitted. Set the matching secret key on the server
28+
* (EMDASH_TURNSTILE_SECRET_KEY) so submissions are actually verified —
29+
* without it the token is ignored.
30+
*/
2631
turnstileSiteKey?: string;
2732
}
2833
@@ -182,10 +187,6 @@ const endpoint = `/_emdash/api/comments/${encodeURIComponent(collection)}/${enco
182187
"textarea[name='body']"
183188
);
184189
if (textarea) textarea.value = "";
185-
// Reset Turnstile if present
186-
if (typeof window.turnstile !== "undefined") {
187-
window.turnstile.reset();
188-
}
189190
} else {
190191
statusEl.textContent =
191192
result.error?.message ||
@@ -197,6 +198,12 @@ const endpoint = `/_emdash/api/comments/${encodeURIComponent(collection)}/${enco
197198
statusEl.textContent = "Network error. Please try again.";
198199
statusEl.classList.add("ec-comment-form-error");
199200
} finally {
201+
// Reset Turnstile on success and failure alike (incl. network
202+
// errors) — tokens are single-use, so any retry needs a fresh
203+
// challenge
204+
if (typeof window.turnstile !== "undefined") {
205+
window.turnstile.reset();
206+
}
200207
submitBtn.disabled = false;
201208
submitBtn.textContent = "Post Comment";
202209
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/**
2+
* Server-side Turnstile enforcement on
3+
* POST /_emdash/api/comments/:collection/:contentId (issue #1589).
4+
*
5+
* The form widget submits a `turnstileToken`, but the route previously
6+
* never verified it — a bot POSTing directly to the API bypassed
7+
* Turnstile entirely. When a secret key is configured
8+
* (EMDASH_TURNSTILE_SECRET_KEY / TURNSTILE_SECRET_KEY), the route must
9+
* verify the token via Cloudflare's siteverify before persisting, and
10+
* reject submissions without a valid token. Without a configured secret
11+
* the behavior is unchanged (backward compatible).
12+
*/
13+
14+
import type { APIContext } from "astro";
15+
import type { Kysely } from "kysely";
16+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17+
18+
import { POST as postComment } from "../../../src/astro/routes/api/comments/[collection]/[contentId]/index.js";
19+
import type { Database } from "../../../src/database/types.js";
20+
import { SchemaRegistry } from "../../../src/schema/registry.js";
21+
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
22+
23+
function buildRequest(body: Record<string, unknown>, headers?: Record<string, string>): Request {
24+
return new Request("http://localhost/_emdash/api/comments/post/post-1", {
25+
method: "POST",
26+
headers: { "content-type": "application/json", ...headers },
27+
body: JSON.stringify(body),
28+
});
29+
}
30+
31+
function buildContext(
32+
db: Kysely<Database>,
33+
request: Request,
34+
config: Record<string, unknown> = {},
35+
): APIContext {
36+
return {
37+
params: { collection: "post", contentId: "post-1" },
38+
request,
39+
locals: {
40+
emdash: {
41+
db,
42+
config,
43+
hooks: {
44+
runCommentBeforeCreate: async (event: unknown) => event,
45+
invokeExclusiveHook: async () => null,
46+
runCommentAfterCreate: async () => undefined,
47+
},
48+
},
49+
user: null,
50+
},
51+
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal stub for tests
52+
} as unknown as APIContext;
53+
}
54+
55+
const VALID_BODY = {
56+
authorName: "Alice",
57+
authorEmail: "alice@example.com",
58+
body: "Nice post!",
59+
};
60+
61+
/** Stub global fetch for the siteverify call; returns the spy. */
62+
function stubSiteverify(success: boolean) {
63+
const fetchSpy = vi.fn(async () =>
64+
Response.json(success ? { success: true } : { success: false, "error-codes": ["bad-token"] }),
65+
);
66+
vi.stubGlobal("fetch", fetchSpy);
67+
return fetchSpy;
68+
}
69+
70+
describe("POST /comments — Turnstile verification", () => {
71+
let db: Kysely<Database>;
72+
73+
beforeEach(async () => {
74+
db = await setupTestDatabase();
75+
const registry = new SchemaRegistry(db);
76+
await registry.createCollection({
77+
slug: "post",
78+
label: "Posts",
79+
labelSingular: "Post",
80+
commentsEnabled: true,
81+
});
82+
await registry.createField("post", { slug: "title", label: "Title", type: "string" });
83+
await db
84+
.insertInto("ec_post" as never)
85+
.values({
86+
id: "post-1",
87+
slug: "post-1",
88+
status: "published",
89+
published_at: new Date().toISOString(),
90+
title: "Test post",
91+
} as never)
92+
.execute();
93+
});
94+
95+
afterEach(async () => {
96+
await teardownTestDatabase(db);
97+
vi.unstubAllEnvs();
98+
vi.unstubAllGlobals();
99+
});
100+
101+
async function commentCount(): Promise<number> {
102+
const rows = await db.selectFrom("_emdash_comments").select("id").execute();
103+
return rows.length;
104+
}
105+
106+
it("rejects a submission without a token when a secret is configured", async () => {
107+
vi.stubEnv("EMDASH_TURNSTILE_SECRET_KEY", "test-secret");
108+
const fetchSpy = stubSiteverify(true);
109+
110+
const res = await postComment(buildContext(db, buildRequest(VALID_BODY)));
111+
112+
expect(res.status).toBe(403);
113+
// No siteverify subrequest for a missing token
114+
expect(fetchSpy).not.toHaveBeenCalled();
115+
expect(await commentCount()).toBe(0);
116+
});
117+
118+
it("rejects a submission whose token fails siteverify", async () => {
119+
vi.stubEnv("EMDASH_TURNSTILE_SECRET_KEY", "test-secret");
120+
const fetchSpy = stubSiteverify(false);
121+
122+
const res = await postComment(
123+
buildContext(db, buildRequest({ ...VALID_BODY, turnstileToken: "forged" })),
124+
);
125+
126+
expect(res.status).toBe(403);
127+
expect(fetchSpy).toHaveBeenCalledOnce();
128+
expect(await commentCount()).toBe(0);
129+
});
130+
131+
it("accepts a submission whose token passes siteverify", async () => {
132+
vi.stubEnv("EMDASH_TURNSTILE_SECRET_KEY", "test-secret");
133+
const fetchSpy = stubSiteverify(true);
134+
135+
const res = await postComment(
136+
buildContext(db, buildRequest({ ...VALID_BODY, turnstileToken: "valid-token" })),
137+
);
138+
139+
expect(res.status).toBe(201);
140+
expect(fetchSpy).toHaveBeenCalledOnce();
141+
// The secret and token must reach siteverify
142+
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- shape fixed by the code under test
143+
const [url, init] = fetchSpy.mock.calls[0] as unknown as [string, { body: string }];
144+
expect(url).toBe("https://challenges.cloudflare.com/turnstile/v0/siteverify");
145+
expect(JSON.parse(init.body)).toMatchObject({
146+
secret: "test-secret",
147+
response: "valid-token",
148+
});
149+
expect(await commentCount()).toBe(1);
150+
});
151+
152+
it("forwards the trusted remote IP to siteverify", async () => {
153+
vi.stubEnv("EMDASH_TURNSTILE_SECRET_KEY", "test-secret");
154+
const fetchSpy = stubSiteverify(true);
155+
156+
const request = buildRequest(
157+
{ ...VALID_BODY, turnstileToken: "valid-token" },
158+
{ "x-forwarded-for": "203.0.113.45" },
159+
);
160+
const res = await postComment(
161+
buildContext(db, request, { trustedProxyHeaders: ["x-forwarded-for"] }),
162+
);
163+
164+
expect(res.status).toBe(201);
165+
expect(fetchSpy).toHaveBeenCalledOnce();
166+
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- shape fixed by the code under test
167+
const [, init] = fetchSpy.mock.calls[0] as unknown as [string, { body: string }];
168+
expect(JSON.parse(init.body)).toMatchObject({
169+
secret: "test-secret",
170+
response: "valid-token",
171+
remoteip: "203.0.113.45",
172+
});
173+
});
174+
175+
it("fails closed when siteverify itself errors", async () => {
176+
vi.stubEnv("EMDASH_TURNSTILE_SECRET_KEY", "test-secret");
177+
vi.stubGlobal(
178+
"fetch",
179+
vi.fn(async () => {
180+
throw new Error("network down");
181+
}),
182+
);
183+
184+
const res = await postComment(
185+
buildContext(db, buildRequest({ ...VALID_BODY, turnstileToken: "valid-token" })),
186+
);
187+
188+
expect(res.status).toBe(403);
189+
expect(await commentCount()).toBe(0);
190+
});
191+
192+
it("ignores the token when no secret is configured (backward compatible)", async () => {
193+
const fetchSpy = stubSiteverify(true);
194+
195+
const res = await postComment(
196+
buildContext(db, buildRequest({ ...VALID_BODY, turnstileToken: "anything" })),
197+
);
198+
199+
expect(res.status).toBe(201);
200+
expect(fetchSpy).not.toHaveBeenCalled();
201+
expect(await commentCount()).toBe(1);
202+
});
203+
});

0 commit comments

Comments
 (0)