Skip to content

Commit

Permalink
Added Captcha service module
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sam-lord authored Jan 9, 2025
1 parent 3cf1abf commit 8dd5b08
Show file tree
Hide file tree
Showing 8 changed files with 1,449 additions and 13 deletions.
6 changes: 6 additions & 0 deletions ghost/captcha-service/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};
23 changes: 23 additions & 0 deletions ghost/captcha-service/README.md
Original file line number Diff line number Diff line change
@@ -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

1 change: 1 addition & 0 deletions ghost/captcha-service/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('./lib/CaptchaService');
69 changes: 69 additions & 0 deletions ghost/captcha-service/lib/CaptchaService.js
Original file line number Diff line number Diff line change
@@ -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;
28 changes: 28 additions & 0 deletions ghost/captcha-service/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
6 changes: 6 additions & 0 deletions ghost/captcha-service/test/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};
124 changes: 124 additions & 0 deletions ghost/captcha-service/test/CaptchaService.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading

0 comments on commit 8dd5b08

Please sign in to comment.