-
-
Notifications
You must be signed in to change notification settings - Fork 10.5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
8 changed files
with
1,449 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
module.exports = { | ||
plugins: ['ghost'], | ||
extends: [ | ||
'plugin:ghost/node' | ||
] | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
module.exports = require('./lib/CaptchaService'); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
module.exports = { | ||
plugins: ['ghost'], | ||
extends: [ | ||
'plugin:ghost/test' | ||
] | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.