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