Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3cec1dc
Added implementation plan for Cloudflare Turnstile signup protection
sam-lord Jul 6, 2026
230de41
Updated Turnstile plan with integration points from hCaptcha cleanup …
sam-lord Jul 6, 2026
c8a93f7
Added turnstile private labs flag
sam-lord Jul 6, 2026
2b32d55
Added turnstile_sitekey and turnstile_secret_key settings
sam-lord Jul 6, 2026
df52051
Added TurnstileService for verifying Cloudflare Turnstile tokens
sam-lord Jul 6, 2026
eca29e5
Added turnstile.siteverifyUrl config default
sam-lord Jul 6, 2026
108cf29
Added Turnstile verification to the send-magic-link endpoint
sam-lord Jul 6, 2026
a0830d3
Added Turnstile script injection to ghost_head
sam-lord Jul 6, 2026
cba5421
Updated Turnstile plan with passed srcDoc iframe spike results
sam-lord Jul 6, 2026
f1bb55e
Added Turnstile overlay helper and site helpers to Portal
sam-lord Jul 6, 2026
f18712b
Added Turnstile verification to Portal signup and signin flows
sam-lord Jul 6, 2026
eb81cfa
Added Turnstile verification to data-attribute signup forms
sam-lord Jul 6, 2026
76a9416
Added translations for Turnstile verification error messages
sam-lord Jul 6, 2026
59ba4cd
Added Turnstile settings to the Spam filters group in Admin
sam-lord Jul 6, 2026
e3de979
Updated Turnstile plan with full test sweep results
sam-lord Jul 6, 2026
43560fc
Moved turnstile setting migrations to 6.51 after rebasing onto main
sam-lord Jul 6, 2026
6ee04f8
Fixed CI failures from the new Turnstile settings
sam-lord Jul 7, 2026
e4d56b8
Bumped Ghost to 6.52.0-rc.0
sam-lord Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ const features: Feature[] = [{
title: 'Get helper deduplication',
description: 'Deduplicate identical {{#get}} helper queries within a single request to avoid redundant database calls',
flag: 'getHelperDeduplication'
}, {
title: 'Cloudflare Turnstile',
description: 'Protect member signup and signin with Cloudflare Turnstile bot verification',
flag: 'turnstile'
}];

const AlphaFeatures: React.FC = () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import React from 'react';
import TopLevelGroup from '../../top-level-group';
import useFeatureFlag from '../../../hooks/use-feature-flag';
import useSettingGroup from '../../../hooks/use-setting-group';
import {SettingGroupContent, TextArea, withErrorBoundary} from '@tryghost/admin-x-design-system';
import {Banner, Separator, SettingGroupContent, TextArea, TextField, withErrorBoundary} from '@tryghost/admin-x-design-system';
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';

