diff --git a/apps/portal/src/components/common/InputField.js b/apps/portal/src/components/common/InputField.js index 00e6387756..492e0f2801 100644 --- a/apps/portal/src/components/common/InputField.js +++ b/apps/portal/src/components/common/InputField.js @@ -3,6 +3,9 @@ import {hasMode} from '../../utils/check-mode'; import {isCookiesDisabled} from '../../utils/helpers'; export const InputFieldStyles = ` + .gh-portal-input-section.hidden { + display: none; + } .gh-portal-input { -webkit-appearance: none; -moz-appearance: none; @@ -81,6 +84,7 @@ function InputError({message, style}) { function InputField({ name, id, + hidden, label, hideLabel, type, @@ -97,6 +101,7 @@ function InputField({ }) { const fieldNode = useRef(null); id = id || `input-${name}`; + const sectionClasses = hidden ? 'gh-portal-input-section hidden' : 'gh-portal-input-section'; const labelClasses = hideLabel ? 'gh-portal-input-label hidden' : 'gh-portal-input-label'; const inputClasses = errorMessage ? 'gh-portal-input error' : 'gh-portal-input'; if (isCookiesDisabled()) { @@ -130,7 +135,7 @@ function InputField({ } }, [autoFocus]); return ( -
+
diff --git a/apps/portal/src/components/common/InputForm.js b/apps/portal/src/components/common/InputForm.js index 0d809051d4..56bfecdabf 100644 --- a/apps/portal/src/components/common/InputForm.js +++ b/apps/portal/src/components/common/InputForm.js @@ -12,6 +12,7 @@ const FormInput = ({field, onChange, onBlur = () => { }, onKeyDown = () => {}}) label = {field.label} type={field.type} name={field.name} + hidden={field.hidden} placeholder={field.placeholder} disabled={field.disabled} value={field.value} diff --git a/apps/portal/src/components/pages/SigninPage.js b/apps/portal/src/components/pages/SigninPage.js index ad60cf44d8..b5b73c1025 100644 --- a/apps/portal/src/components/pages/SigninPage.js +++ b/apps/portal/src/components/pages/SigninPage.js @@ -34,11 +34,11 @@ export default class SigninPage extends React.Component { errors: ValidateInputForm({fields: this.getInputFields({state})}) }; }, async () => { - const {email, errors} = this.state; + const {email, honeypot, errors} = this.state; const {redirect} = this.context.pageData ?? {}; const hasFormErrors = (errors && Object.values(errors).filter(d => !!d).length > 0); if (!hasFormErrors) { - this.context.onAction('signin', {email, redirect}); + this.context.onAction('signin', {email, honeypot, redirect}); } }); } @@ -71,6 +71,18 @@ export default class SigninPage extends React.Component { required: true, errorMessage: errors.email || '', autoFocus: true + }, + { + type: 'text', + value: state.honeypot, + placeholder: '+1 (123) 456-7890', + // Doesn't need translation, hidden field + label: 'Phone number', + name: 'phonenumber', + required: false, + tabindex: -1, + autocomplete: 'off', + hidden: true } ]; return fields; diff --git a/apps/portal/src/components/pages/SignupPage.js b/apps/portal/src/components/pages/SignupPage.js index be8e7ad7a9..9509ac7ae4 100644 --- a/apps/portal/src/components/pages/SignupPage.js +++ b/apps/portal/src/components/pages/SignupPage.js @@ -397,7 +397,7 @@ class SignupPage extends React.Component { }; }, () => { const {site, onAction} = this.context; - const {name, email, plan, errors} = this.state; + const {name, email, plan, honeypot, errors} = this.state; const hasFormErrors = (errors && Object.values(errors).filter(d => !!d).length > 0); if (!hasFormErrors) { if (hasMultipleNewsletters({site})) { @@ -410,7 +410,7 @@ class SignupPage extends React.Component { this.setState({ errors: {} }); - onAction('signup', {name, email, plan}); + onAction('signup', {name, email, honeypot, plan}); } } }); @@ -484,6 +484,18 @@ class SignupPage extends React.Component { required: true, tabindex: 2, errorMessage: errors.email || '' + }, + { + type: 'text', + value: state.honeypot, + placeholder: '+1 (123) 456-7890', + // Doesn't need translation, hidden field + label: 'Phone number', + name: 'phonenumber', + required: false, + tabindex: -1, + autocomplete: 'off', + hidden: true } ]; diff --git a/ghost/members-api/lib/controllers/RouterController.js b/ghost/members-api/lib/controllers/RouterController.js index 3ef803d5a0..8482e1b084 100644 --- a/ghost/members-api/lib/controllers/RouterController.js +++ b/ghost/members-api/lib/controllers/RouterController.js @@ -470,7 +470,7 @@ module.exports = class RouterController { } async sendMagicLink(req, res) { - const {email, autoRedirect} = req.body; + const {email, honeypot, autoRedirect} = req.body; let {emailType, redirect} = req.body; let referer = req.get('referer'); @@ -492,6 +492,13 @@ module.exports = class RouterController { }); } + if (honeypot) { + // Honeypot field is filled, this is a bot. + // Pretend that the email was sent successfully. + res.writeHead(201); + return res.end('Created.'); + } + if (!emailType) { // Default to subscribe form that also allows to login (safe fallback for older clients) if (!this._allowSelfSignup()) { diff --git a/ghost/members-api/test/unit/lib/controllers/router.test.js b/ghost/members-api/test/unit/lib/controllers/router.test.js index cd85e09321..95bc8e8c5e 100644 --- a/ghost/members-api/test/unit/lib/controllers/router.test.js +++ b/ghost/members-api/test/unit/lib/controllers/router.test.js @@ -388,5 +388,52 @@ describe('RouterController', function () { await controller.sendMagicLink(req, res).should.be.rejectedWith(`Cannot subscribe to archived newsletters Newsletter 2`); }); }); + + describe('honeypot', function () { + let req, res, sendEmailWithMagicLinkStub; + + const createRouterController = (deps = {}) => { + return new RouterController({ + allowSelfSignup: sinon.stub().returns(true), + memberAttributionService: { + getAttribution: sinon.stub().resolves({}) + }, + sendEmailWithMagicLink: sendEmailWithMagicLinkStub, + ...deps + }); + }; + + beforeEach(function () { + req = { + body: { + email: 'jamie@example.com', + emailType: 'signup' + }, + get: sinon.stub() + }; + res = { + writeHead: sinon.stub(), + + end: sinon.stub() + }; + sendEmailWithMagicLinkStub = sinon.stub().resolves(); + }); + + it('Sends emails when honeypot is not filled', async function () { + const controller = createRouterController(); + + req.body.honeypot = 'filled!'; + + await controller.sendMagicLink(req, res).should.be.fulfilled(); + sendEmailWithMagicLinkStub.notCalled.should.be.true(); + }); + + it('Does not send emails when honeypot is filled', async function () { + const controller = createRouterController(); + + await controller.sendMagicLink(req, res).should.be.fulfilled(); + sendEmailWithMagicLinkStub.calledOnce.should.be.true(); + }); + }); }); });