Jelajahi Sumber

[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
Jakob Ackermann 1 tahun lalu
induk
melakukan
e94473a1ce

+ 10 - 5
services/web/app/src/Features/Captcha/CaptchaMiddleware.js

@@ -6,6 +6,7 @@ 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({
@@ -41,9 +42,11 @@ async function canSkipCaptcha(req, res) {
 
 function validateCaptcha(action) {
   return expressify(async function (req, res, next) {
+    const email = EmailsHelper.parseEmail(req.body?.email)
     const trustedUser =
-      req.body?.email &&
-      Settings.recaptcha.trustedUsers.includes(req.body.email)
+      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' })
@@ -51,15 +54,17 @@ function validateCaptcha(action) {
       Metrics.inc('captcha', 1, { path: action, status: 'disabled' })
       return next()
     }
-    if (trustedUser && action === 'login') {
-      AuthenticationController.setAuditInfo(req, { captcha: 'trusted' })
+    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(req.body?.email)
+      const fromKnownDevice = req.deviceHistory.has(email)
       AuthenticationController.setAuditInfo(req, { fromKnownDevice })
       if (!reCaptchaResponse && fromKnownDevice) {
         // The user has previously logged in from this device, which required

+ 1 - 1
services/web/app/src/Features/Helpers/EmailHelper.js

@@ -11,7 +11,7 @@ function getDomain(email) {
 }
 
 function parseEmail(email, parseRfcAddress = false) {
-  if (email == null) {
+  if (typeof email !== 'string' || !email) {
     return null
   }
 

+ 4 - 0
services/web/config/settings.defaults.js

@@ -779,6 +779,10 @@ module.exports = {
       .split(',')
       .map(x => x.trim())
       .filter(x => x !== ''),
+    trustedUsersRegex: process.env.CAPTCHA_TRUSTED_USERS_REGEX
+      ? // Enforce matching of the entire input.
+        new RegExp(`^${process.env.CAPTCHA_TRUSTED_USERS_REGEX}$`)
+      : null,
     disabled: {
       invite: true,
       login: true,

+ 18 - 0
services/web/test/unit/src/HelperFiles/EmailHelperTests.js

@@ -18,6 +18,24 @@ describe('EmailHelper', function () {
     expect(parseEmail(address, true)).to.equal(expected)
   })
 
+  it('should return null for garbage input', function () {
+    const cases = [
+      undefined,
+      null,
+      '',
+      42,
+      ['test@example.com'],
+      {},
+      { length: 42 },
+      { trim: true, match: true },
+      { toString: true },
+    ]
+    for (const input of cases) {
+      expect(parseEmail(input)).to.equal(null, input)
+      expect(parseEmail(input, true)).to.equal(null, input)
+    }
+  })
+
   it('should return null for an invalid single email', function () {
     const address = 'testexample.com'
     expect(parseEmail(address)).to.equal(null)