const SpamFilters: React.FC<{ keywords: string[] }> = ({keywords}) => {
const hasTurnstileFlag = useFeatureFlag('turnstile');

const {
localSettings,
isEditing,
Expand All @@ -17,6 +20,11 @@ const SpamFilters: React.FC<{ keywords: string[] }> = ({keywords}) => {
handleEditingChange
} = useSettingGroup({
onValidate: () => {
const [sitekey, secretKey] = getSettingValues(localSettings, ['turnstile_sitekey', 'turnstile_secret_key']) as string[];
if (hasTurnstileFlag && !!sitekey !== !!secretKey) {
const message = 'Enter both a site key and a secret key to enable Turnstile, or clear both to disable it';
return sitekey ? {turnstileSecretKey: message} : {turnstileSitekey: message};
}
return {};
}
});
Expand All @@ -25,6 +33,8 @@ const SpamFilters: React.FC<{ keywords: string[] }> = ({keywords}) => {
const initialBlockedEmailDomains = JSON.parse(initialBlockedEmailDomainsJSON || '[]') as string[];
const [blockedEmailDomains, setBlockedEmailDomains] = React.useState(initialBlockedEmailDomains.join('\n'));

const [turnstileSitekey, turnstileSecretKey] = getSettingValues(localSettings, ['turnstile_sitekey', 'turnstile_secret_key']) as string[];

const updateBlockedEmailDomainsSetting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const input = e.target.value;
setBlockedEmailDomains(input);
Expand All @@ -41,12 +51,26 @@ const SpamFilters: React.FC<{ keywords: string[] }> = ({keywords}) => {
}
};

const updateTurnstileSetting = (key: 'turnstile_sitekey' | 'turnstile_secret_key', e: React.ChangeEvent<HTMLInputElement>) => {
updateSetting(key, e.target.value.trim() || null);

if (!isEditing) {
handleEditingChange(true);
}
};

const hint = (
<>
Prevent unwanted signups by blocking email domains. Add one domain per line, e.g., <code>spam.xyz</code> to block signups from email addresses like <code>hello@spam.xyz</code>.
</>
);

const turnstileHint = (
<>
Verify member signups and signins with <a className="text-green hover:text-green-400" href="https://developers.cloudflare.com/turnstile/" rel="noreferrer" target="_blank">Cloudflare Turnstile</a>. Create a widget in the Cloudflare dashboard to get a site key and secret key.
</>
);

return (
<TopLevelGroup
description='Protect your member signups from spam'
Expand All @@ -73,7 +97,39 @@ const SpamFilters: React.FC<{ keywords: string[] }> = ({keywords}) => {
onChange={updateBlockedEmailDomainsSetting}
onKeyDown={() => clearError('spam-filters')}
/>

{hasTurnstileFlag && (<>
<Separator className="border-grey-200 dark:border-grey-900" />
<div className="flex flex-col gap-4">
<div>
<div className="text-sm font-medium tracking-normal text-grey-900 dark:text-grey-400">Cloudflare Turnstile</div>
<div className="mt-1 text-xs text-grey-700">{turnstileHint}</div>
</div>
<Banner color='yellow' data-testid='turnstile-warning'>
While Turnstile is enabled, custom or third-party signup forms that post to the members API directly will stop working, and signup forms embedded on other websites only work if those domains are added to the widget&apos;s hostname allowlist in the Cloudflare dashboard.
</Banner>
Comment on lines +107 to +109
<TextField
error={!!errors.turnstileSitekey}
hint={errors.turnstileSitekey}
title='Turnstile site key'
value={turnstileSitekey || ''}
onChange={(e) => {
updateTurnstileSetting('turnstile_sitekey', e);
}}
onKeyDown={() => clearError('turnstileSitekey')}
/>
<TextField
error={!!errors.turnstileSecretKey}
hint={errors.turnstileSecretKey}
title='Turnstile secret key'
type='password'
value={turnstileSecretKey || ''}
onChange={(e) => {
updateTurnstileSetting('turnstile_secret_key', e);
}}
onKeyDown={() => clearError('turnstileSecretKey')}
/>
</div>
</>)}
</SettingGroupContent>
</TopLevelGroup>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {useGlobalData} from '../../providers/global-data-provider';

export const searchKeywords = {
access: ['membership', 'default', 'access', 'subscription', 'post', 'membership', 'comments', 'commenting', 'signup', 'sign up', 'spam', 'filters', 'prevention', 'prevent', 'block', 'domains', 'email', 'password protection', 'lock site', 'private site', 'private site mode', 'make this site private'],
spamFilters: ['membership', 'signup', 'sign up', 'spam', 'filters', 'prevention', 'prevent', 'block', 'domains', 'email', 'turnstile', 'captcha', 'bot', 'cloudflare'],
tiers: ['membership', 'tiers', 'payment', 'paid', 'stripe'],
portal: ['membership', 'portal', 'signup', 'sign up', 'signin', 'sign in', 'login', 'account', 'membership', 'support', 'email', 'address', 'support email address', 'support address'],
giftSubscriptions: ['membership', 'gift', 'gifts', 'gift subscriptions', 'present', 'share', 'shareable link'],
Expand All @@ -27,6 +28,7 @@ const MembershipSettings: React.FC = () => {
const hasAutomations = useFeatureFlag('automations');
const visibleSearchKeywords = [
searchKeywords.access,
searchKeywords.spamFilters,
searchKeywords.tiers,
searchKeywords.portal,
...(paidMembersEnabled ? [searchKeywords.giftSubscriptions] : []),
Expand All @@ -37,7 +39,7 @@ const MembershipSettings: React.FC = () => {
return (
<SearchableSection keywords={visibleSearchKeywords} title='Membership'>
<Access keywords={searchKeywords.access} />
<SpamFilters keywords={searchKeywords.access} />
<SpamFilters keywords={searchKeywords.spamFilters} />
<Tiers keywords={searchKeywords.tiers} />
<Portal keywords={searchKeywords.portal} />
{paidMembersEnabled && <GiftSubscriptions keywords={searchKeywords.giftSubscriptions} />}
Expand Down
101 changes: 100 additions & 1 deletion apps/admin-x-settings/test/acceptance/advanced/spam-filters.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {expect, test} from '@playwright/test';
import {globalDataRequests, mockApi, responseFixtures, updatedSettingsResponse} from '@tryghost/admin-x-framework/test/acceptance';
import {globalDataRequests, mockApi, responseFixtures, toggleLabsFlag, updatedSettingsResponse} from '@tryghost/admin-x-framework/test/acceptance';

test.describe('Spam prevention settings', async () => {
test('Supports adding blocked email domains', async ({page}) => {
Expand Down Expand Up @@ -109,5 +109,104 @@ test.describe('Spam prevention settings', async () => {
settings: [{key: 'blocked_email_domains', value: '["spam.xyz","junk.com"]'}]
});
});

test.describe('Turnstile', () => {
test('Is hidden when the labs flag is off', async ({page}) => {
toggleLabsFlag('turnstile', false);

await mockApi({page, requests: {
...globalDataRequests
}});

await page.goto('/');
const section = page.getByTestId('spam-filters');

await expect(section.getByLabel('Blocked email domains')).toBeVisible();
await expect(section.getByLabel('Turnstile site key')).toBeHidden();
await expect(section.getByLabel('Turnstile secret key')).toBeHidden();
});

test('Supports setting the Turnstile keys and shows the third-party form warning', async ({page}) => {
toggleLabsFlag('turnstile', true);

const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([
{key: 'turnstile_sitekey', value: '1x00000000000000000000BB'},
{key: 'turnstile_secret_key', value: 'test-secret-key'}
])}
}});

await page.goto('/');
const section = page.getByTestId('spam-filters');

await expect(section.getByText(/custom or third-party signup forms/)).toBeVisible();

await section.getByLabel('Turnstile site key').fill('1x00000000000000000000BB');
await section.getByLabel('Turnstile secret key').fill('test-secret-key');
await section.getByRole('button', {name: 'Save'}).click();

expect(lastApiRequests.editSettings?.body).toEqual({
settings: [
{key: 'turnstile_sitekey', value: '1x00000000000000000000BB'},
{key: 'turnstile_secret_key', value: 'test-secret-key'}
]
});
});

test('Requires both keys to be set together', async ({page}) => {
toggleLabsFlag('turnstile', true);

const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
editSettings: {method: 'PUT', path: '/settings/', response: responseFixtures.settings}
}});

await page.goto('/');
const section = page.getByTestId('spam-filters');

await section.getByLabel('Turnstile site key').fill('1x00000000000000000000BB');
await section.getByRole('button', {name: 'Save'}).click();

await expect(section.getByText(/Enter both a site key and a secret key/)).toBeVisible();
expect(lastApiRequests.editSettings).toBeUndefined();
});

test('Can clear both keys to disable Turnstile', async ({page}) => {
toggleLabsFlag('turnstile', true);

const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseSettings: {method: 'GET', path: /^\/settings\/\?group=/, response: {
meta: {...responseFixtures.settings.meta},
settings: [
...responseFixtures.settings.settings,
{key: 'turnstile_sitekey', value: '1x00000000000000000000BB'},
{key: 'turnstile_secret_key', value: '••••••••'}
]
}},
editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([
{key: 'turnstile_sitekey', value: null},
{key: 'turnstile_secret_key', value: null}
])}
}});

await page.goto('/');
const section = page.getByTestId('spam-filters');

await expect(section.getByLabel('Turnstile site key')).toHaveValue('1x00000000000000000000BB');

await section.getByLabel('Turnstile site key').fill('');
await section.getByLabel('Turnstile secret key').fill('');
await section.getByRole('button', {name: 'Save'}).click();

expect(lastApiRequests.editSettings?.body).toEqual({
settings: [
{key: 'turnstile_sitekey', value: null},
{key: 'turnstile_secret_key', value: null}
]
});
});
});
});

