Files
overleaf-cep/services/web/app/src/Features/Captcha/CaptchaMiddleware.js
Jakob Ackermann 392d2f1c26 [web] add support for regex based allow-list for skipping captcha (#24266)
* [web] double down on validating email addresses

* [web] normalize emails in captcha middleware

* [web] add support for regex based allow-list for skipping captcha

* [web] skip captcha for trusted users on all actions

GitOrigin-RevId: a994ebf6b74e80f462d2dab1fe5113bbffa676a9
2025-03-24 10:45:53 +00:00

120 lines
3.9 KiB
JavaScript

const { fetchJson } = require('@overleaf/fetch-utils')
const logger = require('@overleaf/logger')
const Settings = require('@overleaf/settings')
const Metrics = require('@overleaf/metrics')
const OError = require('@overleaf/o-error')
const DeviceHistory = require('./DeviceHistory')
const AuthenticationController = require('../Authentication/AuthenticationController')
const { expressify } = require('@overleaf/promise-utils')
const EmailsHelper = require('../Helpers/EmailHelper')
function respondInvalidCaptcha(req, res) {
res.status(400).json({
errorReason: 'cannot_verify_user_not_robot',
message: {
text: req.i18n.translate('cannot_verify_user_not_robot'),
},
})
}
async function initializeDeviceHistory(req) {
req.deviceHistory = new DeviceHistory()
try {
await req.deviceHistory.parse(req)
} catch (err) {
logger.err({ err }, 'cannot parse deviceHistory')
}
}
async function canSkipCaptcha(req, res) {
const trustedUser =
req.body?.email && Settings.recaptcha.trustedUsers.includes(req.body.email)
if (trustedUser) {
return res.json(true)
}
await initializeDeviceHistory(req)
const canSkip = req.deviceHistory.has(req.body?.email)
Metrics.inc('captcha_pre_flight', 1, {
status: canSkip ? 'skipped' : 'missing',
})
res.json(canSkip)
}
function validateCaptcha(action) {
return expressify(async function (req, res, next) {
const email = EmailsHelper.parseEmail(req.body?.email)
const trustedUser =
email &&
(Settings.recaptcha.trustedUsers.includes(email) ||
Settings.recaptcha.trustedUsersRegex?.test(email))
if (!Settings.recaptcha?.siteKey || Settings.recaptcha.disabled[action]) {
if (action === 'login') {
AuthenticationController.setAuditInfo(req, { captcha: 'disabled' })
}
Metrics.inc('captcha', 1, { path: action, status: 'disabled' })
return next()
}
if (trustedUser) {
if (action === 'login') {
AuthenticationController.setAuditInfo(req, { captcha: 'trusted' })
}
Metrics.inc('captcha', 1, { path: action, status: 'trusted' })
return next()
}
const reCaptchaResponse = req.body['g-recaptcha-response']
if (action === 'login') {
await initializeDeviceHistory(req)
const fromKnownDevice = req.deviceHistory.has(email)
AuthenticationController.setAuditInfo(req, { fromKnownDevice })
if (!reCaptchaResponse && fromKnownDevice) {
// The user has previously logged in from this device, which required
// solving a captcha or keeping the device history alive.
// We can skip checking the (missing) captcha response.
AuthenticationController.setAuditInfo(req, { captcha: 'skipped' })
Metrics.inc('captcha', 1, { path: action, status: 'skipped' })
return next()
}
}
if (!reCaptchaResponse) {
Metrics.inc('captcha', 1, { path: action, status: 'missing' })
return respondInvalidCaptcha(req, res)
}
let body
try {
body = await fetchJson(Settings.recaptcha.endpoint, {
method: 'POST',
body: new URLSearchParams([
['secret', Settings.recaptcha.secretKey],
['response', reCaptchaResponse],
]),
})
} catch (err) {
Metrics.inc('captcha', 1, { path: action, status: 'error' })
throw OError.tag(err, 'failed recaptcha siteverify request', {
body: err.body,
})
}
if (!body.success) {
logger.warn(
{ statusCode: 200, body },
'failed recaptcha siteverify request'
)
Metrics.inc('captcha', 1, { path: action, status: 'failed' })
return respondInvalidCaptcha(req, res)
}
Metrics.inc('captcha', 1, { path: action, status: 'solved' })
if (action === 'login') {
AuthenticationController.setAuditInfo(req, { captcha: 'solved' })
}
next()
})
}
module.exports = {
respondInvalidCaptcha,
validateCaptcha,
canSkipCaptcha: expressify(canSkipCaptcha),
}