0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-08 02:52:39 -05:00

Added honeypot field to prevent bot signup/signin

ref KTLO-1
Should prevent untargeted attacks using headless browser bots.
This commit is contained in:
Sam Lord 2024-08-22 13:47:31 +01:00 committed by Sam Lord
parent 244e612f53
commit 0a9d2fadba
6 changed files with 90 additions and 6 deletions

View file

@ -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 (
<section className='gh-portal-input-section'>
<section className={sectionClasses}>
<div className='gh-portal-input-labelcontainer'>
<label htmlFor={id} className={labelClasses}> {label} </label>
<InputError message={errorMessage} name={name} />

View file

@ -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}

View file

@ -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;

View file

@ -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
}
];

View file

@ -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()) {

View file

@ -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();
});
});
});
});