20 changes: 18 additions & 2 deletions apps/portal/src/actions.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import setupGhostApi from './utils/api';
import {chooseBestErrorMessage} from './utils/errors';
import {getGiftRedemptionErrorMessage, getGiftRedemptionSuccessMessage} from './utils/gift-redemption-notification';
import {createNotification, createPopupNotification, getMemberEmail, getMemberName, getProductCadenceFromPrice, removePortalLinkFromUrl, getRefDomain} from './utils/helpers';
import {createNotification, createPopupNotification, getMemberEmail, getMemberName, getProductCadenceFromPrice, removePortalLinkFromUrl, getRefDomain, hasTurnstileEnabled, getTurnstileSitekey} from './utils/helpers';
import {getTurnstileToken} from './utils/turnstile';
import {t} from './utils/i18n';

const CANNOT_CHECKOUT_WITH_EXISTING_SUBSCRIPTION = 'CANNOT_CHECKOUT_WITH_EXISTING_SUBSCRIPTION';

// Runs Turnstile verification inside the popup iframe's document, so the
// challenge overlay (shown only if Cloudflare requires interaction) appears
// over the popup content. Resolves to undefined when Turnstile is inactive.
async function getPopupTurnstileToken({state}) {
if (!hasTurnstileEnabled({site: state.site})) {
return undefined;
}
const popupFrame = document.querySelector('iframe[data-testid="portal-popup-frame"]');
const doc = popupFrame?.contentDocument || document;
return getTurnstileToken({doc, sitekey: getTurnstileSitekey({site: state.site})});
}

