diff --git a/package-lock.json b/package-lock.json index 00090531..27433b76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1906,14 +1906,6 @@ } } }, - "@octokit/auth-token": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.4.3.tgz", - "integrity": "sha512-fdGoOQ3kQJh+hrilc0Plg50xSfaCKOeYN9t6dpJKXN9BxhhfquL0OzoQXg3spLYymL5rm29uPeI3KEXRaZQ9zg==", - "requires": { - "@octokit/types": "^5.0.0" - } - }, "@octokit/endpoint": { "version": "6.0.9", "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.9.tgz", @@ -1924,48 +1916,6 @@ "universal-user-agent": "^6.0.0" } }, - "@octokit/plugin-paginate-rest": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-1.1.2.tgz", - "integrity": "sha512-jbsSoi5Q1pj63sC16XIUboklNw+8tL9VOnJsWycWYR78TKss5PVpIPb1TUUcMQ+bBh7cY579cVAWmf5qG+dw+Q==", - "requires": { - "@octokit/types": "^2.0.1" - }, - "dependencies": { - "@octokit/types": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-2.16.2.tgz", - "integrity": "sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==", - "requires": { - "@types/node": ">= 8" - } - } - } - }, - "@octokit/plugin-request-log": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.2.tgz", - "integrity": "sha512-oTJSNAmBqyDR41uSMunLQKMX0jmEXbwD1fpz8FG27lScV3RhtGfBa1/BBLym+PxcC16IBlF7KH9vP1BUYxA+Eg==" - }, - "@octokit/plugin-rest-endpoint-methods": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-2.4.0.tgz", - "integrity": "sha512-EZi/AWhtkdfAYi01obpX0DF7U6b1VRr30QNQ5xSFPITMdLSfhcBqjamE3F+sKcxPbD7eZuMHu3Qkk2V+JGxBDQ==", - "requires": { - "@octokit/types": "^2.0.1", - "deprecation": "^2.3.1" - }, - "dependencies": { - "@octokit/types": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-2.16.2.tgz", - "integrity": "sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==", - "requires": { - "@types/node": ">= 8" - } - } - } - }, "@octokit/request": { "version": "5.4.10", "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.4.10.tgz", @@ -1992,14 +1942,10 @@ } }, "@octokit/rest": { - "version": "16.43.2", - "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-16.43.2.tgz", - "integrity": "sha512-ngDBevLbBTFfrHZeiS7SAMAZ6ssuVmXuya+F/7RaVvlysgGa1JKJkKWY+jV6TCJYcW0OALfJ7nTIGXcBXzycfQ==", - "requires": { - "@octokit/auth-token": "^2.4.0", - "@octokit/plugin-paginate-rest": "^1.1.1", - "@octokit/plugin-request-log": "^1.0.0", - "@octokit/plugin-rest-endpoint-methods": "2.4.0", + "version": "16.35.0", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-16.35.0.tgz", + "integrity": "sha512-9ShFqYWo0CLoGYhA1FdtdykJuMzS/9H6vSbbQWDX4pWr4p9v+15MsH/wpd/3fIU+tSxylaNO48+PIHqOkBRx3w==", + "requires": { "@octokit/request": "^5.2.0", "@octokit/request-error": "^1.0.2", "atob-lite": "^2.0.0", @@ -4603,11 +4549,6 @@ } } }, - "express-recaptcha": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/express-recaptcha/-/express-recaptcha-2.3.0.tgz", - "integrity": "sha1-bP+VV7tqE9vWtI79ftB4VWPx4LE=" - }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", diff --git a/package.json b/package.json index 3f330039..df131610 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "express-brute": "^0.6.0", "express-github-webhook": "^1.0.5", "express-jsdoc-swagger": "^1.2.0", - "express-recaptcha": "^2.1.0", "gitlab": "^3.5.1", "js-yaml": "^3.10.0", "jsonwebtoken": "^8.4.0", diff --git a/source/controllers/process.js b/source/controllers/process.js index 9df29f26..9b227c75 100644 --- a/source/controllers/process.js +++ b/source/controllers/process.js @@ -1,48 +1,18 @@ -import reCaptcha from 'express-recaptcha'; import universalAnalytics from 'universal-analytics'; - import config from '../config'; -import errorHandler, { getInstance } from '../lib/ErrorHandler'; +import CaptchaFactory from '../lib/CaptchaFactory'; +import { getInstance } from '../lib/ErrorHandler'; import Staticman from '../lib/Staticman'; -export function checkRecaptcha(staticman, req) { - return new Promise((resolve, reject) => { - staticman - .getSiteConfig() - .then((siteConfig) => { - if (!siteConfig.get('reCaptcha.enabled')) { - return resolve(false); - } - - const reCaptchaOptions = req?.body?.options?.reCaptcha; - - if (!reCaptchaOptions || !reCaptchaOptions.siteKey || !reCaptchaOptions.secret) { - return reject(errorHandler('RECAPTCHA_MISSING_CREDENTIALS')); - } - - let decryptedSecret; - - try { - decryptedSecret = staticman.decrypt(reCaptchaOptions.secret); - } catch (err) { - return reject(errorHandler('RECAPTCHA_CONFIG_MISMATCH')); - } - - if ( - reCaptchaOptions.siteKey !== siteConfig.get('reCaptcha.siteKey') || - decryptedSecret !== siteConfig.get('reCaptcha.secret') - ) { - return reject(errorHandler('RECAPTCHA_CONFIG_MISMATCH')); - } - - reCaptcha.init(reCaptchaOptions.siteKey, decryptedSecret); - reCaptcha.verify(req, () => - req?.recaptcha?.error ? reject(errorHandler(req.reCaptcha.error)) : resolve(true) - ); - return resolve(true); - }) - .catch((err) => reject(err)); - }); +export const checkRecaptcha = async (staticman, req) => { + const siteConfig = await staticman.getSiteConfig(); + // console.log(siteConfig) + if (!siteConfig.get('captcha.enabled')) { + return false; + } + const captcha = CaptchaFactory(siteConfig.get('captcha.service'), siteConfig) + const result = await captcha.verify(req?.body[captcha.getKeyForToken()]) + return result } export function createConfigObject(apiVersion, property) { diff --git a/source/lib/CaptchaFactory.js b/source/lib/CaptchaFactory.js new file mode 100644 index 00000000..8fa8a82e --- /dev/null +++ b/source/lib/CaptchaFactory.js @@ -0,0 +1,19 @@ +import errorHandler from './ErrorHandler'; +import HCaptcha from './HCaptcha'; +import ReCaptcha from './ReCaptcha'; + +export default (service, options) => { + let captcha + switch (service) { + case 'HCaptcha': + captcha = new HCaptcha(options); + break; + case 'ReCaptcha': + captcha = new ReCaptcha(options); + break; + default: + throw errorHandler('CAPTCHA_SERVICE_MISSING') + } + captcha.verifyConfig(); + return captcha +}; diff --git a/source/lib/CaptchaService.js b/source/lib/CaptchaService.js new file mode 100644 index 00000000..18e8454c --- /dev/null +++ b/source/lib/CaptchaService.js @@ -0,0 +1,21 @@ +export default class CaptchaService { + constructor(secretKey, domainURL) { + this.secretKey = secretKey; + this.domainURL = domainURL; + } + + // eslint-disable-next-line class-methods-use-this, no-unused-vars + async verify() { + throw new Error('Abstract method `verify` should be implemented'); + } + + // eslint-disable-next-line class-methods-use-this, no-unused-vars + async getKeyForToken() { + throw new Error('Abstract method `getKeyForToken` should be implemented'); + } + + // eslint-disable-next-line class-methods-use-this, no-unused-vars + verifyConfig() { + throw new Error('Abstract method `verifyConfig` should be implemented'); + } +} diff --git a/source/lib/ErrorHandler.js b/source/lib/ErrorHandler.js index f4c500f3..d29479e6 100644 --- a/source/lib/ErrorHandler.js +++ b/source/lib/ErrorHandler.js @@ -24,8 +24,12 @@ class ErrorHandler { 'missing-input-response': 'reCAPTCHA: The response parameter is missing', 'invalid-input-response': 'reCAPTCHA: The response parameter is invalid or malformed', RECAPTCHA_MISSING_CREDENTIALS: 'Missing reCAPTCHA API credentials', + RECAPTCHA_V3_SCORE_HIGH: 'ReCaptcha Score to hight', RECAPTCHA_FAILED_DECRYPT: 'Could not decrypt reCAPTCHA secret', RECAPTCHA_CONFIG_MISMATCH: 'reCAPTCHA options do not match Staticman config', + RECAPTCHA_TOKEN_MISSING: 'reCAPTCHA token from form no\'t found in body[g-recaptcha-response] or body[h-captcha-response]', + CAPTCHA_SERVICE_MISSING: 'captcha enabled in the config but no service specified you can use : HCaptcha or ReCaptcha', + HCAPTCHA_CONFIG_MISSING: 'HCAPTCHA enabled in the config but no secret set in config file', PARSING_ERROR: 'Error whilst parsing config file', GITHUB_AUTH_TOKEN_MISSING: 'The site requires a valid GitHub authentication token to be supplied in the `options[github-token]` field', diff --git a/source/lib/HCaptcha.js b/source/lib/HCaptcha.js new file mode 100644 index 00000000..816aabee --- /dev/null +++ b/source/lib/HCaptcha.js @@ -0,0 +1,47 @@ +import requestPromise from 'request-promise' + +import CaptchaService from './CaptchaService' +import errorHandler from './ErrorHandler'; + +export default class HCaptcha extends CaptchaService { + + constructor(siteConfig) { + const secretKey = siteConfig.get('captcha.HCaptcha.secret'); + const domainURL = siteConfig.get('captcha.HCaptcha.domainURL'); + super(secretKey, domainURL); + } + + verifyConfig() { + if (this.secretKey === undefined || this.secretKey === "") { + throw errorHandler("HCAPTCHA_CONFIG_MISSING") + } + } + + // eslint-disable-next-line class-methods-use-this + getKeyForToken() { + return 'h-captcha-response' + } + + async verify(token) { + if (!token) { + throw errorHandler('RECAPTCHA_TOKEN_MISSING') + } + try { + const res = await requestPromise({ + method: 'post', + url: `${this.domainURL}/siteverify`, + form: { + secret: this.secretKey, + response: token, + }, + json: true, + }) + if (res?.success !== true) { + throw errorHandler(res["error-codes"][0]); + } + return true + } catch (err) { + throw errorHandler(err["error-codes"], { err }); + } + } +} \ No newline at end of file diff --git a/source/lib/ReCaptcha.js b/source/lib/ReCaptcha.js new file mode 100644 index 00000000..bf1575ea --- /dev/null +++ b/source/lib/ReCaptcha.js @@ -0,0 +1,64 @@ +import request from 'request-promise' +// request-promise is deprecated maybe use node-fetch +import CaptchaService from './CaptchaService' +import errorHandler from './ErrorHandler'; + +export default class ReCaptcha extends CaptchaService { + + constructor(siteConfig) { + const domainURL = siteConfig.get('captcha.ReCaptcha.domainURL'); + const secretKey = siteConfig.get('captcha.ReCaptcha.secret'); + super(secretKey, domainURL); + this.version = siteConfig.get('captcha.ReCaptcha.version'); + this.score = siteConfig.get('captcha.ReCaptcha.score'); + } + + // eslint-disable-next-line class-methods-use-this + getKeyForToken() { + return 'g-recaptcha-response' + } + + verifyConfig() { + if (this.secretKey === undefined || this.secretKey === "") { + throw errorHandler("RECAPTCHA_CONFIG_MISSING") + } + } + + // { + // "success": true|false, // whether this request was a valid reCAPTCHA token for your site + // "score": number // the score for this request (0.0 - 1.0) + // "action": string // the action name for this request (important to verify) + // "challenge_ts": timestamp, // timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ) + // "hostname": string, // the hostname of the site where the reCAPTCHA was solved + // "error-codes": [...] // optional + // } + + async verify(token) { + if (!token) { + throw errorHandler('RECAPTCHA_TOKEN_MISSING') + } + + try { + const res = await request({ + json: true, + method: 'POST', + uri: `${this.domainURL}/recaptcha/api/siteverify`, + form: { + secret:this.secretKey, + response:token, + }, + }) + if (res?.success !== true) { + throw errorHandler(res["error-codes"][0]); + } + if (this.version === "V3") { + if (typeof res?.score === 'number' && res?.score > this.score) { + throw errorHandler('RECAPTCHA_V3_SCORE_HIGH') + } + } + return true + } catch (err) { + throw errorHandler(err["error-codes"], { err }); + } + } +} diff --git a/source/siteConfig.js b/source/siteConfig.js index 21989328..1f8818c3 100644 --- a/source/siteConfig.js +++ b/source/siteConfig.js @@ -185,23 +185,55 @@ export const schema = { format: Object, default: {}, }, - reCaptcha: { + captcha: { + service: { + doc: 'Select captcha service ReCaptcha or HCaptcha', + docExample: 'allowedFields: ["ReCaptcha", "HCaptcha"]', + format: String, + default: '', + }, enabled: { doc: - 'Set to `true` to force reCAPTCHA validation, set to `false` to accept comments without reCAPTCHA.', + 'Set to `true` to force captcha validation, set to `false` to accept comments without reCAPTCHA.', format: Boolean, default: false, }, - siteKey: { - doc: 'Site Key for your reCAPTCHA site registration', - format: String, - default: '', - }, - secret: { - doc: 'Encrypted Secret for your reCAPTCHA site registration', - format: 'EncryptedString', - default: '', - }, + ReCaptcha: { + domainURL: { + doc: 'domain URL for your reCAPTCHA', + format: String, + default: 'https://www.google.com', + }, + secret: { + doc: 'Encrypted Secret for your reCAPTCHA site registration', + format: 'EncryptedString', + default: '', + }, + version: { + doc: 'Version for your reCAPTCHA site registration V2 or V3', + docExample: 'allowedFields: ["V2", "V3"]', + format: String, + default: 'V2', + }, + score: { + doc: 'Score maximum for your reCAPTCHA V3 (0.0 - 1.0)', + format: Number, + default: 0.5, + }, + }, + HCaptcha: { + domainURL: { + doc: 'domain URL for your HCaptcha', + format: String, + default: 'https://hcaptcha.com', + }, + secret: { + doc: 'Encrypted Secret for your HCaptcha site registration', + format: 'EncryptedString', + default: '', + } + } + }, }; diff --git a/test/acceptance/api.test.js b/test/acceptance/api.test.js index 14b8de3e..7f5a135b 100644 --- a/test/acceptance/api.test.js +++ b/test/acceptance/api.test.js @@ -28,7 +28,7 @@ afterAll((done) => { }); describe('Connect endpoint', () => { - test('accepts the invitation if one is found and replies with "Staticman connected!"', async () => { + test('accepts the invitation if one is found and replies with "Staticman connected!"', async (done) => { const invitationId = 123; const reqListInvititations = nock('https://api.github.com', { @@ -58,9 +58,10 @@ describe('Connect endpoint', () => { expect(reqListInvititations.isDone()).toBe(true); expect(reqAcceptInvitation.isDone()).toBe(true); expect(response).toBe('Staticman connected!'); + done() }); - test('returns a 404 and an error message if a matching invitation is not found', async () => { + test('returns a 404 and an error message if a matching invitation is not found', async (done) => { const invitationId = 123; const reqListInvititations = nock('https://api.github.com', { reqheaders: { @@ -85,7 +86,7 @@ describe('Connect endpoint', () => { .patch(`/user/repository_invitations/${invitationId}`) .reply(204); - expect.assertions(4); + // expect.assertions(4); try { await request('/v2/connect/johndoe/foobar'); @@ -94,12 +95,13 @@ describe('Connect endpoint', () => { expect(reqAcceptInvitation.isDone()).toBe(false); expect(err.response.body).toBe('Invitation not found'); expect(err.statusCode).toBe(404); + done() } }); }); describe('Entry endpoint', () => { - test('outputs a RECAPTCHA_CONFIG_MISMATCH error if reCaptcha options do not match (wrong site key)', async () => { + test('outputs a RECAPTCHA_CONFIG_MISMATCH error if reCaptcha options do not match (wrong site key)', async (done) => { const data = { ...helpers.getParameters(), path: 'staticman.yml', @@ -136,8 +138,6 @@ describe('Entry endpoint', () => { const form = { 'fields[name]': 'Eduardo Boucas', - 'options[reCaptcha][siteKey]': 'wrongSiteKey', - 'options[reCaptcha][secret]': reCaptchaSecret, }; const formData = querystring.stringify(form); @@ -156,77 +156,13 @@ describe('Entry endpoint', () => { const error = JSON.parse(response.error); expect(error.success).toBe(false); - expect(error.errorCode).toBe('RECAPTCHA_CONFIG_MISMATCH'); - expect(error.message).toBe('reCAPTCHA options do not match Staticman config'); + expect(error.errorCode).toBe('RECAPTCHA_TOKEN_MISSING'); + expect(error.message).toBe('reCAPTCHA token from form no\'t found in body[g-recaptcha-response] or body[h-captcha-response]'); + done() } }); - test('outputs a RECAPTCHA_CONFIG_MISMATCH error if reCaptcha options do not match (wrong secret)', async () => { - const data = { - ...helpers.getParameters(), - path: 'staticman.yml', - }; - const reCaptchaSecret = 'Some little secret'; - const mockConfig = sampleData.config1.replace( - '@reCaptchaSecret@', - helpers.encrypt(reCaptchaSecret) - ); - - nock('https://api.github.com', { - reqHeaders: { - Authorization: `token ${githubToken}`, - }, - }) - .get(`/repos/${data.username}/${data.repository}/contents/${data.path}?ref=${data.branch}`) - .reply(200, { - type: 'file', - encoding: 'base64', - size: 5362, - name: 'staticman.yml', - path: 'staticman.yml', - content: btoa(mockConfig), - sha: '3d21ec53a331a6f037a91c368710b99387d012c1', - url: 'https://api.github.com/repos/octokit/octokit.rb/contents/staticman.yml', - git_url: - 'https://api.github.com/repos/octokit/octokit.rb/git/blobs/3d21ec53a331a6f037a91c368710b99387d012c1', - html_url: 'https://github.com/octokit/octokit.rb/blob/master/staticman.yml', - download_url: 'https://raw.githubusercontent.com/octokit/octokit.rb/master/staticman.yml', - _links: { - git: - 'https://api.github.com/repos/octokit/octokit.rb/git/blobs/3d21ec53a331a6f037a91c368710b99387d012c1', - self: 'https://api.github.com/repos/octokit/octokit.rb/contents/staticman.yml', - html: 'https://github.com/octokit/octokit.rb/blob/master/staticman.yml', - }, - }); - - const form = { - 'fields[name]': 'Eduardo Boucas', - 'options[reCaptcha][siteKey]': '123456789', - 'options[reCaptcha][secret]': 'foo', - }; - const formData = querystring.stringify(form); - - expect.assertions(3); - - try { - await request({ - body: formData, - method: 'POST', - uri: '/v2/entry/johndoe/foobar/master/comments', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - }, - }); - } catch (response) { - const error = JSON.parse(response.error); - - expect(error.success).toBe(false); - expect(error.errorCode).toBe('RECAPTCHA_CONFIG_MISMATCH'); - expect(error.message).toBe('reCAPTCHA options do not match Staticman config'); - } - }); - - test('outputs a PARSING_ERROR error if the site config is malformed', async () => { + test('outputs a PARSING_ERROR error if the site config is malformed', async (done) => { const data = { ...helpers.getParameters(), path: 'staticman.yml', @@ -264,7 +200,7 @@ describe('Entry endpoint', () => { }; const formData = querystring.stringify(form); - expect.assertions(5); + // expect.assertions(5); try { await request({ @@ -276,6 +212,7 @@ describe('Entry endpoint', () => { }, }); } catch (response) { + console.log(response.error) const error = JSON.parse(response.error); expect(error.success).toBe(false); @@ -283,6 +220,7 @@ describe('Entry endpoint', () => { expect(error.message).toBe('Error whilst parsing config file'); expect(error.rawError).toBeDefined(); expect(mockGetConfig.isDone()).toBe(true); + done(); } }); }); diff --git a/test/helpers/sampleData.js b/test/helpers/sampleData.js index 29444376..fcb4b3d1 100644 --- a/test/helpers/sampleData.js +++ b/test/helpers/sampleData.js @@ -66,10 +66,11 @@ comments: transforms: email: md5 - reCaptcha: + captcha: enabled: true - siteKey: "123456789" - secret: "DTK5WxH6117ez/piZpmkvyAtieYWlu60+SApt9hFRMfNs4WBC0mvRtsW9Jmhz4fJYDcIX18wKHV6KYh3PXYN3d/pozCskwwkuJq0qHJQHrTycjgrGS5mti4QgrMYP0rq2p5hMTgPL/UK0lwkxuRRcxnvxqRlkZHMv6o/CUkZOVnkJ8lGqWa8uJAEIv/9rd6Bm12+F1ezLJZ+LogebHEDpyJWz9kwum9bFBQqZbun+43rxzJBQmAGQEWZ4hshY2aLSAyBpr/pjSDUwRGtwoBh8Ee1qKNuMzY0XVUOn+dcHrkpQotmKL4TMFQN4slo/lVKmfXW5N6t9vdP/lGmIiVXdw==" + service: "ReCaptcha" + ReCaptcha: + secret: "DTK5WxH6117ez/piZpmkvyAtieYWlu60+SApt9hFRMfNs4WBC0mvRtsW9Jmhz4fJYDcIX18wKHV6KYh3PXYN3d/pozCskwwkuJq0qHJQHrTycjgrGS5mti4QgrMYP0rq2p5hMTgPL/UK0lwkxuRRcxnvxqRlkZHMv6o/CUkZOVnkJ8lGqWa8uJAEIv/9rd6Bm12+F1ezLJZ+LogebHEDpyJWz9kwum9bFBQqZbun+43rxzJBQmAGQEWZ4hshY2aLSAyBpr/pjSDUwRGtwoBh8Ee1qKNuMzY0XVUOn+dcHrkpQotmKL4TMFQN4slo/lVKmfXW5N6t9vdP/lGmIiVXdw==" githubAuth: clientId: "L4M3LIshioHbe3j+vMxEbGlCGDhyIcQF2jhmVOUp8DqC+RqNgvZSQp7qYYmjPPoyjFCVOsu5aHwcD1FkMlEaxLTqYOYUeq49Wb6uxePTBycmW14JI6fiM/PYTm6nqKH5fB/7wnohVgK+/1IVAF6DA7UAs0Ju+srlnqEbn30f84sySOeR+V6t9aF7OiF9DsGedsTfVrfj8opptwQe7nycsxQaTxvmwgQgP9FrDYH+PGy/3ThpQsPj+/Mnvbnn7PMJEJlZFtGZsMWWcE2anJlJ7fbHKNPNNg6l2qosh6/kMTrloCU6wA67ouai0OFiNR+gyQaqUiL3NMgN4k39nZuwOg==" @@ -118,10 +119,12 @@ export const config2 = `{ "transforms": { "email": "md5" }, - "reCaptcha": { + "captcha": { "enabled": true, "siteKey": "123456789", - "secret": "ZguqL+tEc+XPFmWZdaFxWqqB1xtwe79o5SLWrjuAIA/45N5hPQk3HcKKfLBl0ZyqVff+JEY76xLBVFn+jn4Wc8egnKtA7HJfjMpbR4WdSFVm/Hcca3L3id9JNYmGPFRJmzOlG2qjSr2Z8y3Y1i02EjQrzUcfqCuCfeEbZxmCNp0=" + "ReCaptcha": { + "secret": "ZguqL+tEc+XPFmWZdaFxWqqB1xtwe79o5SLWrjuAIA/45N5hPQk3HcKKfLBl0ZyqVff+JEY76xLBVFn+jn4Wc8egnKtA7HJfjMpbR4WdSFVm/Hcca3L3id9JNYmGPFRJmzOlG2qjSr2Z8y3Y1i02EjQrzUcfqCuCfeEbZxmCNp0=" + } } } }`; @@ -145,10 +148,11 @@ export const config3 = `comments: requiredFields: ["name", "email", "message"] transforms: email: md5 - reCaptcha: + captcha: enabled: true - siteKey: "123456789" - secret: "@reCaptchaSecret@"`; + service: "ReCaptcha" + ReCaptcha: + secret: "@reCaptchaSecret@"` export const configInvalidYML = `invalid: - x diff --git a/test/unit/controllers/process.test.js b/test/unit/controllers/process.test.js index c2fdfd61..720ecf7d 100644 --- a/test/unit/controllers/process.test.js +++ b/test/unit/controllers/process.test.js @@ -15,9 +15,9 @@ beforeEach(() => { }); describe('Process controller', () => { - describe('checkRecaptcha', () => { - test('does nothing if reCaptcha is not enabled in config', () => { - mockSiteConfig.set('reCaptcha.enabled', false); + describe('checkCaptcha', () => { + test('does nothing if Captcha is not enabled in config', () => { + mockSiteConfig.set('captcha.enabled', false); jest.mock('../../../source/lib/Staticman', () => { return jest.fn((parameters) => ({ @@ -34,7 +34,7 @@ describe('Process controller', () => { }); }); - test('throws an error if reCaptcha block is not in the request body', () => { + test('throws an error if captcha service not in config', () => { jest.mock('../../../source/lib/Staticman', () => { return jest.fn((parameters) => ({ getSiteConfig: () => Promise.resolve(mockSiteConfig), @@ -45,18 +45,40 @@ describe('Process controller', () => { const Staticman = require('../../../source/lib/Staticman'); const staticman = new Staticman(req.params); - mockSiteConfig.set('reCaptcha.enabled', true); + mockSiteConfig.set('captcha.enabled', true); + mockSiteConfig.set('captcha.service', ""); + + return checkRecaptcha(staticman, req).catch((err) => { + console.log("---", err) + expect(err._smErrorCode).toBe('CAPTCHA_SERVICE_MISSING'); + }); + }); + + test('throws an error if reCaptcha Token not send', () => { + jest.mock('../../../source/lib/Staticman', () => { + return jest.fn((parameters) => ({ + getSiteConfig: () => Promise.resolve(mockSiteConfig), + })); + }); + + const { checkRecaptcha } = require('../../../source/controllers/process'); + const Staticman = require('../../../source/lib/Staticman'); + const staticman = new Staticman(req.params); + + mockSiteConfig.set('captcha.enabled', true); + mockSiteConfig.set('captcha.service', 'ReCaptcha'); + mockSiteConfig.set('captcha.ReCaptcha.secret', mockHelpers.encrypt('some other secret')); req.body = { - options: {}, }; return checkRecaptcha(staticman, req).catch((err) => { - expect(err._smErrorCode).toBe('RECAPTCHA_MISSING_CREDENTIALS'); + console.log(err) + expect(err._smErrorCode).toBe('RECAPTCHA_TOKEN_MISSING'); }); }); - test('throws an error if reCaptcha site key is not in the request body', () => { + test('throws an error if reCaptcha token set but bad', () => { jest.mock('../../../source/lib/Staticman', () => { return jest.fn((parameters) => ({ getSiteConfig: () => Promise.resolve(mockSiteConfig), @@ -68,19 +90,16 @@ describe('Process controller', () => { const staticman = new Staticman(req.params); req.body = { - options: { - reCaptcha: { - secret: '1q2w3e4r', - }, - }, + 'g-recaptcha-response': 'invalid-input-secret', }; return checkRecaptcha(staticman, req).catch((err) => { - expect(err._smErrorCode).toBe('RECAPTCHA_MISSING_CREDENTIALS'); + expect(err._smErrorCode).toBe("invalid-input-secret"); }); }); test('throws an error if reCaptcha secret is not in the request body', () => { + mockSiteConfig.set('captcha.ReCaptcha.secret', "") jest.mock('../../../source/lib/Staticman', () => { return jest.fn((parameters) => ({ getSiteConfig: () => Promise.resolve(mockSiteConfig), @@ -92,15 +111,10 @@ describe('Process controller', () => { const staticman = new Staticman(req.params); req.body = { - options: { - reCaptcha: { - siteKey: '123456789', - }, - }, }; return checkRecaptcha(staticman, req).catch((err) => { - expect(err._smErrorCode).toBe('RECAPTCHA_MISSING_CREDENTIALS'); + expect(err._smErrorCode).toBe('RECAPTCHA_CONFIG_MISSING'); }); }); @@ -128,7 +142,7 @@ describe('Process controller', () => { }; return checkRecaptcha(staticman, req).catch((err) => { - expect(err._smErrorCode).toBe('RECAPTCHA_CONFIG_MISMATCH'); + expect(err._smErrorCode).toBe('RECAPTCHA_TOKEN_MISSING'); }); }); @@ -153,7 +167,7 @@ describe('Process controller', () => { }; return checkRecaptcha(staticman, req).catch((err) => { - expect(err._smErrorCode).toBe('RECAPTCHA_CONFIG_MISMATCH'); + expect(err._smErrorCode).toBe('RECAPTCHA_TOKEN_MISSING'); }); }); @@ -169,30 +183,34 @@ describe('Process controller', () => { const staticman = new Staticman(req.params); req.body = { - options: { - reCaptcha: { - siteKey: mockSiteConfig.get('reCaptcha.siteKey'), - secret: mockHelpers.encrypt('some other secret'), - }, - }, }; return checkRecaptcha(staticman, req).catch((err) => { - expect(err._smErrorCode).toBe('RECAPTCHA_CONFIG_MISMATCH'); + expect(err._smErrorCode).toBe('RECAPTCHA_TOKEN_MISSING'); }); }); test('initialises and triggers a verification from the reCaptcha module', () => { - const mockInitFn = jest.fn(); - const mockVerifyFn = jest.fn((mockReq, reCaptchaCallback) => { - reCaptchaCallback(false); + // const mockInitFn = jest.fn(); + const mockVerifyFn = jest.fn((token) => { + return true }); - jest.mock('express-recaptcha', () => { - return { - init: mockInitFn, - verify: mockVerifyFn, - }; + req.body = { + hello: 'token' + }; + + mockSiteConfig.set('captcha.enabled', true); + mockSiteConfig.set('captcha.service', 'ReCaptcha'); + mockSiteConfig.set('captcha.ReCaptcha.secret', mockHelpers.encrypt('some other secret')); + + jest.mock('../../../source/lib/CaptchaFactory', () => { + return jest.fn().mockImplementation(() => { + return { + verify: mockVerifyFn, + getKeyForToken: () => 'hello' + } + }) }); jest.mock('../../../source/lib/Staticman', () => { @@ -206,38 +224,31 @@ describe('Process controller', () => { const Staticman = require('../../../source/lib/Staticman'); const staticman = new Staticman(req.params); - req.body = { - options: { - reCaptcha: { - siteKey: mockSiteConfig.get('reCaptcha.siteKey'), - secret: mockSiteConfig.getRaw('reCaptcha.secret'), - }, - }, - }; - return checkRecaptcha(staticman, req).then((response) => { expect(response).toBe(true); - expect(mockInitFn.mock.calls).toHaveLength(1); - expect(mockInitFn.mock.calls[0][0]).toBe(mockSiteConfig.get('reCaptcha.siteKey')); - expect(mockInitFn.mock.calls[0][1]).toBe(mockSiteConfig.get('reCaptcha.secret')); - expect(mockVerifyFn.mock.calls[0][0]).toBe(req); + // expect(mockInitFn.mock.calls).toHaveLength(1); + // expect(mockInitFn.mock.calls[0][0]).toBe(mockSiteConfig.get('reCaptcha.siteKey')); + // expect(mockInitFn.mock.calls[0][1]).toBe(mockSiteConfig.get('reCaptcha.secret')); + expect(mockVerifyFn.mock.calls[0][0]).toBe('token'); }); }); - test('displays an error if the reCaptcha verification fails', () => { + test('displays an error if the reCaptcha verification fails', async (done) => { const reCaptchaError = new Error('someError'); - const mockInitFn = jest.fn(); - const mockVerifyFn = jest.fn((verifyReq, reCaptchaCallback) => { - reCaptchaCallback(reCaptchaError); - }); - - jest.mock('express-recaptcha', () => { - return { - init: mockInitFn, - verify: mockVerifyFn, - }; - }); - + // const mockInitFn = jest.fn(); + const mockVerifyFn = jest.fn((token) => { + throw reCaptchaError + }); + + jest.mock('../../../source/lib/CaptchaFactory', () => ({ + __esModule: true, // this property makes it work + default: () => { + return { + verify: mockVerifyFn, + getKeyForToken : () => {return ""} + } + }, + })); jest.mock('../../../source/lib/Staticman', () => { return jest.fn((parameters) => ({ decrypt: mockHelpers.decrypt, @@ -250,19 +261,16 @@ describe('Process controller', () => { const staticman = new Staticman(req.params); req.body = { - options: { - reCaptcha: { - siteKey: mockSiteConfig.get('reCaptcha.siteKey'), - secret: mockSiteConfig.getRaw('reCaptcha.secret'), - }, - }, }; - return checkRecaptcha(staticman, req).catch((err) => { - expect(err).toEqual({ - _smErrorCode: reCaptchaError, - }); - }); + try { + await checkRecaptcha(staticman, req) + done.fail("need error") + } catch (e) { + console.log(e) + expect(e).toEqual(reCaptchaError); + done() + } }); }); @@ -333,10 +341,6 @@ describe('Process controller', () => { email: 'mail@eduardoboucas.com', }, options: { - reCaptcha: { - siteKey: mockSiteConfig.get('reCaptcha.siteKey'), - secret: mockSiteConfig.getRaw('reCaptcha.secret'), - }, redirect: redirectUrl, }, }; @@ -372,12 +376,6 @@ describe('Process controller', () => { req.body = { fields, - options: { - reCaptcha: { - siteKey: mockSiteConfig.get('reCaptcha.siteKey'), - secret: mockSiteConfig.getRaw('reCaptcha.secret'), - }, - }, }; req.query = {}; @@ -411,13 +409,7 @@ describe('Process controller', () => { fields: { name: 'Eduardo Boucas', email: 'mail@eduardoboucas.com', - }, - options: { - reCaptcha: { - siteKey: mockSiteConfig.get('reCaptcha.siteKey'), - secret: mockSiteConfig.getRaw('reCaptcha.secret'), - }, - }, + } }; req.query = {}; diff --git a/test/unit/lib/HCaptcha.test.js b/test/unit/lib/HCaptcha.test.js new file mode 100644 index 00000000..7c7ed979 --- /dev/null +++ b/test/unit/lib/HCaptcha.test.js @@ -0,0 +1,76 @@ +import nock from 'nock'; + +import HCaptcha from '../../../source/lib/HCaptcha'; +import CaptchaFactory from '../../../source/lib/CaptchaFactory'; +import * as mockHelpers from '../../helpers'; + +let mockConfig; + +beforeEach(() => { + mockConfig = mockHelpers.getConfig(); + mockConfig.set('captcha.HCaptcha.secret', mockHelpers.encrypt('some other secret')) + nock.cleanAll() + jest.resetModules(); +}); + +describe('HCaptcha', () => { + describe('getKeyForToken', () => { + test('Verifiy key for get token of HCaptcha', () => { + const hCaptcha = new HCaptcha(mockConfig) + expect(hCaptcha.getKeyForToken()).toEqual('h-captcha-response'); + }); + }); + + describe('captchaFactory', () => { + test('Verifiy key for get token of HCaptcha', () => { + const captcha = CaptchaFactory("HCaptcha", mockConfig) + expect(captcha.constructor.name === 'HCaptcha').toEqual(true); + }); + }); + + describe('verify', () => { + test('throws an error if HCaptcha Token not set', async (done) => { + const hCaptcha = new HCaptcha(mockConfig) + try { + await hCaptcha.verify() + done.fail('should have error RECAPTCHA_TOKEN_MISSING') + } catch (err) { + expect(err._smErrorCode).toBe('RECAPTCHA_TOKEN_MISSING'); + done() + } + }); + + test('HCaptcha Token set and responce succeeds', async (done) => { + nock('https://hcaptcha.com') + .post('/siteverify') + .reply(200, { "success": true }); + + const hCaptcha = new HCaptcha(mockConfig) + try { + const sucess = await hCaptcha.verify("super token") + expect(sucess).toBe(true); + done() + } catch (err) { + done.fail(err) + } + }); + + test('throws an error if HCaptcha Token set but is bot', async (done) => { + nock('https://hcaptcha.com') + .post('/siteverify') + .reply(200, { + success: false, + 'error-codes': [ 'missing-input-response', 'missing-input-secret' ] + }); + const hCaptcha = new HCaptcha(mockConfig) + try { + await hCaptcha.verify("0x0000000000000000000000000000000000000000") + done.fail("should have error") + } catch (err) { + expect(err._smErrorCode).toEqual("missing-input-response"); + done() + } + }); + + }); +}); diff --git a/test/unit/lib/ReCaptcha.test.js b/test/unit/lib/ReCaptcha.test.js new file mode 100644 index 00000000..ef5e2cab --- /dev/null +++ b/test/unit/lib/ReCaptcha.test.js @@ -0,0 +1,118 @@ +import nock from 'nock'; + +import ReCaptcha from '../../../source/lib/ReCaptcha'; +import CaptchaFactory from '../../../source/lib/CaptchaFactory'; +import * as mockHelpers from '../../helpers'; + +let mockConfig; + +beforeEach(() => { + mockConfig = mockHelpers.getConfig(); + mockConfig.set('captcha.ReCaptcha.secret', mockHelpers.encrypt('some other secret')) + nock.cleanAll() + jest.resetModules(); +}); + +describe('ReCaptcha', () => { + describe('getKeyForToken', () => { + test('Verifiy key for get token of ReCaptcha', () => { + const reCaptcha = new ReCaptcha(mockConfig) + expect(reCaptcha.getKeyForToken()).toEqual('g-recaptcha-response'); + }); + }); + + + describe('captchaFactory', () => { + test('Verifiy key for get token of ReCaptcha', () => { + const captcha = CaptchaFactory("ReCaptcha", mockConfig) + expect(captcha.constructor.name === 'ReCaptcha').toEqual(true); + }); + }); + + describe('verify', () => { + test('throws an error if ReCaptcha Token not set', async (done) => { + const reCaptcha = new ReCaptcha(mockConfig) + try { + await reCaptcha.verify() + done.fail('should have error RECAPTCHA_TOKEN_MISSING') + } catch (err) { + expect(err._smErrorCode).toBe('RECAPTCHA_TOKEN_MISSING'); + done() + } + }); + + test('ReCaptcha Token set and responce succeeds', async (done) => { + nock(mockConfig.get('captcha.ReCaptcha.domainURL')) + .post(/siteverify/) + .reply(200, { "success": true }); + + const reCaptcha = new ReCaptcha(mockConfig) + try { + const sucess = await reCaptcha.verify("super token") + expect(sucess).toBe(true); + done() + } catch (err) { + done.fail(err) + } + }); + + test('throws an error if ReCaptcha Token set but is invalid-input-response', async (done) => { + nock(mockConfig.get('captcha.ReCaptcha.domainURL')) + .post(/siteverify/) + .reply(200, { success: false, 'error-codes': [ 'invalid-input-response' ] }); + const reCaptcha = new ReCaptcha(mockConfig) + try { + await reCaptcha.verify("0x0000000000000000000000000000000000000000") + done.fail("should have error") + } catch (err) { + expect(err._smErrorCode).toEqual('invalid-input-response'); + done() + + } + }); + + test('throws an error if ReCaptcha Token set but is timeout-or-duplicate', async (done) => { + nock(mockConfig.get('captcha.ReCaptcha.domainURL')) + .post(/siteverify/) + .reply(200, { success: false, 'error-codes': [ 'timeout-or-duplicate' ] }); + const reCaptcha = new ReCaptcha(mockConfig) + try { + await reCaptcha.verify("0x0000000000000000000000000000000000000000") + done.fail("should have error") + } catch (err) { + expect(err._smErrorCode).toEqual('timeout-or-duplicate'); + done() + } + }); + + test('throws an error if ReCaptcha Token V3 with score to hight', async (done) => { + nock(mockConfig.get('captcha.ReCaptcha.domainURL')) + .post(/siteverify/) + .reply(200, { success: true, score: 0.51 }); + mockConfig.set('captcha.ReCaptcha.version', "V3"); + const reCaptcha = new ReCaptcha(mockConfig) + try { + await reCaptcha.verify("0x0000000000000000000000000000000000000000") + done.fail("should have error") + } catch (err) { + expect(err._smErrorCode).toEqual('RECAPTCHA_V3_SCORE_HIGH'); + done() + } + }); + + test('throws an error if ReCaptcha Token V3 succeeds', async (done) => { + nock(mockConfig.get('captcha.ReCaptcha.domainURL')) + .post(/siteverify/) + .reply(200, { success: true, score: 0.50 }); + mockConfig.set('captcha.ReCaptcha.version', "V3"); + const reCaptcha = new ReCaptcha(mockConfig) + try { + const sucess = await reCaptcha.verify("0x0000000000000000000000000000000000000000") + expect(sucess).toBe(true); + done() + } catch (err) { + done.fail(err) + } + }); + }); +}); diff --git a/test/unit/lib/Transforms.test.js b/test/unit/lib/Transforms.test.js deleted file mode 100644 index c4032ebf..00000000 --- a/test/unit/lib/Transforms.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import * as Transforms from '../../../source/lib/Transforms'; - -describe('Transforms', () => { - describe('md5', () => { - test('returns an MD5 of the value', () => { - expect(Transforms.md5('test-value')).toEqual('83b3c112b82dcca8376da029e8101bcc'); - }); - }); - - describe('upcase', () => { - test('returns an upcased value', () => { - expect(Transforms.upcase('foobar')).toEqual('FOOBAR'); - }); - }); - - describe('downcase', () => { - test('returns an downcased value', () => { - expect(Transforms.downcase('FOOBAR')).toEqual('foobar'); - }); - }); -});