0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-03 23:00:14 -05:00

Added Captcha service module

ref BAE-369

Added captcha-service module. Currently unused but idea here is that we
can add this middleware to forms protected by Captcha to validate the
response.
This commit is contained in:
Sam Lord 2025-01-09 16:25:40 +00:00 committed by GitHub
parent 3cf1abfc49
commit 8dd5b0883a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1449 additions and 13 deletions

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View file

@ -0,0 +1,23 @@
# Captcha Service
Validate CAPTCHAs in Ghost for sign-ups
## Usage
## Develop
This is a monorepo package.
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests

View file

@ -0,0 +1 @@
module.exports = require('./lib/CaptchaService');

View file

@ -0,0 +1,69 @@
const hcaptcha = require('hcaptcha');
const logging = require('@tryghost/logging');
const {InternalServerError, BadRequestError, utils: errorUtils} = require('@tryghost/errors');
class CaptchaService {
#enabled;
#scoreThreshold;
#secretKey;
/**
* @param {Object} options
* @param {boolean} [options.enabled] Whether hCaptcha is enabled
* @param {number} [options.scoreThreshold] Score threshold for bot detection
* @param {string} [options.secretKey] hCaptcha secret key
*/
constructor({
enabled,
scoreThreshold,
secretKey
}) {
this.#enabled = enabled;
this.#secretKey = secretKey;
this.#scoreThreshold = scoreThreshold;
}
getMiddleware() {
const scoreThreshold = this.#scoreThreshold;
const secretKey = this.#secretKey;
if (!this.#enabled) {
return function captchaNoOpMiddleware(req, res, next) {
next();
};
}
return async function captchaMiddleware(req, res, next) {
let captchaResponse;
try {
if (!req.body || !req.body.token) {
throw new BadRequestError({
message: 'hCaptcha token missing'
});
}
captchaResponse = await hcaptcha.verify(secretKey, req.body.token, req.ip);
if (captchaResponse.score < scoreThreshold) {
next();
} else {
logging.error(`Blocking request due to high score (${captchaResponse.score})`);
// Intentionally left sparse to avoid leaking information
throw new InternalServerError();
}
} catch (err) {
if (errorUtils.isGhostError(err)) {
return next(err);
} else {
return next(new InternalServerError({
message: 'Failed to verify hCaptcha token'
}));
}
}
};
}
}
module.exports = CaptchaService;

View file

@ -0,0 +1,28 @@
{
"name": "@tryghost/captcha-service",
"version": "0.0.0",
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/captcha-service",
"author": "Ghost Foundation",
"private": true,
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test:unit": "NODE_ENV=testing c8 --all --check-coverage --100 --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"test": "yarn test:unit",
"lint:code": "eslint *.js lib/ --ext .js --cache",
"lint": "yarn lint:code && yarn lint:test",
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache"
},
"files": [
"index.js",
"lib"
],
"devDependencies": {
"c8": "10.1.3",
"mocha": "11.0.1",
"sinon": "19.0.2"
},
"dependencies": {
"hcaptcha": "0.2.0"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View file

@ -0,0 +1,124 @@
const assert = require('assert/strict');
const sinon = require('sinon');
const CaptchaService = require('../index');
const hcaptcha = require('hcaptcha');
describe('CaptchaService', function () {
beforeEach(function () {
sinon.stub(hcaptcha, 'verify');
});
afterEach(function () {
hcaptcha.verify.restore();
});
it('Creates a middleware when enabled', function () {
const captchaService = new CaptchaService({
enabled: true,
secretKey: 'test-secret'
});
const captchaMiddleware = captchaService.getMiddleware();
assert.equal(captchaMiddleware.length, 3);
});
it('No-ops if CAPTCHA score is safe', function (done) {
hcaptcha.verify.resolves({score: 0.6});
const captchaService = new CaptchaService({
enabled: true,
scoreThreshold: 0.8,
secretKey: 'test-secret'
});
const captchaMiddleware = captchaService.getMiddleware();
const req = {
body: {
token: 'test-token'
}
};
captchaMiddleware(req, null, (err) => {
assert.equal(err, undefined);
done();
});
});
it('Errors when CAPTCHA score is suspicious', function (done) {
hcaptcha.verify.resolves({score: 0.8});
const captchaService = new CaptchaService({
enabled: true,
scoreThreshold: 0.8,
secretKey: 'test-secret'
});
const captchaMiddleware = captchaService.getMiddleware();
const req = {
body: {
token: 'test-token'
}
};
captchaMiddleware(req, null, (err) => {
assert.equal(err.message, 'The server has encountered an error.');
done();
});
});
it('Fails gracefully if hcaptcha verification fails', function (done) {
hcaptcha.verify.rejects(new Error('Test error'));
const captchaService = new CaptchaService({
enabled: true,
scoreThreshold: 0.8,
secretKey: 'test-secret'
});
const captchaMiddleware = captchaService.getMiddleware();
const req = {
body: {
token: 'test-token'
}
};
captchaMiddleware(req, null, (err) => {
assert.equal(err.message, 'Failed to verify hCaptcha token');
done();
});
});
it('Returns a 400 if no token provided', function (done) {
const captchaService = new CaptchaService({
enabled: true,
scoreThreshold: 0.8,
secret: 'test-secret'
});
const captchaMiddleware = captchaService.getMiddleware();
const req = {
body: {}
};
captchaMiddleware(req, null, (err) => {
assert.equal(err.message, 'hCaptcha token missing');
done();
});
});
it('Returns no-op middleware when not enabled', function (done) {
const captchaService = new CaptchaService({
enabled: false,
secretKey: 'test-secret'
});
const captchaMiddleware = captchaService.getMiddleware();
captchaMiddleware(null, null, () => {
done();
});
});
});

1205
yarn.lock

File diff suppressed because it is too large Load diff