function switchPage({data, state}) {
return {
page: data.page,
Expand Down Expand Up @@ -109,11 +122,13 @@ async function signout({api, state}) {

async function signin({data, api, state}) {
try {
const turnstileToken = await getPopupTurnstileToken({state});
const integrityToken = await api.member.getIntegrityToken();
const payload = {
...data,
emailType: 'signin',
integrityToken,
turnstileToken,
includeOTC: true
};
const {otc_ref: otcRef, inboxLinks} = await api.member.sendMagicLink(payload);
Expand Down Expand Up @@ -191,8 +206,9 @@ async function signup({data, state, api}) {

let inboxLinks;
if (plan.toLowerCase() === 'free') {
const turnstileToken = await getPopupTurnstileToken({state});
const integrityToken = await api.member.getIntegrityToken();
({inboxLinks} = await api.member.sendMagicLink({emailType: 'signup', integrityToken, ...data, name}));
({inboxLinks} = await api.member.sendMagicLink({emailType: 'signup', integrityToken, turnstileToken, ...data, name}));
} else {
// An existing (logged-in) member starting a paid checkout is upgrading, not signing up,
// so flag it as an upgrade to suppress the signup email.
Expand Down
14 changes: 11 additions & 3 deletions apps/portal/src/data-attributes.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable no-console */
import {getCheckoutSessionDataFromPlanAttribute, getUrlHistory} from './utils/helpers';
import {getCheckoutSessionDataFromPlanAttribute, getUrlHistory, hasTurnstileEnabled, getTurnstileSitekey} from './utils/helpers';
import {getTurnstileToken} from './utils/turnstile';
import {HumanReadableError, chooseBestErrorMessage} from './utils/errors';
import {t} from './utils/i18n';

Expand All @@ -16,7 +17,7 @@ function handleError(error, form, errorEl) {
}

export async function formSubmitHandler(
{event, form, errorEl, siteUrl, submitHandler, doAction, captureException}
{event, form, errorEl, siteUrl, submitHandler, doAction, captureException, turnstileSitekey}
) {
form.removeEventListener('submit', submitHandler);
event.preventDefault();
Expand Down Expand Up @@ -80,6 +81,12 @@ export async function formSubmitHandler(
const integrityTokenRes = await fetch(`${siteUrl}/members/api/integrity-token/`, {method: 'GET'});
const integrityToken = await integrityTokenRes.text();

if (turnstileSitekey) {
// Runs the widget in the main page's document; the overlay only
// appears if Cloudflare requires interaction
reqBody.turnstileToken = await getTurnstileToken({doc: document, sitekey: turnstileSitekey});
}

const magicLinkRes = await fetch(`${siteUrl}/members/api/send-magic-link/`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
Expand Down Expand Up @@ -210,10 +217,11 @@ export function handleDataAttributes({siteUrl, site = {}, member, offers = [], d
}

siteUrl = siteUrl.replace(/\/$/, '');
const turnstileSitekey = hasTurnstileEnabled({site}) ? getTurnstileSitekey({site}) : null;
Array.prototype.forEach.call(document.querySelectorAll('form[data-members-form]'), function (form) {
let errorEl = form.querySelector('[data-members-error]');
function submitHandler(event) {
formSubmitHandler({event, errorEl, form, siteUrl, submitHandler, doAction, captureException});
formSubmitHandler({event, errorEl, form, siteUrl, submitHandler, doAction, captureException, turnstileSitekey});
}
form.addEventListener('submit', submitHandler);
});
Expand Down
3 changes: 2 additions & 1 deletion apps/portal/src/utils/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
* otc_ref?: string;
* }}
*/
async sendMagicLink({email, emailType, labels, name, oldEmail, newsletters, redirect, integrityToken, phonenumber, customUrlHistory, token, giftToken, autoRedirect = true, includeOTC}) {
async sendMagicLink({email, emailType, labels, name, oldEmail, newsletters, redirect, integrityToken, turnstileToken, phonenumber, customUrlHistory, token, giftToken, autoRedirect = true, includeOTC}) {
const url = endpointFor({type: 'members', resource: 'send-magic-link'});
const body = {
name,
Expand All @@ -334,6 +334,7 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
requestSrc: 'portal',
redirect,
integrityToken,
turnstileToken,
// we don't actually use a phone #, this is from a hidden field to prevent bot activity
honeypot: phonenumber,
token,
Expand Down
4 changes: 4 additions & 0 deletions apps/portal/src/utils/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ export function chooseBestErrorMessage(error, alreadyTranslatedDefaultMessage) {
t('Too many sign-up attempts, try again later');
t('Memberships from this email domain are currently restricted.');
t('Invalid verification code');
t('Turnstile verification failed');
t('Security verification failed');
t('Security verification expired');
t('Failed to load security verification');
}
};

Expand Down
8 changes: 8 additions & 0 deletions apps/portal/src/utils/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,14 @@ export function hasFreeProductPrice({site}) {
return isFreeSignupAllowed({site}) && portalPlans.includes('free');
}

export function hasTurnstileEnabled({site}) {
return !!(site?.labs?.turnstile && site?.turnstile_sitekey);
}
Comment on lines +504 to +506

export function getTurnstileSitekey({site}) {
return site?.turnstile_sitekey || '';
}

export function getSiteNewsletters({site}) {
const {
newsletters = []
Expand Down
Loading
Loading