From 439bbf8b79042fd6e227bb14a1ecc55891d77c8d Mon Sep 17 00:00:00 2001 From: Sam Lord Date: Mon, 27 Jan 2025 15:57:31 +0000 Subject: [PATCH] Use Captcha middleware in members API ref BAE-104 The members send-magic-link API should be protected by Captcha. This required initialising the Captcha service in the members API, and putting the middleware into the send-magic-link API. If it's enabled via lab flag and config, then the service will prevent API calls that don't have a valid Captcha response. --- .../server/api/endpoints/settings-public.js | 16 ++- .../core/core/server/services/members/api.js | 6 + ghost/core/package.json | 1 + .../__snapshots__/settings.test.js.snap | 107 ++++++++++++++++++ .../test/e2e-api/content/settings.test.js | 30 +++++ ghost/members-api/lib/members-api.js | 2 + 6 files changed, 161 insertions(+), 1 deletion(-) diff --git a/ghost/core/core/server/api/endpoints/settings-public.js b/ghost/core/core/server/api/endpoints/settings-public.js index 72de70973e..730a2b582d 100644 --- a/ghost/core/core/server/api/endpoints/settings-public.js +++ b/ghost/core/core/server/api/endpoints/settings-public.js @@ -1,6 +1,19 @@ const settingsCache = require('../../../shared/settings-cache'); const urlUtils = require('../../../shared/url-utils'); const ghostVersion = require('@tryghost/version'); +const config = require('../../../shared/config'); +const labs = require('../../../shared/labs'); + +const getCaptchaSettings = () => { + if (labs.isSet('captcha')) { + return { + captcha_enabled: config.get('captcha:enabled'), + captcha_sitekey: config.get('captcha:siteKey') + }; + } else { + return {}; + } +}; /** @type {import('@tryghost/api-framework').Controller} */ const controller = { @@ -18,7 +31,8 @@ const controller = { settingsCache.getPublic(), { url: urlUtils.urlFor('home', true), version: ghostVersion.safe - } + }, + getCaptchaSettings() ); } } diff --git a/ghost/core/core/server/services/members/api.js b/ghost/core/core/server/services/members/api.js index 26f5d46d7c..cbdcd18a7c 100644 --- a/ghost/core/core/server/services/members/api.js +++ b/ghost/core/core/server/services/members/api.js @@ -18,6 +18,7 @@ const tiersService = require('../tiers'); const newslettersService = require('../newsletters'); const memberAttributionService = require('../member-attribution'); const emailSuppressionList = require('../email-suppression-list'); +const CaptchaService = require('@tryghost/captcha-service'); const {t} = require('../i18n'); const sentry = require('../../../shared/sentry'); const sharedConfig = require('../../../shared/config'); @@ -240,6 +241,11 @@ function createApiInstance(config) { settingsCache, sentry, settingsHelpers, + captchaService: new CaptchaService({ + enabled: labsService.isSet('captcha') && sharedConfig.get('captcha:enabled'), + scoreThreshold: sharedConfig.get('captcha:scoreThreshold'), + secretKey: sharedConfig.get('captcha:secretKey') + }), config: sharedConfig }); diff --git a/ghost/core/package.json b/ghost/core/package.json index aac4af6a3b..ce75556c25 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -71,6 +71,7 @@ "@tryghost/audience-feedback": "0.0.0", "@tryghost/bookshelf-plugins": "0.6.25", "@tryghost/bootstrap-socket": "0.0.0", + "@tryghost/captcha-service": "0.0.0", "@tryghost/color-utils": "0.2.2", "@tryghost/config-url-helpers": "1.0.12", "@tryghost/constants": "0.0.0", diff --git a/ghost/core/test/e2e-api/content/__snapshots__/settings.test.js.snap b/ghost/core/test/e2e-api/content/__snapshots__/settings.test.js.snap index 20070fc80b..74c51a3ecf 100644 --- a/ghost/core/test/e2e-api/content/__snapshots__/settings.test.js.snap +++ b/ghost/core/test/e2e-api/content/__snapshots__/settings.test.js.snap @@ -104,3 +104,110 @@ Object { "x-powered-by": "Express", } `; + +exports[`Settings Content API Captcha settings Can request captcha settings 1: [body] 1`] = ` +Object { + "meta": Object {}, + "settings": Object { + "accent_color": "#FF1A75", + "allow_self_signup": true, + "captcha_enabled": true, + "captcha_sitekey": "testkey", + "codeinjection_foot": null, + "codeinjection_head": null, + "comments_enabled": "off", + "cover_image": "https://static.ghost.org/v5.0.0/images/publication-cover.jpg", + "default_email_address": "noreply@127.0.0.1", + "description": "Thoughts, stories and ideas", + "editor_default_email_recipients": "visibility", + "facebook": "ghost", + "firstpromoter_account": null, + "icon": null, + "labs": Any, + "lang": "en", + "locale": "en", + "logo": null, + "members_enabled": true, + "members_invite_only": false, + "members_signup_access": "all", + "members_support_address": "noreply", + "meta_description": null, + "meta_title": null, + "navigation": Array [ + Object { + "label": "Home", + "url": "/", + }, + Object { + "label": "About", + "url": "/about/", + }, + Object { + "label": "Collection", + "url": "/tag/getting-started/", + }, + Object { + "label": "Author", + "url": "/author/ghost/", + }, + Object { + "label": "Portal", + "url": "/portal/", + }, + ], + "og_description": null, + "og_image": null, + "og_title": null, + "outbound_link_tagging": true, + "paid_members_enabled": true, + "portal_button": true, + "portal_button_icon": null, + "portal_button_signup_text": "Subscribe", + "portal_button_style": "icon-and-text", + "portal_default_plan": "yearly", + "portal_name": true, + "portal_plans": Array [ + "free", + ], + "portal_signup_checkbox_required": false, + "portal_signup_terms_html": null, + "recommendations_enabled": false, + "secondary_navigation": Array [ + Object { + "label": "Data & privacy", + "url": "/privacy/", + }, + Object { + "label": "Contact", + "url": "/contact/", + }, + Object { + "label": "Contribute →", + "url": "/contribute/", + }, + ], + "support_email_address": "noreply@127.0.0.1", + "timezone": "Etc/UTC", + "title": "Ghost", + "twitter": "@ghost", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "url": "http://127.0.0.1:2369/", + "version": Any, + }, +} +`; + +exports[`Settings Content API Captcha settings Can request captcha settings 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "*", + "cache-control": "public, max-age=0", + "content-length": StringMatching /\\\\d\\+/, + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/content/settings.test.js b/ghost/core/test/e2e-api/content/settings.test.js index bc3e558d1a..19d8f65887 100644 --- a/ghost/core/test/e2e-api/content/settings.test.js +++ b/ghost/core/test/e2e-api/content/settings.test.js @@ -1,5 +1,6 @@ const {agentProvider, fixtureManager, matchers} = require('../../utils/e2e-framework'); const {anyEtag, anyContentLength, anyContentVersion} = matchers; +const configUtils = require('../../utils/configUtils'); const settingsMatcher = { version: matchers.anyString, @@ -27,4 +28,33 @@ describe('Settings Content API', function () { settings: settingsMatcher }); }); + + describe('Captcha settings', function () { + beforeEach(function () { + configUtils.set('captcha', { + enabled: true, + siteKey: 'testkey' + }); + }); + + afterEach(function () { + configUtils.restore(); + }); + + it('Can request captcha settings', async function () { + await agent.get('settings/') + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag, + 'content-version': anyContentVersion, + 'content-length': anyContentLength + }) + .matchBodySnapshot({ + settings: Object.assign({}, settingsMatcher, { + captcha_enabled: true, + captcha_sitekey: 'testkey' + }) + }); + }); + }); }); diff --git a/ghost/members-api/lib/members-api.js b/ghost/members-api/lib/members-api.js index 660bc6cdca..a426c50725 100644 --- a/ghost/members-api/lib/members-api.js +++ b/ghost/members-api/lib/members-api.js @@ -74,6 +74,7 @@ module.exports = function MembersAPI({ settingsCache, sentry, settingsHelpers, + captchaService, config }) { const tokenService = new TokenService({ @@ -337,6 +338,7 @@ module.exports = function MembersAPI({ const middleware = { sendMagicLink: Router().use( body.json(), + captchaService.getMiddleware(), forwardError((req, res) => routerController.sendMagicLink(req, res)) ), createCheckoutSession: Router().use(