Skip to content

Commit fb528c9

Browse files
authored
fix: Skip reCAPTCHA and a console warning if key is missing (#1346)
- Don't block developers if they don't have a reCAPTCHA key; notify them in the console to add one before going to production. - Add unit tests for controllers/contact.js
1 parent 9548777 commit fb528c9

File tree

5 files changed

+191
-15
lines changed

5 files changed

+191
-15
lines changed

.env.example

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ SMTP_PASSWORD=hdgfadsfahg--i.e.Sendgrid-apikey---hdgfadsfahg
1818
SMTP_HOST=smtp.sendgrid.net
1919

2020
# Needed for proper rendering of the Contact Form
21-
RECAPTCHA_SITE_KEY=JKHGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxJG
21+
RECAPTCHA_SITE_KEY=
2222
RECAPTCHA_SECRET_KEY=87ehxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx3298
2323

2424
#

README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,10 @@ _What to get and configure:_
153153
- For user workflows for reset password and verify email
154154
- For contact form processing
155155
- reCAPTCHA
156-
- For contact form submission
156+
- For contact form submission, but you can skip it during your development
157157
- OAuth for social logins (Sign in with / Login with)
158158
- Depending on your application need, obtain keys from Google, Facebook, X (Twitter), LinkedIn, Twitch, GitHub. You don't have to obtain valid keys for any provider that you don't need. Just remove the buttons and links in the login and account pug views before your demo.
159-
- API keys for service providers in the API Examples if you are planning to use them.
159+
- API keys for service providers that you need in the API Examples if you are planning to use them.
160160

161161
- MongoDB Atlas
162162

controllers/contact.js

+18-8
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@ async function validateReCAPTCHA(token) {
1818
exports.getContact = (req, res) => {
1919
const unknownUser = !req.user;
2020

21+
if (!process.env.RECAPTCHA_SITE_KEY) {
22+
console.warn('\x1b[33mWARNING: RECAPTCHA_SITE_KEY is missing. Add a key to your .env, env variable, or use a WebApp Firewall with an interactive challenge before going to production.\x1b[0m');
23+
}
24+
2125
res.render('contact', {
2226
title: 'Contact',
23-
sitekey: process.env.RECAPTCHA_SITE_KEY,
27+
sitekey: process.env.RECAPTCHA_SITE_KEY || null, // Pass null if the key is missing
2428
unknownUser,
2529
});
2630
};
@@ -39,14 +43,20 @@ exports.postContact = async (req, res, next) => {
3943
}
4044
if (validator.isEmpty(req.body.message)) validationErrors.push({ msg: 'Please enter your message.' });
4145

42-
try {
43-
const reCAPTCHAResponse = await validateReCAPTCHA(req.body['g-recaptcha-response']);
44-
if (!reCAPTCHAResponse.data.success) {
45-
validationErrors.push({ msg: 'reCAPTCHA validation failed.' });
46+
if (!process.env.RECAPTCHA_SITE_KEY) {
47+
console.warn('\x1b[33mWARNING: RECAPTCHA_SITE_KEY is missing. Add a key to your .env or use a WebApp Firewall for CAPTCHA validation before going to production.\x1b[0m');
48+
} else if (!validator.isEmpty(req.body['g-recaptcha-response'])) {
49+
try {
50+
const reCAPTCHAResponse = await validateReCAPTCHA(req.body['g-recaptcha-response']);
51+
if (!reCAPTCHAResponse.success) {
52+
validationErrors.push({ msg: 'reCAPTCHA validation failed.' });
53+
}
54+
} catch (error) {
55+
console.error('Error validating reCAPTCHA:', error);
56+
validationErrors.push({ msg: 'Error validating reCAPTCHA. Please try again.' });
4657
}
47-
} catch (error) {
48-
console.error('Error validating reCAPTCHA:', error);
49-
validationErrors.push({ msg: 'Error validating reCAPTCHA. Please try again.' });
58+
} else {
59+
validationErrors.push({ msg: 'reCAPTCHA response was missing.' });
5060
}
5161

5262
if (validationErrors.length) {

test/contact.js

+153
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
const { expect } = require('chai');
2+
const sinon = require('sinon');
3+
const request = require('supertest');
4+
const express = require('express');
5+
const session = require('express-session');
6+
const contactController = require('../controllers/contact');
7+
8+
let app;
9+
let sendMailStub;
10+
let fetchStub;
11+
const OLD_ENV = { ...process.env };
12+
13+
function setupApp(controller) {
14+
const app = express();
15+
app.use(express.urlencoded({ extended: false }));
16+
app.use(session({ secret: 'test', resave: false, saveUninitialized: false }));
17+
18+
// Set a dummy CSRF token for all requests
19+
app.use((req, res, next) => {
20+
req.flash = (type, msg) => {
21+
req.session[type] = msg;
22+
};
23+
req.csrfToken = () => 'testcsrf';
24+
res.render = () => res.status(200).send('Contact Form');
25+
next();
26+
});
27+
28+
app.get('/contact', controller.getContact);
29+
app.post('/contact', controller.postContact);
30+
return app;
31+
}
32+
33+
describe('Contact Controller', () => {
34+
before(() => {
35+
process.env.SITE_CONTACT_EMAIL = '[email protected]';
36+
process.env.RECAPTCHA_SITE_KEY = 'dummy';
37+
process.env.RECAPTCHA_SECRET_KEY = 'dummy';
38+
});
39+
40+
beforeEach(() => {
41+
// Stub nodemailerConfig.sendMail
42+
sendMailStub = sinon.stub().resolves();
43+
// Patch require cache for nodemailerConfig
44+
const nodemailerConfig = require.cache[require.resolve('../config/nodemailer')];
45+
if (nodemailerConfig) {
46+
nodemailerConfig.exports.sendMail = sendMailStub;
47+
}
48+
49+
// Stub global fetch for reCAPTCHA
50+
fetchStub = sinon.stub().resolves({
51+
json: () => Promise.resolve({ success: true }),
52+
});
53+
global.fetch = fetchStub;
54+
55+
app = setupApp(contactController);
56+
});
57+
58+
afterEach(() => {
59+
sinon.restore();
60+
if (sendMailStub) sendMailStub.resetHistory();
61+
delete global.fetch;
62+
});
63+
64+
after(() => {
65+
process.env = OLD_ENV;
66+
});
67+
68+
describe('GET /contact', () => {
69+
it('renders the contact form', (done) => {
70+
request(app)
71+
.get('/contact')
72+
.expect(200)
73+
.end((err) => {
74+
if (err) return done(err);
75+
expect(true).to.be.true; // keep assertion for lint, actual check is above
76+
done();
77+
});
78+
});
79+
});
80+
81+
describe('POST /contact', () => {
82+
it('rejects missing name/email for unknown user', (done) => {
83+
request(app)
84+
.post('/contact')
85+
.type('form')
86+
.send({ _csrf: 'testcsrf', name: '', email: '', message: 'Hello', 'g-recaptcha-response': 'token' })
87+
.expect(302)
88+
.expect('Location', '/contact')
89+
.end((err) => {
90+
if (err) return done(err);
91+
expect(sendMailStub.called).to.be.false;
92+
done();
93+
});
94+
});
95+
96+
it('rejects missing message', (done) => {
97+
request(app)
98+
.post('/contact')
99+
.type('form')
100+
.send({ _csrf: 'testcsrf', name: 'Test', email: '[email protected]', message: '', 'g-recaptcha-response': 'token' })
101+
.expect(302)
102+
.expect('Location', '/contact')
103+
.end((err) => {
104+
if (err) return done(err);
105+
expect(sendMailStub.called).to.be.false;
106+
done();
107+
});
108+
});
109+
110+
it('rejects missing reCAPTCHA', (done) => {
111+
request(app)
112+
.post('/contact')
113+
.type('form')
114+
.send({ _csrf: 'testcsrf', name: 'Test', email: '[email protected]', message: 'Hello', 'g-recaptcha-response': '' })
115+
.expect(302)
116+
.expect('Location', '/contact')
117+
.end((err) => {
118+
if (err) return done(err);
119+
expect(sendMailStub.called).to.be.false;
120+
done();
121+
});
122+
});
123+
124+
it('sends email if all fields are valid', (done) => {
125+
request(app)
126+
.post('/contact')
127+
.type('form')
128+
.send({ _csrf: 'testcsrf', name: 'Test', email: '[email protected]', message: 'Hello', 'g-recaptcha-response': 'token' })
129+
.expect(302)
130+
.expect('Location', '/contact')
131+
.end((err) => {
132+
if (err) return done(err);
133+
expect(sendMailStub.calledOnce).to.be.true;
134+
done();
135+
});
136+
});
137+
138+
it('handles reCAPTCHA failure', (done) => {
139+
fetchStub.resolves({ json: () => Promise.resolve({ success: false }) });
140+
request(app)
141+
.post('/contact')
142+
.type('form')
143+
.send({ _csrf: 'testcsrf', name: 'Test', email: '[email protected]', message: 'Hello', 'g-recaptcha-response': 'token' })
144+
.expect(302)
145+
.expect('Location', '/contact')
146+
.end((err) => {
147+
if (err) return done(err);
148+
expect(sendMailStub.called).to.be.false;
149+
done();
150+
});
151+
});
152+
});
153+
});

views/contact.pug

+17-4
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
extends layout
22

33
block head
4-
script(src='https://www.google.com/recaptcha/api.js', async='', defer='')
4+
if sitekey
5+
script(src='https://www.google.com/recaptcha/api.js', async='', defer='')
56

67
block content
78
.pb-2.mt-2.mb-4.border-bottom
89
h3 Contact Form
910

10-
form(method='POST')
11+
form#contactForm(method='POST')
1112
input(type='hidden', name='_csrf', value=_csrf)
1213
if (unknownUser)
1314
.form-group.row.mb-3
@@ -24,8 +25,20 @@ block content
2425
textarea#message.form-control(name='message', rows='7', autofocus=(!unknownUser).toString(), required)
2526
.form-group
2627
.offset-md-2.col-md-8.p-1
27-
.g-recaptcha(data-sitekey=sitekey)
28+
if sitekey
29+
#recaptchaWidget.g-recaptcha(data-sitekey=sitekey)
30+
span#recaptchaError.text-danger.d-none.mt-2 Please complete the reCAPTCHA before submitting the form.
2831
br
29-
button.col-md-2.btn.btn-primary(type='submit')
32+
button#submitBtn.col-md-2.btn.btn-primary(type='submit')
3033
i.far.fa-envelope.fa-sm.iconpadding
3134
| Send
35+
script.
36+
document.getElementById('contactForm').addEventListener('submit', function (event) {
37+
const recaptchaError = document.getElementById('recaptchaError');
38+
if (typeof grecaptcha !== 'undefined' && !grecaptcha.getResponse()) {
39+
event.preventDefault(); // Prevent form submission
40+
recaptchaError.classList.remove('d-none');
41+
} else {
42+
recaptchaError.classList.add('d-none');
43+
}
44+
});

0 commit comments

Comments
 (0)