From 6ca58edf72f2869671c0070d961dc2941517d034 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Wed, 5 Aug 2026 13:00:09 +0200 Subject: [PATCH] [web] add infrastructure level zod change (#36190) * [web] add infrastructure level zod change * [web] set req validation mode for tests to 'enforce' GitOrigin-RevId: 6dbc8bff7ed9f89045c398df93d84bca589de4e9 --- ...h-oauth2-server-npm-5.3.0-9d66d9183c.patch | 67 ++++ ...l-passport-saml-npm-5.1.0-5d7df575fa.patch | 99 ++++++ .../patches/csurf-npm-1.11.0-c1b9cbb35b.patch | 46 +++ ...ss-bearer-token-npm-2.4.0-731c8c5c99.patch | 55 +++ .../passport-local-npm-1.0.0-ed89961a0c.patch | 25 ++ .yarn/patches/passport-oauth2-npm-1.6.1.patch | 74 +++- .../src/Features/Analytics/RequestHelper.mjs | 8 + .../AuthenticationController.mjs | 29 +- .../Authorization/AuthorizationMiddleware.mjs | 69 ++-- .../Features/Captcha/CaptchaMiddleware.mjs | 51 ++- .../Security/RateLimiterMiddleware.mjs | 7 +- services/web/app/src/infrastructure/Csrf.mjs | 31 +- .../app/src/infrastructure/ExpressLocals.mjs | 8 +- .../src/infrastructure/RedirectManager.mjs | 4 +- .../SessionAutostartMiddleware.mjs | 9 +- .../UnsupportedBrowserMiddleware.mjs | 28 +- .../web/app/src/infrastructure/Validation.mjs | 20 +- services/web/app/src/router.mjs | 27 +- services/web/package.json | 10 +- .../web/test/acceptance/src/CaptchaTests.mjs | 59 ++++ services/web/test/acceptance/src/Init.mjs | 2 + .../acceptance/src/PasswordResetTests.mjs | 8 + .../web/test/acceptance/src/RouterTests.mjs | 47 +++ .../src/UnsupportedBrowserTests.mjs | 28 ++ .../test/acceptance/src/helpers/InitApp.mjs | 34 +- .../web/test/acceptance/src/helpers/User.mjs | 39 ++- .../acceptance/src/mocks/AbstractMockApi.mjs | 11 +- .../acceptance/src/mocks/MockAnalyticsApi.mjs | 49 ++- .../test/acceptance/src/mocks/MockChatApi.mjs | 148 ++++++-- .../test/acceptance/src/mocks/MockClsiApi.mjs | 42 ++- .../acceptance/src/mocks/MockClsiCache.mjs | 146 ++++++++ .../acceptance/src/mocks/MockClsiNginxApi.mjs | 15 +- .../src/mocks/MockDocUpdaterApi.mjs | 138 +++++++- .../acceptance/src/mocks/MockDocstoreApi.mjs | 111 +++++- .../acceptance/src/mocks/MockGitBridgeApi.mjs | 18 +- .../src/mocks/MockGoogleOauthApi.mjs | 29 +- .../src/mocks/MockHaveIBeenPwnedApi.mjs | 12 +- .../mocks/MockHistoryBackupDeletionApi.mjs | 8 +- .../acceptance/src/mocks/MockOrcidApi.mjs | 16 +- .../src/mocks/MockProjectHistoryApi.mjs | 81 ++++- .../acceptance/src/mocks/MockReCaptchaApi.mjs | 14 +- .../acceptance/src/mocks/MockRecurlyApi.mjs | 52 ++- .../acceptance/src/mocks/MockSpellingApi.mjs | 30 +- .../acceptance/src/mocks/MockStripeApi.mjs | 319 ++++++++++++++++-- .../test/acceptance/src/mocks/MockV1Api.mjs | 304 ++++++++++++----- .../acceptance/src/mocks/MockV1HistoryApi.mjs | 99 ++++-- .../AuthenticationController.test.mjs | 19 ++ .../AuthorizationMiddleware.test.mjs | 113 ++++++- .../src/Captcha/CaptchaMiddleware.test.mjs | 110 ++++++ .../unit/src/infrastructure/Csrf.test.mjs | 65 ++++ .../UnsupportedBrowserMiddleware.test.mjs | 56 +++ yarn.lock | 69 +++- 52 files changed, 2657 insertions(+), 301 deletions(-) create mode 100644 .yarn/patches/@node-oauth-oauth2-server-npm-5.3.0-9d66d9183c.patch create mode 100644 .yarn/patches/@node-saml-passport-saml-npm-5.1.0-5d7df575fa.patch create mode 100644 .yarn/patches/csurf-npm-1.11.0-c1b9cbb35b.patch create mode 100644 .yarn/patches/express-bearer-token-npm-2.4.0-731c8c5c99.patch create mode 100644 .yarn/patches/passport-local-npm-1.0.0-ed89961a0c.patch create mode 100644 services/web/test/acceptance/src/RouterTests.mjs create mode 100644 services/web/test/acceptance/src/mocks/MockClsiCache.mjs create mode 100644 services/web/test/unit/src/Captcha/CaptchaMiddleware.test.mjs create mode 100644 services/web/test/unit/src/infrastructure/UnsupportedBrowserMiddleware.test.mjs diff --git a/.yarn/patches/@node-oauth-oauth2-server-npm-5.3.0-9d66d9183c.patch b/.yarn/patches/@node-oauth-oauth2-server-npm-5.3.0-9d66d9183c.patch new file mode 100644 index 0000000000..3ab5aca167 --- /dev/null +++ b/.yarn/patches/@node-oauth-oauth2-server-npm-5.3.0-9d66d9183c.patch @@ -0,0 +1,67 @@ +diff --git a/lib/request.js b/lib/request.js +index f42960ca2b94b784497392cfb85ef31e4e63a1ac..7feea2ed29873e519aee0ba5e1f5b36e09fe865d 100644 +--- a/lib/request.js ++++ b/lib/request.js +@@ -6,6 +6,30 @@ + const InvalidArgumentError = require('./errors/invalid-argument-error'); + /* type-is: https://github.com/jshttp/type-is */ + const typeis = require('type-is'); ++// --- Overleaf patch: request-input lockdown compatibility --- ++// The constructor below destructures query/body directly off the raw ++// framework request object it's handed (see AuthenticationController.mjs's ++// `new Oauth2Server.Request(req)`), which is otherwise indistinguishable ++// from unvalidated application code reading raw request input under ++// Overleaf's request-input lockdown (see libraries/validation-tools and the ++// patched express in .yarn/patches/express-npm-*.patch). The lockdown ++// captures the original parsed values in symbol-keyed fields, registered in ++// the global symbol registry (the same ones @overleaf/validation-tools' ++// lockdown.js reads), and only turns the *public* req.query/req.body ++// properties into throwing accessors -- reading through these raw fields ++// instead sidesteps that, and is identical to plain req.query/req.body when ++// the lockdown isn't installed (i.e. for every consumer of this package ++// outside this monorepo). ++const __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed'); ++const __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery'); ++const __overleafLockdownRawBody = Symbol.for('overleaf.lockdown.rawBody'); ++function __overleafRawQuery(req) { ++ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query; ++} ++function __overleafRawBody(req) { ++ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body; ++} ++// --- end Overleaf patch --- + + /** + * Wrapper for webserver's request. +@@ -29,7 +53,12 @@ class Request { + * @param otherOptions {...object} any other properties that should be assigned to the request by your webserver + * @throws {InvalidArgumentError} if one of headers, method or query are missing. + */ +- constructor({ headers, method, query, body, ...otherOptions } = {}) { ++ constructor(req = {}) { ++ const headers = req.headers; ++ const method = req.method; ++ const query = __overleafRawQuery(req); ++ const body = __overleafRawBody(req); ++ + if (!headers) { + throw new InvalidArgumentError('Missing parameter: `headers`'); + } +@@ -52,7 +81,16 @@ class Request { + this.headers[header.toLowerCase()] = value; + }); + +- // Store additional properties of the request object passed in ++ // Store additional properties of the request object passed in (mirrors ++ // the original `...otherOptions` rest destructuring, but built off ++ // Object.assign so query/body -- already handled above via the raw ++ // lockdown fields -- aren't read a second time through the throwing ++ // accessors). ++ const otherOptions = Object.assign({}, req); ++ delete otherOptions.headers; ++ delete otherOptions.method; ++ delete otherOptions.query; ++ delete otherOptions.body; + Object.entries(otherOptions) + .filter(([property]) => !this[property]) + .forEach(([property, value]) => { diff --git a/.yarn/patches/@node-saml-passport-saml-npm-5.1.0-5d7df575fa.patch b/.yarn/patches/@node-saml-passport-saml-npm-5.1.0-5d7df575fa.patch new file mode 100644 index 0000000000..2f728a96d8 --- /dev/null +++ b/.yarn/patches/@node-saml-passport-saml-npm-5.1.0-5d7df575fa.patch @@ -0,0 +1,99 @@ +diff --git a/lib/strategy.js b/lib/strategy.js +index 4de162f7e92a20b1ccd4d34dd9bcc0916f4c8c43..c67bb4e0b0287d615eac4c01b84c83d2bac91446 100644 +--- a/lib/strategy.js ++++ b/lib/strategy.js +@@ -5,6 +5,29 @@ const passport_strategy_1 = require("passport-strategy"); + const assert_1 = require("assert"); + const url = require("url"); + const node_saml_1 = require("@node-saml/node-saml"); ++// --- Overleaf patch: request-input lockdown compatibility --- ++// This library reads req.query/req.body directly to parse the SAML protocol ++// fields (SAMLResponse, SAMLRequest, RelayState) it's built around, which is ++// otherwise indistinguishable from unvalidated application code reading raw ++// request input under Overleaf's request-input lockdown (see ++// libraries/validation-tools and the patched express in ++// .yarn/patches/express-npm-*.patch). The lockdown captures the original ++// parsed values in symbol-keyed fields, registered in the global symbol ++// registry (the same ones @overleaf/validation-tools' lockdown.js reads), and ++// only turns the *public* req.query/req.body properties into throwing ++// accessors -- reading through these raw fields instead sidesteps that, and ++// is identical to plain req.query/req.body when the lockdown isn't installed ++// (i.e. for every consumer of this package outside this monorepo). ++const __overleafLockdownInstalled = Symbol.for("overleaf.lockdown.installed"); ++const __overleafLockdownRawQuery = Symbol.for("overleaf.lockdown.rawQuery"); ++const __overleafLockdownRawBody = Symbol.for("overleaf.lockdown.rawBody"); ++function __overleafRawQuery(req) { ++ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query; ++} ++function __overleafRawBody(req) { ++ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body; ++} ++// --- end Overleaf patch --- + class AbstractStrategy extends passport_strategy_1.Strategy { + constructor(options, signonVerify, logoutVerify) { + super(); +@@ -52,7 +75,7 @@ class AbstractStrategy extends passport_strategy_1.Strategy { + catch (_c) { + userMatch = false; + } +- const RelayState = ((_a = req.query) === null || _a === void 0 ? void 0 : _a.RelayState) || ((_b = req.body) === null || _b === void 0 ? void 0 : _b.RelayState); ++ const RelayState = ((_a = __overleafRawQuery(req)) === null || _a === void 0 ? void 0 : _a.RelayState) || ((_b = __overleafRawBody(req)) === null || _b === void 0 ? void 0 : _b.RelayState); + if (this._saml == null) { + return this.error(new Error("Can't get logout response URL without a SAML provider defined.")); + } +@@ -125,22 +148,22 @@ class AbstractStrategy extends passport_strategy_1.Strategy { + this.redirect(url); + } + }; +- if (((_a = req.query) === null || _a === void 0 ? void 0 : _a.SAMLResponse) || ((_b = req.query) === null || _b === void 0 ? void 0 : _b.SAMLRequest)) { ++ if (((_a = __overleafRawQuery(req)) === null || _a === void 0 ? void 0 : _a.SAMLResponse) || ((_b = __overleafRawQuery(req)) === null || _b === void 0 ? void 0 : _b.SAMLRequest)) { + const originalQuery = (_c = url.parse(req.url).query) !== null && _c !== void 0 ? _c : ""; + this._saml +- .validateRedirectAsync(req.query, originalQuery) ++ .validateRedirectAsync(__overleafRawQuery(req), originalQuery) + .then(validateCallback) + .catch((err) => this.error(err)); + } +- else if ((_d = req.body) === null || _d === void 0 ? void 0 : _d.SAMLResponse) { ++ else if ((_d = __overleafRawBody(req)) === null || _d === void 0 ? void 0 : _d.SAMLResponse) { + this._saml +- .validatePostResponseAsync(req.body) ++ .validatePostResponseAsync(__overleafRawBody(req)) + .then(validateCallback) + .catch((err) => this.error(err)); + } +- else if ((_e = req.body) === null || _e === void 0 ? void 0 : _e.SAMLRequest) { ++ else if ((_e = __overleafRawBody(req)) === null || _e === void 0 ? void 0 : _e.SAMLRequest) { + this._saml +- .validatePostRequestAsync(req.body) ++ .validatePostRequestAsync(__overleafRawBody(req)) + .then(validateCallback) + .catch((err) => this.error(err)); + } +@@ -151,7 +174,7 @@ class AbstractStrategy extends passport_strategy_1.Strategy { + if (this._saml == null) { + throw new Error("Can't process login request without a SAML provider defined."); + } +- const RelayState = (req.query && req.query.RelayState) || (req.body && req.body.RelayState); ++ const RelayState = (__overleafRawQuery(req) && __overleafRawQuery(req).RelayState) || (__overleafRawBody(req) && __overleafRawBody(req).RelayState); + const host = req.headers && req.headers.host; + if (this._saml.options.authnRequestBinding === "HTTP-POST") { + const data = await this._saml.getAuthorizeFormAsync(RelayState, host); +@@ -172,7 +195,7 @@ class AbstractStrategy extends passport_strategy_1.Strategy { + throw new Error("Can't process logout request without a SAML provider defined."); + } + try { +- const RelayState = (req.query && req.query.RelayState) || (req.body && req.body.RelayState); ++ const RelayState = (__overleafRawQuery(req) && __overleafRawQuery(req).RelayState) || (__overleafRawBody(req) && __overleafRawBody(req).RelayState); + // Defaults to HTTP-Redirect + this.redirect(await this._saml.getLogoutUrlAsync(req.user, RelayState, options)); + } +@@ -188,7 +211,7 @@ class AbstractStrategy extends passport_strategy_1.Strategy { + if (this._saml == null) { + throw new Error("Can't logout without a SAML provider defined."); + } +- const RelayState = (req.query && req.query.RelayState) || (req.body && req.body.RelayState); ++ const RelayState = (__overleafRawQuery(req) && __overleafRawQuery(req).RelayState) || (__overleafRawBody(req) && __overleafRawBody(req).RelayState); + this._saml + .getLogoutUrlAsync(req.user, RelayState, {}) + .then((url) => callback(null, url)) diff --git a/.yarn/patches/csurf-npm-1.11.0-c1b9cbb35b.patch b/.yarn/patches/csurf-npm-1.11.0-c1b9cbb35b.patch new file mode 100644 index 0000000000..6906ccc2f9 --- /dev/null +++ b/.yarn/patches/csurf-npm-1.11.0-c1b9cbb35b.patch @@ -0,0 +1,46 @@ +diff --git a/index.js b/index.js +index 367bdbafbc71b61daf3907913c3861d155140f07..b31ff2d98186ac29446718edfccc03a3562e796a 100644 +--- a/index.js ++++ b/index.js +@@ -17,6 +17,30 @@ var Cookie = require('cookie') + var createError = require('http-errors') + var sign = require('cookie-signature').sign + var Tokens = require('csrf') ++// --- Overleaf patch: request-input lockdown compatibility --- ++// The default value getter reads req.body/req.query directly to find the ++// CSRF token submitted alongside a mutating request -- otherwise ++// indistinguishable from unvalidated application code reading raw request ++// input under Overleaf's request-input lockdown (see ++// libraries/validation-tools and the patched express in ++// .yarn/patches/express-npm-*.patch). The lockdown captures the original ++// parsed values in symbol-keyed fields, registered in the global symbol ++// registry (the same ones @overleaf/validation-tools' lockdown.js reads), ++// and only turns the *public* req.query/req.body properties into throwing ++// accessors -- reading through these raw fields instead sidesteps that, and ++// is identical to plain req.query/req.body when the lockdown isn't ++// installed (i.e. for every consumer of this package outside this ++// monorepo). ++var __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed') ++var __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery') ++var __overleafLockdownRawBody = Symbol.for('overleaf.lockdown.rawBody') ++function __overleafRawQuery (req) { ++ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query ++} ++function __overleafRawBody (req) { ++ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body ++} ++// --- end Overleaf patch --- + + /** + * Module exports. +@@ -128,8 +152,8 @@ function csurf (options) { + */ + + function defaultValue (req) { +- return (req.body && req.body._csrf) || +- (req.query && req.query._csrf) || ++ return (__overleafRawBody(req) && __overleafRawBody(req)._csrf) || ++ (__overleafRawQuery(req) && __overleafRawQuery(req)._csrf) || + (req.headers['csrf-token']) || + (req.headers['xsrf-token']) || + (req.headers['x-csrf-token']) || diff --git a/.yarn/patches/express-bearer-token-npm-2.4.0-731c8c5c99.patch b/.yarn/patches/express-bearer-token-npm-2.4.0-731c8c5c99.patch new file mode 100644 index 0000000000..14b15a3721 --- /dev/null +++ b/.yarn/patches/express-bearer-token-npm-2.4.0-731c8c5c99.patch @@ -0,0 +1,55 @@ +diff --git a/index.js b/index.js +index 49f181005f74cf11db3bd5b0ecf512520163ec71..d47ad4270883b90766152342c99495a07cb952e0 100644 +--- a/index.js ++++ b/index.js +@@ -1,5 +1,29 @@ + const parseCookie = require('cookie').parse; + const decodeCookie = require('cookie-parser').signedCookie; ++// --- Overleaf patch: request-input lockdown compatibility --- ++// This middleware is mounted globally, before any router split, and reads ++// req.query/req.body directly to look for a bearer token -- otherwise ++// indistinguishable from unvalidated application code reading raw request ++// input under Overleaf's request-input lockdown (see ++// libraries/validation-tools and the patched express in ++// .yarn/patches/express-npm-*.patch). The lockdown captures the original ++// parsed values in symbol-keyed fields, registered in the global symbol ++// registry (the same ones @overleaf/validation-tools' lockdown.js reads), ++// and only turns the *public* req.query/req.body properties into throwing ++// accessors -- reading through these raw fields instead sidesteps that, and ++// is identical to plain req.query/req.body when the lockdown isn't ++// installed (i.e. for every consumer of this package outside this ++// monorepo). ++const __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed'); ++const __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery'); ++const __overleafLockdownRawBody = Symbol.for('overleaf.lockdown.rawBody'); ++function __overleafRawQuery(req) { ++ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query; ++} ++function __overleafRawBody(req) { ++ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body; ++} ++// --- end Overleaf patch --- + + const getCookie = (serialized_cookies, key) => parseCookie(serialized_cookies)[key] || false; + +@@ -32,16 +56,16 @@ module.exports = opts => { + let error; + + // query +- if (req.query && req.query[queryKey]) { +- token = req.query[queryKey]; ++ if (__overleafRawQuery(req) && __overleafRawQuery(req)[queryKey]) { ++ token = __overleafRawQuery(req)[queryKey]; + } + + // body +- if (req.body && req.body[bodyKey]) { ++ if (__overleafRawBody(req) && __overleafRawBody(req)[bodyKey]) { + if (token) { + error = true; + } +- token = req.body[bodyKey]; ++ token = __overleafRawBody(req)[bodyKey]; + } + + // headers diff --git a/.yarn/patches/passport-local-npm-1.0.0-ed89961a0c.patch b/.yarn/patches/passport-local-npm-1.0.0-ed89961a0c.patch new file mode 100644 index 0000000000..1d7cba8e7a --- /dev/null +++ b/.yarn/patches/passport-local-npm-1.0.0-ed89961a0c.patch @@ -0,0 +1,25 @@ +diff --git a/lib/strategy.js b/lib/strategy.js +index e54e92a86c8e3d40338ed8c6e57e8729393d85a3..cad017ef2027ad676782515a9803835138efa6b3 100644 +--- a/lib/strategy.js ++++ b/lib/strategy.js +@@ -66,10 +66,18 @@ util.inherits(Strategy, passport.Strategy); + * @param {Object} req + * @api protected + */ ++var LOCKDOWN_INSTALLED = Symbol.for('overleaf.lockdown.installed'); ++var LOCKDOWN_RAW_BODY = Symbol.for('overleaf.lockdown.rawBody'); ++var LOCKDOWN_RAW_QUERY = Symbol.for('overleaf.lockdown.rawQuery'); ++ + Strategy.prototype.authenticate = function(req, options) { + options = options || {}; +- var username = lookup(req.body, this._usernameField) || lookup(req.query, this._usernameField); +- var password = lookup(req.body, this._passwordField) || lookup(req.query, this._passwordField); ++ // with the Overleaf request-input lockdown installed, read the raw ++ // symbol-keyed fields; the public req.body/req.query accessors warn/throw ++ var body = req[LOCKDOWN_INSTALLED] ? req[LOCKDOWN_RAW_BODY] : req.body; ++ var query = req[LOCKDOWN_INSTALLED] ? req[LOCKDOWN_RAW_QUERY] : req.query; ++ var username = lookup(body, this._usernameField) || lookup(query, this._usernameField); ++ var password = lookup(body, this._passwordField) || lookup(query, this._passwordField); + + if (!username || !password) { + return this.fail({ message: options.badRequestMessage || 'Missing credentials' }, 400); diff --git a/.yarn/patches/passport-oauth2-npm-1.6.1.patch b/.yarn/patches/passport-oauth2-npm-1.6.1.patch index 835ad51b19..fbe07e7a59 100644 --- a/.yarn/patches/passport-oauth2-npm-1.6.1.patch +++ b/.yarn/patches/passport-oauth2-npm-1.6.1.patch @@ -1,10 +1,82 @@ +diff --git a/lib/strategy.js b/lib/strategy.js +index b05aacce9a16fd32743a989b68f179bb35b20545..de1293a75c82452089eea37ab1031d4bbe42849c 100644 +--- a/lib/strategy.js ++++ b/lib/strategy.js +@@ -14,6 +14,25 @@ var passport = require('passport-strategy') + , AuthorizationError = require('./errors/authorizationerror') + , TokenError = require('./errors/tokenerror') + , InternalOAuthError = require('./errors/internaloautherror'); ++// --- Overleaf patch: request-input lockdown compatibility --- ++// This strategy reads req.query directly to parse the OAuth2 protocol ++// fields (error/error_description/error_uri, code, state) it's built ++// around, which is otherwise indistinguishable from unvalidated application ++// code reading raw request input under Overleaf's request-input lockdown ++// (see libraries/validation-tools and the patched express in ++// .yarn/patches/express-npm-*.patch). The lockdown captures the original ++// parsed values in symbol-keyed fields, registered in the global symbol ++// registry (the same ones @overleaf/validation-tools' lockdown.js reads), ++// and only turns the *public* req.query property into a throwing accessor ++// -- reading through this raw field instead sidesteps that, and is ++// identical to plain req.query when the lockdown isn't installed (i.e. for ++// every consumer of this package outside this monorepo). ++var __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed'); ++var __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery'); ++function __overleafRawQuery(req) { ++ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query; ++} ++// --- end Overleaf patch --- + + + /** +@@ -131,11 +150,11 @@ OAuth2Strategy.prototype.authenticate = function(req, options) { + options = options || {}; + var self = this; + +- if (req.query && req.query.error) { +- if (req.query.error == 'access_denied') { +- return this.fail({ message: req.query.error_description }); ++ if (__overleafRawQuery(req) && __overleafRawQuery(req).error) { ++ if (__overleafRawQuery(req).error == 'access_denied') { ++ return this.fail({ message: __overleafRawQuery(req).error_description }); + } else { +- return this.error(new AuthorizationError(req.query.error_description, req.query.error, req.query.error_uri)); ++ return this.error(new AuthorizationError(__overleafRawQuery(req).error_description, __overleafRawQuery(req).error, __overleafRawQuery(req).error_uri)); + } + } + +@@ -156,14 +175,14 @@ OAuth2Strategy.prototype.authenticate = function(req, options) { + callbackURL: callbackURL + } + +- if (req.query && req.query.code) { ++ if (__overleafRawQuery(req) && __overleafRawQuery(req).code) { + function loaded(err, ok, state) { + if (err) { return self.error(err); } + if (!ok) { + return self.fail(state, 403); + } + +- var code = req.query.code; ++ var code = __overleafRawQuery(req).code; + + var params = self.tokenParams(options); + params.grant_type = 'authorization_code'; +@@ -213,7 +232,7 @@ OAuth2Strategy.prototype.authenticate = function(req, options) { + ); + } + +- var state = req.query.state; ++ var state = __overleafRawQuery(req).state; + try { + var arity = this._stateStore.verify.length; + if (arity == 4) { diff --git a/lib/utils.js b/lib/utils.js index 486f9e1..4584507 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -24,7 +24,7 @@ exports.originalURL = function(req, options) { var trustProxy = options.proxy; - + var proto = (req.headers['x-forwarded-proto'] || '').toLowerCase() - , tls = req.connection.encrypted || (trustProxy && 'https' == proto.split(/\s*,\s*/)[0]) + , tls = (req.connection && req.connection.encrypted) || (trustProxy && 'https' == proto.split(/\s*,\s*/)[0]) diff --git a/services/web/app/src/Features/Analytics/RequestHelper.mjs b/services/web/app/src/Features/Analytics/RequestHelper.mjs index ca030721ca..e8f7a1a291 100644 --- a/services/web/app/src/Features/Analytics/RequestHelper.mjs +++ b/services/web/app/src/Features/Analytics/RequestHelper.mjs @@ -1,5 +1,6 @@ import RefererParser from 'referer-parser' import { URL } from 'node:url' +import { z } from '@overleaf/validation-tools' const UTM_KEYS = [ 'utm_campaign', @@ -11,6 +12,12 @@ const UTM_KEYS = [ 'utm_id', ] +const utmQuerySchema = z.object({ + query: z.object( + Object.fromEntries(UTM_KEYS.map(key => [key, z.string().optional()])) + ), +}) + function parseUtm(query) { const utmValues = {} for (const utmKey of UTM_KEYS) { @@ -61,6 +68,7 @@ function stripUTMKeys(query) { export default { UTM_KEYS, REGISTRATION_UTM_KEYS, + utmQuerySchema, parseUtm, parseReferrer, stripUTMKeys, diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.mjs b/services/web/app/src/Features/Authentication/AuthenticationController.mjs index 8201f01247..be935f4a76 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.mjs +++ b/services/web/app/src/Features/Authentication/AuthenticationController.mjs @@ -16,6 +16,11 @@ import passport from 'passport' import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs' import UrlHelper from '../Helpers/UrlHelper.mjs' import AsyncFormHelper from '../Helpers/AsyncFormHelper.mjs' +import { + getRawReqInput, + parseReq, + z, +} from '../../infrastructure/Validation.mjs' import _ from 'lodash' import UserAuditLogHandler from '../User/UserAuditLogHandler.mjs' import AnalyticsRegistrationSourceHelper from '../Analytics/AnalyticsRegistrationSourceHelper.mjs' @@ -29,6 +34,12 @@ import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs' const { hasAdminAccess } = AdminAuthorizationHelper +// middleware schema is non-strict: it validates only the field this +// middleware consumes; the route schema stays responsible for strictness +const zipUrlQuerySchema = z.object({ + query: z.object({ zipUrl: z.string().optional() }), +}) + function send401WithChallenge(res) { res.setHeader('WWW-Authenticate', 'OverleafLogin') res.sendStatus(401) @@ -534,9 +545,12 @@ const AuthenticationController = { setRedirectInSession(req, value) { if (value == null) { + // the full query string is re-encoded into the post-login redirect + // path verbatim, never read by name here (case 1: verbatim forwarding) + const { query } = getRawReqInput(req) value = - Object.keys(req.query).length > 0 - ? `${req.path}?${querystring.stringify(req.query)}` + Object.keys(query).length > 0 + ? `${req.path}?${querystring.stringify(query)}` : `${req.path}` } if ( @@ -550,8 +564,9 @@ const AuthenticationController = { }, _redirectToLoginOrRegisterPage(req, res) { + const { query } = parseReq(req, zipUrlQuerySchema, { logOnly: true }) if ( - req.query.zipUrl != null || + query.zipUrl != null || req.session.sharedProjectData || req.path === '/user/subscription/new' ) { @@ -567,7 +582,9 @@ const AuthenticationController = { 'user not logged in so redirecting to login page' ) AuthenticationController.setRedirectInSession(req) - const url = `/login?${querystring.stringify(req.query)}` + // the full query string is re-encoded into the login redirect URL + // verbatim, never read by name here (case 1: verbatim forwarding) + const url = `/login?${querystring.stringify(getRawReqInput(req).query)}` res.redirect(url) Metrics.inc('security.login-redirect') }, @@ -588,7 +605,9 @@ const AuthenticationController = { 'user not logged in so redirecting to register page' ) AuthenticationController.setRedirectInSession(req) - const url = `/register?${querystring.stringify(req.query)}` + // the full query string is re-encoded into the register redirect URL + // verbatim, never read by name here (case 1: verbatim forwarding) + const url = `/register?${querystring.stringify(getRawReqInput(req).query)}` res.redirect(url) Metrics.inc('security.login-redirect') }, diff --git a/services/web/app/src/Features/Authorization/AuthorizationMiddleware.mjs b/services/web/app/src/Features/Authorization/AuthorizationMiddleware.mjs index a38d1e6e2f..99a0f7c4be 100644 --- a/services/web/app/src/Features/Authorization/AuthorizationMiddleware.mjs +++ b/services/web/app/src/Features/Authorization/AuthorizationMiddleware.mjs @@ -1,8 +1,6 @@ import AuthorizationManager from './AuthorizationManager.mjs' import logger from '@overleaf/logger' -import mongodb from 'mongodb-legacy' -import Errors from '../Errors/Errors.js' import HttpErrorHandler from '../Errors/HttpErrorHandler.mjs' import AuthenticationController from '../Authentication/AuthenticationController.mjs' import SessionManager from '../Authentication/SessionManager.mjs' @@ -11,8 +9,41 @@ import { expressify } from '@overleaf/promise-utils' import AdminAuthorizationHelper from '../Helpers/AdminAuthorizationHelper.mjs' import UrlHelper from '../Helpers/UrlHelper.mjs' import ChatApiHandler from '../Chat/ChatApiHandler.mjs' +import { + getRawReqInput, + parseReq, + z, + zz, +} from '../../infrastructure/Validation.mjs' -const { ObjectId } = mongodb +// middleware schemas are non-strict: they validate only the fields this +// middleware consumes; the route schema stays responsible for strictness +const projectIdsQuerySchema = z.object({ + query: z.object({ project_ids: z.string().optional() }), +}) + +const projectIdParamsSchema = z.object({ + params: z.object({ + project_id: zz.objectId().optional(), + Project_id: zz.objectId().optional(), + }), +}) + +const threadIdParamsSchema = z.object({ + params: z.object({ thread_id: zz.objectId().optional() }), +}) + +const messageIdParamsSchema = z.object({ + params: z.object({ message_id: zz.objectId().optional() }), +}) + +const settingsBodySchema = z.object({ + body: z.object({ name: z.string().optional() }), +}) + +const restrictedQuerySchema = z.object({ + query: z.object({ from: z.string().optional() }), +}) function _handleAdminDomainRedirect(req, res) { if ( @@ -28,7 +59,8 @@ function _handleAdminDomainRedirect(req, res) { } async function ensureUserCanReadMultipleProjects(req, res, next) { - const projectIds = (req.query.project_ids || '').split(',') + const { query } = parseReq(req, projectIdsQuerySchema, { logOnly: true }) + const projectIds = (query.project_ids || '').split(',') const userId = _getUserId(req) for (const projectId of projectIds) { const token = TokenAccessHandler.getRequestToken(req, projectId) @@ -81,8 +113,9 @@ async function ensureUserCanWriteProjectSettings(req, res, next) { const projectId = _getProjectId(req) const userId = _getUserId(req) const token = TokenAccessHandler.getRequestToken(req, projectId) + const { body } = parseReq(req, settingsBodySchema, { logOnly: true }) - if (req.body.name != null) { + if (body.name != null) { const canRename = await AuthorizationManager.promises.canUserRenameProject( userId, projectId, @@ -93,7 +126,11 @@ async function ensureUserCanWriteProjectSettings(req, res, next) { } } - const otherParams = Object.keys(req.body).filter(x => x !== 'name') + // raw access justified: inspects the key set (not the values) to decide + // which authorization check applies; the route schema validates the values + const otherParams = Object.keys(getRawReqInput(req).body).filter( + x => x !== 'name' + ) if (otherParams.length > 0) { const canWrite = await AuthorizationManager.promises.canUserWriteProjectSettings( @@ -243,35 +280,27 @@ async function ensureUserIsSiteAdmin(req, res, next) { } function _getProjectId(req) { - const projectId = req.params.project_id || req.params.Project_id + const { params } = parseReq(req, projectIdParamsSchema) + const projectId = params.project_id || params.Project_id if (!projectId) { throw new Error('Expected project_id in request parameters') } - if (!ObjectId.isValid(projectId)) { - throw new Errors.NotFoundError(`invalid projectId: ${projectId}`) - } return projectId } function _getThreadId(req) { - const threadId = req.params.thread_id + const threadId = parseReq(req, threadIdParamsSchema).params.thread_id if (!threadId) { throw new Error('Expected thread_id in request parameters') } - if (!ObjectId.isValid(threadId)) { - throw new Errors.NotFoundError(`invalid threadId: ${threadId}`) - } return threadId } function _getMessageId(req) { - const messageId = req.params.message_id + const messageId = parseReq(req, messageIdParamsSchema).params.message_id if (!messageId) { throw new Error('Expected message_id in request parameters') } - if (!ObjectId.isValid(messageId)) { - throw new Errors.NotFoundError(`invalid messageId: ${messageId}`) - } return messageId } @@ -292,7 +321,9 @@ function restricted(req, res, next) { if (SessionManager.isUserLoggedIn(req.session)) { return res.render('user/restricted', { title: 'restricted' }) } - const { from } = req.query + const { from } = parseReq(req, restrictedQuerySchema, { + logOnly: true, + }).query logger.debug({ from }, 'redirecting to login') if (from) { AuthenticationController.setRedirectInSession(req, from) diff --git a/services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs b/services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs index 45e0902617..3879bad7b4 100644 --- a/services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs +++ b/services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs @@ -7,6 +7,30 @@ import DeviceHistory from './DeviceHistory.mjs' import AuthenticationController from '../Authentication/AuthenticationController.mjs' import { expressify } from '@overleaf/promise-utils' import EmailsHelper from '../Helpers/EmailHelper.mjs' +import { + z, + parseReq, + getRawReqInput, +} from '../../infrastructure/Validation.mjs' + +// Non-strict: this middleware only reads the two fields it needs off bodies +// whose full shape is owned by each route's own controller (login, +// passwordReset, addEmail, register) -- rejecting/stripping other fields +// here isn't this middleware's job. +const captchaBodySchema = z.object({ + body: z.object({ + email: z.string().optional(), + // Google reCAPTCHA verification token: an opaque, third-party-defined + // string (its format is a Google implementation detail, not documented + // or guaranteed stable), so this stays a bare string rather than a + // tighter pattern. The frontend's ReCaptcha2 component (recaptcha-2.tsx) + // sends an explicit `null` -- not an omitted field -- whenever it never + // mounted the widget (no sitekey configured, the action's captcha is + // disabled, or `window.Cypress` in dev E2E runs), so this must accept + // null, not just a missing key. + 'g-recaptcha-response': z.string().nullish(), + }), +}) function respondInvalidCaptcha(req, res) { res.status(400).json({ @@ -27,24 +51,39 @@ async function initializeDeviceHistory(req) { } async function canSkipCaptcha(req, res) { + const { body: reqBody } = parseReq(req, captchaBodySchema, { + logOnly: true, + }) const trustedUser = - req.body?.email && - (Settings.recaptcha.trustedUsers.includes(req.body.email) || - Settings.recaptcha.trustedUsersRegex?.test(req.body.email)) + reqBody.email && + (Settings.recaptcha.trustedUsers.includes(reqBody.email) || + Settings.recaptcha.trustedUsersRegex?.test(reqBody.email)) if (trustedUser) { return res.json(true) } await initializeDeviceHistory(req) - const canSkip = req.deviceHistory.has(req.body?.email) + const canSkip = req.deviceHistory.has(reqBody.email) Metrics.inc('captcha_pre_flight', 1, { status: canSkip ? 'skipped' : 'missing', }) res.json(canSkip) } +function stripRecaptchaField(req) { + const { body } = getRawReqInput(req) + if (body && 'g-recaptcha-response' in body) { + const { 'g-recaptcha-response': _x, ...rest } = body + req.body = rest + } +} + function validateCaptcha(action) { return expressify(async function (req, res, next) { - const email = EmailsHelper.parseEmail(req.body?.email) + const { body: reqBody } = parseReq(req, captchaBodySchema, { + logOnly: true, + }) + stripRecaptchaField(req) + const email = EmailsHelper.parseEmail(reqBody.email) const trustedUser = email && (Settings.recaptcha.trustedUsers.includes(email) || @@ -63,7 +102,7 @@ function validateCaptcha(action) { Metrics.inc('captcha', 1, { path: action, status: 'trusted' }) return next() } - const reCaptchaResponse = req.body['g-recaptcha-response'] + const reCaptchaResponse = reqBody['g-recaptcha-response'] if (action === 'login') { await initializeDeviceHistory(req) const fromKnownDevice = req.deviceHistory.has(email) diff --git a/services/web/app/src/Features/Security/RateLimiterMiddleware.mjs b/services/web/app/src/Features/Security/RateLimiterMiddleware.mjs index 7dcc39e46c..7b8bcbd22b 100644 --- a/services/web/app/src/Features/Security/RateLimiterMiddleware.mjs +++ b/services/web/app/src/Features/Security/RateLimiterMiddleware.mjs @@ -2,6 +2,7 @@ import logger from '@overleaf/logger' import SessionManager from '../Authentication/SessionManager.mjs' import LoginRateLimiter from './LoginRateLimiter.mjs' import settings from '@overleaf/settings' +import { getRawReqInput } from '../../infrastructure/Validation.mjs' /** * Return a rate limiting middleware @@ -36,7 +37,9 @@ function rateLimit(rateLimiter, opts = {}) { let key = clientId if (!opts.ipOnly) { - const params = (opts.params || []).map(p => req.params[p]) + // rate limiting runs before validation; raw access is allowlisted + const rawParams = getRawReqInput(req).params + const params = (opts.params || []).map(p => rawParams[p]) params.push(clientId) key = params.join(':') } @@ -58,7 +61,7 @@ function rateLimit(rateLimiter, opts = {}) { function loginRateLimitEmail(emailField = 'email') { return function (req, res, next) { - const email = req.body[emailField] + const email = getRawReqInput(req).body[emailField] if (!email) { return next() } diff --git a/services/web/app/src/infrastructure/Csrf.mjs b/services/web/app/src/infrastructure/Csrf.mjs index fd46ab06ee..ff746a8f23 100644 --- a/services/web/app/src/infrastructure/Csrf.mjs +++ b/services/web/app/src/infrastructure/Csrf.mjs @@ -2,9 +2,27 @@ import csurf from 'csurf' import Settings from '@overleaf/settings' import logger from '@overleaf/logger' import { callbackify } from '@overleaf/promise-utils' +import { getRawReqInput } from './Validation.mjs' const csrf = csurf() +// Native HTML form submissions (and hydrate-form.ts's async-form helper, +// which packs all FormData into its JSON body) carry the token as a hidden +// `_csrf` field alongside the request's real payload. Strip it once it has +// served its purpose so route schemas never need to declare a field they +// don't otherwise care about. +function stripCsrfField(req) { + const { body, query } = getRawReqInput(req) + if (body && '_csrf' in body) { + const { _csrf, ...rest } = body + req.body = rest + } + if (query && '_csrf' in query) { + const { _csrf, ...rest } = query + req.query = rest + } +} + function blockCrossOriginRequests() { return function (req, res, next) { const { origin } = req.headers @@ -79,6 +97,10 @@ export class Csrf { // check whether the request method is excluded for the specified route if (this.excluded_routes[req.path]?.[req.method] === 1) { // ignore the error if it's due to a bad csrf token, and continue + // without stripping `_csrf`: excluded routes still see their own + // separate enforcement further down their middleware stack (see + // OpenInOverleafMiddleware.postMiddleware), which needs the field + // to still be there csrf(req, res, err => { if (err && err.code !== 'EBADCSRFTOKEN') { next(err) @@ -87,7 +109,14 @@ export class Csrf { } }) } else { - csrf(req, res, next) + csrf(req, res, err => { + if (err) { + next(err) + } else { + stripCsrfField(req) + next() + } + }) } } } diff --git a/services/web/app/src/infrastructure/ExpressLocals.mjs b/services/web/app/src/infrastructure/ExpressLocals.mjs index 7656bb1928..8d0c3780b7 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.mjs +++ b/services/web/app/src/infrastructure/ExpressLocals.mjs @@ -7,6 +7,7 @@ import moment from 'moment' import { fetchJson } from '@overleaf/fetch-utils' import contentDisposition from 'content-disposition' import Features from './Features.mjs' +import { getRawReqInput } from './Validation.mjs' import SessionManager from '../Features/Authentication/SessionManager.mjs' import PackageVersions from './PackageVersions.js' import Modules from './Modules.mjs' @@ -256,8 +257,10 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) { }) webRouter.use(function (req, res, next) { + // template locals helper predates validation; raw access is allowlisted + const { query } = getRawReqInput(req) res.locals.getReqQueryParam = field => - req.query != null ? req.query[field] : undefined + query != null ? query[field] : undefined next() }) @@ -333,7 +336,8 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) { }) webRouter.use(function (req, res, next) { - res.locals.websiteRedesignOverride = req.query.redesign === 'enabled' + res.locals.websiteRedesignOverride = + getRawReqInput(req).query.redesign === 'enabled' next() }) diff --git a/services/web/app/src/infrastructure/RedirectManager.mjs b/services/web/app/src/infrastructure/RedirectManager.mjs index 1fa608b9c2..04df365561 100644 --- a/services/web/app/src/infrastructure/RedirectManager.mjs +++ b/services/web/app/src/infrastructure/RedirectManager.mjs @@ -14,6 +14,7 @@ */ import settings from '@overleaf/settings' import { URL } from 'node:url' +import { getRawReqInput } from './Validation.mjs' let RedirectManager @@ -54,7 +55,8 @@ export default RedirectManager = { } if (typeof target.url === 'function') { - url = target.url(req.params) + // redirects run outside validation; raw access is allowlisted + url = target.url(getRawReqInput(req).params) if (!url) { return next() } diff --git a/services/web/app/src/infrastructure/SessionAutostartMiddleware.mjs b/services/web/app/src/infrastructure/SessionAutostartMiddleware.mjs index a58b92b013..b0adb080eb 100644 --- a/services/web/app/src/infrastructure/SessionAutostartMiddleware.mjs +++ b/services/web/app/src/infrastructure/SessionAutostartMiddleware.mjs @@ -1,5 +1,6 @@ import Settings from '@overleaf/settings' import OError from '@overleaf/o-error' +import { getRawReqInput } from './Validation.mjs' const botUserAgents = [ 'kube-probe', @@ -79,7 +80,8 @@ class SessionAutostartMiddleware { middleware(req, _res, next) { if ( !req.signedCookies[this._cookieName] && - req.query?.autostartSession !== 'true' + // session bootstrap runs before validation; raw access is allowlisted + getRawReqInput(req).query?.autostartSession !== 'true' ) { const callback = this.autostartCallbackForRequest(req) if (callback) { @@ -117,11 +119,12 @@ class SessionAutostartMiddleware { ) } - if (req.body.viaGateway) { + const { body } = getRawReqInput(req) + if (body.viaGateway) { return next() } - res.render('general/post-gateway', { form_data: req.body }) + res.render('general/post-gateway', { form_data: body }) } } diff --git a/services/web/app/src/infrastructure/UnsupportedBrowserMiddleware.mjs b/services/web/app/src/infrastructure/UnsupportedBrowserMiddleware.mjs index 58c9be5178..7dbd12e537 100644 --- a/services/web/app/src/infrastructure/UnsupportedBrowserMiddleware.mjs +++ b/services/web/app/src/infrastructure/UnsupportedBrowserMiddleware.mjs @@ -2,9 +2,24 @@ import Bowser from 'bowser' import Settings from '@overleaf/settings' import Url from 'node:url' import UrlHelper from '../Features/Helpers/UrlHelper.mjs' +import { z, parseReq } from './Validation.mjs' const { getSafeRedirectPath } = UrlHelper +// middleware schema is non-strict: it validates only the header this +// middleware consumes, applied globally to every route +const unsupportedBrowserMiddlewareSchema = z.object({ + headers: z.object({ + 'user-agent': z.string().optional(), + }), +}) + +const renderUnsupportedBrowserPageSchema = z.object({ + query: z.object({ + fromURL: z.string().optional(), + }), +}) + function unsupportedBrowserMiddleware(req, res, next) { if (!Settings.unsupportedBrowsers) return next() @@ -12,7 +27,10 @@ function unsupportedBrowserMiddleware(req, res, next) { const path = req.path if (path === '/unsupported-browser') return next() - const userAgent = req.headers['user-agent'] + const { headers } = parseReq(req, unsupportedBrowserMiddlewareSchema, { + logOnly: true, + }) + const userAgent = headers['user-agent'] if (!userAgent) return next() @@ -36,11 +54,13 @@ function unsupportedBrowserMiddleware(req, res, next) { } function renderUnsupportedBrowserPage(req, res) { + const { query } = parseReq(req, renderUnsupportedBrowserPageSchema, { + logOnly: true, + }) let fromURL - if (typeof req.query.fromURL === 'string') { + if (typeof query.fromURL === 'string') { try { - fromURL = - Settings.siteUrl + (getSafeRedirectPath(req.query.fromURL) || '/') + fromURL = Settings.siteUrl + (getSafeRedirectPath(query.fromURL) || '/') } catch (e) {} } res.render('general/unsupported-browser', { fromURL }) diff --git a/services/web/app/src/infrastructure/Validation.mjs b/services/web/app/src/infrastructure/Validation.mjs index 76a8a7cc39..2a1e4a1d26 100644 --- a/services/web/app/src/infrastructure/Validation.mjs +++ b/services/web/app/src/infrastructure/Validation.mjs @@ -1,11 +1,27 @@ // @ts-check -import { parseReq, z, zz } from '@overleaf/validation-tools' +import { + parseReq, + z, + zz, + getRawReqInput, + isLockdownInstalled, +} from '@overleaf/validation-tools' -export { InvalidParamsError, parseReq, z, zz } from '@overleaf/validation-tools' +export { + InvalidParamsError, + InvalidRequestError, + parseReq, + z, + zz, + getRawReqInput, + isLockdownInstalled, +} from '@overleaf/validation-tools' export default { parseReq, z, zz, + getRawReqInput, + isLockdownInstalled, } diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 86df8ce04c..263076b570 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -65,6 +65,12 @@ import { plainTextResponse } from './infrastructure/Response.mjs' import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs' import ClsiCacheController from './Features/Compile/ClsiCacheController.mjs' import AsyncLocalStorage from './infrastructure/AsyncLocalStorage.mjs' +import { + getRawReqInput, + parseReq, + z, + zz, +} from './infrastructure/Validation.mjs' const { renderUnsupportedBrowserPage, unsupportedBrowserMiddleware } = UnsupportedBrowserMiddleware @@ -203,6 +209,12 @@ const rateLimiters = { }), } +const statusCompilerSchema = z.object({ + params: z.strictObject({ + Project_id: zz.objectId(), + }), +}) + async function initialize(webRouter, privateApiRouter, publicApiRouter) { webRouter.use(unsupportedBrowserMiddleware) @@ -1196,7 +1208,9 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { RateLimiterMiddleware.rateLimit(rateLimiters.statusCompiler), AuthorizationMiddleware.ensureUserCanReadProject, function (req, res) { - const projectId = req.params.Project_id + const { + params: { Project_id: projectId }, + } = parseReq(req, statusCompilerSchema, { logOnly: true }) // use a valid user id for testing const testUserId = '123456789012345678901234' const sendRes = _.once(function (statusCode, message, clsiServerId) { @@ -1247,10 +1261,13 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { ) webRouter.post('/error/client', function (req, res, next) { - logger.warn( - { err: req.body.error, meta: req.body.meta }, - 'client side error' - ) + // This route only logs whatever diagnostic payload the client reports + // (arbitrary properties copied off a JS Error, plus free-form metadata -- + // see ide-react-context.tsx's reportError) and always 204s; it never + // branches on the content, so there is nothing to validate it against. + // (case 2: final error-handler logging) + const { error, meta } = getRawReqInput(req).body + logger.warn({ err: error, meta }, 'client side error') metrics.inc('client-side-error') res.sendStatus(204) }) diff --git a/services/web/package.json b/services/web/package.json index 24bdccdf28..672a3e0c35 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -89,8 +89,8 @@ "@customerio/cdp-analytics-node": "^0.3.9", "@google-cloud/bigquery": "^8.1.1", "@google-cloud/storage": "^7.19.0", - "@node-oauth/oauth2-server": "^5.3.0", - "@node-saml/passport-saml": "^5.1.0", + "@node-oauth/oauth2-server": "patch:@node-oauth/oauth2-server@npm%3A5.3.0#~/.yarn/patches/@node-oauth-oauth2-server-npm-5.3.0-9d66d9183c.patch", + "@node-saml/passport-saml": "patch:@node-saml/passport-saml@npm%3A5.1.0#~/.yarn/patches/@node-saml-passport-saml-npm-5.1.0-5d7df575fa.patch", "@overleaf/access-token-encryptor": "workspace:*", "@overleaf/fetch-utils": "workspace:*", "@overleaf/logger": "workspace:*", @@ -128,14 +128,14 @@ "cookie": "^0.2.3", "cookie-parser": "1.4.6", "crc-32": "^1.2.2", - "csurf": "^1.11.0", + "csurf": "patch:csurf@npm%3A1.11.0#~/.yarn/patches/csurf-npm-1.11.0-c1b9cbb35b.patch", "csv": "^6.2.5", "dateformat": "1.0.4-1.2.3", "ejs": "^3.1.10", "email-addresses": "^5.0.0", "eventsource-parser": "^1.1.2", "express": "4.22.1", - "express-bearer-token": "^2.4.0", + "express-bearer-token": "patch:express-bearer-token@npm%3A2.4.0#~/.yarn/patches/express-bearer-token-npm-2.4.0-731c8c5c99.patch", "express-http-proxy": "^1.6.0", "express-session": "^1.17.1", "file-type": "^21.3.4", @@ -174,7 +174,7 @@ "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^2.1.4", "passport-local": "^1.0.0", - "passport-oauth2": "^1.5.0", + "passport-oauth2": "patch:passport-oauth2@npm%3A1.6.1#~/.yarn/patches/passport-oauth2-npm-1.6.1.patch", "passport-orcid": "0.0.4", "pug": "^3.0.3", "pug-runtime": "^3.0.1", diff --git a/services/web/test/acceptance/src/CaptchaTests.mjs b/services/web/test/acceptance/src/CaptchaTests.mjs index 7774492e31..a840d8a775 100644 --- a/services/web/test/acceptance/src/CaptchaTests.mjs +++ b/services/web/test/acceptance/src/CaptchaTests.mjs @@ -3,6 +3,8 @@ import { expect } from 'chai' import Settings from '@overleaf/settings' import UserHelper from './helpers/User.mjs' import MockHaveIBeenPwnedApiClass from './mocks/MockHaveIBeenPwnedApi.mjs' +import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js' +import { setReqValidationModeForTests } from '@overleaf/validation-tools' const User = UserHelper.promises @@ -12,6 +14,14 @@ before(function () { }) describe('Captcha', function () { + beforeEach(function () { + setReqValidationModeForTests('enforce') + }) + + afterEach(function () { + setReqValidationModeForTests(null) + }) + let user beforeEach('create user', async function () { @@ -92,6 +102,55 @@ describe('Captcha', function () { expectSuccessfulLogin(response, body) }) + it('should reject a non-string g-recaptcha-response', async function () { + await user.getCsrfToken() + const { response, body } = await user.doRequest('POST', { + url: '/login', + json: { + email: user.email, + password: user.password, + 'g-recaptcha-response': ['not', 'a', 'string'], + }, + }) + expectValidationErrorRaw( + { statusCode: response.statusCode, body }, + 400, + 'g-recaptcha-response' + ) + }) + + it('should accept (and then reject for missing captcha) an explicit null g-recaptcha-response', async function () { + // The frontend's ReCaptcha2 component (recaptcha-2.tsx) sends a literal + // JSON `null` -- not an omitted field -- whenever it never mounted the + // widget (no sitekey, action disabled, or `window.Cypress` in dev E2E + // runs). captchaBodySchema must accept that shape (schema-level + // regression check); the request should still be treated as "no + // captcha provided" downstream, same as the empty-string case above. + await user.getCsrfToken() + const { response, body } = await user.doRequest('POST', { + url: '/login', + json: { + email: user.email, + password: user.password, + 'g-recaptcha-response': null, + }, + }) + expectBadCaptchaResponse(response, body) + }) + + it('should reject a non-string email at can-skip-captcha', async function () { + await user.getCsrfToken() + const { response, body } = await user.doRequest('POST', { + url: '/login/can-skip-captcha', + json: { email: ['not', 'a', 'string'] }, + }) + expectValidationErrorRaw( + { statusCode: response.statusCode, body }, + 400, + 'email' + ) + }) + it('should note the solved captcha in audit log', async function () { const { response, body } = await loginWithCaptcha('valid') expectSuccessfulLogin(response, body) diff --git a/services/web/test/acceptance/src/Init.mjs b/services/web/test/acceptance/src/Init.mjs index fd16363e31..a5b8790a29 100644 --- a/services/web/test/acceptance/src/Init.mjs +++ b/services/web/test/acceptance/src/Init.mjs @@ -4,6 +4,7 @@ import Features from '../../../app/src/infrastructure/Features.mjs' import MockAnalyticsApi from './mocks/MockAnalyticsApi.mjs' import MockChatApi from './mocks/MockChatApi.mjs' import MockClsiApi from './mocks/MockClsiApi.mjs' +import MockClsiCache from './mocks/MockClsiCache.mjs' import MockClsiNginxApi from './mocks/MockClsiNginxApi.mjs' import MockDocstoreApi from './mocks/MockDocstoreApi.mjs' import MockDocUpdaterApi from './mocks/MockDocUpdaterApi.mjs' @@ -23,6 +24,7 @@ const mockOpts = { MockChatApi.initialize(23010, mockOpts) MockClsiApi.initialize(23013, mockOpts) +MockClsiCache.initialize(23081, mockOpts) MockClsiNginxApi.initialize(23080, mockOpts) MockDocstoreApi.initialize(23016, mockOpts) MockDocUpdaterApi.initialize(23003, mockOpts) diff --git a/services/web/test/acceptance/src/PasswordResetTests.mjs b/services/web/test/acceptance/src/PasswordResetTests.mjs index e7900993db..61bc108ae2 100644 --- a/services/web/test/acceptance/src/PasswordResetTests.mjs +++ b/services/web/test/acceptance/src/PasswordResetTests.mjs @@ -417,6 +417,14 @@ describe('PasswordReset', function () { }) expect(response.status).to.equal(400) }) + + it('should return 200 when a g-recaptcha-response field is included', async function () { + response = await userHelper.fetch(`/user/password/reset`, { + method: 'POST', + body: new URLSearchParams({ email, 'g-recaptcha-response': 'valid' }), + }) + expect(response.status).to.equal(200) + }) }) describe('password set', function () { it('should return 200 if password and passwordResetToken fields are valid', async function () { diff --git a/services/web/test/acceptance/src/RouterTests.mjs b/services/web/test/acceptance/src/RouterTests.mjs new file mode 100644 index 0000000000..0a4647a664 --- /dev/null +++ b/services/web/test/acceptance/src/RouterTests.mjs @@ -0,0 +1,47 @@ +import UserHelper from './helpers/User.mjs' +import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js' + +const User = UserHelper.promises + +describe('Router', function () { + let user + + beforeEach(async function () { + user = new User() + await user.login() + }) + + describe('GET /status/compiler/:Project_id', function () { + it('should reject a malformed project id with 404', async function () { + const { response, body } = await user.doRequest('get', { + url: '/status/compiler/not-an-object-id', + }) + expectValidationErrorRaw( + { statusCode: response.statusCode, body }, + 404, + 'Project_id' + ) + }) + }) + + describe('POST /error/client', function () { + it('should log an arbitrary client-reported error and return 204', async function () { + const { response } = await user.doRequest('post', { + url: '/error/client', + json: { + error: { message: 'boom', stack: 'Error: boom\n at x' }, + meta: { anything: 'goes', nested: { too: true } }, + }, + }) + response.statusCode.should.equal(204) + }) + + it('should not crash when error/meta are missing', async function () { + const { response } = await user.doRequest('post', { + url: '/error/client', + json: {}, + }) + response.statusCode.should.equal(204) + }) + }) +}) diff --git a/services/web/test/acceptance/src/UnsupportedBrowserTests.mjs b/services/web/test/acceptance/src/UnsupportedBrowserTests.mjs index 28093e67a9..4846004f07 100644 --- a/services/web/test/acceptance/src/UnsupportedBrowserTests.mjs +++ b/services/web/test/acceptance/src/UnsupportedBrowserTests.mjs @@ -1,5 +1,7 @@ import { expect } from 'chai' +import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js' import User from './helpers/User.mjs' +import { setReqValidationModeForTests } from '@overleaf/validation-tools' const botUserAgents = new Map([ [ @@ -52,6 +54,14 @@ const supportedUserAgents = new Map([ ]) describe('UnsupportedBrowsers', function () { + beforeEach(function () { + setReqValidationModeForTests('enforce') + }) + + afterEach(function () { + setReqValidationModeForTests(null) + }) + beforeEach(function () { this.user = new User() }) @@ -198,4 +208,22 @@ describe('UnsupportedBrowsers', function () { } ) }) + + describe('validation errors', function () { + it('should reject a malformed fromURL query value', function (done) { + this.user.request( + { + url: '/unsupported-browser?fromURL=a&fromURL=b', + headers: { + 'user-agent': unsupportedUserAgents.get('IE 11'), + }, + }, + (error, response) => { + expect(error).to.not.exist + expectValidationErrorRaw(response, 400, 'fromURL') + done() + } + ) + }) + }) }) diff --git a/services/web/test/acceptance/src/helpers/InitApp.mjs b/services/web/test/acceptance/src/helpers/InitApp.mjs index 259e844f67..46c3a67362 100644 --- a/services/web/test/acceptance/src/helpers/InitApp.mjs +++ b/services/web/test/acceptance/src/helpers/InitApp.mjs @@ -11,9 +11,29 @@ import SplitTestHandler from '../../../../app/src/Features/SplitTests/SplitTestH import SplitTestSessionHandler from '../../../../app/src/Features/SplitTests/SplitTestSessionHandler.mjs' import Modules from '../../../../app/src/infrastructure/Modules.mjs' import testLogRecorder from '@overleaf/logger/test-log-recorder.js' +import { parseReq, z } from '@overleaf/validation-tools' const app = Server.app +const devSessionSchema = z.object({ + // dev-only test route: the whole point is to copy arbitrary + // caller-supplied query keys onto the session, so this is a + // genuinely open map, not an escape hatch. + query: z.record(z.string(), z.unknown()), +}) + +const devSetInSessionSchema = z.object({ + // dev-only test route: same genuinely-open-map shape as above, for body. + body: z.record(z.string(), z.unknown()), +}) + +const devGetAssignmentSchema = z.object({ + query: z.object({ + splitTestName: z.string(), + includeReferer: z.string().optional(), + }), +}) + MongoHelper.initialize() RedisHelper.initialize() MockReCAPTCHAApi.initialize(2222) @@ -29,8 +49,9 @@ before('start main app', function (done) { router.get('/dev/session', (req, res) => { // allow changing the session directly for testing, assign any // properties in the query string to req.session - if (req.query && Object.keys(req.query).length > 0) { - Object.assign(req.session, req.query) + const { query } = parseReq(req, devSessionSchema) + if (Object.keys(query).length > 0) { + Object.assign(req.session, query) } return res.json(req.session) }) @@ -41,7 +62,8 @@ before('start main app', function (done) { route => route.path && route.path === '/dev/csrf', router => { router.post('/dev/set_in_session', (req, res) => { - for (const [key, value] of Object.entries(req.body)) { + const { body } = parseReq(req, devSetInSessionSchema) + for (const [key, value] of Object.entries(body)) { req.session[key] = value } return res.sendStatus(200) @@ -53,11 +75,11 @@ before('start main app', function (done) { route => route.path && route.path === '/dev/csrf', router => { router.get('/dev/split_test/get_assignment', (req, res) => { - const { splitTestName, includeReferer } = req.query + const { query } = parseReq(req, devGetAssignmentSchema) SplitTestHandler.promises - .getAssignment(req, res, splitTestName, { + .getAssignment(req, res, query.splitTestName, { sync: true, - includeReferer: includeReferer === 'true', + includeReferer: query.includeReferer === 'true', }) .then(assignment => res.json(assignment)) .catch(error => { diff --git a/services/web/test/acceptance/src/helpers/User.mjs b/services/web/test/acceptance/src/helpers/User.mjs index 978419038f..6458461015 100644 --- a/services/web/test/acceptance/src/helpers/User.mjs +++ b/services/web/test/acceptance/src/helpers/User.mjs @@ -778,7 +778,7 @@ class User { url: `/project/${projectId}/doc`, json: { name, - parentFolderId, + parent_folder_id: parentFolderId, }, }, (error, response, body) => { @@ -1305,12 +1305,47 @@ class User { ) }) } + + /** + * Submit a real application/x-www-form-urlencoded body over the shared + * session cookie jar, the way a plain HTML form (no JS) does: the csrf + * token only ever travels as a `_csrf` field, never as a header. Bypasses + * `this.request`, whose defaults bake in an `x-csrf-token` header once + * `getCsrfToken` has run, which a genuine native-form submission never + * sends. + */ + submitNativeForm(url, fields, callback) { + request.post( + { url, jar: this.jar, form: { _csrf: this.csrfToken, ...fields } }, + callback + ) + } } User.promises = promisifyClass(User, { - without: ['setExtraAttributes', 'sessionCookie', 'setSessionCookie'], + without: [ + 'setExtraAttributes', + 'sessionCookie', + 'setSessionCookie', + 'submitNativeForm', + ], }) +User.promises.prototype.submitNativeForm = async function (url, fields) { + return new Promise((resolve, reject) => { + request.post( + { url, jar: this.jar, form: { _csrf: this.csrfToken, ...fields } }, + (err, response, body) => { + if (err) { + reject(err) + } else { + resolve({ response, body }) + } + } + ) + }) +} + User.promises.prototype.doRequest = async function (method, params) { return new Promise((resolve, reject) => { this.request[method.toLowerCase()](params, (err, response, body) => { diff --git a/services/web/test/acceptance/src/mocks/AbstractMockApi.mjs b/services/web/test/acceptance/src/mocks/AbstractMockApi.mjs index 39b95934c2..78dd8ef5d6 100644 --- a/services/web/test/acceptance/src/mocks/AbstractMockApi.mjs +++ b/services/web/test/acceptance/src/mocks/AbstractMockApi.mjs @@ -1,7 +1,10 @@ import logger from '@overleaf/logger' import OError from '@overleaf/o-error' import express from 'express' -import { handleValidationError } from '@overleaf/validation-tools' +import { + getRawReqInput, + handleValidationError, +} from '@overleaf/validation-tools' /** * Abstract class for running a mock API via Express. Handles setting up of @@ -60,7 +63,11 @@ class AbstractMockApi { applyDebugRoutes() { if (!this.debug) return this.app.use((req, res, next) => { - const { method, path, query, params, body } = req + const { method, path } = req + // Debug-only wire dump: this prints exactly what was sent on the + // wire for troubleshooting acceptance test failures (case 3: raw + // test assertions), not a functional read of a named field. + const { query, params, body } = getRawReqInput(req) // eslint-disable-next-line no-console console.log(`${this.constructor.name} REQUEST`, { method, diff --git a/services/web/test/acceptance/src/mocks/MockAnalyticsApi.mjs b/services/web/test/acceptance/src/mocks/MockAnalyticsApi.mjs index d0aa5bd582..4d8276b2b7 100644 --- a/services/web/test/acceptance/src/mocks/MockAnalyticsApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockAnalyticsApi.mjs @@ -1,18 +1,38 @@ import AbstractMockApi from './AbstractMockApi.mjs' +import { getRawReqInput } from '@overleaf/validation-tools' class MockAnalyticsApi extends AbstractMockApi { reset() { this.updates = {} this.lastGraphRequest = null + this.lastSplitTestAssignmentsRequest = null + this.lastSplitTestCalcRequest = null + this.lastSplitTestEventsRequest = null } getLastGraphRequest() { return this.lastGraphRequest } + getLastSplitTestAssignmentsRequest() { + return this.lastSplitTestAssignmentsRequest + } + + getLastSplitTestCalcRequest() { + return this.lastSplitTestCalcRequest + } + + getLastSplitTestEventsRequest() { + return this.lastSplitTestEventsRequest + } + applyRoutes() { this.app.get('/graphs/:graph', (req, res) => { - this.lastGraphRequest = { path: req.path, query: req.query } + // case 3: recorded verbatim for later assertion by acceptance tests + this.lastGraphRequest = { + path: req.path, + query: getRawReqInput(req).query, + } return res.json({}) }) @@ -33,6 +53,33 @@ class MockAnalyticsApi extends AbstractMockApi { }, }) }) + + this.app.get('/split-test/get_daily_assignments', (req, res) => { + // case 3: recorded verbatim for later assertion by acceptance tests + this.lastSplitTestAssignmentsRequest = { + query: getRawReqInput(req).query, + } + res.json({ default: [0], 'variant-1': [0] }) + }) + + this.app.post('/split-test/calc', (req, res) => { + // case 3: recorded verbatim for later assertion by acceptance tests + this.lastSplitTestCalcRequest = { body: getRawReqInput(req).body } + res.json({ + durationDays: 14, + currentConversionRate: 0.1, + expectedConversionRate: 0.11, + requiredCohortSize: 1000, + totalRequiredUsers: 2000, + usersPerDay: 100, + }) + }) + + this.app.get('/split-test/events', (req, res) => { + // case 3: recorded verbatim for later assertion by acceptance tests + this.lastSplitTestEventsRequest = { query: getRawReqInput(req).query } + res.json({ 'editor-open': { source: ['ide-open'] } }) + }) } } diff --git a/services/web/test/acceptance/src/mocks/MockChatApi.mjs b/services/web/test/acceptance/src/mocks/MockChatApi.mjs index e752deff6b..4f6c010996 100644 --- a/services/web/test/acceptance/src/mocks/MockChatApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockChatApi.mjs @@ -1,9 +1,65 @@ import AbstractMockApi from './AbstractMockApi.mjs' import { ObjectId } from '../../../../app/src/infrastructure/mongodb.mjs' +import { parseReq, z, zz } from '@overleaf/validation-tools' + +// Mirrors services/chat/app/js/Features/Messages/MessageHttpSchemas.js (this +// mock stands in for chat's own API in web's acceptance tests). This mock's +// own routes bind params as snake_case (project_id/thread_id/message_id) +// where chat's real Express routes bind the same URL shapes as camelCase +// (projectId/threadId/messageId) -- param names never travel over the wire, +// so the schemas below just follow this file's own route definitions. +// matches chat's MessageHttpSchemas.js MAX_MESSAGE_LENGTH (10kb, not 10,000 +// bytes) +const MAX_MESSAGE_LENGTH = 10 * 1024 +const messageContent = z.string().min(1).max(MAX_MESSAGE_LENGTH) + +const projectParams = z.strictObject({ project_id: zz.objectId() }) +const threadParams = projectParams.extend({ thread_id: zz.objectId() }) +const messageParams = projectParams.extend({ message_id: zz.objectId() }) +const threadMessageParams = threadParams.extend({ message_id: zz.objectId() }) + +// matches chat's sendMessageBody +const sendMessageBody = z.strictObject({ + user_id: zz.objectId(), + content: messageContent, +}) + +// matches chat's editMessageBody -- web always sends both `content` and +// `userId` (camelCase, unlike send's `user_id`) even though the handlers +// below only read `content`. +const editMessageBody = z.strictObject({ + content: messageContent, + userId: zz.objectId().optional(), +}) + +const getGlobalMessagesSchema = z.object({ params: projectParams }) +const sendGlobalMessageSchema = z.object({ + params: projectParams, + body: sendMessageBody, +}) +const getGlobalMessageSchema = z.object({ params: messageParams }) +const editGlobalMessageSchema = z.object({ + params: messageParams, + body: editMessageBody, +}) +const deleteGlobalMessageSchema = z.object({ params: messageParams }) +const getThreadMessagesSchema = z.object({ params: threadParams }) +const getThreadMessageSchema = z.object({ params: threadMessageParams }) +const sendMessageSchema = z.object({ + params: threadParams, + body: sendMessageBody, +}) +const editMessageSchema = z.object({ + params: threadMessageParams, + body: editMessageBody, +}) +const destroyProjectSchema = z.object({ params: projectParams }) +const getResolvedThreadIdsSchema = z.object({ params: projectParams }) class MockChatApi extends AbstractMockApi { reset() { this.projects = new Map() + this.resolvedThreadIds = new Map() } getThread(projectId, threadId) { @@ -36,16 +92,42 @@ class MockChatApi extends AbstractMockApi { this.projects.delete(projectId) } + deleteMessage(projectId, threadId, messageId) { + const thread = this.getThread(projectId, threadId) + const index = thread.findIndex(message => message.id === messageId) + if (index === -1) return false + thread.splice(index, 1) + return true + } + + editMessage(projectId, threadId, messageId, content) { + const thread = this.getThread(projectId, threadId) + const message = thread.find(message => message.id === messageId) + if (!message) return false + message.content = content + return true + } + + setResolvedThreadIds(projectId, resolvedThreadIds) { + this.resolvedThreadIds.set(projectId, resolvedThreadIds) + } + + getResolvedThreadIds(projectId) { + return this.resolvedThreadIds.get(projectId) || [] + } + applyRoutes() { this.app.get('/project/:project_id/messages', (req, res) => { - res.json(this.getThread(req.params.project_id, 'global')) + const { params } = parseReq(req, getGlobalMessagesSchema) + res.json(this.getThread(params.project_id, 'global')) }) this.app.post('/project/:project_id/messages', (req, res) => { - res.json(this.sendMessage(req.params.project_id, 'global', req.body)) + const { params, body } = parseReq(req, sendGlobalMessageSchema) + res.json(this.sendMessage(params.project_id, 'global', body)) }) this.app.get('/project/:project_id/messages/:message_id', (req, res) => { - const projectId = req.params.project_id - const messageId = req.params.message_id + const { params } = parseReq(req, getGlobalMessageSchema) + const { project_id: projectId, message_id: messageId } = params const thread = this.getThread(projectId, 'global') const message = thread.find(msg => msg.id === messageId) if (!message) { @@ -56,15 +138,19 @@ class MockChatApi extends AbstractMockApi { this.app.get( '/project/:project_id/thread/:thread_id/messages', (req, res) => { - res.json(this.getThread(req.params.project_id, req.params.thread_id)) + const { params } = parseReq(req, getThreadMessagesSchema) + res.json(this.getThread(params.project_id, params.thread_id)) } ) this.app.get( '/project/:project_id/thread/:thread_id/messages/:message_id', (req, res) => { - const projectId = req.params.project_id - const threadId = req.params.thread_id - const messageId = req.params.message_id + const { params } = parseReq(req, getThreadMessageSchema) + const { + project_id: projectId, + thread_id: threadId, + message_id: messageId, + } = params const thread = this.getThread(projectId, threadId) const message = thread.find(msg => msg.id === messageId) if (!message) { @@ -76,24 +162,20 @@ class MockChatApi extends AbstractMockApi { this.app.post( '/project/:project_id/thread/:thread_id/messages', (req, res) => { - res.json( - this.sendMessage( - req.params.project_id, - req.params.thread_id, - req.body - ) - ) + const { params, body } = parseReq(req, sendMessageSchema) + res.json(this.sendMessage(params.project_id, params.thread_id, body)) } ) this.app.post( '/project/:project_id/thread/:thread_id/messages/:message_id/edit', (req, res) => { + const { params, body } = parseReq(req, editMessageSchema) const { project_id: projectId, thread_id: threadId, message_id: messageId, - } = req.params - const { content, userId } = req.body + } = params + const { content, userId } = body const thread = this.getThread(projectId, threadId) const message = thread.find(msg => msg.id === messageId) if (!message || (userId && message.user_id !== userId)) { @@ -105,10 +187,38 @@ class MockChatApi extends AbstractMockApi { } ) this.app.delete('/project/:project_id', (req, res) => { - const projectId = req.params.project_id - this.destroyProject(projectId) + const { params } = parseReq(req, destroyProjectSchema) + this.destroyProject(params.project_id) res.sendStatus(204) }) + this.app.delete('/project/:project_id/messages/:message_id', (req, res) => { + const { params } = parseReq(req, deleteGlobalMessageSchema) + const found = this.deleteMessage( + params.project_id, + 'global', + params.message_id + ) + res.sendStatus(found ? 204 : 404) + }) + this.app.post( + '/project/:project_id/messages/:message_id/edit', + (req, res) => { + const { params, body } = parseReq(req, editGlobalMessageSchema) + const found = this.editMessage( + params.project_id, + 'global', + params.message_id, + body.content + ) + res.sendStatus(found ? 204 : 404) + } + ) + this.app.get('/project/:project_id/resolved-thread-ids', (req, res) => { + const { params } = parseReq(req, getResolvedThreadIdsSchema) + res.json({ + resolvedThreadIds: this.getResolvedThreadIds(params.project_id), + }) + }) } } diff --git a/services/web/test/acceptance/src/mocks/MockClsiApi.mjs b/services/web/test/acceptance/src/mocks/MockClsiApi.mjs index f5d4798263..ad826679bc 100644 --- a/services/web/test/acceptance/src/mocks/MockClsiApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockClsiApi.mjs @@ -1,20 +1,44 @@ import AbstractMockApi from './AbstractMockApi.mjs' +import { parseReq, z, zz } from '@overleaf/validation-tools' +import { zipAttachment } from '../../../../app/src/infrastructure/Response.mjs' + +// Mirrors services/clsi/app/js/CompileController.js's projectOrUserParamsSchema +// (this mock stands in for clsi's own API in web's acceptance tests). +const compileParamsSchema = z.object({ + params: z.strictObject({ + project_id: zz.objectId().or(zz.submissionId()), + user_id: zz.objectId().optional(), + }), +}) + +// Mirrors services/clsi/app/js/OutputController.js's createOutputZipSchema +// (this mock stands in for clsi's own output.zip archive generation, as +// opposed to clsi-nginx's static file serving handled by +// MockClsiNginxApi.mjs). +const outputZipParamsSchema = z.object({ + params: z.strictObject({ + project_id: zz.objectId().or(zz.submissionId()), + user_id: zz.objectId().optional(), + build_id: zz.buildId(), + }), +}) class MockClsiApi extends AbstractMockApi { static compile(req, res) { + const { params } = parseReq(req, compileParamsSchema) res.json({ compile: { status: 'success', error: null, outputFiles: [ { - url: `http://clsi:8080/project/${req.params.project_id}/build/1234-5678/output/output.pdf`, + url: `http://clsi:8080/project/${params.project_id}/build/1234-5678/output/output.pdf`, path: 'output.pdf', type: 'pdf', build: '1234-5678', }, { - url: `http://clsi:8080/project/${req.params.project_id}/build/1234-5678/output/output.log`, + url: `http://clsi:8080/project/${params.project_id}/build/1234-5678/output/output.log`, path: 'output.log', type: 'log', build: '1234-5678', @@ -24,6 +48,11 @@ class MockClsiApi extends AbstractMockApi { }) } + static outputZip(req, res) { + const { params } = parseReq(req, outputZipParamsSchema) + zipAttachment(res, `mock-zip: ${params.build_id}`, 'output.zip') + } + applyRoutes() { this.app.post('/project/:project_id/compile', MockClsiApi.compile) this.app.post( @@ -34,6 +63,15 @@ class MockClsiApi extends AbstractMockApi { this.app.get('/project/:project_id/status', (req, res) => { res.sendStatus(200) }) + + this.app.get( + '/project/:project_id/build/:build_id/output/output.zip', + MockClsiApi.outputZip + ) + this.app.get( + '/project/:project_id/user/:user_id/build/:build_id/output/output.zip', + MockClsiApi.outputZip + ) } } diff --git a/services/web/test/acceptance/src/mocks/MockClsiCache.mjs b/services/web/test/acceptance/src/mocks/MockClsiCache.mjs new file mode 100644 index 0000000000..c963f7816f --- /dev/null +++ b/services/web/test/acceptance/src/mocks/MockClsiCache.mjs @@ -0,0 +1,146 @@ +import AbstractMockApi from './AbstractMockApi.mjs' +import { parseReq, z, zz } from '@overleaf/validation-tools' + +// Mirrors the shape of the real lookup paths ClsiCacheHandler builds (see +// getOutputFile/getLatestOutputFile in ClsiCacheHandler.mjs) -- validating +// them here (like MockV1HistoryApi's inline schema.safeParse) catches an +// app-side path-construction mistake as a 400 from this mock, rather than +// a confusing miss against `entries` that looks identical to "nothing +// cached yet" (a plain 404). +const lookupSchema = z.object({ + params: z.strictObject({ + project_id: zz.objectId(), + user_id: zz.objectId().optional(), + // matches clsi-cache's own getLastOutputFileParamsSchema / + // getLatestOutputFileParamsSchema (HTTPController.js) + editorBuildId: zz.editorBuildId().optional(), + // this is a wildcard `:filename(.*)` param forwarded to a storage + // lookup key, so it gets the same zz.filepath() traversal hardening as + // clsi-cache's own filenameSchema + filename: zz.filepath(), + // this repo's patched Express additionally populates req.params[0] + // (the filename minus its first character) for a *named* regex + // wildcard like `:filename(.*)` -- unlike a bare `*` (see + // MockClsiNginxApi.mjs's outputWildcardSchema, which only gets the + // numeric key). A strictObject that doesn't account for this key + // rejects every real (non-empty filename) request with a 404 + // "Unrecognized key: 0" before it ever reaches the lookup below. + 0: z.string(), + }), +}) + +const contentSchema = z.object({ + params: z.strictObject({ + contentId: z.string().nonempty(), + }), +}) + +/** + * Stand-in for a clsi-cache instance (services/clsi-cache). + * + * ClsiCacheHandler's getOutputFile/getLatestOutputFile each make a "search" + * or "latest" lookup request (redirect: 'manual') and expect either a 404 + * (nothing cached) or a 3xx redirect carrying X-Zone/X-Shard/ + * X-Last-Modified/X-Content-Length/X-All-Files headers and a Location the + * caller then fetches separately for the actual bytes -- see + * getRedirectWithFallback in ClsiCacheHandler.mjs. This mock reproduces both + * legs: register a cache entry at the exact lookup path the app will + * request (via `addEntry`), and this class serves the redirect plus the + * content it points to. + */ +class MockClsiCache extends AbstractMockApi { + reset() { + this.entries = new Map() // lookup path -> { contentId, lastModified, zone, shard, allFiles } + this.content = new Map() // contentId -> { content, contentType } + } + + /** + * @param {string} path - the exact request path ClsiCacheHandler will look + * up, e.g. `/project/ID/user/UID/build/BUILD/search/output/output.pdf` + * or `/project/ID/latest/output/output.overleaf.json` + * @param {object} options + * @param {string|Buffer} options.content + * @param {string} [options.contentType] + * @param {Date} [options.lastModified] + * @param {string} [options.zone] + * @param {string} [options.shard] + * @param {string[]} [options.allFiles] + */ + addEntry( + path, + { + content, + contentType = 'text/plain', + lastModified = new Date(), + zone = 'zone1', + shard = 'shard1', + allFiles, + } + ) { + const contentId = `${this.content.size}` + this.content.set(contentId, { content, contentType }) + this.entries.set(path, { + contentId, + lastModified, + zone, + shard, + allFiles: allFiles || [path.split('/').pop()], + }) + } + + _lookup(req, res) { + parseReq(req, lookupSchema) + const entry = this.entries.get(req.path) + if (!entry) { + return res.sendStatus(404) + } + const { content } = this.content.get(entry.contentId) + res.set('X-Zone', entry.zone) + res.set('X-Shard', entry.shard) + res.set('X-Last-Modified', entry.lastModified.toISOString()) + res.set('X-Content-Length', String(Buffer.byteLength(content))) + res.set('X-All-Files', JSON.stringify(entry.allFiles)) + res.redirect(302, `/content/${entry.contentId}`) + } + + applyRoutes() { + // getOutputFile's two routes (with/without the per-user segment) + this.app.get( + '/project/:project_id/user/:user_id/build/:editorBuildId/search/output/:filename(.*)', + (req, res) => this._lookup(req, res) + ) + this.app.get( + '/project/:project_id/build/:editorBuildId/search/output/:filename(.*)', + (req, res) => this._lookup(req, res) + ) + // getLatestOutputFile's two routes (with/without the per-user segment) + this.app.get( + '/project/:project_id/user/:user_id/latest/output/:filename(.*)', + (req, res) => this._lookup(req, res) + ) + this.app.get( + '/project/:project_id/latest/output/:filename(.*)', + (req, res) => this._lookup(req, res) + ) + + this.app.get('/content/:contentId', (req, res) => { + const { params } = parseReq(req, contentSchema) + const entry = this.content.get(params.contentId) + if (!entry) { + return res.sendStatus(404) + } + res.set('Content-Type', entry.contentType) + res.end(entry.content) + }) + } +} + +export default MockClsiCache + +// type hint for the inherited `instance` method +/** + * @function instance + * @memberOf MockClsiCache + * @static + * @returns {MockClsiCache} + */ diff --git a/services/web/test/acceptance/src/mocks/MockClsiNginxApi.mjs b/services/web/test/acceptance/src/mocks/MockClsiNginxApi.mjs index a07a6c8605..5439fdf8bd 100644 --- a/services/web/test/acceptance/src/mocks/MockClsiNginxApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockClsiNginxApi.mjs @@ -1,12 +1,25 @@ import AbstractMockApi from './AbstractMockApi.mjs' import { plainTextResponse } from '../../../../app/src/infrastructure/Response.mjs' +import { parseReq, z, zz } from '@overleaf/validation-tools' + +// Mirrors services/clsi/app/js/OutputController.js's createOutputZipSchema +// (this mock stands in for clsi-nginx's own output serving). +const outputWildcardSchema = z.object({ + params: z.strictObject({ + project_id: zz.objectId().or(zz.submissionId()), + build_id: zz.buildId(), + // bare `*` wildcard -- Express populates the match under numeric key 0 + 0: zz.filepath(), + }), +}) class MockClsiNginxApi extends AbstractMockApi { applyRoutes() { this.app.get( '/project/:project_id/build/:build_id/output/*', (req, res) => { - const filename = req.params[0] + const { params } = parseReq(req, outputWildcardSchema) + const filename = params[0] if (filename === 'output.pdf') { plainTextResponse(res, 'mock-pdf') } else if (filename === 'output.log') { diff --git a/services/web/test/acceptance/src/mocks/MockDocUpdaterApi.mjs b/services/web/test/acceptance/src/mocks/MockDocUpdaterApi.mjs index 2c6313e609..2926dd3d21 100644 --- a/services/web/test/acceptance/src/mocks/MockDocUpdaterApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockDocUpdaterApi.mjs @@ -1,4 +1,111 @@ import AbstractMockApi from './AbstractMockApi.mjs' +import { parseReq, z, zz } from '@overleaf/validation-tools' +import editorCoreSchemas from 'overleaf-editor-core/lib/schemas.js' + +const docParamsSchema = z.strictObject({ + projectId: zz.objectId(), + docId: zz.objectId(), +}) + +const getDocSchema = z.object({ + params: docParamsSchema, + query: z.object({ + historyOTSupport: z.stringbool().default(false), + fromVersion: z.coerce.number().int().default(-1), + }), +}) + +const setDocSchema = z.object({ + params: docParamsSchema, + body: z.strictObject({ + lines: z.array(z.string()), + source: z.string(), + user_id: zz.objectId().nullish(), + undoing: z.boolean().optional(), + trackChanges: z.boolean().optional(), + }), +}) + +// Ranges data as document-updater persists it (RangesTracker format) -- +// mirrors services/document-updater/app/js/schemas.js's `ranges` export +// (this mock stands in for document-updater's own API in web's acceptance +// tests). +const insertOp = z.strictObject({ + i: z.string(), + p: z.number().int().min(0), + u: z.boolean().optional(), +}) +const deleteOp = z.strictObject({ + d: z.string(), + p: z.number().int().min(0), + u: z.boolean().optional(), +}) +const commentOp = z.strictObject({ + c: z.string(), + p: z.number().int().min(0), + t: zz.objectId().optional(), + u: z.boolean().optional(), + resolved: z.boolean().optional(), +}) +const rangeMetadata = z.strictObject({ + user_id: z.string(), + ts: z.string(), +}) +const comment = z.strictObject({ + id: zz.objectId().optional(), + op: commentOp, + metadata: rangeMetadata.optional(), +}) +const trackedChange = z.strictObject({ + id: z.string().optional(), + op: insertOp.or(deleteOp), + metadata: rangeMetadata, +}) +const rangesSchema = z.strictObject({ + comments: z.array(comment).optional(), + changes: z.array(trackedChange).optional(), +}) + +// Mirrors services/document-updater/app/js/HttpController.js's +// updateProjectSchema (renameUpdateSchema/addUpdateSchema) -- this mock's +// own routes use :projectId/:docId casing rather than document-updater's +// :project_id/:doc_id. +const renameUpdateSchema = z.strictObject({ + type: z.enum(['rename-doc', 'rename-file']), + id: zz.objectId(), + pathname: zz.safePath(), + // deletes are renames to an empty newPathname + newPathname: zz.safePath().or(z.literal('')), +}) + +const addUpdateSchema = z.strictObject({ + type: z.enum(['add-doc', 'add-file']), + id: zz.objectId(), + pathname: zz.safePath(), + docLines: z.string().optional(), + ranges: rangesSchema.optional(), + historyRangesSupport: z.boolean().optional(), + // legacy filestore url for files without a created blob + url: z.string().nullish(), + hash: z.string().optional(), + metadata: editorCoreSchemas.rawFileMetadata.optional(), + createdBlob: z.boolean().optional(), +}) + +const projectParamsSchema = z.object({ + params: z.strictObject({ projectId: zz.objectId() }), +}) + +const updateProjectSchema = z.object({ + params: z.strictObject({ projectId: zz.objectId() }), + body: z.strictObject({ + projectHistoryId: z.union([z.number(), z.string()]).optional(), + userId: zz.objectId().nullish(), + updates: z.array(renameUpdateSchema.or(addUpdateSchema)).default([]), + version: z.union([z.number(), z.string()]), + source: editorCoreSchemas.rawOrigin.or(z.string()).nullish(), + }), +}) class MockDocUpdaterApi extends AbstractMockApi { reset() { @@ -47,13 +154,21 @@ class MockDocUpdaterApi extends AbstractMockApi { } applyRoutes() { + this.app.get('/project/:projectId/last_updated_at', (req, res) => { + // no project in this mock has ever been touched via document-updater, + // matching the real service's response for a project it has no + // knowledge of (see DocumentUpdaterHandler.getProjectLastUpdatedAt) + res.json({ lastUpdatedAt: null }) + }) + this.app.post('/project/:projectId/flush', (req, res) => { res.sendStatus(204) }) this.app.post('/project/:projectId', (req, res) => { - const { projectId } = req.params - const { userId, updates, version } = req.body + const { params, body } = parseReq(req, updateProjectSchema) + const { projectId } = params + const { userId, updates, version } = body this.addProjectStructureUpdates(projectId, userId, updates, version) res.sendStatus(200) }) @@ -69,8 +184,11 @@ class MockDocUpdaterApi extends AbstractMockApi { ) this.app.get('/project/:projectId/doc/:docId', (req, res) => { - const { projectId, docId } = req.params - this.receivedGetDocRequests.push({ projectId, docId, query: req.query }) + const { + query, + params: { projectId, docId }, + } = parseReq(req, getDocSchema) + this.receivedGetDocRequests.push({ projectId, docId, query }) const doc = this.docsByProject.get(projectId)?.get(docId) if (doc == null) { return res.sendStatus(404) @@ -84,9 +202,12 @@ class MockDocUpdaterApi extends AbstractMockApi { }) }) - this.app.post('/project/:projectId/doc/:doc_id', (req, res) => { - const { projectId, doc_id: docId } = req.params - this.receivedSetDocRequests.push({ projectId, docId, body: req.body }) + this.app.post('/project/:projectId/doc/:docId', (req, res) => { + const { + params: { projectId, docId }, + body, + } = parseReq(req, setDocSchema) + this.receivedSetDocRequests.push({ projectId, docId, body }) res.sendStatus(204) }) @@ -107,7 +228,8 @@ class MockDocUpdaterApi extends AbstractMockApi { }) this.app.get('/project/:projectId/ranges', (req, res) => { - const docsById = this.docsByProject.get(req.params.projectId) + const { params } = parseReq(req, projectParamsSchema) + const docsById = this.docsByProject.get(params.projectId) const docs = docsById == null ? [] : Array.from(docsById.values()) res.json({ docs: docs.map(doc => ({ diff --git a/services/web/test/acceptance/src/mocks/MockDocstoreApi.mjs b/services/web/test/acceptance/src/mocks/MockDocstoreApi.mjs index b3726a4524..3e85876b8e 100644 --- a/services/web/test/acceptance/src/mocks/MockDocstoreApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockDocstoreApi.mjs @@ -1,5 +1,86 @@ import { db, ObjectId } from '../../../../app/src/infrastructure/mongodb.mjs' import AbstractMockApi from './AbstractMockApi.mjs' +import { parseReq, z, zz } from '@overleaf/validation-tools' + +// Mirrors services/docstore/app/js/schemas.js (this mock stands in for +// docstore's own API in web's acceptance tests). +const insertOp = z.strictObject({ + i: z.string(), + p: z.number().int().min(0), + u: z.boolean().optional(), +}) +const deleteOp = z.strictObject({ + d: z.string(), + p: z.number().int().min(0), + u: z.boolean().optional(), +}) +const commentOp = z.strictObject({ + c: z.string().optional(), + p: z.number().int().min(0).optional(), + t: z.string().optional(), + u: z.boolean().optional(), + resolved: z.boolean().optional(), +}) +const rangeMetadata = z.strictObject({ + user_id: z.string().optional(), + ts: z.string().optional(), +}) +const comment = z.strictObject({ + id: z.string().optional(), + op: commentOp, + metadata: rangeMetadata.optional(), +}) +const trackedChange = z.strictObject({ + id: z.string().optional(), + op: insertOp.or(deleteOp).optional(), + metadata: rangeMetadata.optional(), +}) +const rangesSchema = z.strictObject({ + comments: z.array(comment).optional(), + changes: z.array(trackedChange).optional(), +}) + +const docParamsSchema = z.strictObject({ + projectId: zz.objectId(), + docId: zz.objectId(), +}) + +const updateDocSchema = z.object({ + params: docParamsSchema, + body: z.strictObject({ + lines: z.array(z.string()), + version: z.number(), + ranges: rangesSchema, + }), +}) + +const projectParamsSchema = z.object({ + params: z.strictObject({ projectId: zz.objectId() }), +}) + +const getDocSchema = z.object({ + params: docParamsSchema, + query: z.object({ + include_deleted: z.stringbool().default(false), + }), +}) + +const docParamsOnlySchema = z.object({ params: docParamsSchema }) + +const patchDocSchema = z.object({ + params: docParamsSchema, + // matches docstore's own patchDocSchema -- DocstoreManager#deleteDoc is + // the sole caller and always sends all three fields together. + body: z.strictObject({ + deleted: z.literal(true), + deletedAt: z.coerce.date(), + name: z.string(), + }), +}) + +const destroySchema = z.object({ + params: z.strictObject({ projectId: zz.objectId() }), +}) class MockDocstoreApi extends AbstractMockApi { reset() { @@ -42,8 +123,9 @@ class MockDocstoreApi extends AbstractMockApi { applyRoutes() { this.app.post('/project/:projectId/doc/:docId', (req, res) => { - const { projectId, docId } = req.params - const { lines, version, ranges } = req.body + const { params, body } = parseReq(req, updateDocSchema) + const { projectId, docId } = params + const { lines, version, ranges } = body if (this.docs[projectId] == null) { this.docs[projectId] = {} } @@ -64,11 +146,13 @@ class MockDocstoreApi extends AbstractMockApi { }) this.app.get('/project/:projectId/doc', (req, res) => { - res.json(Object.values(this.docs[req.params.projectId] || {})) + const { params } = parseReq(req, projectParamsSchema) + res.json(Object.values(this.docs[params.projectId] || {})) }) this.app.get('/project/:projectId/ranges', (req, res) => { - const { projectId } = req.params + const { params } = parseReq(req, projectParamsSchema) + const { projectId } = params const docs = Object.values(this.docs[projectId] || {}) .filter(doc => !doc.deleted) .map(doc => ({ _id: doc._id, ranges: doc.ranges })) @@ -76,13 +160,15 @@ class MockDocstoreApi extends AbstractMockApi { }) this.app.get('/project/:projectId/doc-deleted', (req, res) => { - res.json(this.getDeletedDocs(req.params.projectId)) + const { params } = parseReq(req, projectParamsSchema) + res.json(this.getDeletedDocs(params.projectId)) }) this.app.get('/project/:projectId/doc/:docId', (req, res) => { - const { projectId, docId } = req.params + const { params, query } = parseReq(req, getDocSchema) + const { projectId, docId } = params const doc = this.docs[projectId][docId] - if (!doc || (doc.deleted && !req.query.include_deleted)) { + if (!doc || (doc.deleted && !query.include_deleted)) { res.sendStatus(404) } else { res.json(doc) @@ -90,7 +176,8 @@ class MockDocstoreApi extends AbstractMockApi { }) this.app.get('/project/:projectId/doc/:docId/deleted', (req, res) => { - const { projectId, docId } = req.params + const { params } = parseReq(req, docParamsOnlySchema) + const { projectId, docId } = params if (!this.docs[projectId] || !this.docs[projectId][docId]) { res.sendStatus(404) } else { @@ -99,19 +186,21 @@ class MockDocstoreApi extends AbstractMockApi { }) this.app.patch('/project/:projectId/doc/:docId', (req, res) => { - const { projectId, docId } = req.params + const { params, body } = parseReq(req, patchDocSchema) + const { projectId, docId } = params if (!this.docs[projectId]) { res.sendStatus(404) } else if (!this.docs[projectId][docId]) { res.sendStatus(404) } else { - Object.assign(this.docs[projectId][docId], req.body) + Object.assign(this.docs[projectId][docId], body) res.sendStatus(204) } }) this.app.post('/project/:projectId/destroy', async (req, res) => { - const { projectId } = req.params + const { params } = parseReq(req, destroySchema) + const { projectId } = params delete this.docs[projectId] await db.docs.deleteMany({ project_id: new ObjectId(projectId) }) res.sendStatus(204) diff --git a/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs b/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs index 805bbdd8fe..09daaedc26 100644 --- a/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs @@ -1,4 +1,13 @@ import AbstractMockApi from './AbstractMockApi.mjs' +import { parseReq, z, getRawReqInput } from '@overleaf/validation-tools' + +const deleteProjectSchema = z.object({ + params: z.strictObject({ projectId: z.string() }), +}) + +const postbackParamsSchema = z.object({ + params: z.strictObject({ id: z.string() }), +}) class MockGitBridgeApi extends AbstractMockApi { reset() { @@ -16,7 +25,8 @@ class MockGitBridgeApi extends AbstractMockApi { } deleteProject(req, res) { - const projectId = req.params.projectId + const { params } = parseReq(req, deleteProjectSchema) + const projectId = params.projectId delete this.projects[projectId] res.sendStatus(204) } @@ -31,8 +41,10 @@ class MockGitBridgeApi extends AbstractMockApi { } postback(req, res) { - const { id } = req.params - const postbackData = req.body + const { params } = parseReq(req, postbackParamsSchema) + const { id } = params + // case 3: recorded verbatim for later assertion by acceptance tests + const postbackData = getRawReqInput(req).body if (this.postbacks[id]) { this.postbacks[id].resolve(postbackData) } diff --git a/services/web/test/acceptance/src/mocks/MockGoogleOauthApi.mjs b/services/web/test/acceptance/src/mocks/MockGoogleOauthApi.mjs index 5661f665f6..02318f9571 100644 --- a/services/web/test/acceptance/src/mocks/MockGoogleOauthApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockGoogleOauthApi.mjs @@ -1,4 +1,23 @@ import AbstractMockApi from './AbstractMockApi.mjs' +import { parseReq, z } from '@overleaf/validation-tools' + +// Token exchange body as built by passport-oauth2/the `oauth` library's +// OAuth2#getOAuthAccessToken (standard authorization_code grant). +const tokenSchema = z.object({ + body: z.strictObject({ + code: z.string(), + client_id: z.string(), + client_secret: z.string(), + grant_type: z.literal('authorization_code'), + redirect_uri: z.string(), + }), +}) + +const userinfoSchema = z.object({ + query: z.object({ + access_token: z.string(), + }), +}) class MockGoogleOauthApi extends AbstractMockApi { reset() { @@ -18,19 +37,21 @@ class MockGoogleOauthApi extends AbstractMockApi { applyRoutes() { this.app.post('/oauth/token', (req, res, next) => { - if (!this.tokens[req.body.code]) { + const { body } = parseReq(req, tokenSchema) + if (!this.tokens[body.code]) { return res.sendStatus(400) } res.json({ - access_token: this.tokens[req.body.code], + access_token: this.tokens[body.code], }) }) this.app.get('/oauth2/v3/userinfo', (req, res, next) => { - if (!this.profiles[req.query.access_token]) { + const { query } = parseReq(req, userinfoSchema) + if (!this.profiles[query.access_token]) { return res.sendStatus(400) } - res.json(this.profiles[req.query.access_token]) + res.json(this.profiles[query.access_token]) }) } } diff --git a/services/web/test/acceptance/src/mocks/MockHaveIBeenPwnedApi.mjs b/services/web/test/acceptance/src/mocks/MockHaveIBeenPwnedApi.mjs index 0425d986e5..08f1ab7e31 100644 --- a/services/web/test/acceptance/src/mocks/MockHaveIBeenPwnedApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockHaveIBeenPwnedApi.mjs @@ -1,5 +1,14 @@ import AbstractMockApi from './AbstractMockApi.mjs' import { plainTextResponse } from '../../../../app/src/infrastructure/Response.mjs' +import { parseReq, z } from '@overleaf/validation-tools' + +// HaveIBeenPwned.mjs sends the first 5 hex characters of an uppercase sha1 +// digest (k-anonymity range query). +const rangeSchema = z.object({ + params: z.strictObject({ + prefix: z.string().regex(/^[0-9A-F]{5}$/), + }), +}) class MockHaveIBeenPwnedApi extends AbstractMockApi { reset() { @@ -30,7 +39,8 @@ class MockHaveIBeenPwnedApi extends AbstractMockApi { applyRoutes() { this.app.get('/range/:prefix', (req, res) => { - const { prefix } = req.params + const { params } = parseReq(req, rangeSchema) + const { prefix } = params if (prefix === 'C8893') { plainTextResponse(res, '74D74EFD7B158D2ADD283D67FF3E53B55D7:broken') } else { diff --git a/services/web/test/acceptance/src/mocks/MockHistoryBackupDeletionApi.mjs b/services/web/test/acceptance/src/mocks/MockHistoryBackupDeletionApi.mjs index e16ddf923f..200486bde0 100644 --- a/services/web/test/acceptance/src/mocks/MockHistoryBackupDeletionApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockHistoryBackupDeletionApi.mjs @@ -1,4 +1,9 @@ import AbstractMockApi from './AbstractMockApi.mjs' +import { parseReq, z, zz } from '@overleaf/validation-tools' + +const deleteProjectSchema = z.object({ + params: z.strictObject({ project_id: zz.objectId() }), +}) class MockHistoryBackupDeletionApi extends AbstractMockApi { reset() { @@ -10,7 +15,8 @@ class MockHistoryBackupDeletionApi extends AbstractMockApi { } deleteProject(req, res) { - const projectId = req.params.project_id + const { params } = parseReq(req, deleteProjectSchema) + const projectId = params.project_id const status = this.projects[projectId] if (status === 422) { return res.sendStatus(422) diff --git a/services/web/test/acceptance/src/mocks/MockOrcidApi.mjs b/services/web/test/acceptance/src/mocks/MockOrcidApi.mjs index 42c2e75ef9..25ecc0369b 100644 --- a/services/web/test/acceptance/src/mocks/MockOrcidApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockOrcidApi.mjs @@ -1,4 +1,17 @@ import AbstractMockApi from './AbstractMockApi.mjs' +import { parseReq, z } from '@overleaf/validation-tools' + +// Token exchange body as built by passport-oauth2/the `oauth` library's +// OAuth2#getOAuthAccessToken (standard authorization_code grant). +const tokenSchema = z.object({ + body: z.strictObject({ + code: z.string(), + client_id: z.string(), + client_secret: z.string(), + grant_type: z.literal('authorization_code'), + redirect_uri: z.string(), + }), +}) class MockOrcidApi extends AbstractMockApi { reset() { @@ -14,7 +27,8 @@ class MockOrcidApi extends AbstractMockApi { applyRoutes() { this.app.post('/oauth/token', (req, res) => { - const token = this.tokens[req.body.code] + const { body } = parseReq(req, tokenSchema) + const token = this.tokens[body.code] if (!token) { return res.sendStatus(400) } diff --git a/services/web/test/acceptance/src/mocks/MockProjectHistoryApi.mjs b/services/web/test/acceptance/src/mocks/MockProjectHistoryApi.mjs index 7ff95ec395..a700e43245 100644 --- a/services/web/test/acceptance/src/mocks/MockProjectHistoryApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockProjectHistoryApi.mjs @@ -2,9 +2,67 @@ import AbstractMockApi from './AbstractMockApi.mjs' import _ from 'lodash' import mongodb from 'mongodb-legacy' import { plainTextResponse } from '../../../../app/src/infrastructure/Response.mjs' +import { parseReq, z, zz } from '@overleaf/validation-tools' const { ObjectId } = mongodb +// Mirrors services/project-history/app/js/HttpController.js's historyIdSchema +// (this mock stands in for project-history's own API in web's acceptance +// tests): most :projectId params accept either a Mongo ObjectId or a legacy +// v1-only numeric id. +const historyIdSchema = zz.objectId().or(z.coerce.number()) + +const oldFileParamsSchema = z.object({ + params: z.strictObject({ + projectId: historyIdSchema, + version: z.coerce.number().int(), + pathname: zz.filepath(), + }), +}) + +const projectSnapshotParamsSchema = z.object({ + params: z.strictObject({ + projectId: historyIdSchema, + version: z.coerce.number().int(), + }), +}) + +const projectParamsSchema = z.object({ + params: z.strictObject({ projectId: historyIdSchema }), +}) + +// Mirrors HttpController.js's createLabelSchema body in full -- web's own +// HistoryController.createLabel is the only current caller and always sends +// just { comment, version, user_id }, but created_at/validate_exists are +// genuine fields of the real route (accepted -- with defaults -- from other, +// non-web callers), so they are modelled here too rather than narrowed to +// only what's exercised today. +const createLabelSchema = z.object({ + params: z.strictObject({ projectId: historyIdSchema }), + body: z.strictObject({ + comment: z.string(), + version: z.number().int(), + created_at: zz.datetime().optional(), + validate_exists: z.boolean().default(true), + user_id: zz.objectId().nullable().optional(), + }), +}) + +const deleteLabelForUserParamsSchema = z.object({ + params: z.strictObject({ + projectId: historyIdSchema, + user_id: zz.objectId(), + labelId: zz.objectId(), + }), +}) + +const deleteLabelParamsSchema = z.object({ + params: z.strictObject({ + projectId: historyIdSchema, + labelId: zz.objectId(), + }), +}) + class MockProjectHistoryApi extends AbstractMockApi { reset() { this.docs = {} @@ -64,7 +122,8 @@ class MockProjectHistoryApi extends AbstractMockApi { this.app.get( '/project/:projectId/version/:version/:pathname', (req, res) => { - const { projectId, version, pathname } = req.params + const { params } = parseReq(req, oldFileParamsSchema) + const { projectId, version, pathname } = params const key = `${projectId}:${version}:${pathname}` if (this.oldFiles[key] != null) { plainTextResponse(res, this.oldFiles[key]) @@ -75,7 +134,8 @@ class MockProjectHistoryApi extends AbstractMockApi { ) this.app.get('/project/:projectId/version/:version', (req, res) => { - const { projectId, version } = req.params + const { params } = parseReq(req, projectSnapshotParamsSchema) + const { projectId, version } = params const key = `${projectId}:${version}` if (this.projectSnapshots[key] != null) { res.json(this.projectSnapshots[key]) @@ -85,7 +145,8 @@ class MockProjectHistoryApi extends AbstractMockApi { }) this.app.get('/project/:projectId/version', (req, res) => { - const { projectId } = req.params + const { params } = parseReq(req, projectParamsSchema) + const { projectId } = params if (this.projectVersions[projectId] != null) { res.json(this.projectVersions[projectId]) } else { @@ -94,7 +155,8 @@ class MockProjectHistoryApi extends AbstractMockApi { }) this.app.get('/project/:projectId/labels', (req, res) => { - const { projectId } = req.params + const { params } = parseReq(req, projectParamsSchema) + const { projectId } = params const labels = this.getLabels(projectId) if (labels != null) { res.json(labels) @@ -104,8 +166,9 @@ class MockProjectHistoryApi extends AbstractMockApi { }) this.app.post('/project/:projectId/labels', (req, res) => { - const { projectId } = req.params - const { comment, version } = req.body + const { params, body } = parseReq(req, createLabelSchema) + const { projectId } = params + const { comment, version } = body const labelId = new ObjectId().toString() this.addLabel(projectId, { id: labelId, comment, version }) res.json({ label_id: labelId, comment, version }) @@ -114,7 +177,8 @@ class MockProjectHistoryApi extends AbstractMockApi { this.app.delete( '/project/:projectId/user/:user_id/labels/:labelId', (req, res) => { - const { projectId, labelId } = req.params + const { params } = parseReq(req, deleteLabelForUserParamsSchema) + const { projectId, labelId } = params const label = this.labels[projectId] != null ? this.labels[projectId][labelId] @@ -129,7 +193,8 @@ class MockProjectHistoryApi extends AbstractMockApi { ) this.app.delete('/project/:projectId/labels/:labelId', (req, res) => { - const { projectId, labelId } = req.params + const { params } = parseReq(req, deleteLabelParamsSchema) + const { projectId, labelId } = params const label = this.labels[projectId] != null ? this.labels[projectId][labelId] diff --git a/services/web/test/acceptance/src/mocks/MockReCaptchaApi.mjs b/services/web/test/acceptance/src/mocks/MockReCaptchaApi.mjs index cb12e8bb1a..8634215029 100644 --- a/services/web/test/acceptance/src/mocks/MockReCaptchaApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockReCaptchaApi.mjs @@ -1,10 +1,22 @@ import AbstractMockApi from './AbstractMockApi.mjs' +import { parseReq, z } from '@overleaf/validation-tools' + +// Body sent by CaptchaMiddleware's siteverify request: `secret` (the +// configured recaptcha.secretKey) plus the client-supplied +// `g-recaptcha-response` forwarded as `response`. +const siteVerifySchema = z.object({ + body: z.strictObject({ + secret: z.string(), + response: z.string(), + }), +}) class MockReCaptchaApi extends AbstractMockApi { applyRoutes() { this.app.post('/recaptcha/api/siteverify', (req, res) => { + const { body } = parseReq(req, siteVerifySchema) res.json({ - success: req.body.response === 'valid', + success: body.response === 'valid', }) }) } diff --git a/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs b/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs index d25789d92c..f3d7d5b61d 100644 --- a/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs @@ -1,6 +1,35 @@ import AbstractMockApi from './AbstractMockApi.mjs' import SubscriptionController from '../../../../app/src/Features/Subscription/SubscriptionController.mjs' import { xmlResponse } from '../../../../app/src/infrastructure/Response.mjs' +import { parseReq, z, zz } from '@overleaf/validation-tools' + +// Recurly's own ids: subscription uuids and coupon codes are opaque +// Recurly-assigned strings, not Mongo ObjectIds -- only the account "id" is +// ours (it's always set to the Overleaf user's Mongo id, see +// RecurlyWrapper.mjs's `accounts/${userId}` calls). +const subscriptionParamsSchema = z.object({ + params: z.strictObject({ id: z.string() }), +}) + +const accountParamsSchema = z.object({ + params: z.strictObject({ id: zz.objectId() }), +}) + +// Mirrors RecurlyWrapper.updateAccountEmailAddress, the sole production +// caller of this route: it PUTs `...`, +// which recurlyNotificationParser parses into { account: { email } }. +const updateAccountSchema = z.object({ + params: z.strictObject({ id: zz.objectId() }), + body: z.strictObject({ + account: z.strictObject({ + email: z.string(), + }), + }), +}) + +const couponParamsSchema = z.object({ + params: z.strictObject({ code: z.string() }), +}) class MockRecurlyApi extends AbstractMockApi { reset() { @@ -44,7 +73,8 @@ class MockRecurlyApi extends AbstractMockApi { next() }) this.app.get('/subscriptions/:id', (req, res) => { - const subscription = this.getMockSubscriptionById(req.params.id) + const { params } = parseReq(req, subscriptionParamsSchema) + const subscription = this.getMockSubscriptionById(params.id) if (!subscription) { res.sendStatus(404) } else { @@ -68,7 +98,8 @@ class MockRecurlyApi extends AbstractMockApi { }) this.app.get('/accounts/:id', (req, res) => { - const subscription = this.getMockSubscriptionByAccountId(req.params.id) + const { params } = parseReq(req, accountParamsSchema) + const subscription = this.getMockSubscriptionByAccountId(params.id) if (!subscription) { res.sendStatus(404) } else { @@ -76,7 +107,7 @@ class MockRecurlyApi extends AbstractMockApi { res, `\ - ${req.params.id} + ${params.id} ${subscription.account.hosted_login_token} ${subscription.account.email} \ @@ -89,16 +120,17 @@ class MockRecurlyApi extends AbstractMockApi { '/accounts/:id', SubscriptionController.recurlyNotificationParser, // required to parse XML requests (req, res) => { - const subscription = this.getMockSubscriptionByAccountId(req.params.id) + const { params, body } = parseReq(req, updateAccountSchema) + const subscription = this.getMockSubscriptionByAccountId(params.id) if (!subscription) { res.sendStatus(404) } else { - Object.assign(subscription.account, req.body.account) + Object.assign(subscription.account, body.account) xmlResponse( res, `\ - ${req.params.id} + ${params.id} ${subscription.account.email} \ ` @@ -108,7 +140,8 @@ class MockRecurlyApi extends AbstractMockApi { ) this.app.get('/coupons/:code', (req, res) => { - const coupon = this.coupons[req.params.code] + const { params } = parseReq(req, couponParamsSchema) + const coupon = this.coupons[params.code] if (!coupon) { res.sendStatus(404) } else { @@ -116,7 +149,7 @@ class MockRecurlyApi extends AbstractMockApi { res, `\ - ${req.params.code} + ${params.code} ${coupon.name || ''} ${coupon.description || ''} \ @@ -126,7 +159,8 @@ class MockRecurlyApi extends AbstractMockApi { }) this.app.get('/accounts/:id/redemptions', (req, res) => { - const redemptions = this.redemptions[req.params.id] || [] + const { params } = parseReq(req, accountParamsSchema) + const redemptions = this.redemptions[params.id] || [] let redemptionsListXml = '' for (const redemption of Array.from(redemptions)) { redemptionsListXml += `\ diff --git a/services/web/test/acceptance/src/mocks/MockSpellingApi.mjs b/services/web/test/acceptance/src/mocks/MockSpellingApi.mjs index 55a31c4e72..2791085425 100644 --- a/services/web/test/acceptance/src/mocks/MockSpellingApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockSpellingApi.mjs @@ -1,4 +1,16 @@ import AbstractMockApi from './AbstractMockApi.mjs' +import { parseReq, z, zz } from '@overleaf/validation-tools' + +const userParamsSchema = z.object({ + params: z.strictObject({ userId: zz.objectId() }), +}) + +const wordSchema = z.object({ + params: z.strictObject({ userId: zz.objectId() }), + // learn/unlearn silently no-op on a missing word (see the `if (word)` + // checks below), so word is optional rather than required. + body: z.strictObject({ word: z.string().optional() }), +}) class MockSpellingApi extends AbstractMockApi { reset() { @@ -7,20 +19,21 @@ class MockSpellingApi extends AbstractMockApi { applyRoutes() { this.app.get('/user/:userId', (req, res) => { - const { userId } = req.params - const words = this.words[userId] || [] + const { params } = parseReq(req, userParamsSchema) + const words = this.words[params.userId] || [] res.json(words) }) this.app.delete('/user/:userId', (req, res) => { - const { userId } = req.params - this.words.delete(userId) + const { params } = parseReq(req, userParamsSchema) + this.words.delete(params.userId) res.sendStatus(200) }) this.app.post('/user/:userId/learn', (req, res) => { - const word = req.body.word - const { userId } = req.params + const { params, body } = parseReq(req, wordSchema) + const word = body.word + const { userId } = params if (word) { this.words[userId] = this.words[userId] || [] if (!this.words[userId].includes(word)) { @@ -31,8 +44,9 @@ class MockSpellingApi extends AbstractMockApi { }) this.app.post('/user/:userId/unlearn', (req, res) => { - const word = req.body.word - const { userId } = req.params + const { params, body } = parseReq(req, wordSchema) + const word = body.word + const { userId } = params if (word && this.words[userId]) { const wordIndex = this.words[userId].indexOf(word) if (wordIndex !== -1) { diff --git a/services/web/test/acceptance/src/mocks/MockStripeApi.mjs b/services/web/test/acceptance/src/mocks/MockStripeApi.mjs index 99a8064368..97488a6997 100644 --- a/services/web/test/acceptance/src/mocks/MockStripeApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockStripeApi.mjs @@ -1,4 +1,240 @@ import AbstractMockApi from './AbstractMockApi.mjs' +import { parseReq, z } from '@overleaf/validation-tools' + +// This mock stands in for Stripe's own API in web's acceptance tests. Unlike +// docstore/clsi, there is no internal production schema to mirror here -- +// Stripe's real API is a third-party, open-ended surface -- so these schemas +// model the shape web's StripeClient actually sends to each route (per its +// call sites in modules/subscriptions/app/src/StripeClient.mjs) rather than +// only the handful of fields each handler destructures. +const stripeIdSchema = z.string().min(1) + +// Stripe metadata values are always flat string->string maps. +const metadataSchema = z.record(z.string(), z.string()) + +const addressSchema = z.strictObject({ + city: z.string().optional(), + country: z.string().optional(), + line1: z.string().optional(), + line2: z.string().optional(), + postal_code: z.string().optional(), + state: z.string().optional(), +}) + +const expandSchema = z.array(z.string()) + +const customerParamsSchema = z.strictObject({ id: stripeIdSchema }) + +const createCustomerSchema = z.object({ + body: z.strictObject({ + email: z.string(), + name: z.string().optional(), + address: addressSchema.optional(), + tax: z + .strictObject({ + validate_location: z.string().optional(), + }) + .optional(), + metadata: metadataSchema.optional(), + tax_exempt: z.string().optional(), + expand: expandSchema.optional(), + }), +}) + +const listCustomersSchema = z.object({ + query: z.object({ + email: z.string().optional(), + limit: z.coerce.number().int().optional(), + expand: expandSchema.optional(), + }), +}) + +const getCustomerSchema = z.object({ params: customerParamsSchema }) + +// Backs the generic `customers.update()` call, used with different subsets +// of these fields across StripeClient (subscription-creation flow, plain +// email/metadata updates, and customer-merge billing-details copy). +const updateCustomerSchema = z.object({ + params: customerParamsSchema, + body: z.strictObject({ + email: z.string().optional(), + name: z.string().optional(), + address: addressSchema.optional(), + tax: z + .strictObject({ + validate_location: z.string().optional(), + }) + .optional(), + metadata: metadataSchema.optional(), + tax_exempt: z.string().optional(), + expand: expandSchema.optional(), + }), +}) + +const createTaxIdSchema = z.object({ + params: customerParamsSchema, + body: z.strictObject({ + type: z.string(), + value: z.string(), + }), +}) + +const deleteTaxIdSchema = z.object({ + params: z.strictObject({ + id: stripeIdSchema, + taxId: stripeIdSchema, + }), +}) + +const createSetupIntentSchema = z.object({ + body: z.strictObject({ + customer: stripeIdSchema, + usage: z.string().optional(), + metadata: metadataSchema.optional(), + }), +}) + +const setupIntentParamsSchema = z.strictObject({ id: stripeIdSchema }) + +const getSetupIntentSchema = z.object({ + params: setupIntentParamsSchema, + query: z.object({ + expand: expandSchema.optional(), + }), +}) + +// Subscription item as sent by `subscriptions.create()` -- either a plain +// `{ price, quantity }` pair (the common case) or one carrying its own `id` +// (`subscriptionSchedules` phase items reuse the same shape upstream). +const subscriptionItemSchema = z.strictObject({ + id: z.string().optional(), + price: stripeIdSchema, + quantity: z.coerce.number().int().optional(), +}) + +const createSubscriptionSchema = z.object({ + body: z.strictObject({ + customer: stripeIdSchema, + items: z.array(subscriptionItemSchema).optional(), + collection_method: z.string().optional(), + payment_behavior: z.string().optional(), + payment_settings: z + .strictObject({ + save_default_payment_method: z.string().optional(), + }) + .optional(), + default_payment_method: z.string().optional(), + trial_period_days: z.coerce.number().int().optional(), + metadata: metadataSchema.optional(), + automatic_tax: z + .strictObject({ + enabled: z.stringbool().optional(), + }) + .optional(), + discounts: z + .array( + z.strictObject({ + promotion_code: z.string().optional(), + }) + ) + .optional(), + expand: expandSchema.optional(), + }), +}) + +const subscriptionParamsSchema = z.strictObject({ id: stripeIdSchema }) + +const getSubscriptionSchema = z.object({ + params: subscriptionParamsSchema, + query: z.object({ + expand: expandSchema.optional(), + }), +}) + +const listSubscriptionsSchema = z.object({ + query: z.object({ + customer: z.string().optional(), + status: z.string().optional(), + expand: expandSchema.optional(), + }), +}) + +const listPricesSchema = z.object({ + query: z.object({ + active: z.stringbool().optional(), + limit: z.coerce.number().int().optional(), + lookup_keys: expandSchema.optional(), + expand: expandSchema.optional(), + }), +}) + +const priceParamsSchema = z.strictObject({ id: stripeIdSchema }) + +const getPriceSchema = z.object({ params: priceParamsSchema }) + +// `invoices.createPreview()` is called from two different sites with two +// different shapes: previewing a brand-new subscription (customer_details + +// subscription_details.items) and previewing the next invoice for an +// existing subscription (customer + subscription). Model both as optional +// since the mock's response doesn't depend on which shape was sent. +const createInvoicePreviewSchema = z.object({ + body: z.strictObject({ + customer: z.string().optional(), + subscription: z.string().optional(), + customer_details: z + .strictObject({ + address: addressSchema.optional(), + tax_ids: z + .array( + z.strictObject({ + type: z.string(), + value: z.string(), + }) + ) + .optional(), + tax_exempt: z.string().optional(), + }) + .optional(), + automatic_tax: z + .strictObject({ + enabled: z.stringbool().optional(), + }) + .optional(), + subscription_details: z + .strictObject({ + items: z + .array( + z.strictObject({ + id: z.string().optional(), + price: z.string().optional(), + quantity: z.coerce.number().int().optional(), + }) + ) + .optional(), + proration_behavior: z.string().optional(), + }) + .optional(), + currency: z.string().optional(), + preview_mode: z.string().optional(), + discounts: z + .array( + z.strictObject({ + promotion_code: z.string().optional(), + }) + ) + .optional(), + expand: expandSchema.optional(), + }), +}) + +const listPromotionCodesSchema = z.object({ + query: z.object({ + code: z.string().optional(), + active: z.stringbool().optional(), + expand: expandSchema.optional(), + }), +}) + class MockStripeApi extends AbstractMockApi { reset() { this.customers = {} @@ -156,14 +392,15 @@ class MockStripeApi extends AbstractMockApi { applyRoutes() { this.app.post('/v1/customers', (req, res) => { + const { body } = parseReq(req, createCustomerSchema) const id = this._nextId('cus') const customer = { id, - email: req.body.email, - name: req.body.name, - address: req.body.address, - metadata: req.body.metadata || {}, - tax_exempt: req.body.tax_exempt || 'none', + email: body.email, + name: body.name, + address: body.address, + metadata: body.metadata || {}, + tax_exempt: body.tax_exempt || 'none', tax_ids: [], } this.customers[id] = customer @@ -171,9 +408,10 @@ class MockStripeApi extends AbstractMockApi { }) this.app.get('/v1/customers', (req, res) => { + const { query } = parseReq(req, listCustomersSchema) let data = Object.values(this.customers) - if (req.query.email) { - data = data.filter(c => c.email === req.query.email) + if (query.email) { + data = data.filter(c => c.email === query.email) } res.json({ object: 'list', @@ -183,68 +421,73 @@ class MockStripeApi extends AbstractMockApi { }) this.app.get('/v1/customers/:id', (req, res) => { - const customer = this.customers[req.params.id] + const { params } = parseReq(req, getCustomerSchema) + const customer = this.customers[params.id] if (!customer) return this._notFound(res, 'customer') res.json(this._customerResponse(customer)) }) this.app.post('/v1/customers/:id', (req, res) => { - const customer = this.customers[req.params.id] + const { params, body } = parseReq(req, updateCustomerSchema) + const customer = this.customers[params.id] if (!customer) return this._notFound(res, 'customer') - Object.assign(customer, req.body) + Object.assign(customer, body) res.json(this._customerResponse(customer)) }) this.app.post('/v1/customers/:id/tax_ids', (req, res) => { - const customer = this.customers[req.params.id] + const { params, body } = parseReq(req, createTaxIdSchema) + const customer = this.customers[params.id] if (!customer) return this._notFound(res, 'customer') const taxId = { id: this._nextId('txi'), object: 'tax_id', - type: req.body.type, - value: req.body.value, + type: body.type, + value: body.value, } customer.tax_ids.push(taxId) res.json(taxId) }) this.app.delete('/v1/customers/:id/tax_ids/:taxId', (req, res) => { - const customer = this.customers[req.params.id] + const { params } = parseReq(req, deleteTaxIdSchema) + const customer = this.customers[params.id] if (customer) { - customer.tax_ids = customer.tax_ids.filter( - t => t.id !== req.params.taxId - ) + customer.tax_ids = customer.tax_ids.filter(t => t.id !== params.taxId) } - res.json({ id: req.params.taxId, object: 'tax_id', deleted: true }) + res.json({ id: params.taxId, object: 'tax_id', deleted: true }) }) this.app.post('/v1/setup_intents', (req, res) => { + const { body } = parseReq(req, createSetupIntentSchema) const id = this._nextId('seti') const setupIntent = { id, object: 'setup_intent', status: 'requires_payment_method', client_secret: `${id}_secret_mock`, - customer: req.body.customer, - usage: req.body.usage, + customer: body.customer, + usage: body.usage, payment_method: null, last_setup_error: null, - metadata: req.body.metadata || {}, + metadata: body.metadata || {}, } this.setupIntents[id] = setupIntent res.json(setupIntent) }) this.app.get('/v1/setup_intents/:id', (req, res) => { - const setupIntent = this.setupIntents[req.params.id] + const { params, query } = parseReq(req, getSetupIntentSchema) + const setupIntent = this.setupIntents[params.id] if (!setupIntent) return this._notFound(res, 'setup_intent') - const expand = [].concat(req.query.expand || []) + const expand = [].concat(query.expand || []) res.json(this._expandSetupIntent(setupIntent, expand)) }) this.app.post('/v1/subscriptions', (req, res) => { + const { body } = parseReq(req, createSubscriptionSchema) const id = this._nextId('sub') - const items = [].concat(req.body.items || []).map(item => ({ + const items = [].concat(body.items || []).map(item => ({ id: this._nextId('si'), priceId: item.price, quantity: parseInt(item.quantity || '1', 10), @@ -254,12 +497,12 @@ class MockStripeApi extends AbstractMockApi { // with an open invoice carrying a confirmation secret. const subscription = { id, - customerId: req.body.customer, - status: req.body.trial_period_days ? 'trialing' : 'incomplete', + customerId: body.customer, + status: body.trial_period_days ? 'trialing' : 'incomplete', currency: 'usd', - collection_method: req.body.collection_method || 'charge_automatically', + collection_method: body.collection_method || 'charge_automatically', items, - metadata: req.body.metadata || {}, + metadata: body.metadata || {}, discounts: [], current_period_start: 1700000000, current_period_end: 1702592000, @@ -283,15 +526,17 @@ class MockStripeApi extends AbstractMockApi { }) this.app.get('/v1/subscriptions/:id', (req, res) => { - const subscription = this.subscriptions[req.params.id] + const { params } = parseReq(req, getSubscriptionSchema) + const subscription = this.subscriptions[params.id] if (!subscription) return this._notFound(res, 'subscription') res.json(this._subscriptionResponse(subscription)) }) this.app.get('/v1/subscriptions', (req, res) => { + const { query } = parseReq(req, listSubscriptionsSchema) let data = Object.values(this.subscriptions) - if (req.query.customer) { - data = data.filter(s => s.customerId === req.query.customer) + if (query.customer) { + data = data.filter(s => s.customerId === query.customer) } res.json({ object: 'list', @@ -301,9 +546,10 @@ class MockStripeApi extends AbstractMockApi { }) this.app.get('/v1/prices', (req, res) => { + const { query } = parseReq(req, listPricesSchema) let data = Object.values(this.prices) - if (req.query.lookup_keys) { - const keys = [].concat(req.query.lookup_keys) + if (query.lookup_keys) { + const keys = [].concat(query.lookup_keys) data = data.filter(p => keys.includes(p.lookup_key)) for (const key of keys) { if (!data.some(p => p.lookup_key === key)) { @@ -315,7 +561,8 @@ class MockStripeApi extends AbstractMockApi { }) this.app.get('/v1/prices/:id', (req, res) => { - const price = this.prices[req.params.id] + const { params } = parseReq(req, getPriceSchema) + const price = this.prices[params.id] if (!price) return this._notFound(res, 'price') res.json(price) }) @@ -329,9 +576,10 @@ class MockStripeApi extends AbstractMockApi { }) this.app.post('/v1/invoices/create_preview', (req, res) => { + const { body } = parseReq(req, createInvoicePreviewSchema) res.json({ object: 'invoice', - currency: req.body.currency || 'usd', + currency: body.currency || 'usd', subtotal: 1500, total: 1500, total_excluding_tax: 1500, @@ -343,6 +591,7 @@ class MockStripeApi extends AbstractMockApi { }) this.app.get('/v1/promotion_codes', (req, res) => { + parseReq(req, listPromotionCodesSchema) res.json({ object: 'list', has_more: false, diff --git a/services/web/test/acceptance/src/mocks/MockV1Api.mjs b/services/web/test/acceptance/src/mocks/MockV1Api.mjs index e72d4070c7..df230d0c7d 100644 --- a/services/web/test/acceptance/src/mocks/MockV1Api.mjs +++ b/services/web/test/acceptance/src/mocks/MockV1Api.mjs @@ -1,6 +1,121 @@ import AbstractMockApi from './AbstractMockApi.mjs' import moment from 'moment' import sinon from 'sinon' +import { parseReq, z, zz, getRawReqInput } from '@overleaf/validation-tools' + +const v1UserIdParamsSchema = z.object({ + params: z.strictObject({ v1_user_id: z.string() }), +}) + +const exportByIdSchema = z.object({ + params: z.strictObject({ id: z.string() }), + query: z.object({ token: z.string().optional() }), +}) + +// zip_url only reads req.query.token -- unlike exportByIdSchema above, the +// route's own :id param is never read by the handler, so it is left out. +const exportZipUrlSchema = z.object({ + query: z.object({ token: z.string().optional() }), +}) + +const userIdParamsSchema = z.object({ + params: z.strictObject({ userId: zz.objectId() }), +}) + +// Institution id sent from web varies by caller: UserEmailsController.mjs +// forwards a client-supplied number, while the SAML/group-domain-capture +// call sites always .toString() it first -- see +// app/src/Features/User/SAMLIdentityManager.mjs and +// modules/saas-authentication/app/src/SAML/SAMLController.mjs. +const affiliationUniversitySchema = z + .strictObject({ + id: z.union([z.number(), z.string()]).optional(), + name: z.string().optional(), + country_code: z.string().optional(), + }) + .optional() + +// Mirrors every field InstitutionsAPI.mjs#addAffiliation actually puts on +// the wire (app/src/Features/Institutions/InstitutionsAPI.mjs) -- the mock +// handler itself only reads email/entitlement/confirmedAt, but the other +// fields are genuinely sent by real callers (UserEmailsController, +// SAMLIdentityManager, SAMLController, GroupDomainCaptureHandler) and must +// still be accepted by this strict schema. +const addAffiliationSchema = z.object({ + params: z.strictObject({ userId: zz.objectId() }), + body: z.strictObject({ + email: z.string(), + university: affiliationUniversitySchema, + department: z.string().optional(), + role: z.string().optional(), + entitlement: z.boolean().optional(), + confirmedAt: z.coerce.date().optional(), + rejectIfBlocklisted: z.boolean().optional(), + }), +}) + +const brandSlugSchema = z.object({ + params: z.strictObject({ slug: z.string() }), +}) + +const universitiesListSchema = z.object({ + query: z.object({ + country_code: z.string().optional(), + search: z.string().optional(), + max_results: z.string().optional(), + }), +}) + +const universityByIdSchema = z.object({ + params: z.strictObject({ id: z.string() }), +}) + +const universityDomainsSchema = z.object({ + query: z.object({ hostname: z.string().optional() }), +}) + +const updateEmailSchema = z.object({ + params: z.strictObject({ id: z.string() }), + body: z.strictObject({ + user: z.strictObject({ email: z.string() }), + }), +}) + +const loginSchema = z.object({ + body: z.strictObject({ + email: z.string(), + password: z.string(), + }), +}) + +const partnerConversionSchema = z.object({ + params: z.strictObject({ + partner: z.string(), + id: z.string(), + }), +}) + +const brandVariationSchema = z.object({ + params: z.strictObject({ id: z.string() }), +}) + +const tokenParamsSchema = z.object({ + params: z.strictObject({ + user_id: z.string().optional(), + token: z.string(), + }), +}) + +const templateSchema = z.object({ + params: z.strictObject({ templateId: z.string() }), +}) + +const fakeRouteApiHandlerTestsSchema = z.object({ + query: z.object({ + expectedStatus: z.string(), + expectedBody: z.string(), + }), +}) class MockV1Api extends AbstractMockApi { reset() { @@ -163,7 +278,8 @@ class MockV1Api extends AbstractMockApi { applyRoutes() { this.app.get('/api/v1/overleaf/users/:v1_user_id/plan_code', (req, res) => { - const user = this.users[req.params.v1_user_id] + const { params } = parseReq(req, v1UserIdParamsSchema) + const user = this.users[params.v1_user_id] if (user) { res.json(user) } else { @@ -174,7 +290,8 @@ class MockV1Api extends AbstractMockApi { this.app.get( '/api/v1/overleaf/users/:v1_user_id/subscriptions', (req, res) => { - const user = this.users[req.params.v1_user_id] + const { params } = parseReq(req, v1UserIdParamsSchema) + const user = this.users[params.v1_user_id] if (user && user.subscription) { res.json(user.subscription) } else { @@ -186,7 +303,8 @@ class MockV1Api extends AbstractMockApi { this.app.get( '/api/v1/overleaf/users/:v1_user_id/subscription_status', (req, res) => { - const user = this.users[req.params.v1_user_id] + const { params } = parseReq(req, v1UserIdParamsSchema) + const user = this.users[params.v1_user_id] if (user && user.subscription_status) { res.json(user.subscription_status) } else { @@ -198,7 +316,8 @@ class MockV1Api extends AbstractMockApi { this.app.delete( '/api/v1/overleaf/users/:v1_user_id/subscription', (req, res) => { - const user = this.users[req.params.v1_user_id] + const { params } = parseReq(req, v1UserIdParamsSchema) + const user = this.users[params.v1_user_id] if (user) { user.canceled = true res.sendStatus(200) @@ -209,29 +328,37 @@ class MockV1Api extends AbstractMockApi { ) this.app.post('/api/v1/overleaf/users/:v1_user_id/sync', (req, res) => { - this.syncUserFeatures(req.params.v1_user_id) + const { params } = parseReq(req, v1UserIdParamsSchema) + this.syncUserFeatures(params.v1_user_id) res.sendStatus(200) }) this.app.post('/api/v1/overleaf/exports', (req, res) => { - this.exportParams = Object.assign({}, req.body) + // case 3: acceptance tests assert on the exact raw body sent to v1's + // export endpoint via getLastExportParams() (see + // modules/saas-authentication/test/acceptance/src/ExportsTests.mjs) -- + // this records the wire payload verbatim for later inspection rather + // than reading any field of it itself. + this.exportParams = Object.assign({}, getRawReqInput(req).body) res.json({ exportId: this.exportId, token: this.exportToken }) }) this.app.get('/api/v1/overleaf/exports/:id', (req, res) => { - const { token } = req.query + const { params, query } = parseReq(req, exportByIdSchema) + const { token } = query if (token && token !== this.exportToken) { return res.sendStatus(404) } res.json({ - id: parseInt(req.params.id, 10), + id: parseInt(params.id, 10), status_summary: 'succeeded', token: this.exportToken, }) }) this.app.get('/api/v1/overleaf/exports/:id/zip_url', (req, res) => { - const { token } = req.query + const { query } = parseReq(req, exportZipUrlSchema) + const { token } = query if (token && token !== this.exportToken) { return res.sendStatus(404) } @@ -240,65 +367,62 @@ class MockV1Api extends AbstractMockApi { }) this.app.get('/api/v2/users/:userId/affiliations', (req, res) => { - if (!this.affiliations[req.params.userId]) return res.json([]) - const affiliations = this.affiliations[req.params.userId].map( - affiliation => { - const institutionId = affiliation.institution.id - const domain = affiliation.email.split('@').pop() - const domainData = - this.institutionDomains[institutionId][domain] || {} - const institutionData = this.institutions[institutionId] || {} + const { params } = parseReq(req, userIdParamsSchema) + if (!this.affiliations[params.userId]) return res.json([]) + const affiliations = this.affiliations[params.userId].map(affiliation => { + const institutionId = affiliation.institution.id + const domain = affiliation.email.split('@').pop() + const domainData = this.institutionDomains[institutionId][domain] || {} + const institutionData = this.institutions[institutionId] || {} - affiliation.institution = { - id: institutionId, - name: institutionData.name, - commonsAccount: institutionData.commonsAccount, - writefullCommonsAccount: - institutionData.writefullCommonsAccount || false, - isUniversity: !institutionData.institution, - ssoBeta: institutionData.sso_beta || false, - ssoEnabled: institutionData.sso_enabled || false, - maxConfirmationMonths: - institutionData.maxConfirmationMonths || null, - } - - affiliation.institution.confirmed = !!domainData.confirmed - - affiliation.licence = 'free' - if ( - institutionData.commonsAccount && - (!institutionData.sso_enabled || - (institutionData.sso_enabled && - affiliation.cached_entitlement === true)) - ) { - affiliation.licence = 'pro_plus' - } - - if ( - institutionData.maxConfirmationMonths && - affiliation.cached_reconfirmed_at - ) { - const lastDayToReconfirm = moment( - affiliation.cached_reconfirmed_at - ).add(institutionData.maxConfirmationMonths, 'months') - affiliation.last_day_to_reconfirm = lastDayToReconfirm.toDate() - affiliation.past_reconfirm_date = lastDayToReconfirm.isBefore() - } - - affiliation.domainCapturedByGroup = domainData.captured_by_group - - return affiliation + affiliation.institution = { + id: institutionId, + name: institutionData.name, + commonsAccount: institutionData.commonsAccount, + writefullCommonsAccount: + institutionData.writefullCommonsAccount || false, + isUniversity: !institutionData.institution, + ssoBeta: institutionData.sso_beta || false, + ssoEnabled: institutionData.sso_enabled || false, + maxConfirmationMonths: institutionData.maxConfirmationMonths || null, } - ) + + affiliation.institution.confirmed = !!domainData.confirmed + + affiliation.licence = 'free' + if ( + institutionData.commonsAccount && + (!institutionData.sso_enabled || + (institutionData.sso_enabled && + affiliation.cached_entitlement === true)) + ) { + affiliation.licence = 'pro_plus' + } + + if ( + institutionData.maxConfirmationMonths && + affiliation.cached_reconfirmed_at + ) { + const lastDayToReconfirm = moment( + affiliation.cached_reconfirmed_at + ).add(institutionData.maxConfirmationMonths, 'months') + affiliation.last_day_to_reconfirm = lastDayToReconfirm.toDate() + affiliation.past_reconfirm_date = lastDayToReconfirm.isBefore() + } + + affiliation.domainCapturedByGroup = domainData.captured_by_group + return affiliation + }) res.json(affiliations) }) this.app.post('/api/v2/users/:userId/affiliations', (req, res) => { + const { params, body } = parseReq(req, addAffiliationSchema) this.addAffiliation( - req.params.userId, - req.body.email, - req.body.entitlement, - req.body.confirmedAt + params.userId, + body.email, + body.entitlement, + body.confirmedAt ) res.sendStatus(201) }) @@ -332,8 +456,9 @@ class MockV1Api extends AbstractMockApi { ) this.app.get('/api/v2/brands/:slug', (req, res) => { + const { params } = parseReq(req, brandSlugSchema) let brand - if ((brand = this.brands[req.params.slug])) { + if ((brand = this.brands[params.slug])) { res.json(brand) } else { res.sendStatus(404) @@ -341,6 +466,7 @@ class MockV1Api extends AbstractMockApi { }) this.app.get('/universities/list', (req, res) => { + const { query } = parseReq(req, universitiesListSchema) const response = [] const university1 = { @@ -357,27 +483,29 @@ class MockV1Api extends AbstractMockApi { departments: [], } - if (req.query.country_code === 'en') { + if (query.country_code === 'en') { response.push(university1) } - if (req.query.search === 'Institution') { + if (query.search === 'Institution') { response.push(university1) - if (req.query.max_results !== '1') { + if (query.max_results !== '1') { response.push(university2) } } res.json(response) }) - this.app.get('/universities/list/:id', (req, res) => + this.app.get('/universities/list/:id', (req, res) => { + const { params } = parseReq(req, universityByIdSchema) res.json({ - id: parseInt(req.params.id), - name: `Institution ${req.params.id}`, + id: parseInt(params.id), + name: `Institution ${params.id}`, }) - ) + }) this.app.get('/university/domains', (req, res) => { - if (req.query.hostname === 'overleaf.com') { + const { query } = parseReq(req, universityDomainsSchema) + if (query.hostname === 'overleaf.com') { res.json([ { id: 42, @@ -393,7 +521,7 @@ class MockV1Api extends AbstractMockApi { }, }, ]) - } else if (req.query.hostname === 'sharelatex.com') { + } else if (query.hostname === 'sharelatex.com') { res.json([ { id: 44, @@ -416,22 +544,24 @@ class MockV1Api extends AbstractMockApi { }) this.app.put('/api/v1/overleaf/users/:id/email', (req, res) => { - const { email } = req.body && req.body.user + const { params, body } = parseReq(req, updateEmailSchema) + const { email } = body.user if (this.existingEmails.includes(email)) { res.sendStatus(409) } else { - this.updateEmail(parseInt(req.params.id), email) + this.updateEmail(parseInt(params.id), email) res.sendStatus(200) } }) this.app.post('/api/v1/overleaf/login', (req, res) => { + const { body } = parseReq(req, loginSchema) for (const id in this.users) { const user = this.users[id] if ( user && - user.email === req.body.email && - user.password === req.body.password + user.email === body.email && + user.password === body.password ) { return res.json({ email: user.email, @@ -441,15 +571,16 @@ class MockV1Api extends AbstractMockApi { } } res.status(403).json({ - email: req.body.email, + email: body.email, valid: false, }) }) this.app.get('/api/v2/partners/:partner/conversions/:id', (req, res) => { - const partner = this.validation_clients[req.params.partner] + const { params } = parseReq(req, partnerConversionSchema) + const partner = this.validation_clients[params.partner] const conversion = - partner && partner.conversions && partner.conversions[req.params.id] + partner && partner.conversions && partner.conversions[params.id] if (conversion) { res.status(200).json({ @@ -462,7 +593,8 @@ class MockV1Api extends AbstractMockApi { }) this.app.get('/api/v2/brand_variations/:id', (req, res) => { - const variation = this.brand_variations[req.params.id] + const { params } = parseReq(req, brandVariationSchema) + const variation = this.brand_variations[params.id] if (variation) { res.status(200).json(variation) } else { @@ -477,7 +609,8 @@ class MockV1Api extends AbstractMockApi { this.app.get( '/api/v1/overleaf/users/:user_id/docs/:token/info', (req, res) => { - const info = this.getDocInfo(req.params.token) || { + const { params } = parseReq(req, tokenParamsSchema) + const info = this.getDocInfo(params.token) || { exists: false, exported: false, } @@ -486,7 +619,8 @@ class MockV1Api extends AbstractMockApi { ) this.app.get('/api/v1/overleaf/docs/:token/info', (req, res) => { - const info = this.getDocInfo(req.params.token) || { + const { params } = parseReq(req, tokenParamsSchema) + const info = this.getDocInfo(params.token) || { exists: false, exported: false, } @@ -501,7 +635,8 @@ class MockV1Api extends AbstractMockApi { ) this.app.get('/api/v2/templates/:templateId', (req, res) => { - const template = this.templates[req.params.templateId] + const { params } = parseReq(req, templateSchema) + const template = this.templates[params.templateId] if (!template) { return res.sendStatus(404) } @@ -511,8 +646,9 @@ class MockV1Api extends AbstractMockApi { this.app.get( '/api/v1/overleaf/fake_route_api_handler_tests', (req, res) => { - const expectedStatus = req.query.expectedStatus - const expectedBody = req.query.expectedBody + const { query } = parseReq(req, fakeRouteApiHandlerTestsSchema) + const expectedStatus = query.expectedStatus + const expectedBody = query.expectedBody const statusCode = Number(expectedStatus) if ( !Number.isInteger(statusCode) || diff --git a/services/web/test/acceptance/src/mocks/MockV1HistoryApi.mjs b/services/web/test/acceptance/src/mocks/MockV1HistoryApi.mjs index 3b424c7774..b309bbc37d 100644 --- a/services/web/test/acceptance/src/mocks/MockV1HistoryApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockV1HistoryApi.mjs @@ -4,7 +4,66 @@ import { zipAttachment, prepareZipAttachment, } from '../../../../app/src/infrastructure/Response.mjs' -import { z } from 'zod' +import { Blob } from 'overleaf-editor-core' +import { parseReq, z, zz } from '@overleaf/validation-tools' + +// Mirrors services/history-v1/api/schema.js (the canonical template for this +// whole epic) for routes that correspond to a real history-v1 endpoint. +// Routes that only exist as internal mock plumbing (fake-zip-download, +// manufactured by this mock's own createZip handler, not a real history-v1 +// route) get their own narrow schema instead. + +const hexHashPattern = new RegExp(Blob.HEX_HASH_RX_STRING) + +// matches history-v1's getProjectBlobsStats +const blobStatsSchema = z.object({ + body: z.strictObject({ + projectIds: z.array(zz.projectHistoryId()), + }), +}) + +// matches history-v1's getBlobStats -- this mock binds the project id param +// as `historyId` rather than `project_id`. +const projectBlobStatsSchema = z.object({ + params: z.strictObject({ historyId: zz.projectHistoryId() }), + body: z.strictObject({ blobHashes: z.array(z.string()) }), +}) + +// matches history-v1's getZip/createZip +const versionZipParamsSchema = z.object({ + params: z.strictObject({ + project_id: zz.projectHistoryId(), + version: z.coerce.number(), + }), +}) + +// Not a real history-v1 route -- this is the URL this mock's own createZip +// handler manufactures for the client to poll/download from. `version` is +// compared against literal strings below, so it stays an un-coerced string +// here rather than mirroring versionZipParamsSchema's z.coerce.number(). +const fakeZipDownloadParamsSchema = z.object({ + params: z.strictObject({ + project_id: zz.projectHistoryId(), + version: z.string(), + }), +}) + +// matches history-v1's createProjectBlob/headProjectBlob/getProjectBlob +const blobParamsSchema = z.object({ + params: z.strictObject({ + projectId: zz.projectHistoryId(), + hash: z.string().regex(hexHashPattern), + }), +}) + +// matches history-v1's copyProjectBlob query (params aren't read by this +// mock's handler, so they're left out of the schema) +const copyProjectBlobSchema = z.object({ + query: z.object({ + copyFrom: zz.projectHistoryId(), + sizeLimit: z.coerce.number().optional(), + }), +}) class MockV1HistoryApi extends AbstractMockApi { reset() { @@ -65,17 +124,19 @@ class MockV1HistoryApi extends AbstractMockApi { applyRoutes() { this.app.post('/api/projects/blob-stats', (req, res, next) => { + const { body } = parseReq(req, blobStatsSchema) res.json( // Calculate actual sizes from uploaded blobs - req.body.projectIds.map(projectId => { + body.projectIds.map(projectId => { return this.computeBlobStats(projectId) }) ) }) this.app.post('/api/projects/:historyId/blob-stats', (req, res, next) => { - const { historyId } = req.params - const { blobHashes } = req.body + const { params, body } = parseReq(req, projectBlobStatsSchema) + const { historyId } = params + const { blobHashes } = body // Calculate actual sizes from uploaded blobs const result = this.computeBlobStats(historyId, blobHashes) res.json(result) @@ -84,10 +145,11 @@ class MockV1HistoryApi extends AbstractMockApi { this.app.get( '/api/projects/:project_id/version/:version/zip', (req, res, next) => { + const { params } = parseReq(req, versionZipParamsSchema) this.sentChunks++ zipAttachment( res, - `Mock zip for ${req.params.project_id} at version ${req.params.version}`, + `Mock zip for ${params.project_id} at version ${params.version}`, 'project.zip' ) } @@ -96,13 +158,14 @@ class MockV1HistoryApi extends AbstractMockApi { this.app.get( '/fake-zip-download/:project_id/version/:version', (req, res, next) => { + const { params } = parseReq(req, fakeZipDownloadParamsSchema) if (!(this.fakeZipCall++ > 0)) { return res.sendStatus(404) } - if (req.params.version === '42') { + if (params.version === '42') { return zipAttachment( res, - `Mock zip for ${req.params.project_id} at version ${req.params.version}`, + `Mock zip for ${params.project_id} at version ${params.version}`, 'project.zip' ) } @@ -124,7 +187,7 @@ class MockV1HistoryApi extends AbstractMockApi { }, 10 * 1000) res.on('end', () => clearTimeout(deadLine)) } - if (req.params.version === '100') { + if (params.version === '100') { return writeEvery(100) } res.sendStatus(400) @@ -134,10 +197,11 @@ class MockV1HistoryApi extends AbstractMockApi { this.app.post( '/api/projects/:project_id/version/:version/zip', (req, res, next) => { + const { params } = parseReq(req, versionZipParamsSchema) this.requestedZipPacks++ this.events.emit('v1-history-pack-zip') res.json({ - zipUrl: `http://127.0.0.1:23100/fake-zip-download/${req.params.project_id}/version/${req.params.version}`, + zipUrl: `http://127.0.0.1:23100/fake-zip-download/${params.project_id}/version/${params.version}`, }) } ) @@ -147,10 +211,11 @@ class MockV1HistoryApi extends AbstractMockApi { }) this.app.put('/api/projects/:projectId/blobs/:hash', (req, res, next) => { + const { params } = parseReq(req, blobParamsSchema) + const { projectId, hash } = params const chunks = [] req.on('data', chunk => chunks.push(chunk)) req.on('end', () => { - const { projectId, hash } = req.params if (!this.blobs[projectId]) { this.blobs[projectId] = {} } @@ -159,27 +224,23 @@ class MockV1HistoryApi extends AbstractMockApi { }) }) this.app.head('/api/projects/:projectId/blobs/:hash', (req, res, next) => { - const { projectId, hash } = req.params + const { params } = parseReq(req, blobParamsSchema) + const { projectId, hash } = params const buf = this.blobs[projectId]?.[hash] if (!buf) return res.status(404).end() res.set('Content-Length', buf.byteLength) res.status(200).end() }) this.app.get('/api/projects/:projectId/blobs/:hash', (req, res, next) => { - const { projectId, hash } = req.params + const { params } = parseReq(req, blobParamsSchema) + const { projectId, hash } = params const buf = this.blobs[projectId]?.[hash] if (!buf) return res.status(404).end() res.status(200).end(buf) }) this.app.post('/api/projects/:project_id/blobs/:hash', (req, res, next) => { - const schema = z.object({ - copyFrom: z.coerce.number(), - }) - const { error } = schema.safeParse(req.query) - if (error) { - return res.sendStatus(400) - } + parseReq(req, copyProjectBlobSchema) res.sendStatus(204) }) } diff --git a/services/web/test/unit/src/Authentication/AuthenticationController.test.mjs b/services/web/test/unit/src/Authentication/AuthenticationController.test.mjs index 44d20b305e..b8465305ff 100644 --- a/services/web/test/unit/src/Authentication/AuthenticationController.test.mjs +++ b/services/web/test/unit/src/Authentication/AuthenticationController.test.mjs @@ -5,6 +5,7 @@ import MockRequest from '../helpers/MockRequest.mjs' import MockResponse from '../helpers/MockResponse.mjs' import mongodb from 'mongodb-legacy' import AuthenticationErrors from '../../../../app/src/Features/Authentication/AuthenticationErrors.mjs' +import { setReqValidationModeForTests } from '@overleaf/validation-tools' const modulePath = '../../../../app/src/Features/Authentication/AuthenticationController.mjs' @@ -1097,6 +1098,24 @@ describe('AuthenticationController', function () { .should.equal(false) }) }) + + describe('request validation', function () { + beforeEach(function () { + setReqValidationModeForTests('enforce') + }) + + afterEach(function () { + setReqValidationModeForTests(null) + }) + + it('rejects a non-string zipUrl query param', function (ctx) { + ctx.req.query.zipUrl = ['a', 'b'] + ctx.SessionManager.isUserLoggedIn = sinon.stub().returns(false) + expect(() => ctx.middleware(ctx.req, ctx.res, ctx.next)).toThrowError( + expect.objectContaining({ name: 'InvalidRequestError' }) + ) + }) + }) }) describe('_redirectToRegisterPage', function () { diff --git a/services/web/test/unit/src/Authorization/AuthorizationMiddleware.test.mjs b/services/web/test/unit/src/Authorization/AuthorizationMiddleware.test.mjs index 85d3108797..c549d546aa 100644 --- a/services/web/test/unit/src/Authorization/AuthorizationMiddleware.test.mjs +++ b/services/web/test/unit/src/Authorization/AuthorizationMiddleware.test.mjs @@ -1,7 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import sinon from 'sinon' import mongodb from 'mongodb-legacy' -import Errors from '../../../../app/src/Features/Errors/Errors.js' +import { setReqValidationModeForTests } from '@overleaf/validation-tools' const { ObjectId } = mongodb @@ -15,7 +15,9 @@ describe('AuthorizationMiddleware', function () { ctx.doc_id = new ObjectId().toString() ctx.thread_id = new ObjectId().toString() ctx.token = 'some-token' - ctx.AuthenticationController = {} + ctx.AuthenticationController = { + setRedirectInSession: sinon.stub(), + } ctx.SessionManager = { getSessionUser: sinon.stub().returns(null), getLoggedInUserId: sinon.stub().returns(ctx.userId), @@ -46,10 +48,6 @@ describe('AuthorizationMiddleware', function () { }, } - vi.doMock('../../../../app/src/Features/Errors/Errors.js', () => ({ - default: Errors, - })) - vi.doMock( '../../../../app/src/Features/Authorization/AuthorizationManager', () => ({ @@ -118,6 +116,10 @@ describe('AuthorizationMiddleware', function () { ctx.next = sinon.stub() }) + afterEach(function () { + setReqValidationModeForTests(null) + }) + describe('ensureCanReadProject', function () { testMiddleware('ensureUserCanReadProject', 'canUserReadProject') }) @@ -185,6 +187,23 @@ describe('AuthorizationMiddleware', function () { 'canUserWriteProjectSettings' ) }) + + describe('request validation', function () { + beforeEach(function (ctx) { + setReqValidationModeForTests('enforce') + ctx.req.body.name = 42 + }) + + invokeMiddleware('ensureUserCanWriteProjectSettings') + expectBadRequest() + + it('does not check any write permissions', function (ctx) { + expect(ctx.AuthorizationManager.promises.canUserRenameProject).to.not + .have.been.called + expect(ctx.AuthorizationManager.promises.canUserWriteProjectSettings).to + .not.have.been.called + }) + }) }) describe('ensureUserCanAdminProject', function () { @@ -327,6 +346,73 @@ describe('AuthorizationMiddleware', function () { }) }) }) + + describe('request validation', function () { + beforeEach(function (ctx) { + setReqValidationModeForTests('enforce') + ctx.req.query = { project_ids: ['project1', 'project2'] } + }) + + invokeMiddleware('ensureUserCanReadMultipleProjects') + expectBadRequest() + + it('does not check any project permissions', function (ctx) { + expect(ctx.AuthorizationManager.promises.canUserReadProject).to.not.have + .been.called + }) + }) + }) + + describe('restricted', function () { + setupAnonymousUser() + + describe('with a redirect target', function () { + beforeEach(function (ctx) { + ctx.req.query = { from: '/project/123' } + }) + + it('stores the redirect target and sends the user to login', function (ctx) { + ctx.AuthorizationMiddleware.restricted(ctx.req, ctx.res, ctx.next) + expect( + ctx.AuthenticationController.setRedirectInSession + ).to.have.been.calledWith(ctx.req, '/project/123') + expect(ctx.res.redirect).to.have.been.calledWith('/login') + }) + }) + + describe('without a redirect target', function () { + beforeEach(function (ctx) { + ctx.req.query = {} + }) + + it('sends the user to login', function (ctx) { + ctx.AuthorizationMiddleware.restricted(ctx.req, ctx.res, ctx.next) + expect(ctx.AuthenticationController.setRedirectInSession).to.not.have + .been.called + expect(ctx.res.redirect).to.have.been.calledWith('/login') + }) + }) + + describe('request validation', function () { + beforeEach(function (ctx) { + setReqValidationModeForTests('enforce') + ctx.req.query = { from: ['not-a-string'] } + }) + + it('rejects a non-string from parameter', function (ctx) { + let error + try { + ctx.AuthorizationMiddleware.restricted(ctx.req, ctx.res, ctx.next) + } catch (err) { + error = err + } + expect(error).to.exist + expect(error.name).to.equal('InvalidRequestError') + expect(ctx.AuthenticationController.setRedirectInSession).to.not.have + .been.called + expect(ctx.res.redirect).to.not.have.been.called + }) + }) }) }) @@ -467,8 +553,19 @@ function expectError() { function expectNotFound() { it('raises a 404', function (ctx) { + // a malformed id fails zz.objectId() validation in parseReq(), which + // throws InvalidParamsError (@overleaf/validation-tools) -- handled by + // handleValidationError into a 404, same as the old manual check expect(ctx.next).to.have.been.calledWith( - sinon.match.instanceOf(Errors.NotFoundError) + sinon.match.has('name', 'InvalidParamsError') + ) + }) +} + +function expectBadRequest() { + it('raises a 400', function (ctx) { + expect(ctx.next).to.have.been.calledWith( + sinon.match.has('name', 'InvalidRequestError') ) }) } diff --git a/services/web/test/unit/src/Captcha/CaptchaMiddleware.test.mjs b/services/web/test/unit/src/Captcha/CaptchaMiddleware.test.mjs new file mode 100644 index 0000000000..8cc5711548 --- /dev/null +++ b/services/web/test/unit/src/Captcha/CaptchaMiddleware.test.mjs @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import sinon from 'sinon' + +const MODULE_PATH = '../../../../app/src/Features/Captcha/CaptchaMiddleware.mjs' + +describe('CaptchaMiddleware', function () { + beforeEach(async function (ctx) { + // No siteKey configured -> validateCaptcha takes its "disabled" branch + // and calls next() right after stripRecaptchaField, without needing to + // stub the recaptcha siteverify network call or DeviceHistory. + ctx.Settings = { + recaptcha: { + siteKey: undefined, + disabled: {}, + trustedUsers: [], + }, + deviceHistory: { + cookieName: 'deviceHistory', + entryExpiry: 90 * 24 * 60 * 60 * 1000, + maxEntries: 10, + secret: undefined, + }, + } + + ctx.Metrics = { + inc: sinon.stub(), + } + + ctx.AuthenticationController = { + setAuditInfo: sinon.stub(), + } + + ctx.fetchJson = sinon + .stub() + .rejects(new Error('fetchJson should not be called in this test')) + + vi.doMock('@overleaf/settings', () => ({ default: ctx.Settings })) + + vi.doMock('@overleaf/metrics', () => ({ default: ctx.Metrics })) + + vi.doMock('@overleaf/fetch-utils', () => ({ fetchJson: ctx.fetchJson })) + + vi.doMock( + '../../../../app/src/Features/Authentication/AuthenticationController', + () => ({ default: ctx.AuthenticationController }) + ) + + ctx.CaptchaMiddleware = (await import(MODULE_PATH)).default + + ctx.res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + } + ctx.next = sinon.stub() + }) + + describe('stripRecaptchaField (via validateCaptcha)', function () { + async function runValidateCaptcha(ctx, body) { + ctx.req = { body } + await ctx.CaptchaMiddleware.validateCaptcha('login')( + ctx.req, + ctx.res, + ctx.next + ) + } + + it('strips a captcha-token string', async function (ctx) { + await runValidateCaptcha(ctx, { + email: 'user@example.com', + 'g-recaptcha-response': 'some-captcha-token', + }) + expect(ctx.next).to.have.been.calledWithExactly() + expect(ctx.req.body).to.not.have.property('g-recaptcha-response') + expect(ctx.req.body).to.deep.equal({ email: 'user@example.com' }) + }) + + it('strips an explicit null', async function (ctx) { + await runValidateCaptcha(ctx, { + email: 'user@example.com', + 'g-recaptcha-response': null, + }) + expect(ctx.next).to.have.been.calledWithExactly() + expect(ctx.req.body).to.not.have.property('g-recaptcha-response') + expect(ctx.req.body).to.deep.equal({ email: 'user@example.com' }) + }) + + it('strips an empty string', async function (ctx) { + await runValidateCaptcha(ctx, { + email: 'user@example.com', + 'g-recaptcha-response': '', + }) + expect(ctx.next).to.have.been.calledWithExactly() + expect(ctx.req.body).to.not.have.property('g-recaptcha-response') + expect(ctx.req.body).to.deep.equal({ email: 'user@example.com' }) + }) + + it('leaves a body without the field unchanged', async function (ctx) { + const body = { email: 'user@example.com', otherField: 'value' } + await runValidateCaptcha(ctx, body) + expect(ctx.next).to.have.been.calledWithExactly() + // stripRecaptchaField only reassigns req.body when the key is + // present, so the original object reference is preserved here. + expect(ctx.req.body).to.equal(body) + expect(ctx.req.body).to.deep.equal({ + email: 'user@example.com', + otherField: 'value', + }) + }) + }) +}) diff --git a/services/web/test/unit/src/infrastructure/Csrf.test.mjs b/services/web/test/unit/src/infrastructure/Csrf.test.mjs index 30e8157f17..af1e889d1a 100644 --- a/services/web/test/unit/src/infrastructure/Csrf.test.mjs +++ b/services/web/test/unit/src/infrastructure/Csrf.test.mjs @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import sinon from 'sinon' +import { getRawReqInput } from '@overleaf/validation-tools' const modulePath = '../../../../app/src/infrastructure/Csrf.mjs' describe('Csrf', function () { @@ -96,6 +97,70 @@ describe('Csrf', function () { expect(ctx.next.calledWith(ctx.err)).to.equal(false) }) }) + + describe('stripping _csrf after a successful check', function () { + beforeEach(async function (ctx) { + vi.resetModules() + vi.doMock('csurf', () => ({ + default: sinon.stub().returns(sinon.stub().callsArg(2)), + })) + const module = await import(modulePath) + ctx.Csrf = module.default + ctx.CsrfClass = module.Csrf + ctx.csrf = new ctx.CsrfClass() + }) + + it('removes _csrf from req.body, leaving other fields untouched', function (ctx) { + const req = { + path: ctx.path, + method: 'POST', + body: { _csrf: 'a-token', other: 'field' }, + } + ctx.csrf.middleware(req, ctx.res, ctx.next) + // test assertion on raw wire data (case 3 of getRawReqInput's + // allowlist): inspecting exactly what the middleware left on req + expect(getRawReqInput(req).body).to.deep.equal({ other: 'field' }) + expect(ctx.next.calledWith()).to.equal(true) + }) + + it('removes _csrf from req.query, leaving other fields untouched', function (ctx) { + const req = { + path: ctx.path, + method: 'GET', + query: { _csrf: 'a-token', other: 'field' }, + } + ctx.csrf.middleware(req, ctx.res, ctx.next) + expect(getRawReqInput(req).query).to.deep.equal({ other: 'field' }) + }) + + it('is a no-op when there is no _csrf field', function (ctx) { + const req = { + path: ctx.path, + method: 'POST', + body: { other: 'field' }, + } + ctx.csrf.middleware(req, ctx.res, ctx.next) + expect(getRawReqInput(req).body).to.deep.equal({ other: 'field' }) + }) + + it('does not strip _csrf on an excluded route', function (ctx) { + // Excluded routes (e.g. /docs) still run their own second, + // independent csrf check further down their middleware stack (see + // OpenInOverleafMiddleware.postMiddleware), which needs the field + // to still be there. + ctx.csrf.disableDefaultCsrfProtection(ctx.path, 'POST') + const req = { + path: ctx.path, + method: 'POST', + body: { _csrf: 'a-token', other: 'field' }, + } + ctx.csrf.middleware(req, ctx.res, ctx.next) + expect(getRawReqInput(req).body).to.deep.equal({ + _csrf: 'a-token', + other: 'field', + }) + }) + }) }) describe('validateRequest', function () { diff --git a/services/web/test/unit/src/infrastructure/UnsupportedBrowserMiddleware.test.mjs b/services/web/test/unit/src/infrastructure/UnsupportedBrowserMiddleware.test.mjs new file mode 100644 index 0000000000..23fd53d688 --- /dev/null +++ b/services/web/test/unit/src/infrastructure/UnsupportedBrowserMiddleware.test.mjs @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import sinon from 'sinon' +import { + InvalidRequestError, + setReqValidationModeForTests, +} from '@overleaf/validation-tools' +import MockRequest from '../helpers/MockRequest.mjs' +import MockResponse from '../helpers/MockResponse.mjs' + +const MODULE_PATH = + '../../../../app/src/infrastructure/UnsupportedBrowserMiddleware.mjs' + +describe('UnsupportedBrowserMiddleware', function () { + beforeEach(async function (ctx) { + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = { + unsupportedBrowsers: { chrome: '<50' }, + siteUrl: 'https://www.overleaf.com', + }), + })) + + ctx.UnsupportedBrowserMiddleware = (await import(MODULE_PATH)).default + ctx.req = new MockRequest(vi) + ctx.req.path = '/project' + ctx.res = new MockResponse(vi) + ctx.next = sinon.stub() + }) + + afterEach(function () { + setReqValidationModeForTests(null) + }) + + describe('unsupportedBrowserMiddleware', function () { + describe('request validation', function () { + beforeEach(function (ctx) { + setReqValidationModeForTests('enforce') + ctx.req.headers = { 'user-agent': ['a', 'b'] } + }) + + it('rejects a non-string user-agent header', function (ctx) { + let error + try { + ctx.UnsupportedBrowserMiddleware.unsupportedBrowserMiddleware( + ctx.req, + ctx.res, + ctx.next + ) + } catch (err) { + error = err + } + expect(error).to.be.instanceOf(InvalidRequestError) + sinon.assert.notCalled(ctx.next) + }) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index c5e3a68c5e..46e4c37436 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5497,7 +5497,7 @@ __metadata: languageName: node linkType: hard -"@node-oauth/oauth2-server@npm:^5.3.0": +"@node-oauth/oauth2-server@npm:5.3.0": version: 5.3.0 resolution: "@node-oauth/oauth2-server@npm:5.3.0" dependencies: @@ -5508,6 +5508,17 @@ __metadata: languageName: node linkType: hard +"@node-oauth/oauth2-server@patch:@node-oauth/oauth2-server@npm%3A5.3.0#~/.yarn/patches/@node-oauth-oauth2-server-npm-5.3.0-9d66d9183c.patch": + version: 5.3.0 + resolution: "@node-oauth/oauth2-server@patch:@node-oauth/oauth2-server@npm%3A5.3.0#~/.yarn/patches/@node-oauth-oauth2-server-npm-5.3.0-9d66d9183c.patch::version=5.3.0&hash=acb1f1" + dependencies: + "@node-oauth/formats": "npm:1.0.0" + basic-auth: "npm:2.0.1" + type-is: "npm:2.0.1" + checksum: 10c0/e3b5c0303b9c13049ae65c93779b8e06bc63fae9ef554e37d2bd8cb0d1afb7fab279fe6d007267b50ab36570c1f692a61c8791cd148196623f276b9b0804a65e + languageName: node + linkType: hard + "@node-rs/crc32@npm:^0.1.7": version: 0.1.8 resolution: "@node-rs/crc32@npm:0.1.8" @@ -5546,7 +5557,7 @@ __metadata: languageName: node linkType: hard -"@node-saml/passport-saml@npm:^5.1.0": +"@node-saml/passport-saml@npm:5.1.0": version: 5.1.0 resolution: "@node-saml/passport-saml@npm:5.1.0" dependencies: @@ -5560,6 +5571,20 @@ __metadata: languageName: node linkType: hard +"@node-saml/passport-saml@patch:@node-saml/passport-saml@npm%3A5.1.0#~/.yarn/patches/@node-saml-passport-saml-npm-5.1.0-5d7df575fa.patch": + version: 5.1.0 + resolution: "@node-saml/passport-saml@patch:@node-saml/passport-saml@npm%3A5.1.0#~/.yarn/patches/@node-saml-passport-saml-npm-5.1.0-5d7df575fa.patch::version=5.1.0&hash=efcef3" + dependencies: + "@node-saml/node-saml": "npm:^5.1.0" + "@types/express": "npm:^4.17.23" + "@types/passport": "npm:^1.0.17" + "@types/passport-strategy": "npm:^0.2.38" + passport: "npm:^0.7.0" + passport-strategy: "npm:^1.0.0" + checksum: 10c0/0c26d6e048386dcd7edc5d9d8031246b2a2600bd9d3d03749973247c70d8cd261a8d29364ec26c75dbded32922cf4a2f192c2fc17d7611cb71a890435aa0e905 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -7924,8 +7949,8 @@ __metadata: "@lezer/highlight": "npm:1.2.3" "@lezer/lr": "npm:1.4.7" "@lezer/markdown": "npm:1.6.3" - "@node-oauth/oauth2-server": "npm:^5.3.0" - "@node-saml/passport-saml": "npm:^5.1.0" + "@node-oauth/oauth2-server": "patch:@node-oauth/oauth2-server@npm%3A5.3.0#~/.yarn/patches/@node-oauth-oauth2-server-npm-5.3.0-9d66d9183c.patch" + "@node-saml/passport-saml": "patch:@node-saml/passport-saml@npm%3A5.1.0#~/.yarn/patches/@node-saml-passport-saml-npm-5.1.0-5d7df575fa.patch" "@overleaf/access-token-encryptor": "workspace:*" "@overleaf/codemirror-tree-view": "npm:^0.1.3" "@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.3.tar.gz" @@ -8065,7 +8090,7 @@ __metadata: crc-32: "npm:^1.2.2" css-loader: "npm:^7.1.4" css-minimizer-webpack-plugin: "npm:^8.0.0" - csurf: "npm:^1.11.0" + csurf: "patch:csurf@npm%3A1.11.0#~/.yarn/patches/csurf-npm-1.11.0-c1b9cbb35b.patch" csv: "npm:^6.2.5" csv-parse: "npm:^6.2.1" csv-stringify: "npm:^6.7.0" @@ -8101,7 +8126,7 @@ __metadata: eventsource-client: "npm:^1.1.4" eventsource-parser: "npm:^1.1.2" express: "npm:4.22.1" - express-bearer-token: "npm:^2.4.0" + express-bearer-token: "patch:express-bearer-token@npm%3A2.4.0#~/.yarn/patches/express-bearer-token-npm-2.4.0-731c8c5c99.patch" express-http-proxy: "npm:^1.6.0" express-session: "npm:^1.17.1" fake-indexeddb: "npm:^6.0.0" @@ -8175,7 +8200,7 @@ __metadata: passport-google-oauth20: "npm:^2.0.0" passport-ldapauth: "npm:^2.1.4" passport-local: "npm:^1.0.0" - passport-oauth2: "npm:^1.5.0" + passport-oauth2: "patch:passport-oauth2@npm%3A1.6.1#~/.yarn/patches/passport-oauth2-npm-1.6.1.patch" passport-orcid: "npm:0.0.4" path-browserify: "npm:^1.0.1" pdfjs-dist: "npm:5.1.91" @@ -16123,7 +16148,7 @@ __metadata: languageName: node linkType: hard -"csurf@npm:^1.11.0": +"csurf@npm:1.11.0": version: 1.11.0 resolution: "csurf@npm:1.11.0" dependencies: @@ -16135,6 +16160,18 @@ __metadata: languageName: node linkType: hard +"csurf@patch:csurf@npm%3A1.11.0#~/.yarn/patches/csurf-npm-1.11.0-c1b9cbb35b.patch": + version: 1.11.0 + resolution: "csurf@patch:csurf@npm%3A1.11.0#~/.yarn/patches/csurf-npm-1.11.0-c1b9cbb35b.patch::version=1.11.0&hash=21d28e" + dependencies: + cookie: "npm:0.4.0" + cookie-signature: "npm:1.0.6" + csrf: "npm:3.1.0" + http-errors: "npm:~1.7.3" + checksum: 10c0/4485c22cea896eb41398c8a1b5b00904c5e1bb817737dbd3af1f3c177ddaa86787379a93d4c5af224937a277dc63afbb7f362b61677d21c58432671e2256bcf6 + languageName: node + linkType: hard + "csv-generate@npm:^3.4.3": version: 3.4.3 resolution: "csv-generate@npm:3.4.3" @@ -18393,7 +18430,7 @@ __metadata: languageName: node linkType: hard -"express-bearer-token@npm:^2.4.0": +"express-bearer-token@npm:2.4.0": version: 2.4.0 resolution: "express-bearer-token@npm:2.4.0" dependencies: @@ -18403,6 +18440,16 @@ __metadata: languageName: node linkType: hard +"express-bearer-token@patch:express-bearer-token@npm%3A2.4.0#~/.yarn/patches/express-bearer-token-npm-2.4.0-731c8c5c99.patch": + version: 2.4.0 + resolution: "express-bearer-token@patch:express-bearer-token@npm%3A2.4.0#~/.yarn/patches/express-bearer-token-npm-2.4.0-731c8c5c99.patch::version=2.4.0&hash=77675f" + dependencies: + cookie: "npm:^0.3.1" + cookie-parser: "npm:^1.4.4" + checksum: 10c0/340caa2940aa6c3b38923995c1d3e77210c0c4284bba6e4a67b9df77693184603eed4ed0f2df17584b2315f3e0f1da00c2f2e1c48cc4465b3330d5149e0703b8 + languageName: node + linkType: hard + "express-flash@npm:0.0.2": version: 0.0.2 resolution: "express-flash@npm:0.0.2" @@ -26569,14 +26616,14 @@ __metadata: "passport-oauth2@patch:passport-oauth2@npm%3A1.6.1#~/.yarn/patches/passport-oauth2-npm-1.6.1.patch": version: 1.6.1 - resolution: "passport-oauth2@patch:passport-oauth2@npm%3A1.6.1#~/.yarn/patches/passport-oauth2-npm-1.6.1.patch::version=1.6.1&hash=94a003" + resolution: "passport-oauth2@patch:passport-oauth2@npm%3A1.6.1#~/.yarn/patches/passport-oauth2-npm-1.6.1.patch::version=1.6.1&hash=fc4394" dependencies: base64url: "npm:3.x.x" oauth: "npm:0.9.x" passport-strategy: "npm:1.x.x" uid2: "npm:0.0.x" utils-merge: "npm:1.x.x" - checksum: 10c0/f35a6c83ace4ad9543ebaf2b3681720305679cb794ac44e7623cd88c9a19cecdcdb66cb17a4ea3092de48e6bc8d3869176d56cfc3a57a14cacc5d376f2300892 + checksum: 10c0/37aed993f924369a29bac3b40c748e8c4094f97acd8b102a78bd1ee6d7c8f5004a9cfac13e424d7355878382cf7f86d0f5f2b18c9150af387c0718a633b8e428 languageName: node linkType: hard