mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
[monorepo] prepare for zod everywhere epic (#36027)
GitOrigin-RevId: f082ae7fc7857fa72b2c1823645050bd4f6df305
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
diff --git a/lib/read.js b/lib/read.js
|
||||
index fce6283f50961e68c2f576031ed5e3d4fdc39984..6131c31fb8ba8d413e0a3bf887ebd87f1c3a4007 100644
|
||||
--- a/lib/read.js
|
||||
+++ b/lib/read.js
|
||||
@@ -18,7 +18,7 @@ var iconv = require('iconv-lite')
|
||||
var onFinished = require('on-finished')
|
||||
var unpipe = require('unpipe')
|
||||
var zlib = require('zlib')
|
||||
-
|
||||
+var Stream = require('stream')
|
||||
/**
|
||||
* Module exports.
|
||||
*/
|
||||
@@ -166,25 +166,25 @@ function contentstream (req, debug, inflate) {
|
||||
case 'deflate':
|
||||
stream = zlib.createInflate()
|
||||
debug('inflate body')
|
||||
- req.pipe(stream)
|
||||
+ // req.pipe(stream)
|
||||
break
|
||||
case 'gzip':
|
||||
stream = zlib.createGunzip()
|
||||
debug('gunzip body')
|
||||
- req.pipe(stream)
|
||||
+ // req.pipe(stream)
|
||||
break
|
||||
case 'identity':
|
||||
stream = req
|
||||
stream.length = length
|
||||
- break
|
||||
+ return req
|
||||
default:
|
||||
throw createError(415, 'unsupported content encoding "' + encoding + '"', {
|
||||
encoding: encoding,
|
||||
type: 'encoding.unsupported'
|
||||
})
|
||||
}
|
||||
-
|
||||
- return stream
|
||||
+ var pass = new Stream.PassThrough(); Stream.pipeline(req, stream, pass, () => {})
|
||||
+ return pass
|
||||
}
|
||||
|
||||
/**
|
||||
diff --git a/lib/types/json.js b/lib/types/json.js
|
||||
index 59f3f7e28f2416cac957b5e35dc78839a5019e0d..f23e52eca828a726f12e915f8ea52698d3761d68 100644
|
||||
--- a/lib/types/json.js
|
||||
+++ b/lib/types/json.js
|
||||
@@ -17,6 +17,8 @@ var contentType = require('content-type')
|
||||
var createError = require('http-errors')
|
||||
var debug = require('debug')('body-parser:json')
|
||||
var read = require('../read')
|
||||
+var LOCKDOWN_INSTALLED = Symbol.for('overleaf.lockdown.installed')
|
||||
+var LOCKDOWN_RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
|
||||
var typeis = require('type-is')
|
||||
|
||||
/**
|
||||
@@ -105,7 +107,7 @@ function json (options) {
|
||||
return
|
||||
}
|
||||
|
||||
- req.body = req.body || {}
|
||||
+ req.body = (req[LOCKDOWN_INSTALLED] ? req[LOCKDOWN_RAW_BODY] : req.body) || {}
|
||||
|
||||
// skip requests without bodies
|
||||
if (!typeis.hasBody(req)) {
|
||||
diff --git a/lib/types/raw.js b/lib/types/raw.js
|
||||
index f5d1b67475405284e3dac312f92ade101571329f..251780a624b09340b8a821974565fa330f9792b7 100644
|
||||
--- a/lib/types/raw.js
|
||||
+++ b/lib/types/raw.js
|
||||
@@ -13,6 +13,8 @@
|
||||
var bytes = require('bytes')
|
||||
var debug = require('debug')('body-parser:raw')
|
||||
var read = require('../read')
|
||||
+var LOCKDOWN_INSTALLED = Symbol.for('overleaf.lockdown.installed')
|
||||
+var LOCKDOWN_RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
|
||||
var typeis = require('type-is')
|
||||
|
||||
/**
|
||||
@@ -59,7 +61,7 @@ function raw (options) {
|
||||
return
|
||||
}
|
||||
|
||||
- req.body = req.body || {}
|
||||
+ req.body = (req[LOCKDOWN_INSTALLED] ? req[LOCKDOWN_RAW_BODY] : req.body) || {}
|
||||
|
||||
// skip requests without bodies
|
||||
if (!typeis.hasBody(req)) {
|
||||
diff --git a/lib/types/text.js b/lib/types/text.js
|
||||
index 083a00908a6299a8ef72f477983359f5675f82ef..4ea3ee88191bf02ecedf6c975d20144d4d7f2f47 100644
|
||||
--- a/lib/types/text.js
|
||||
+++ b/lib/types/text.js
|
||||
@@ -14,6 +14,8 @@ var bytes = require('bytes')
|
||||
var contentType = require('content-type')
|
||||
var debug = require('debug')('body-parser:text')
|
||||
var read = require('../read')
|
||||
+var LOCKDOWN_INSTALLED = Symbol.for('overleaf.lockdown.installed')
|
||||
+var LOCKDOWN_RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
|
||||
var typeis = require('type-is')
|
||||
|
||||
/**
|
||||
@@ -61,7 +63,7 @@ function text (options) {
|
||||
return
|
||||
}
|
||||
|
||||
- req.body = req.body || {}
|
||||
+ req.body = (req[LOCKDOWN_INSTALLED] ? req[LOCKDOWN_RAW_BODY] : req.body) || {}
|
||||
|
||||
// skip requests without bodies
|
||||
if (!typeis.hasBody(req)) {
|
||||
diff --git a/lib/types/urlencoded.js b/lib/types/urlencoded.js
|
||||
index 832992c2c3e0324c9bcb612a01f2fe2fb3e19414..4da13852cd5a89c97d8d7be2b81ba8fd62551f9e 100644
|
||||
--- a/lib/types/urlencoded.js
|
||||
+++ b/lib/types/urlencoded.js
|
||||
@@ -18,6 +18,8 @@ var createError = require('http-errors')
|
||||
var debug = require('debug')('body-parser:urlencoded')
|
||||
var deprecate = require('depd')('body-parser')
|
||||
var read = require('../read')
|
||||
+var LOCKDOWN_INSTALLED = Symbol.for('overleaf.lockdown.installed')
|
||||
+var LOCKDOWN_RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
|
||||
var typeis = require('type-is')
|
||||
|
||||
/**
|
||||
@@ -83,7 +85,7 @@ function urlencoded (options) {
|
||||
return
|
||||
}
|
||||
|
||||
- req.body = req.body || {}
|
||||
+ req.body = (req[LOCKDOWN_INSTALLED] ? req[LOCKDOWN_RAW_BODY] : req.body) || {}
|
||||
|
||||
// skip requests without bodies
|
||||
if (!typeis.hasBody(req)) {
|
||||
@@ -1,44 +0,0 @@
|
||||
diff --git a/lib/read.js b/lib/read.js
|
||||
index fce6283..6131c31 100644
|
||||
--- a/lib/read.js
|
||||
+++ b/lib/read.js
|
||||
@@ -18,7 +18,7 @@ var iconv = require('iconv-lite')
|
||||
var onFinished = require('on-finished')
|
||||
var unpipe = require('unpipe')
|
||||
var zlib = require('zlib')
|
||||
-
|
||||
+var Stream = require('stream')
|
||||
/**
|
||||
* Module exports.
|
||||
*/
|
||||
@@ -166,25 +166,25 @@ function contentstream (req, debug, inflate) {
|
||||
case 'deflate':
|
||||
stream = zlib.createInflate()
|
||||
debug('inflate body')
|
||||
- req.pipe(stream)
|
||||
+ // req.pipe(stream)
|
||||
break
|
||||
case 'gzip':
|
||||
stream = zlib.createGunzip()
|
||||
debug('gunzip body')
|
||||
- req.pipe(stream)
|
||||
+ // req.pipe(stream)
|
||||
break
|
||||
case 'identity':
|
||||
stream = req
|
||||
stream.length = length
|
||||
- break
|
||||
+ return req
|
||||
default:
|
||||
throw createError(415, 'unsupported content encoding "' + encoding + '"', {
|
||||
encoding: encoding,
|
||||
type: 'encoding.unsupported'
|
||||
})
|
||||
}
|
||||
-
|
||||
- return stream
|
||||
+ var pass = new Stream.PassThrough(); Stream.pipeline(req, stream, pass, () => {})
|
||||
+ return pass
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,287 @@
|
||||
diff --git a/lib/lockdown.js b/lib/lockdown.js
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..7acc55484421812ddb66edf015cdf0e1125b1cab
|
||||
--- /dev/null
|
||||
+++ b/lib/lockdown.js
|
||||
@@ -0,0 +1,157 @@
|
||||
+/*!
|
||||
+ * Overleaf request-input lockdown.
|
||||
+ *
|
||||
+ * Captures parsed request input in symbol-keyed fields and, when
|
||||
+ * REQ_LOCKDOWN_MODE is 'warn' or 'throw', turns the public req.body,
|
||||
+ * req.query and req.params properties into lockdown accessors:
|
||||
+ *
|
||||
+ * - reads log a warning ('warn' mode) or throw ('throw' mode); request
|
||||
+ * input must be read through parseReq() from @overleaf/validation-tools,
|
||||
+ * which validates it against a zod schema, or through getRawReqInput()
|
||||
+ * for allowlisted middleware
|
||||
+ * - writes always work and are stored in the symbol-keyed fields, so body
|
||||
+ * parsers and middleware that assign req.body keep functioning unchanged
|
||||
+ *
|
||||
+ * With REQ_LOCKDOWN_MODE unset (or 'off') nothing is installed and express
|
||||
+ * behaves exactly like the unpatched package.
|
||||
+ *
|
||||
+ * The symbols are registered in the global symbol registry so that
|
||||
+ * @overleaf/validation-tools can read the raw fields without importing
|
||||
+ * express internals. Express' own internals (router param processing, the
|
||||
+ * query middleware, res.jsonp) go through the helpers below instead of the
|
||||
+ * public properties so that only application code trips the lockdown.
|
||||
+ *
|
||||
+ * Note: unlike the stock properties, the locked accessors are defined
|
||||
+ * non-enumerable so that iterating/serializing a request (e.g. in logging)
|
||||
+ * does not trip the lockdown.
|
||||
+ */
|
||||
+
|
||||
+'use strict';
|
||||
+
|
||||
+var RAW_BODY = Symbol.for('overleaf.lockdown.rawBody');
|
||||
+var RAW_QUERY = Symbol.for('overleaf.lockdown.rawQuery');
|
||||
+var RAW_PARAMS = Symbol.for('overleaf.lockdown.rawParams');
|
||||
+var INSTALLED = Symbol.for('overleaf.lockdown.installed');
|
||||
+
|
||||
+var VALID_MODES = ['off', 'warn', 'throw'];
|
||||
+var cachedMode = null;
|
||||
+
|
||||
+function mode() {
|
||||
+ if (cachedMode === null) {
|
||||
+ var m = process.env.REQ_LOCKDOWN_MODE || 'off';
|
||||
+ cachedMode = VALID_MODES.indexOf(m) === -1 ? 'off' : m;
|
||||
+ }
|
||||
+ return cachedMode;
|
||||
+}
|
||||
+
|
||||
+/**
|
||||
+ * Override the mode for tests. Pass null to re-read REQ_LOCKDOWN_MODE on the
|
||||
+ * next access. Only affects requests that have not been handled yet.
|
||||
+ */
|
||||
+function setModeForTests(m) {
|
||||
+ if (m === null) {
|
||||
+ cachedMode = null;
|
||||
+ } else {
|
||||
+ cachedMode = VALID_MODES.indexOf(m) === -1 ? 'off' : m;
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+var warned = new Set();
|
||||
+
|
||||
+function report(req, field) {
|
||||
+ var route = req.route && req.route.path;
|
||||
+ var location =
|
||||
+ (req.method || 'UNKNOWN') +
|
||||
+ ' ' +
|
||||
+ (route || req.originalUrl || req.url || 'unknown');
|
||||
+ if (mode() === 'warn') {
|
||||
+ var key = location + ' ' + field;
|
||||
+ if (!warned.has(key)) {
|
||||
+ warned.add(key);
|
||||
+ var stack = new Error('trace').stack.split('\n').slice(2, 7).join('\n');
|
||||
+ console.warn(
|
||||
+ 'req-lockdown: raw request input read: req.' +
|
||||
+ field +
|
||||
+ ' at ' +
|
||||
+ location +
|
||||
+ '\n' +
|
||||
+ stack
|
||||
+ );
|
||||
+ }
|
||||
+ return;
|
||||
+ }
|
||||
+ var err = new Error(
|
||||
+ 'raw request input is forbidden — use parseReq() (read req.' +
|
||||
+ field +
|
||||
+ ' at ' +
|
||||
+ location +
|
||||
+ ')'
|
||||
+ );
|
||||
+ err.code = 'ERR_RAW_REQ_INPUT';
|
||||
+ throw err;
|
||||
+}
|
||||
+
|
||||
+/**
|
||||
+ * Install the lockdown accessors on a request. Idempotent, no-op in 'off'
|
||||
+ * mode. Called from the router, the query middleware and expressInit so that
|
||||
+ * every request is covered no matter how the app is assembled.
|
||||
+ */
|
||||
+function install(req) {
|
||||
+ if (mode() === 'off') return;
|
||||
+ if (req[INSTALLED]) return;
|
||||
+ req[INSTALLED] = true;
|
||||
+ lockField(req, 'body', RAW_BODY);
|
||||
+ lockField(req, 'query', RAW_QUERY);
|
||||
+ lockField(req, 'params', RAW_PARAMS);
|
||||
+}
|
||||
+
|
||||
+function lockField(req, field, symbol) {
|
||||
+ // preserve a value assigned before lockdown was installed
|
||||
+ if (Object.prototype.hasOwnProperty.call(req, field)) {
|
||||
+ req[symbol] = req[field];
|
||||
+ delete req[field];
|
||||
+ }
|
||||
+ Object.defineProperty(req, field, {
|
||||
+ configurable: true,
|
||||
+ enumerable: false,
|
||||
+ get: function () {
|
||||
+ report(this, field);
|
||||
+ return this[symbol]; // only reached in 'warn' mode
|
||||
+ },
|
||||
+ set: function (value) {
|
||||
+ this[symbol] = value;
|
||||
+ },
|
||||
+ });
|
||||
+}
|
||||
+
|
||||
+/**
|
||||
+ * Effective params/query for express internals: the raw fields once lockdown
|
||||
+ * is installed, the plain properties otherwise.
|
||||
+ */
|
||||
+function getParams(req) {
|
||||
+ return req[INSTALLED] ? req[RAW_PARAMS] : req.params;
|
||||
+}
|
||||
+
|
||||
+function getQuery(req) {
|
||||
+ return req[INSTALLED] ? req[RAW_QUERY] : req.query;
|
||||
+}
|
||||
+
|
||||
+/**
|
||||
+ * Property key under which the router saves/restores params on the request.
|
||||
+ */
|
||||
+function paramsProp(req) {
|
||||
+ return req[INSTALLED] ? RAW_PARAMS : 'params';
|
||||
+}
|
||||
+
|
||||
+module.exports = {
|
||||
+ RAW_BODY: RAW_BODY,
|
||||
+ RAW_QUERY: RAW_QUERY,
|
||||
+ RAW_PARAMS: RAW_PARAMS,
|
||||
+ INSTALLED: INSTALLED,
|
||||
+ mode: mode,
|
||||
+ setModeForTests: setModeForTests,
|
||||
+ install: install,
|
||||
+ getParams: getParams,
|
||||
+ getQuery: getQuery,
|
||||
+ paramsProp: paramsProp,
|
||||
+};
|
||||
diff --git a/lib/middleware/init.js b/lib/middleware/init.js
|
||||
index dfd042747bdb971d9760e9575241ddaa1b776cd8..162ec6da452bde5f5384d3d5bd79d5727742ed8f 100644
|
||||
--- a/lib/middleware/init.js
|
||||
+++ b/lib/middleware/init.js
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
var setPrototypeOf = require('setprototypeof')
|
||||
+var lockdown = require('../lockdown')
|
||||
|
||||
/**
|
||||
* Initialization middleware, exposing the
|
||||
@@ -27,6 +28,7 @@ var setPrototypeOf = require('setprototypeof')
|
||||
|
||||
exports.init = function(app){
|
||||
return function expressInit(req, res, next){
|
||||
+ lockdown.install(req);
|
||||
if (app.enabled('x-powered-by')) res.setHeader('X-Powered-By', 'Express');
|
||||
req.res = res;
|
||||
res.req = req;
|
||||
diff --git a/lib/middleware/query.js b/lib/middleware/query.js
|
||||
index 7e9166947aff3be10f7ffec3771c92581ce211da..aad897b7ecd43477c1768e135ac06211a2424d36 100644
|
||||
--- a/lib/middleware/query.js
|
||||
+++ b/lib/middleware/query.js
|
||||
@@ -15,6 +15,7 @@
|
||||
var merge = require('utils-merge')
|
||||
var parseUrl = require('parseurl');
|
||||
var qs = require('qs');
|
||||
+var lockdown = require('../lockdown');
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
@@ -37,7 +38,9 @@ module.exports = function query(options) {
|
||||
}
|
||||
|
||||
return function query(req, res, next){
|
||||
- if (!req.query) {
|
||||
+ lockdown.install(req);
|
||||
+
|
||||
+ if (!lockdown.getQuery(req)) {
|
||||
var val = parseUrl(req).query;
|
||||
req.query = queryparse(val, opts);
|
||||
}
|
||||
diff --git a/lib/response.js b/lib/response.js
|
||||
index 2b654f4c6629c821bbeaaeee970b199a420fd8b3..103df49d3d94abf017022cdcf4611e62cad84a74 100644
|
||||
--- a/lib/response.js
|
||||
+++ b/lib/response.js
|
||||
@@ -29,6 +29,7 @@ var normalizeType = require('./utils').normalizeType;
|
||||
var normalizeTypes = require('./utils').normalizeTypes;
|
||||
var setCharset = require('./utils').setCharset;
|
||||
var cookie = require('cookie');
|
||||
+var lockdown = require('./lockdown');
|
||||
var send = require('send');
|
||||
var extname = path.extname;
|
||||
var mime = send.mime;
|
||||
@@ -312,7 +313,7 @@ res.jsonp = function jsonp(obj) {
|
||||
var replacer = app.get('json replacer');
|
||||
var spaces = app.get('json spaces');
|
||||
var body = stringify(val, replacer, spaces, escape)
|
||||
- var callback = this.req.query[app.get('jsonp callback name')];
|
||||
+ var callback = lockdown.getQuery(this.req)[app.get('jsonp callback name')];
|
||||
|
||||
// content-type
|
||||
if (!this.get('Content-Type')) {
|
||||
diff --git a/lib/router/index.js b/lib/router/index.js
|
||||
index abb3a6f589e834c5e105f2a96907ed2eb6fc0185..66fef252003098bcc268ef2ebcef0d20b3cce62b 100644
|
||||
--- a/lib/router/index.js
|
||||
+++ b/lib/router/index.js
|
||||
@@ -22,6 +22,7 @@ var deprecate = require('depd')('express');
|
||||
var flatten = require('array-flatten');
|
||||
var parseUrl = require('parseurl');
|
||||
var setPrototypeOf = require('setprototypeof')
|
||||
+var lockdown = require('../lockdown');
|
||||
|
||||
/**
|
||||
* Module variables.
|
||||
@@ -136,6 +137,8 @@ proto.param = function param(name, fn) {
|
||||
proto.handle = function handle(req, res, out) {
|
||||
var self = this;
|
||||
|
||||
+ lockdown.install(req);
|
||||
+
|
||||
debug('dispatching %s %s', req.method, req.url);
|
||||
|
||||
var idx = 0;
|
||||
@@ -153,9 +156,9 @@ proto.handle = function handle(req, res, out) {
|
||||
var stack = self.stack;
|
||||
|
||||
// manage inter-router variables
|
||||
- var parentParams = req.params;
|
||||
+ var parentParams = lockdown.getParams(req);
|
||||
var parentUrl = req.baseUrl || '';
|
||||
- var done = restore(out, req, 'baseUrl', 'next', 'params');
|
||||
+ var done = restore(out, req, 'baseUrl', 'next', lockdown.paramsProp(req));
|
||||
|
||||
// setup next layer
|
||||
req.next = next;
|
||||
@@ -368,7 +371,7 @@ proto.process_params = function process_params(layer, called, req, res, done) {
|
||||
paramIndex = 0;
|
||||
key = keys[i++];
|
||||
name = key.name;
|
||||
- paramVal = req.params[name];
|
||||
+ paramVal = lockdown.getParams(req)[name];
|
||||
paramCallbacks = params[name];
|
||||
paramCalled = called[name];
|
||||
|
||||
@@ -380,7 +383,7 @@ proto.process_params = function process_params(layer, called, req, res, done) {
|
||||
if (paramCalled && (paramCalled.match === paramVal
|
||||
|| (paramCalled.error && paramCalled.error !== 'route'))) {
|
||||
// restore value
|
||||
- req.params[name] = paramCalled.value;
|
||||
+ lockdown.getParams(req)[name] = paramCalled.value;
|
||||
|
||||
// next param
|
||||
return param(paramCalled.error);
|
||||
@@ -400,7 +403,7 @@ proto.process_params = function process_params(layer, called, req, res, done) {
|
||||
var fn = paramCallbacks[paramIndex++];
|
||||
|
||||
// store updated value
|
||||
- paramCalled.value = req.params[key.name];
|
||||
+ paramCalled.value = lockdown.getParams(req)[key.name];
|
||||
|
||||
if (err) {
|
||||
// store error
|
||||
@@ -1,8 +1,29 @@
|
||||
diff --git a/lib/make-middleware.js b/lib/make-middleware.js
|
||||
index 2c15f248d4f4d221bd9e2b2e1092b8d5c46d2bdd..f2bceb4479c94e321d4882ca1fe09c3ad280cca7 100644
|
||||
index 2c15f248d4f4d221bd9e2b2e1092b8d5c46d2bdd..859f55f56729f0a3874cfdac53722497cfc3c461 100644
|
||||
--- a/lib/make-middleware.js
|
||||
+++ b/lib/make-middleware.js
|
||||
@@ -174,7 +174,7 @@ function makeMiddleware (setup) {
|
||||
@@ -26,7 +26,10 @@ function makeMiddleware (setup) {
|
||||
var preservePath = options.preservePath
|
||||
var defParamCharset = options.defParamCharset
|
||||
|
||||
- req.body = Object.create(null)
|
||||
+ // keep a local reference: with the Overleaf request-input lockdown
|
||||
+ // installed, writing req.body is allowed but reading it warns/throws
|
||||
+ var body = Object.create(null)
|
||||
+ req.body = body
|
||||
|
||||
var busboy
|
||||
var appender = null
|
||||
@@ -157,7 +160,7 @@ function makeMiddleware (setup) {
|
||||
if (fieldname.split('[').length - 1 > limits.fieldNestingDepth) return abortWithCode('LIMIT_FIELD_NESTING', fieldname)
|
||||
}
|
||||
|
||||
- appendField(req.body, fieldname, value)
|
||||
+ appendField(body, fieldname, value)
|
||||
})
|
||||
|
||||
// handle files
|
||||
@@ -174,7 +177,7 @@ function makeMiddleware (setup) {
|
||||
if (fieldname == null) return abortWithCode('MISSING_FIELD_NAME')
|
||||
|
||||
// don't attach to the files object, if there is no file
|
||||
|
||||
@@ -16,6 +16,7 @@ module.exports = {
|
||||
'require-rel-for-docs-links': require('./require-rel-for-docs-links'),
|
||||
'require-cio-snake-case-properties': require('./require-cio-snake-case-properties'),
|
||||
'no-throw-in-callback': require('./no-throw-in-callback'),
|
||||
'no-raw-req-access': require('./no-raw-req-access'),
|
||||
'no-consecutive-spaces-in-locales': require('./no-consecutive-spaces-in-locales'),
|
||||
'no-straight-apostrophes-in-locales': require('./no-straight-apostrophes-in-locales'),
|
||||
'french-typography-in-locales': require('./french-typography-in-locales'),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
const RAW_FIELDS = new Set(['body', 'query', 'params'])
|
||||
|
||||
function isReqIdentifier(node) {
|
||||
return node && node.type === 'Identifier' && node.name === 'req'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow reading raw request input (req.body/req.query/req.params); validate it with parseReq() instead',
|
||||
},
|
||||
messages: {
|
||||
noRawReqAccess:
|
||||
'Do not read req.{{field}} directly; validate request input with parseReq() from @overleaf/validation-tools. Allowlisted infrastructure middleware may use getRawReqInput().',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
MemberExpression(node) {
|
||||
if (!isReqIdentifier(node.object)) return
|
||||
const property = node.property
|
||||
const field =
|
||||
!node.computed && property.type === 'Identifier'
|
||||
? property.name
|
||||
: node.computed &&
|
||||
property.type === 'Literal' &&
|
||||
typeof property.value === 'string'
|
||||
? property.value
|
||||
: null
|
||||
if (field === null || !RAW_FIELDS.has(field)) return
|
||||
// writes stay allowed: body parsers and middleware assign req.body
|
||||
const parent = node.parent
|
||||
if (
|
||||
parent.type === 'AssignmentExpression' &&
|
||||
parent.left === node &&
|
||||
parent.operator === '='
|
||||
) {
|
||||
return
|
||||
}
|
||||
context.report({ node, messageId: 'noRawReqAccess', data: { field } })
|
||||
},
|
||||
VariableDeclarator(node) {
|
||||
if (!isReqIdentifier(node.init)) return
|
||||
if (node.id.type !== 'ObjectPattern') return
|
||||
for (const prop of node.id.properties) {
|
||||
if (
|
||||
prop.type === 'Property' &&
|
||||
!prop.computed &&
|
||||
prop.key.type === 'Identifier' &&
|
||||
RAW_FIELDS.has(prop.key.name)
|
||||
) {
|
||||
context.report({
|
||||
node: prop,
|
||||
messageId: 'noRawReqAccess',
|
||||
data: { field: prop.key.name },
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { RuleTester } from 'eslint'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
import json from '@eslint/json'
|
||||
import noThrowInCallback from '../../no-throw-in-callback.js'
|
||||
import noRawReqAccess from '../../no-raw-req-access.js'
|
||||
import preferKebabUrl from '../../prefer-kebab-url.js'
|
||||
import noUnnecessaryTrans from '../../no-unnecessary-trans.js'
|
||||
import shouldUnescapeTrans from '../../should-unescape-trans.js'
|
||||
@@ -353,6 +354,65 @@ ruleTester.run('no-throw-in-callback', noThrowInCallback, {
|
||||
],
|
||||
})
|
||||
|
||||
ruleTester.run('no-raw-req-access', noRawReqAccess, {
|
||||
valid: [
|
||||
// validated access goes through parseReq
|
||||
{ code: `const { params, body } = parseReq(req, schema)` },
|
||||
// allowlisted middleware reads raw input explicitly
|
||||
{ code: `const { query } = getRawReqInput(req)` },
|
||||
// writes stay allowed: body parsers and middleware assign req.body
|
||||
{ code: `req.body = {}` },
|
||||
{ code: `req.query = parsed` },
|
||||
// other request fields are not locked
|
||||
{ code: `const ua = req.headers['user-agent']` },
|
||||
{ code: `const s = req.session` },
|
||||
// other objects with the same field names are fine
|
||||
{ code: `const b = options.body` },
|
||||
{ code: `const { body } = response` },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `const id = req.params.project_id`,
|
||||
errors: [{ messageId: 'noRawReqAccess', data: { field: 'params' } }],
|
||||
},
|
||||
{
|
||||
code: `if (req.query.force) { doIt() }`,
|
||||
errors: [{ messageId: 'noRawReqAccess', data: { field: 'query' } }],
|
||||
},
|
||||
{
|
||||
code: `send(req.body)`,
|
||||
errors: [{ messageId: 'noRawReqAccess', data: { field: 'body' } }],
|
||||
},
|
||||
// computed string access
|
||||
{
|
||||
code: `const b = req['body']`,
|
||||
errors: [{ messageId: 'noRawReqAccess', data: { field: 'body' } }],
|
||||
},
|
||||
// destructuring reads
|
||||
{
|
||||
code: `const { body } = req`,
|
||||
errors: [{ messageId: 'noRawReqAccess', data: { field: 'body' } }],
|
||||
},
|
||||
{
|
||||
code: `const { params, query } = req`,
|
||||
errors: [
|
||||
{ messageId: 'noRawReqAccess', data: { field: 'params' } },
|
||||
{ messageId: 'noRawReqAccess', data: { field: 'query' } },
|
||||
],
|
||||
},
|
||||
// compound assignment reads before writing
|
||||
{
|
||||
code: `req.body ||= {}`,
|
||||
errors: [{ messageId: 'noRawReqAccess', data: { field: 'body' } }],
|
||||
},
|
||||
// optional chaining
|
||||
{
|
||||
code: `const q = req?.query`,
|
||||
errors: [{ messageId: 'noRawReqAccess', data: { field: 'query' } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
ruleTester.run('require-rel-for-docs-links', requireRelForDocsLinks, {
|
||||
valid: [
|
||||
{
|
||||
|
||||
@@ -153,6 +153,7 @@ class RequestFailedError extends OError {
|
||||
url,
|
||||
method: opts.method ?? 'GET',
|
||||
status: response.status,
|
||||
...([400, 409, 413, 422].includes(response.status) ? { body } : {}),
|
||||
})
|
||||
|
||||
this.response = response
|
||||
|
||||
@@ -305,6 +305,34 @@ describe('fetch-utils', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('RequestFailedError', function () {
|
||||
it('includes the response body in OError.info for a 400', async function () {
|
||||
const err = await getRejection(fetchNothing(url('/400')))
|
||||
expect(err.info).to.have.property('body', 'boom-400')
|
||||
})
|
||||
|
||||
it('includes the response body in OError.info for a 409', async function () {
|
||||
const err = await getRejection(fetchNothing(url('/409')))
|
||||
expect(err.info).to.have.property('body', 'boom-409')
|
||||
})
|
||||
|
||||
it('includes the response body in OError.info for a 413', async function () {
|
||||
const err = await getRejection(fetchNothing(url('/413')))
|
||||
expect(err.info).to.have.property('body', 'boom-413')
|
||||
})
|
||||
|
||||
it('includes the response body in OError.info for a 422', async function () {
|
||||
const err = await getRejection(fetchNothing(url('/422')))
|
||||
expect(err.info).to.have.property('body', 'boom-422')
|
||||
})
|
||||
|
||||
it('omits the response body from OError.info for a 500', async function () {
|
||||
const err = await getRejection(fetchNothing(url('/500')))
|
||||
expect(err.body).to.equal('Internal Server Error')
|
||||
expect(err.info).to.not.have.property('body')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchString', function () {
|
||||
it('returns a string', async function () {
|
||||
const body = await fetchString(url('/hello'))
|
||||
@@ -509,3 +537,12 @@ async function expectRequestAborted(req) {
|
||||
}
|
||||
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
async function getRejection(promise) {
|
||||
try {
|
||||
await promise
|
||||
} catch (err) {
|
||||
return err
|
||||
}
|
||||
expect.fail('expected promise to reject')
|
||||
}
|
||||
|
||||
@@ -39,6 +39,22 @@ class TestServer {
|
||||
res.sendStatus(500)
|
||||
})
|
||||
|
||||
this.app.get('/400', (req, res) => {
|
||||
res.status(400).send('boom-400')
|
||||
})
|
||||
|
||||
this.app.get('/409', (req, res) => {
|
||||
res.status(409).send('boom-409')
|
||||
})
|
||||
|
||||
this.app.get('/413', (req, res) => {
|
||||
res.status(413).send('boom-413')
|
||||
})
|
||||
|
||||
this.app.get('/422', (req, res) => {
|
||||
res.status(422).send('boom-422')
|
||||
})
|
||||
|
||||
this.app.post('/sink', (req, res) => {
|
||||
req.on('data', () => {})
|
||||
req.on('end', () => {
|
||||
|
||||
@@ -7,6 +7,9 @@ const {
|
||||
GCEMetadataLogLevelChecker,
|
||||
} = require('./log-level-checker')
|
||||
const { setLogger } = require('@overleaf/fetch-utils')
|
||||
const {
|
||||
setLogger: setValidationToolsLogger,
|
||||
} = require('@overleaf/validation-tools')
|
||||
|
||||
const LoggingManager = {
|
||||
/**
|
||||
@@ -33,6 +36,7 @@ const LoggingManager = {
|
||||
this._setupRingBuffer()
|
||||
this._setupLogLevelChecker()
|
||||
setLogger(this)
|
||||
setValidationToolsLogger(this)
|
||||
return this
|
||||
},
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@google-cloud/logging-bunyan": "^5.1.0",
|
||||
"@overleaf/fetch-utils": "workspace:*",
|
||||
"@overleaf/o-error": "workspace:*",
|
||||
"@overleaf/validation-tools": "workspace:*",
|
||||
"bunyan": "^1.8.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const OError = require('@overleaf/o-error')
|
||||
const { getRawReqInput } = require('@overleaf/validation-tools')
|
||||
|
||||
function errSerializer(err) {
|
||||
if (!err) {
|
||||
@@ -36,11 +37,14 @@ function reqSerializer(req) {
|
||||
'content-length': headers['content-length'],
|
||||
},
|
||||
}
|
||||
if (req.params) {
|
||||
const projectId =
|
||||
req.params.projectId || req.params.project_id || req.params.Project_id
|
||||
const userId = req.params.userId || req.params.user_id
|
||||
const docId = req.params.docId || req.params.doc_id
|
||||
// raw access justified: case 2 (final error-handler logging) — this
|
||||
// serializer must not throw while extracting log context, and bunyan
|
||||
// silently drops the whole `req` field (with a stderr warning) if it did
|
||||
const { params } = getRawReqInput(req)
|
||||
if (params) {
|
||||
const projectId = params.projectId || params.project_id || params.Project_id
|
||||
const userId = params.userId || params.user_id
|
||||
const docId = params.docId || params.doc_id
|
||||
if (projectId) {
|
||||
entry.projectId = projectId
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ chai.should()
|
||||
chai.use(sinonChai)
|
||||
|
||||
SandboxedModule.configure({
|
||||
requires: {
|
||||
// pulls in mongodb (via zod ObjectId helpers), which does not compile in
|
||||
// the sandbox (missing TextEncoder)
|
||||
'@overleaf/validation-tools': require('@overleaf/validation-tools'),
|
||||
},
|
||||
globals: { Buffer, JSON, console, process },
|
||||
sourceTransformers: {
|
||||
removeNodePrefix: function (source) {
|
||||
|
||||
@@ -46,10 +46,14 @@ describe('LoggingManager', function () {
|
||||
this.fetchUtils = {
|
||||
setLogger: sinon.stub(),
|
||||
}
|
||||
this.validationTools = {
|
||||
setLogger: sinon.stub(),
|
||||
}
|
||||
this.LoggingManager = SandboxedModule.require(MODULE_PATH, {
|
||||
requires: {
|
||||
bunyan: this.Bunyan,
|
||||
'@overleaf/fetch-utils': this.fetchUtils,
|
||||
'@overleaf/validation-tools': this.validationTools,
|
||||
'./log-level-checker': this.LogLevelChecker,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
const path = require('node:path')
|
||||
const { expect } = require('chai')
|
||||
|
||||
const MODULE_PATH = path.join(__dirname, '../../serializers.js')
|
||||
const serializers = require(MODULE_PATH)
|
||||
|
||||
// Mirrors the symbols the patched express (.yarn/patches/express-npm-*.patch)
|
||||
// installs on req in REQ_LOCKDOWN_MODE=warn|throw, and how
|
||||
// @overleaf/validation-tools' getRawReqInput() reads them.
|
||||
const RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
|
||||
const RAW_QUERY = Symbol.for('overleaf.lockdown.rawQuery')
|
||||
const RAW_PARAMS = Symbol.for('overleaf.lockdown.rawParams')
|
||||
const INSTALLED = Symbol.for('overleaf.lockdown.installed')
|
||||
|
||||
// Replicates what the patched express does to a request in throw mode:
|
||||
// parsed input lives in symbol-keyed fields, the public `params` getter
|
||||
// throws instead of returning it.
|
||||
function lockedRequest({ params, ...rest }) {
|
||||
const req = { ...rest }
|
||||
req[INSTALLED] = true
|
||||
req[RAW_PARAMS] = params
|
||||
req[RAW_QUERY] = {}
|
||||
req[RAW_BODY] = undefined
|
||||
Object.defineProperty(req, 'params', {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
get() {
|
||||
throw new Error('raw request input is forbidden (req.params)')
|
||||
},
|
||||
})
|
||||
return req
|
||||
}
|
||||
|
||||
describe('reqSerializer', function () {
|
||||
it('returns undefined/falsy req untouched', function () {
|
||||
expect(serializers.req(null)).to.equal(null)
|
||||
expect(serializers.req(undefined)).to.equal(undefined)
|
||||
})
|
||||
|
||||
it('extracts method, url, headers and remote address', function () {
|
||||
const req = {
|
||||
method: 'GET',
|
||||
originalUrl: '/project/123',
|
||||
headers: {
|
||||
referer: 'https://example.com',
|
||||
'user-agent': 'test-agent',
|
||||
'content-length': '42',
|
||||
},
|
||||
ip: '127.0.0.1',
|
||||
params: {},
|
||||
}
|
||||
|
||||
expect(serializers.req(req)).to.deep.equal({
|
||||
method: 'GET',
|
||||
url: '/project/123',
|
||||
remoteAddress: '127.0.0.1',
|
||||
headers: {
|
||||
referer: 'https://example.com',
|
||||
'user-agent': 'test-agent',
|
||||
'content-length': '42',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('extracts projectId/userId/docId from ordinary, readable req.params', function () {
|
||||
const req = {
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: {},
|
||||
params: {
|
||||
project_id: 'project-1',
|
||||
user_id: 'user-1',
|
||||
doc_id: 'doc-1',
|
||||
},
|
||||
}
|
||||
|
||||
const entry = serializers.req(req)
|
||||
expect(entry.projectId).to.equal('project-1')
|
||||
expect(entry.userId).to.equal('user-1')
|
||||
expect(entry.docId).to.equal('doc-1')
|
||||
})
|
||||
|
||||
it('prefers the camelCase param name when both spellings are present', function () {
|
||||
const req = {
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: {},
|
||||
params: {
|
||||
projectId: 'camel-project',
|
||||
project_id: 'snake-project',
|
||||
userId: 'camel-user',
|
||||
user_id: 'snake-user',
|
||||
docId: 'camel-doc',
|
||||
doc_id: 'snake-doc',
|
||||
},
|
||||
}
|
||||
|
||||
const entry = serializers.req(req)
|
||||
expect(entry.projectId).to.equal('camel-project')
|
||||
expect(entry.userId).to.equal('camel-user')
|
||||
expect(entry.docId).to.equal('camel-doc')
|
||||
})
|
||||
|
||||
it('omits projectId/userId/docId when req.params has none of the recognised fields', function () {
|
||||
const req = { method: 'GET', url: '/', headers: {}, params: {} }
|
||||
const entry = serializers.req(req)
|
||||
expect(entry).to.not.have.property('projectId')
|
||||
expect(entry).to.not.have.property('userId')
|
||||
expect(entry).to.not.have.property('docId')
|
||||
})
|
||||
|
||||
it('does not throw when req.params is a lockdown-installed throwing getter, and still extracts the ids via the raw accessor', function () {
|
||||
const req = lockedRequest({
|
||||
method: 'GET',
|
||||
url: '/project/123',
|
||||
headers: {},
|
||||
params: {
|
||||
project_id: 'project-1',
|
||||
user_id: 'user-1',
|
||||
doc_id: 'doc-1',
|
||||
},
|
||||
})
|
||||
|
||||
// Sanity check: touching req.params directly really does throw, the way
|
||||
// it would under REQ_LOCKDOWN_MODE=throw.
|
||||
expect(() => req.params).to.throw(/raw request input is forbidden/)
|
||||
|
||||
let entry
|
||||
expect(() => {
|
||||
entry = serializers.req(req)
|
||||
}).to.not.throw()
|
||||
|
||||
expect(entry.projectId).to.equal('project-1')
|
||||
expect(entry.userId).to.equal('user-1')
|
||||
expect(entry.docId).to.equal('doc-1')
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,31 @@
|
||||
const { InvalidParamsError, InvalidRequestError } = require('./Errors')
|
||||
const { z } = require('zod')
|
||||
const { zz } = require('./zodHelpers')
|
||||
const { parseReq } = require('./parseReq')
|
||||
const {
|
||||
parseReq,
|
||||
setLogger,
|
||||
setReqValidationModeForTests,
|
||||
resetReqValidationLoggingForTests,
|
||||
} = require('./parseReq')
|
||||
const { validateSchema } = require('./validateSchema')
|
||||
const {
|
||||
handleValidationError,
|
||||
createHandleValidationError,
|
||||
} = require('./handleValidationError')
|
||||
const { getRawReqInput, isLockdownInstalled } = require('./lockdown')
|
||||
|
||||
module.exports = {
|
||||
z,
|
||||
zz,
|
||||
validateSchema,
|
||||
parseReq,
|
||||
setLogger,
|
||||
setReqValidationModeForTests,
|
||||
resetReqValidationLoggingForTests,
|
||||
handleValidationError,
|
||||
createHandleValidationError,
|
||||
InvalidRequestError,
|
||||
InvalidParamsError,
|
||||
getRawReqInput,
|
||||
isLockdownInstalled,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Access to the request-input lockdown installed by our patched express
|
||||
* (see .yarn/patches/express-npm-*.patch).
|
||||
*
|
||||
* With REQ_LOCKDOWN_MODE=warn|throw, express captures parsed request input in
|
||||
* the symbol-keyed fields below and turns the public req.body / req.query /
|
||||
* req.params into accessors that warn or throw on read. Request input must be
|
||||
* read through parseReq(), which validates it against a zod schema, or — for
|
||||
* the small allowlist of infrastructure middleware that legitimately needs
|
||||
* unvalidated input (authentication, rate limiting, CSRF, ...) — through
|
||||
* getRawReqInput() so that the access is explicit and greppable.
|
||||
*
|
||||
* The symbols live in the global symbol registry, shared with the express and
|
||||
* body-parser patches.
|
||||
*/
|
||||
|
||||
const RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
|
||||
const RAW_QUERY = Symbol.for('overleaf.lockdown.rawQuery')
|
||||
const RAW_PARAMS = Symbol.for('overleaf.lockdown.rawParams')
|
||||
const INSTALLED = Symbol.for('overleaf.lockdown.installed')
|
||||
|
||||
/**
|
||||
* @typedef {import('express').Request} Request
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether the lockdown accessors are installed on this request
|
||||
* (REQ_LOCKDOWN_MODE=warn|throw).
|
||||
*
|
||||
* @param {Request} req
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isLockdownInstalled(req) {
|
||||
// @ts-ignore symbol-keyed field added by the express patch
|
||||
return Boolean(req[INSTALLED])
|
||||
}
|
||||
|
||||
/**
|
||||
* Read raw, unvalidated request input, bypassing the lockdown.
|
||||
*
|
||||
* Allowlisted infrastructure middleware only — application code (routes,
|
||||
* controllers) must use parseReq() with a zod schema instead.
|
||||
*
|
||||
* @param {Request} req
|
||||
* @returns {{ params: any, query: any, body: any }}
|
||||
*/
|
||||
function getRawReqInput(req) {
|
||||
if (!isLockdownInstalled(req)) {
|
||||
return { params: req.params, query: req.query, body: req.body }
|
||||
}
|
||||
return {
|
||||
// @ts-ignore symbol-keyed fields added by the express patch
|
||||
params: req[RAW_PARAMS],
|
||||
// @ts-ignore
|
||||
query: req[RAW_QUERY],
|
||||
// @ts-ignore
|
||||
body: req[RAW_BODY],
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RAW_BODY,
|
||||
RAW_QUERY,
|
||||
RAW_PARAMS,
|
||||
INSTALLED,
|
||||
isLockdownInstalled,
|
||||
getRawReqInput,
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
// @ts-check
|
||||
const { InvalidRequestError, InvalidParamsError } = require('./Errors')
|
||||
const {
|
||||
RAW_BODY,
|
||||
RAW_QUERY,
|
||||
RAW_PARAMS,
|
||||
isLockdownInstalled,
|
||||
} = require('./lockdown')
|
||||
|
||||
/**
|
||||
* @typedef {import('zod').ZodType} ZodType
|
||||
* @typedef {import('zod').ZodError} ZodError
|
||||
* @typedef {import('zod').ZodIssue} ZodIssue
|
||||
* @typedef {import('express').Request} Request
|
||||
*/
|
||||
|
||||
@@ -12,23 +20,285 @@ const { InvalidRequestError, InvalidParamsError } = require('./Errors')
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse and validate a request against a Zod schema
|
||||
* Log-only rollout options for parseReq(), see REQ_VALIDATION_MODE below.
|
||||
*
|
||||
* @typedef {object} ParseReqOptions
|
||||
* @property {boolean} [logOnly] - In REQ_VALIDATION_MODE=log, a primary
|
||||
* schema failure (with no passing fallbackSchema) logs once and returns
|
||||
* the raw, un-coerced request input instead of throwing.
|
||||
* @property {ZodType} [fallbackSchema] - In REQ_VALIDATION_MODE=log, a
|
||||
* primary schema failure is re-parsed against this looser/pre-refinement
|
||||
* schema. If it passes, its output is returned (and the primary schema's
|
||||
* issues are logged). If it also fails, behavior falls back to logOnly
|
||||
* above, classifying/throwing from the fallback's ZodError when logOnly
|
||||
* is not set.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The request to validate. With the request-input lockdown installed
|
||||
* (REQ_LOCKDOWN_MODE=warn|throw), reading the public req.body / req.query /
|
||||
* req.params would warn or throw, so shadow them with the raw symbol-keyed
|
||||
* fields captured by the patched express. Every other field a schema may
|
||||
* reference (headers, file, files, ...) falls through to the request via the
|
||||
* prototype chain. Without the lockdown the request is passed through
|
||||
* unchanged.
|
||||
*
|
||||
* @param {Request} req
|
||||
* @returns {Request}
|
||||
*/
|
||||
function schemaInput(req) {
|
||||
if (!isLockdownInstalled(req)) return req
|
||||
return Object.create(req, {
|
||||
// @ts-ignore symbol-keyed fields added by the express patch
|
||||
params: { value: req[RAW_PARAMS], enumerable: true },
|
||||
// @ts-ignore
|
||||
query: { value: req[RAW_QUERY], enumerable: true },
|
||||
// @ts-ignore
|
||||
body: { value: req[RAW_BODY], enumerable: true },
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Rollout mode for parseReq's log-only instrumentation, controlled by the
|
||||
* REQ_VALIDATION_MODE env var. Default (unset or any invalid value) is
|
||||
* 'log' -- deliberately not coupled to NODE_ENV, nothing automatic.
|
||||
*
|
||||
* - 'log': a schema failure at an instrumented call site (one that passes
|
||||
* opts) logs instead of throwing, see parseReq() below. Call sites that
|
||||
* don't pass opts are unaffected and always throw.
|
||||
* - 'enforce': opts are inert, every call site always throws on failure,
|
||||
* exactly as if parseReq() had no third parameter at all.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string | null | undefined} m
|
||||
* @returns {m is 'log' | 'enforce'}
|
||||
*/
|
||||
function isValidReqValidationMode(m) {
|
||||
return m === 'log' || m === 'enforce'
|
||||
}
|
||||
|
||||
/** @type {'log' | 'enforce' | null} */
|
||||
let cachedMode = null
|
||||
|
||||
/**
|
||||
* @returns {'log' | 'enforce'}
|
||||
*/
|
||||
function mode() {
|
||||
if (cachedMode == null) {
|
||||
const m = process.env.REQ_VALIDATION_MODE
|
||||
cachedMode = isValidReqValidationMode(m) ? m : 'log'
|
||||
}
|
||||
return cachedMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the mode for tests. Pass null to clear the cache and force a
|
||||
* re-read of REQ_VALIDATION_MODE on the next access; pass 'log'/'enforce'/
|
||||
* anything else to force that value (coerced the same way mode() would).
|
||||
*
|
||||
* Exported from index.js -- unlike testUtils.js's stateless assertion
|
||||
* helpers, this mutates module state that must be visible to the exact
|
||||
* parseReq() the app calls into, so tests need to reach it through the same
|
||||
* bare package specifier as app code (@overleaf/validation-tools), not a
|
||||
* direct subpath import. Under this repo's Yarn PnP + Vite/Vitest setup, a
|
||||
* subpath import (@overleaf/validation-tools/parseReq.js) can load this file
|
||||
* into a second, separate module instance with its own `cachedMode`, so a
|
||||
* test's setReqValidationModeForTests() call would silently miss the copy
|
||||
* the code under test actually calls into.
|
||||
*
|
||||
* @param {string | null} m
|
||||
*/
|
||||
function setReqValidationModeForTests(m) {
|
||||
if (m === null) {
|
||||
cachedMode = null
|
||||
} else {
|
||||
cachedMode = isValidReqValidationMode(m) ? m : 'log'
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {{ warn: (ctx: object, msg: string) => void } | undefined} */
|
||||
let logger
|
||||
|
||||
/**
|
||||
* Inject the logger used by parseReq's log-only rollout mode. Called once
|
||||
* from @overleaf/logger's LoggingManager.initialize() at service boot (the
|
||||
* same injection pattern as @overleaf/fetch-utils' setLogger) -- validation
|
||||
* -tools cannot depend on @overleaf/logger directly, since logger's request
|
||||
* serializer already depends on validation-tools (getRawReqInput), and a
|
||||
* static import the other way would be a cycle. Logging is a silent no-op
|
||||
* until a logger has been injected.
|
||||
*
|
||||
* @param {{ warn: (ctx: object, msg: string) => void }} loggerInstance
|
||||
*/
|
||||
function setLogger(loggerInstance) {
|
||||
logger = loggerInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-schema dedup: the *primary* zod schema object maps to the set of
|
||||
* (kind, sanitized issues) signatures already logged for it. A `let`, not a
|
||||
* `const`, so resetReqValidationLoggingForTests() can simply reassign it.
|
||||
* Kept per-schema (not global) so that two different routes/schemas that
|
||||
* happen to produce identical issues both get logged.
|
||||
*
|
||||
* @type {WeakMap<object, Set<string>>}
|
||||
*/
|
||||
let loggedSignatures = new WeakMap()
|
||||
|
||||
// Memory bound against pathological cardinality (e.g. an attacker choosing
|
||||
// record keys that end up in issue paths, generating unbounded distinct
|
||||
// signatures) -- once a schema's set hits this cap, stop adding/logging new
|
||||
// signatures for that schema; already-logged ones keep deduping normally.
|
||||
const MAX_SIGNATURES_PER_SCHEMA = 1000
|
||||
|
||||
/**
|
||||
* Reset the log-only rollout's dedup state between tests.
|
||||
*/
|
||||
function resetReqValidationLoggingForTests() {
|
||||
loggedSignatures = new WeakMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize zod issues for logging: never include user-supplied values, only
|
||||
* shape/metadata that helps fix schemas (error code, path, message, and --
|
||||
* for unrecognized_keys -- the offending key names, which zod formats into
|
||||
* the message text). Never reads issue.input/values/params, and parseReq
|
||||
* never passes { reportInput: true } to safeParse, so zod v4 already omits
|
||||
* raw input from issues by default.
|
||||
*
|
||||
* @param {readonly ZodIssue[]} issues
|
||||
* @param {number} [depth]
|
||||
* @returns {Array<{ code: string | undefined, path: string, message: string, errors?: unknown }>}
|
||||
*/
|
||||
function sanitizeIssues(issues, depth = 0) {
|
||||
return issues.map(issue => {
|
||||
/** @type {{ code: string | undefined, path: string, message: string, errors?: unknown }} */
|
||||
const sanitized = {
|
||||
code: issue.code,
|
||||
path: issue.path.map(seg => String(seg).slice(0, 64)).join('.'),
|
||||
message: String(issue.message).slice(0, 200),
|
||||
}
|
||||
if (issue.code === 'invalid_union' && depth < 2) {
|
||||
sanitized.errors = issue.errors.map(nested =>
|
||||
sanitizeIssues(nested, depth + 1)
|
||||
)
|
||||
}
|
||||
return sanitized
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a schema failure once per unique (kind, sanitized issues) signature
|
||||
* for the given primary schema. No-op if no logger has been injected.
|
||||
*
|
||||
* @param {Request} req
|
||||
* @param {ZodType} schema - the primary schema; the dedup key.
|
||||
* @param {'log-only' | 'fallback-passed'} kind
|
||||
* @param {readonly ZodIssue[]} issues
|
||||
*/
|
||||
function logSchemaFailure(req, schema, kind, issues) {
|
||||
if (!logger) return
|
||||
|
||||
const sanitizedIssues = sanitizeIssues(issues)
|
||||
const signature = JSON.stringify([kind, sanitizedIssues])
|
||||
|
||||
let signatures = loggedSignatures.get(schema)
|
||||
if (!signatures) {
|
||||
signatures = new Set()
|
||||
loggedSignatures.set(schema, signatures)
|
||||
}
|
||||
if (signatures.has(signature)) return
|
||||
if (signatures.size >= MAX_SIGNATURES_PER_SCHEMA) return
|
||||
signatures.add(signature)
|
||||
|
||||
// Deliberately not falling back to req.originalUrl/req.url -- those are
|
||||
// user-controlled and would leak into logs; unmatched routes just get
|
||||
// 'unknown' for the route part.
|
||||
const location = `${req.method} ${req.route?.path || 'unknown'}`
|
||||
const caller = /** @type {Error} */ (new Error('trace')).stack
|
||||
?.split('\n')
|
||||
.slice(2, 7)
|
||||
.join('\n')
|
||||
|
||||
logger.warn(
|
||||
{ location, kind, issues: sanitizedIssues, caller },
|
||||
'req-validation: request failed schema in log-only rollout'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a ZodError and throw the appropriate error: any issue rooted at
|
||||
* "params" means part of the URL path failed to validate, so the request
|
||||
* should 404 rather than 400.
|
||||
*
|
||||
* @param {ZodError} error
|
||||
* @returns {never}
|
||||
*/
|
||||
function throwClassified(error) {
|
||||
if (error.issues.some(issue => issue.path[0] === 'params')) {
|
||||
throw new InvalidParamsError(error)
|
||||
} else {
|
||||
throw new InvalidRequestError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a request against a Zod schema.
|
||||
*
|
||||
* @template {ZodType} T
|
||||
* @param {Request} req - The Express request object
|
||||
* @param {T} schema - The Zod schema to validate against
|
||||
* @returns {output<T>} The validated request object
|
||||
* @param {ParseReqOptions} [opts] - Log-only rollout options. Untouched call
|
||||
* sites that omit opts always enforce (throw on failure), in every mode --
|
||||
* opts only matter when REQ_VALIDATION_MODE=log.
|
||||
* @returns {output<T>} The validated request object. Note: in
|
||||
* REQ_VALIDATION_MODE=log, a failing parse at an instrumented call site
|
||||
* (opts set) does not necessarily produce T -- it may instead return
|
||||
* opts.fallbackSchema's output, or the raw, un-coerced request input, per
|
||||
* the opts handling documented above.
|
||||
*/
|
||||
function parseReq(req, schema) {
|
||||
const parsed = schema.safeParse(req)
|
||||
function parseReq(req, schema, opts) {
|
||||
const input = schemaInput(req)
|
||||
const parsed = schema.safeParse(input)
|
||||
if (parsed.success) {
|
||||
return parsed.data
|
||||
} else if (parsed.error.issues.some(issue => issue.path[0] === 'params')) {
|
||||
// Parts of the URL path failed to validate; throw a specific error
|
||||
throw new InvalidParamsError(parsed.error)
|
||||
} else {
|
||||
throw new InvalidRequestError(parsed.error)
|
||||
}
|
||||
|
||||
if (opts && mode() === 'log') {
|
||||
if (opts.fallbackSchema) {
|
||||
const fallbackParsed = opts.fallbackSchema.safeParse(input)
|
||||
if (fallbackParsed.success) {
|
||||
logSchemaFailure(req, schema, 'fallback-passed', parsed.error.issues)
|
||||
// The fallback's output, not T's -- see the return-type doc comment.
|
||||
return /** @type {output<T>} */ (fallbackParsed.data)
|
||||
}
|
||||
if (opts.logOnly) {
|
||||
logSchemaFailure(req, schema, 'log-only', parsed.error.issues)
|
||||
// The raw, un-coerced input, not T's -- see the return-type doc
|
||||
// comment.
|
||||
return /** @type {output<T>} */ (input)
|
||||
}
|
||||
// Both schemas reject -- no behavior change from what's in production
|
||||
// today, so nothing to log. Classify from the fallback's error to
|
||||
// preserve production's pre-refinement error shape.
|
||||
throwClassified(fallbackParsed.error)
|
||||
} else if (opts.logOnly) {
|
||||
logSchemaFailure(req, schema, 'log-only', parsed.error.issues)
|
||||
// The raw, un-coerced input, not T's -- see the return-type doc
|
||||
// comment.
|
||||
return /** @type {output<T>} */ (input)
|
||||
}
|
||||
// opts present but neither field set: nothing to do in log mode,
|
||||
// fall through to throwing as normal.
|
||||
}
|
||||
|
||||
throwClassified(parsed.error)
|
||||
}
|
||||
|
||||
module.exports = { parseReq }
|
||||
module.exports = {
|
||||
parseReq,
|
||||
setLogger,
|
||||
setReqValidationModeForTests,
|
||||
resetReqValidationLoggingForTests,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { Request } from 'express'
|
||||
import { parseReq } from '../../../parseReq'
|
||||
import { getRawReqInput, isLockdownInstalled } from '../../../lockdown'
|
||||
import { zz } from '../../../zodHelpers'
|
||||
|
||||
const RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
|
||||
const RAW_QUERY = Symbol.for('overleaf.lockdown.rawQuery')
|
||||
const RAW_PARAMS = Symbol.for('overleaf.lockdown.rawParams')
|
||||
const INSTALLED = Symbol.for('overleaf.lockdown.installed')
|
||||
|
||||
// Replicates what the patched express does to a request in warn/throw mode:
|
||||
// parsed input lives in symbol-keyed fields, the public properties throw.
|
||||
function lockedRequest({
|
||||
params = {},
|
||||
query = {},
|
||||
body = undefined,
|
||||
...rest
|
||||
}: {
|
||||
params?: object
|
||||
query?: object
|
||||
body?: unknown
|
||||
[key: string]: unknown
|
||||
}): Request {
|
||||
const req: Record<PropertyKey, unknown> = { ...rest }
|
||||
req[INSTALLED] = true
|
||||
req[RAW_PARAMS] = params
|
||||
req[RAW_QUERY] = query
|
||||
req[RAW_BODY] = body
|
||||
for (const field of ['params', 'query', 'body']) {
|
||||
Object.defineProperty(req, field, {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
get() {
|
||||
throw new Error(`raw request input is forbidden (req.${field})`)
|
||||
},
|
||||
set(value) {
|
||||
this[
|
||||
field === 'params'
|
||||
? RAW_PARAMS
|
||||
: field === 'query'
|
||||
? RAW_QUERY
|
||||
: RAW_BODY
|
||||
] = value
|
||||
},
|
||||
})
|
||||
}
|
||||
return req as unknown as Request
|
||||
}
|
||||
|
||||
describe('parseReq with the request-input lockdown installed', () => {
|
||||
it('validates params/query/body from the raw fields without touching the public accessors', () => {
|
||||
const req = lockedRequest({
|
||||
params: { id: '507f1f77bcf86cd799439011' },
|
||||
query: { verbose: 'true' },
|
||||
body: { name: 'Valid Name' },
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
params: z.strictObject({ id: zz.objectId() }),
|
||||
query: z.strictObject({ verbose: z.stringbool() }),
|
||||
body: z.strictObject({ name: z.string() }),
|
||||
})
|
||||
|
||||
expect(parseReq(req, schema)).toEqual({
|
||||
params: { id: '507f1f77bcf86cd799439011' },
|
||||
query: { verbose: true },
|
||||
body: { name: 'Valid Name' },
|
||||
})
|
||||
})
|
||||
|
||||
it('lets schemas reference unlocked request fields such as headers', () => {
|
||||
const req = lockedRequest({
|
||||
params: { id: '507f1f77bcf86cd799439011' },
|
||||
headers: { range: 'bytes=0-99' },
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
params: z.strictObject({ id: zz.objectId() }),
|
||||
headers: z.object({ range: z.string() }),
|
||||
})
|
||||
|
||||
expect(parseReq(req, schema)).toEqual({
|
||||
params: { id: '507f1f77bcf86cd799439011' },
|
||||
headers: { range: 'bytes=0-99' },
|
||||
})
|
||||
})
|
||||
|
||||
it('throws InvalidParamsError for invalid raw params', () => {
|
||||
const req = lockedRequest({ params: { id: 'not-an-object-id' } })
|
||||
|
||||
expect(() =>
|
||||
parseReq(req, z.object({ params: z.strictObject({ id: zz.objectId() }) }))
|
||||
).toThrowError(expect.objectContaining({ name: 'InvalidParamsError' }))
|
||||
})
|
||||
|
||||
it('rejects unknown keys in strict objects from the raw fields', () => {
|
||||
const req = lockedRequest({ body: { name: 'ok', extra: 'nope' } })
|
||||
|
||||
expect(() =>
|
||||
parseReq(req, z.object({ body: z.strictObject({ name: z.string() }) }))
|
||||
).toThrowError(expect.objectContaining({ name: 'InvalidRequestError' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLockdownInstalled', () => {
|
||||
it('reports the lockdown state of a request', () => {
|
||||
expect(isLockdownInstalled(lockedRequest({}))).toBe(true)
|
||||
expect(isLockdownInstalled({} as Request)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRawReqInput', () => {
|
||||
it('reads the raw fields on a locked request', () => {
|
||||
const req = lockedRequest({
|
||||
params: { id: 'p' },
|
||||
query: { q: '1' },
|
||||
body: { b: 2 },
|
||||
})
|
||||
|
||||
expect(getRawReqInput(req)).toEqual({
|
||||
params: { id: 'p' },
|
||||
query: { q: '1' },
|
||||
body: { b: 2 },
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the public properties on an unlocked request', () => {
|
||||
const req = {
|
||||
params: { id: 'p' },
|
||||
query: { q: '1' },
|
||||
body: { b: 2 },
|
||||
} as unknown as Request
|
||||
|
||||
expect(getRawReqInput(req)).toEqual({
|
||||
params: { id: 'p' },
|
||||
query: { q: '1' },
|
||||
body: { b: 2 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,407 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { Request } from 'express'
|
||||
import {
|
||||
parseReq,
|
||||
setLogger,
|
||||
setReqValidationModeForTests,
|
||||
resetReqValidationLoggingForTests,
|
||||
} from '../../../parseReq'
|
||||
import { zz } from '../../../zodHelpers'
|
||||
|
||||
const RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
|
||||
const RAW_QUERY = Symbol.for('overleaf.lockdown.rawQuery')
|
||||
const RAW_PARAMS = Symbol.for('overleaf.lockdown.rawParams')
|
||||
const INSTALLED = Symbol.for('overleaf.lockdown.installed')
|
||||
|
||||
// Replicates what the patched express does to a request in warn/throw mode
|
||||
// (see lockdown.test.ts): parsed input lives in symbol-keyed fields, the
|
||||
// public properties throw.
|
||||
function lockedRequest({
|
||||
params = {},
|
||||
query = {},
|
||||
body = undefined,
|
||||
...rest
|
||||
}: {
|
||||
params?: object
|
||||
query?: object
|
||||
body?: unknown
|
||||
[key: string]: unknown
|
||||
}): Request {
|
||||
const req: Record<PropertyKey, unknown> = { ...rest }
|
||||
req[INSTALLED] = true
|
||||
req[RAW_PARAMS] = params
|
||||
req[RAW_QUERY] = query
|
||||
req[RAW_BODY] = body
|
||||
for (const field of ['params', 'query', 'body']) {
|
||||
Object.defineProperty(req, field, {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
get() {
|
||||
throw new Error(`raw request input is forbidden (req.${field})`)
|
||||
},
|
||||
set(value) {
|
||||
this[
|
||||
field === 'params'
|
||||
? RAW_PARAMS
|
||||
: field === 'query'
|
||||
? RAW_QUERY
|
||||
: RAW_BODY
|
||||
] = value
|
||||
},
|
||||
})
|
||||
}
|
||||
return req as unknown as Request
|
||||
}
|
||||
|
||||
type WarnCall = [Record<string, any>, string]
|
||||
|
||||
describe('parseReq log-only rollout', () => {
|
||||
// This block intentionally runs before anything else in the file calls
|
||||
// setLogger(), so the module-level logger is still unset here.
|
||||
describe('without an injected logger', () => {
|
||||
beforeEach(() => {
|
||||
resetReqValidationLoggingForTests()
|
||||
setReqValidationModeForTests('log')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setReqValidationModeForTests(null)
|
||||
})
|
||||
|
||||
it('is a no-op: a logOnly failure still returns the raw input without throwing', () => {
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.string() }) })
|
||||
|
||||
let result: unknown
|
||||
expect(() => {
|
||||
result = parseReq(req, schema, { logOnly: true })
|
||||
}).not.toThrow()
|
||||
expect(result).toEqual({ body: { name: 1234 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an injected logger', () => {
|
||||
let warnMock: ReturnType<typeof vi.fn<(ctx: any, msg: string) => void>>
|
||||
|
||||
beforeEach(() => {
|
||||
warnMock = vi.fn<(ctx: any, msg: string) => void>()
|
||||
setLogger({ warn: warnMock })
|
||||
resetReqValidationLoggingForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setReqValidationModeForTests(null)
|
||||
})
|
||||
|
||||
describe('enforce mode', () => {
|
||||
beforeEach(() => {
|
||||
setReqValidationModeForTests('enforce')
|
||||
})
|
||||
|
||||
it('throws even when opts is fully populated -- opts are inert in enforce mode', () => {
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.string() }) })
|
||||
|
||||
expect(() =>
|
||||
parseReq(req, schema, { logOnly: true, fallbackSchema: z.any() })
|
||||
).toThrowError(expect.objectContaining({ name: 'InvalidRequestError' }))
|
||||
expect(warnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still classifies params failures as InvalidParamsError with opts set', () => {
|
||||
const req = { params: { id: 'nope' } } as Request<{ id: string }>
|
||||
const schema = z.object({
|
||||
params: z.object({ id: z.string().regex(/^[0-9]+$/) }),
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
parseReq(req, schema, { logOnly: true, fallbackSchema: z.any() })
|
||||
).toThrowError(expect.objectContaining({ name: 'InvalidParamsError' }))
|
||||
expect(warnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// NOTE: the task description's spec text says "Default (unset OR any
|
||||
// invalid value) is 'log'" for REQ_VALIDATION_MODE (repeated for
|
||||
// setReqValidationModeForTests: "coerced the same way the real getter
|
||||
// would"), which is also the only reading consistent with the rollout's
|
||||
// safety goal (fail open to logging, not enforcing, when a service
|
||||
// hasn't configured the var). We test that documented behavior here.
|
||||
describe('an invalid REQ_VALIDATION_MODE value', () => {
|
||||
beforeEach(() => {
|
||||
setReqValidationModeForTests('bogus')
|
||||
})
|
||||
|
||||
it('behaves as log mode (the documented default for unset/invalid values)', () => {
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.string() }) })
|
||||
|
||||
const result = parseReq(req, schema, { logOnly: true })
|
||||
expect(result).toEqual({ body: { name: 1234 } })
|
||||
expect(warnMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('log mode', () => {
|
||||
beforeEach(() => {
|
||||
setReqValidationModeForTests('log')
|
||||
})
|
||||
|
||||
it('still throws when no opts are passed, and logs nothing', () => {
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.string() }) })
|
||||
|
||||
expect(() => parseReq(req, schema)).toThrowError(
|
||||
expect.objectContaining({ name: 'InvalidRequestError' })
|
||||
)
|
||||
expect(warnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('logOnly', () => {
|
||||
it('returns the raw, un-coerced request input on failure', () => {
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.string() }) })
|
||||
|
||||
const result = parseReq(req, schema, { logOnly: true })
|
||||
expect(result).toBe(req)
|
||||
expect(result).toEqual({ body: { name: 1234 } })
|
||||
})
|
||||
|
||||
it('logs exactly once for a repeated identical failure against the same schema', () => {
|
||||
const schema = z.strictObject({
|
||||
body: z.strictObject({ name: z.string() }),
|
||||
})
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
|
||||
parseReq(req, schema, { logOnly: true })
|
||||
parseReq(req, schema, { logOnly: true })
|
||||
|
||||
expect(warnMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('logs again when the issues differ, against the same schema', () => {
|
||||
const schema = z.strictObject({
|
||||
body: z.strictObject({ name: z.string() }),
|
||||
})
|
||||
const req1 = { body: { name: 1234 } } as Request
|
||||
const req2 = { body: { name: 'ok', extra: true } } as Request
|
||||
|
||||
parseReq(req1, schema, { logOnly: true })
|
||||
parseReq(req2, schema, { logOnly: true })
|
||||
|
||||
expect(warnMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('logs again for a different schema object, even with identical issues (per-schema dedup)', () => {
|
||||
const schemaA = z.strictObject({
|
||||
body: z.strictObject({ name: z.string() }),
|
||||
})
|
||||
const schemaB = z.strictObject({
|
||||
body: z.strictObject({ name: z.string() }),
|
||||
})
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
|
||||
parseReq(req, schemaA, { logOnly: true })
|
||||
parseReq(req, schemaB, { logOnly: true })
|
||||
|
||||
expect(warnMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with the request-input lockdown installed', () => {
|
||||
it('returns the raw, unwrapped params/query/body on a logOnly failure', () => {
|
||||
const req = lockedRequest({ params: { id: 'not-an-object-id' } })
|
||||
const schema = z.object({
|
||||
params: z.strictObject({ id: zz.objectId() }),
|
||||
})
|
||||
|
||||
const result = parseReq(req, schema, {
|
||||
logOnly: true,
|
||||
}) as unknown as Request
|
||||
|
||||
expect(() => result.params).not.toThrow()
|
||||
expect(result.params).toEqual({ id: 'not-an-object-id' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a fallbackSchema', () => {
|
||||
it('returns the fallback output and logs the primary issues (fallback-passed) when the fallback passes', () => {
|
||||
const req = { body: { count: '5' } } as Request
|
||||
const primary = z.object({ body: z.object({ count: z.number() }) })
|
||||
const fallback = z.object({
|
||||
body: z.object({ count: z.coerce.number().default(0) }),
|
||||
})
|
||||
|
||||
const result = parseReq(req, primary, { fallbackSchema: fallback })
|
||||
|
||||
expect(result).toEqual({ body: { count: 5 } })
|
||||
expect(warnMock).toHaveBeenCalledTimes(1)
|
||||
const [ctx] = warnMock.mock.calls[0] as WarnCall
|
||||
expect(ctx.kind).toBe('fallback-passed')
|
||||
expect(ctx.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: 'body.count' }),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('throws classified from the fallback error when both schemas fail and logOnly is not set', () => {
|
||||
const req = {
|
||||
params: { id: 'not-a-number' },
|
||||
body: { name: 'ok' },
|
||||
} as Request<{ id: string }, any, { name: string }>
|
||||
// Fails on body (name is a string, not a number).
|
||||
const primary = z.object({
|
||||
params: z.object({ id: z.string() }),
|
||||
body: z.object({ name: z.number() }),
|
||||
})
|
||||
// Fails on params (id is a string, not a number).
|
||||
const fallback = z.object({
|
||||
params: z.object({ id: z.number() }),
|
||||
body: z.object({ name: z.string() }),
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
parseReq(req, primary, { fallbackSchema: fallback })
|
||||
).toThrowError(
|
||||
expect.objectContaining({ name: 'InvalidParamsError' })
|
||||
)
|
||||
expect(warnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns raw input and logs once (log-only) when both schemas fail and logOnly is set', () => {
|
||||
const req = { body: { name: 1234 } } as Request
|
||||
const primary = z.object({ body: z.object({ name: z.string() }) })
|
||||
const fallback = z.object({
|
||||
body: z.object({ name: z.string().min(10) }),
|
||||
})
|
||||
|
||||
const result = parseReq(req, primary, {
|
||||
fallbackSchema: fallback,
|
||||
logOnly: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ body: { name: 1234 } })
|
||||
expect(warnMock).toHaveBeenCalledTimes(1)
|
||||
const [ctx] = warnMock.mock.calls[0] as WarnCall
|
||||
expect(ctx.kind).toBe('log-only')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue sanitization', () => {
|
||||
it('never leaks a sentinel value present in the request', () => {
|
||||
const req = { body: { name: 'SECRET_VALUE_123' } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.number() }) })
|
||||
|
||||
parseReq(req, schema, { logOnly: true })
|
||||
|
||||
expect(JSON.stringify(warnMock.mock.calls)).not.toContain(
|
||||
'SECRET_VALUE_123'
|
||||
)
|
||||
})
|
||||
|
||||
it('includes an unrecognized key NAME but never its value', () => {
|
||||
const req = {
|
||||
body: { name: 'ok', extraSecretField: 'TOP_SECRET_VALUE' },
|
||||
} as Request
|
||||
const schema = z.strictObject({
|
||||
body: z.strictObject({ name: z.string() }),
|
||||
})
|
||||
|
||||
parseReq(req, schema, { logOnly: true })
|
||||
|
||||
const logged = JSON.stringify(warnMock.mock.calls)
|
||||
expect(logged).toContain('extraSecretField')
|
||||
expect(logged).not.toContain('TOP_SECRET_VALUE')
|
||||
})
|
||||
|
||||
it('truncates a >64-char path segment to exactly 64 chars', () => {
|
||||
const longKey = 'x'.repeat(65)
|
||||
const req = { body: { [longKey]: 'irrelevant' } } as Request
|
||||
const schema = z.object({
|
||||
body: z.object({ [longKey]: z.number() }),
|
||||
})
|
||||
|
||||
parseReq(req, schema, { logOnly: true })
|
||||
|
||||
const logged = JSON.stringify(warnMock.mock.calls)
|
||||
expect(logged).toContain(longKey.slice(0, 64))
|
||||
expect(logged).not.toContain(longKey)
|
||||
})
|
||||
|
||||
it('truncates a >200-char message to exactly 200 chars', () => {
|
||||
const longMessage = 'y'.repeat(250)
|
||||
const req = { body: { name: 'short' } } as Request
|
||||
const schema = z.object({
|
||||
body: z.object({
|
||||
name: z.string().refine(() => false, { message: longMessage }),
|
||||
}),
|
||||
})
|
||||
|
||||
parseReq(req, schema, { logOnly: true })
|
||||
|
||||
const logged = JSON.stringify(warnMock.mock.calls)
|
||||
expect(logged).toContain(longMessage.slice(0, 200))
|
||||
expect(logged).not.toContain(longMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid_union sanitization', () => {
|
||||
it('recursively sanitizes nested union member issues without leaking sentinel values', () => {
|
||||
const req = {
|
||||
body: { value: { secret: 'UNION_SECRET_VALUE' } },
|
||||
} as Request
|
||||
const schema = z.object({
|
||||
body: z.object({ value: z.union([z.string(), z.number()]) }),
|
||||
})
|
||||
|
||||
parseReq(req, schema, { logOnly: true })
|
||||
|
||||
expect(warnMock).toHaveBeenCalledTimes(1)
|
||||
const [ctx] = warnMock.mock.calls[0] as WarnCall
|
||||
const unionIssue = ctx.issues.find(
|
||||
(issue: any) => issue.code === 'invalid_union'
|
||||
)
|
||||
expect(unionIssue).toBeDefined()
|
||||
expect(Array.isArray(unionIssue.errors)).toBe(true)
|
||||
expect(unionIssue.errors.length).toBeGreaterThan(0)
|
||||
|
||||
expect(JSON.stringify(warnMock.mock.calls)).not.toContain(
|
||||
'UNION_SECRET_VALUE'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('success path', () => {
|
||||
it('enforce mode: returns the parsed data and logs nothing, even with opts set', () => {
|
||||
setReqValidationModeForTests('enforce')
|
||||
const req = { body: { name: 'ok' } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.string() }) })
|
||||
|
||||
const result = parseReq(req, schema, {
|
||||
logOnly: true,
|
||||
fallbackSchema: z.any(),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ body: { name: 'ok' } })
|
||||
expect(warnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('log mode: returns the parsed data and logs nothing, even with opts set', () => {
|
||||
setReqValidationModeForTests('log')
|
||||
const req = { body: { name: 'ok' } } as Request
|
||||
const schema = z.object({ body: z.object({ name: z.string() }) })
|
||||
|
||||
const result = parseReq(req, schema, {
|
||||
logOnly: true,
|
||||
fallbackSchema: z.any(),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ body: { name: 'ok' } })
|
||||
expect(warnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
expectValidationError,
|
||||
expectValidationErrorRaw,
|
||||
} from '../../../testUtils'
|
||||
|
||||
function errorBody(status: number, fieldSubstring: string) {
|
||||
return JSON.stringify({
|
||||
statusCode: status,
|
||||
error: `Validation error: Unrecognized key: "${fieldSubstring}" at "body"`,
|
||||
})
|
||||
}
|
||||
|
||||
// Mimics a RequestFailedError from @overleaf/fetch-utils: `.response.status`
|
||||
// and `.body` (raw text).
|
||||
function asRequestFailedError(status: number, fieldSubstring: string) {
|
||||
return {
|
||||
response: { status },
|
||||
body: errorBody(status, fieldSubstring),
|
||||
}
|
||||
}
|
||||
|
||||
// Mimics a raw node:http response object: `.statusCode` and `.body` (raw
|
||||
// text) directly on the object.
|
||||
function asRawResponse(status: number, fieldSubstring: string) {
|
||||
return {
|
||||
statusCode: status,
|
||||
body: errorBody(status, fieldSubstring),
|
||||
}
|
||||
}
|
||||
|
||||
// Mimics an HTTP client wrapper that never rejects and pre-parses the body
|
||||
// from JSON before returning it, e.g. templates' fetchWithResponse().
|
||||
function asResolvedResultWithParsedBody(
|
||||
status: number,
|
||||
fieldSubstring: string
|
||||
) {
|
||||
return {
|
||||
response: { status },
|
||||
body: JSON.parse(errorBody(status, fieldSubstring)),
|
||||
}
|
||||
}
|
||||
|
||||
describe('expectValidationError', () => {
|
||||
it('passes when the status and field substring match', () => {
|
||||
const err = asRequestFailedError(400, 'projectId')
|
||||
expect(() => expectValidationError(err, 400, 'projectId')).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws when the status code does not match', () => {
|
||||
const err = asRequestFailedError(400, 'projectId')
|
||||
expect(() => expectValidationError(err, 404, 'projectId')).toThrow()
|
||||
})
|
||||
|
||||
it('throws when the field substring is not present in the error body', () => {
|
||||
const err = asRequestFailedError(400, 'projectId')
|
||||
expect(() => expectValidationError(err, 400, 'someOtherField')).toThrow()
|
||||
})
|
||||
|
||||
it('throws when the object is not a rejected request', () => {
|
||||
expect(() =>
|
||||
expectValidationError({ notAResponse: true }, 400, 'projectId')
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('passes when the body is already parsed into an object', () => {
|
||||
const result = asResolvedResultWithParsedBody(404, 'template_id')
|
||||
expect(() =>
|
||||
expectValidationError(result, 404, 'template_id')
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws when the pre-parsed body does not include the field substring', () => {
|
||||
const result = asResolvedResultWithParsedBody(404, 'template_id')
|
||||
expect(() => expectValidationError(result, 404, 'someOtherField')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('expectValidationErrorRaw', () => {
|
||||
it('passes when the status and field substring match', () => {
|
||||
const res = asRawResponse(404, 'filename')
|
||||
expect(() => expectValidationErrorRaw(res, 404, 'filename')).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws when the status code does not match', () => {
|
||||
const res = asRawResponse(404, 'filename')
|
||||
expect(() => expectValidationErrorRaw(res, 400, 'filename')).toThrow()
|
||||
})
|
||||
|
||||
it('throws when the field substring is not present in the error body', () => {
|
||||
const res = asRawResponse(404, 'filename')
|
||||
expect(() => expectValidationErrorRaw(res, 404, 'projectId')).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -334,4 +334,569 @@ describe('zodHelpers', () => {
|
||||
expect(parsed.data).toBe('foo/output.pdf')
|
||||
})
|
||||
})
|
||||
|
||||
describe('routeSegment', () => {
|
||||
it('fails to parse with empty input', () => {
|
||||
const parsed = zz.routeSegment().safeParse('')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toHaveLength(1)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'route segment is empty',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a segment containing a path separator', () => {
|
||||
const parsed = zz.routeSegment().safeParse('foo/bar')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'route segment contains a path, query or fragment separator',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a segment containing a backslash', () => {
|
||||
const parsed = zz.routeSegment().safeParse('foo\\bar')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'route segment contains a path, query or fragment separator',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a segment containing a query separator', () => {
|
||||
const parsed = zz.routeSegment().safeParse('foo?bar=1')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'route segment contains a path, query or fragment separator',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a segment containing a fragment separator', () => {
|
||||
const parsed = zz.routeSegment().safeParse('foo#bar')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'route segment contains a path, query or fragment separator',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a relative path component', () => {
|
||||
const parsed = zz.routeSegment().safeParse('..')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'route segment is a relative path component',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a single dot', () => {
|
||||
const parsed = zz.routeSegment().safeParse('.')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'route segment is a relative path component',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully when provided a plain segment', () => {
|
||||
const parsed = zz.routeSegment().safeParse('conversion')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('conversion')
|
||||
})
|
||||
|
||||
it('parses successfully when provided a mongo ObjectId-shaped segment', () => {
|
||||
const parsed = zz.routeSegment().safeParse('507f1f77bcf86cd799439011')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('507f1f77bcf86cd799439011')
|
||||
})
|
||||
})
|
||||
|
||||
describe('safePath', () => {
|
||||
it('fails to parse with empty input', () => {
|
||||
const parsed = zz.safePath().safeParse('')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path is empty',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully with an absolute (root-relative) path', () => {
|
||||
// unlike filepath(), a leading "/" is allowed -- project doc/file
|
||||
// paths are root-relative in production (see web's
|
||||
// ProjectEntityHandler.getAllEntitiesFromProject).
|
||||
const parsed = zz.safePath().safeParse('/output.pdf')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('/output.pdf')
|
||||
})
|
||||
|
||||
it('fails to parse a path ending in a slash (a folder, not a file)', () => {
|
||||
const parsed = zz.safePath().safeParse('foo/')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path is a folder, not a file',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse when provided with path traversal', () => {
|
||||
const parsed = zz.safePath().safeParse('../output.pdf')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path traversal detected',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path that is exactly "."', () => {
|
||||
// matches SafePath.mjs's BADFILE_RX (^\.$), not just "..": a lone "."
|
||||
// component is also rejected there, cross-checked against
|
||||
// SafePath.test.mjs's isCleanFilename/isCleanPath cases.
|
||||
const parsed = zz.safePath().safeParse('.')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path segment is "." or has leading/trailing whitespace',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path segment with leading whitespace', () => {
|
||||
const parsed = zz.safePath().safeParse(' foobar.tex')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path segment is "." or has leading/trailing whitespace',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path segment with trailing whitespace', () => {
|
||||
const parsed = zz.safePath().safeParse('foobar.tex ')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path segment is "." or has leading/trailing whitespace',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path containing a null byte', () => {
|
||||
const parsed = zz
|
||||
.safePath()
|
||||
.safeParse('foo' + String.fromCharCode(0) + '.tex')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path contains a disallowed character',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path containing a C1 control character', () => {
|
||||
// \x80-\x9F, per SafePath.mjs's BADCHAR_RX
|
||||
const parsed = zz
|
||||
.safePath()
|
||||
.safeParse('foo' + String.fromCharCode(0x90) + '.tex')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path contains a disallowed character',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path containing a lone surrogate', () => {
|
||||
// \uD800-\uDFFF, per SafePath.mjs's BADCHAR_RX
|
||||
const parsed = zz.safePath().safeParse('foo\uD800.tex')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path contains a disallowed character',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path containing an asterisk', () => {
|
||||
const parsed = zz.safePath().safeParse('foo*.tex')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path contains a disallowed character',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path containing a backslash', () => {
|
||||
const parsed = zz.safePath().safeParse('foo\\bar.tex')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path contains a disallowed character',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path with a __proto__ segment', () => {
|
||||
const parsed = zz.safePath().safeParse('__proto__/output.pdf')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path segment is an unsafe property name',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path that is exactly "constructor"', () => {
|
||||
const parsed = zz.safePath().safeParse('constructor')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path segment is an unsafe property name',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a nested segment matching a built-in Object.prototype method name', () => {
|
||||
// full BLOCKEDFILE_RX parity, not just the prototype-pollution subset
|
||||
// (__proto__/constructor/prototype) -- and checked per segment, not
|
||||
// only the top-level path as SafePath.mjs's isCleanPath does.
|
||||
const parsed = zz.safePath().safeParse('foo/toString')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path segment is an unsafe property name',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path that is exactly "hasOwnProperty"', () => {
|
||||
const parsed = zz.safePath().safeParse('hasOwnProperty')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path segment is an unsafe property name',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully when provided a valid nested path', () => {
|
||||
const parsed = zz.safePath().safeParse('foo/output.pdf')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('foo/output.pdf')
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectHistoryId', () => {
|
||||
it('fails to parse an invalid id', () => {
|
||||
const parsed = zz.projectHistoryId().safeParse('not-an-id')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'invalid project history id',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path-traversal payload', () => {
|
||||
const parsed = zz.projectHistoryId().safeParse('a/../../../../../etc')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'invalid project history id',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully when provided a valid Mongo ObjectId', () => {
|
||||
const parsed = zz.projectHistoryId().safeParse('507f1f77bcf86cd799439011')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('507f1f77bcf86cd799439011')
|
||||
})
|
||||
|
||||
it('parses successfully when provided a valid Postgres id', () => {
|
||||
const parsed = zz.projectHistoryId().safeParse('12345')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('12345')
|
||||
})
|
||||
|
||||
it('fails to parse a Postgres id with a leading zero', () => {
|
||||
const parsed = zz.projectHistoryId().safeParse('0123')
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chunkId', () => {
|
||||
it('fails to parse an invalid id', () => {
|
||||
const parsed = zz.chunkId().safeParse('not-an-id')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'invalid chunk id',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path-traversal payload', () => {
|
||||
const parsed = zz.chunkId().safeParse('a/../../../../../etc')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'invalid chunk id',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully when provided a valid Mongo ObjectId', () => {
|
||||
const parsed = zz.chunkId().safeParse('507f1f77bcf86cd799439011')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('507f1f77bcf86cd799439011')
|
||||
})
|
||||
|
||||
it('parses successfully when provided a valid Postgres id', () => {
|
||||
const parsed = zz.chunkId().safeParse('12345')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('12345')
|
||||
})
|
||||
|
||||
it('fails to parse a Postgres id with a leading zero', () => {
|
||||
const parsed = zz.chunkId().safeParse('0123')
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitTestName', () => {
|
||||
it('fails to parse a non-kebab-case name', () => {
|
||||
const parsed = zz.splitTestName().safeParse('Not_Valid')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'split test name must be kebab-case',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a name shorter than 3 characters', () => {
|
||||
const parsed = zz.splitTestName().safeParse('ab')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'split test name must be at least 3 characters long',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path-traversal-shaped payload', () => {
|
||||
const parsed = zz.splitTestName().safeParse('../../etc/passwd')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'split test name must be kebab-case',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully when provided a valid kebab-case name', () => {
|
||||
const parsed = zz.splitTestName().safeParse('my-split-test')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('my-split-test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('variantName', () => {
|
||||
it('fails to parse a non-kebab-case name', () => {
|
||||
const parsed = zz.variantName().safeParse('Not_Valid')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'variant name must be kebab-case',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a name shorter than 3 characters', () => {
|
||||
const parsed = zz.variantName().safeParse('ab')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'variant name must be at least 3 characters long',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a path-traversal-shaped payload', () => {
|
||||
const parsed = zz.variantName().safeParse('../../etc/passwd')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'variant name must be kebab-case',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully when provided a valid kebab-case name', () => {
|
||||
const parsed = zz.variantName().safeParse('variant-1')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('variant-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('eventName', () => {
|
||||
it('fails to parse a name containing a disallowed character', () => {
|
||||
const parsed = zz.eventName().safeParse('bad event!')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'invalid event name',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse an empty string', () => {
|
||||
// both the explicit .min(1) and the regex reject an empty string, so
|
||||
// two issues are reported, each with the same message
|
||||
const parsed = zz.eventName().safeParse('')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues.length).toBeGreaterThan(0)
|
||||
for (const issue of parsed.error?.issues ?? []) {
|
||||
expect(issue).toMatchObject({ message: 'invalid event name' })
|
||||
}
|
||||
})
|
||||
|
||||
it('parses successfully when provided a name with letters, digits and hyphens/underscores', () => {
|
||||
const parsed = zz.eventName().safeParse('editor-click_something-2')
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe('editor-click_something-2')
|
||||
})
|
||||
|
||||
it('fails to parse a name containing an uppercase letter or dots, colons, semicolons, commas and slashes', () => {
|
||||
const parsed = zz.eventName().safeParse('I.did:something,here/now;too')
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'invalid event name',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('parses successfully when provided a name that is exactly 240 characters long', () => {
|
||||
const name = 'a'.repeat(240)
|
||||
const parsed = zz.eventName().safeParse(name)
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe(name)
|
||||
})
|
||||
|
||||
it('fails to parse a name that is 241 characters long', () => {
|
||||
const name = 'a'.repeat(241)
|
||||
const parsed = zz.eventName().safeParse(name)
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'event name is too long',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('streamedBody', () => {
|
||||
it('parses undefined (no body parser mounted) to undefined', () => {
|
||||
const parsed = zz.streamedBody().safeParse(undefined)
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe(undefined)
|
||||
})
|
||||
|
||||
it('parses the empty object left by a skipping body parser to undefined', () => {
|
||||
const parsed = zz.streamedBody().safeParse({})
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toBe(undefined)
|
||||
})
|
||||
|
||||
it('fails to parse a parsed JSON body', () => {
|
||||
const parsed = zz.streamedBody().safeParse({ some: 'payload' })
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'unexpected parsed request body on streamed endpoint',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse a raw buffer body', () => {
|
||||
const parsed = zz.streamedBody().safeParse(Buffer.alloc(0))
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
|
||||
it('fails to parse a string body', () => {
|
||||
const parsed = zz.streamedBody().safeParse('text body')
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadedFile', () => {
|
||||
const validFile = {
|
||||
fieldname: 'qqfile',
|
||||
originalname: 'paper.tex',
|
||||
encoding: '7bit',
|
||||
mimetype: 'application/x-tex',
|
||||
size: 1234,
|
||||
destination: '/tmp/uploads',
|
||||
filename: 'a1b2c3d4e5f6',
|
||||
path: '/tmp/uploads/a1b2c3d4e5f6',
|
||||
}
|
||||
|
||||
it('parses a multer disk-storage file object', () => {
|
||||
const parsed = zz.uploadedFile().safeParse(validFile)
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data).toEqual(validFile)
|
||||
})
|
||||
|
||||
it('parses a file object without the deprecated encoding field', () => {
|
||||
const { encoding, ...rest } = validFile
|
||||
const parsed = zz.uploadedFile().safeParse(rest)
|
||||
expect(parsed.success).toBe(true)
|
||||
})
|
||||
|
||||
it('fails to parse an originalname with path traversal', () => {
|
||||
const parsed = zz
|
||||
.uploadedFile()
|
||||
.safeParse({ ...validFile, originalname: '../../etc/passwd' })
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.error?.issues).toMatchObject([
|
||||
expect.objectContaining({
|
||||
message: 'path traversal detected',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('fails to parse an absolute originalname', () => {
|
||||
const parsed = zz
|
||||
.uploadedFile()
|
||||
.safeParse({ ...validFile, originalname: '/etc/passwd' })
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unknown keys such as a memory-storage buffer', () => {
|
||||
const parsed = zz
|
||||
.uploadedFile()
|
||||
.safeParse({ ...validFile, buffer: Buffer.alloc(1) })
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
|
||||
it('fails to parse a negative size', () => {
|
||||
const parsed = zz.uploadedFile().safeParse({ ...validFile, size: -1 })
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,6 +58,194 @@ const zz = {
|
||||
.refine(s => !s.split('/').includes('..'), {
|
||||
message: 'path traversal detected',
|
||||
}),
|
||||
/**
|
||||
* A single opaque segment interpolated into a URL path for route/path
|
||||
* dispatch. Covers two shapes of the same sink: (1) internal re-dispatch
|
||||
* of the current request -- rewriting req.url before `next('route')`, or
|
||||
* a redirect Location -- and (2) a value forwarded, unescaped, into an
|
||||
* *outbound* URL built for a different service that does its own route
|
||||
* dispatch on it. Not a filesystem path, so filepath()'s "no leading /"
|
||||
* rule is too permissive (a bare `/` anywhere still splits the rebuilt
|
||||
* URL into extra segments) and its "no .. component" rule is too narrow
|
||||
* (query/fragment separators are just as dangerous here).
|
||||
*/
|
||||
routeSegment: () =>
|
||||
z
|
||||
.string()
|
||||
.nonempty({ message: 'route segment is empty' })
|
||||
.refine(s => !/[/\\?#]/.test(s), {
|
||||
message: 'route segment contains a path, query or fragment separator',
|
||||
})
|
||||
.refine(s => s !== '.' && s !== '..', {
|
||||
message: 'route segment is a relative path component',
|
||||
}),
|
||||
/**
|
||||
* A project-internal document/file path (e.g. document-updater's
|
||||
* pathname/newPathname, or a resync's docs[]/files[] path) -- these are
|
||||
* root-relative ("/main.tex") in production (see web's
|
||||
* ProjectEntityHandler.getAllEntitiesFromProject, which path.join()s from
|
||||
* '/'), so unlike filepath() a leading "/" is allowed here, not rejected.
|
||||
* Mirrors web's app/src/Features/Project/SafePath.mjs (BADCHAR_RX,
|
||||
* BADFILE_RX, and BLOCKEDFILE_RX), which every path in a project's file
|
||||
* tree is already validated against at the point of origin -- this
|
||||
* closes the gap for services that receive that path secondhand (e.g.
|
||||
* forwarded into history-v1's archive/zip builder). Cross-checked
|
||||
* against every case in web's SafePath.test.mjs (isCleanPath) via a
|
||||
* throwaway script requiring both modules independently; the only
|
||||
* remaining differences from that reference are two deliberate ones:
|
||||
* (1) BADCHAR_RX's leading "/" is not ported -- here "/" is the segment
|
||||
* separator, not a disallowed character, so a traversal payload is
|
||||
* still caught (as "." or "..") per split segment below, just not via
|
||||
* this character check; (2) BLOCKEDFILE_RX's property-name check is
|
||||
* applied per segment, not only to the top-level path the way
|
||||
* SafePath.mjs's isCleanPath does -- stricter on purpose, since any
|
||||
* segment here (not just the first) could end up as an object key or
|
||||
* archive/zip entry name downstream.
|
||||
*/
|
||||
safePath: () => {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const BAD_CHAR_RX = /[\\*\x00-\x1f\x7f\x80-\x9f\uD800-\uDFFF]/
|
||||
const BAD_SEGMENT_RX = /^\.$|^\s|\s$/
|
||||
const UNSAFE_SEGMENT_RX =
|
||||
/^(prototype|constructor|toString|toLocaleString|valueOf|hasOwnProperty|isPrototypeOf|propertyIsEnumerable|__defineGetter__|__lookupGetter__|__defineSetter__|__lookupSetter__|__proto__)$/
|
||||
return z
|
||||
.string()
|
||||
.nonempty({ message: 'path is empty' })
|
||||
.refine(s => !s.endsWith('/'), {
|
||||
message: 'path is a folder, not a file',
|
||||
})
|
||||
.refine(s => !s.split('/').includes('..'), {
|
||||
message: 'path traversal detected',
|
||||
})
|
||||
.refine(s => !s.split('/').some(seg => BAD_SEGMENT_RX.test(seg)), {
|
||||
message: 'path segment is "." or has leading/trailing whitespace',
|
||||
})
|
||||
.refine(s => !BAD_CHAR_RX.test(s), {
|
||||
message: 'path contains a disallowed character',
|
||||
})
|
||||
.refine(s => !s.split('/').some(seg => UNSAFE_SEGMENT_RX.test(seg)), {
|
||||
message: 'path segment is an unsafe property name',
|
||||
})
|
||||
},
|
||||
/**
|
||||
* A history-v1 project id: either a 24-hex Mongo ObjectId or a Postgres
|
||||
* integer id, per history-v1's storage/lib/assert.js projectId().
|
||||
* Validating this at the schema boundary (not just deep in BlobStore's
|
||||
* constructor) means a malformed id 404s at the route instead of
|
||||
* depending on every call path remembering to construct a BlobStore (or
|
||||
* equivalent) first.
|
||||
*/
|
||||
projectHistoryId: () =>
|
||||
z.string().regex(/^([0-9a-f]{24}|[1-9][0-9]{0,9})$/, {
|
||||
message: 'invalid project history id',
|
||||
}),
|
||||
/**
|
||||
* A history-v1 chunk id: either a 24-hex Mongo ObjectId or a Postgres
|
||||
* integer id, per history-v1's storage/lib/assert.js chunkId(). Validating
|
||||
* this at the schema boundary (not just deep in BlobStore's constructor)
|
||||
* means a malformed id 404s at the route instead of depending on every
|
||||
* call path remembering to construct a BlobStore (or equivalent) first.
|
||||
*/
|
||||
chunkId: () =>
|
||||
z.string().regex(/^([0-9a-f]{24}|[1-9][0-9]{0,9})$/, {
|
||||
message: 'invalid chunk id',
|
||||
}),
|
||||
/**
|
||||
* A split test's name, per the frontend's yup schema in split-test/
|
||||
* frontend/js/features/components/split-test-create.jsx (mirrored in
|
||||
* split-test-edit.tsx): kebab-case, at least 3 characters long. Enforcing
|
||||
* this at the schema boundary matters beyond cosmetics: a split test name
|
||||
* is used as a dynamic property key (e.g.
|
||||
* SplitTestHandler.setOverrideInSession's
|
||||
* `session.splitTestOverrides[splitTestName] = ...` on a plain object), so
|
||||
* restricting the charset to [a-z0-9-] also rules out
|
||||
* __proto__/constructor/prototype and any path-traversal shape.
|
||||
*/
|
||||
splitTestName: () =>
|
||||
z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]+$/, {
|
||||
message: 'split test name must be kebab-case',
|
||||
})
|
||||
.min(3, {
|
||||
message: 'split test name must be at least 3 characters long',
|
||||
}),
|
||||
/**
|
||||
* A split test variant's name, per the frontend's yup schema in
|
||||
* split-test/frontend/js/features/components/split-test-create.jsx
|
||||
* (mirrored in split-test-edit.tsx): kebab-case, at least 3 characters
|
||||
* long. Same underlying rule as zz.splitTestName() (and the same rationale
|
||||
* for enforcing it), just a semantically distinct field.
|
||||
*/
|
||||
variantName: () =>
|
||||
z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]+$/, { message: 'variant name must be kebab-case' })
|
||||
.min(3, { message: 'variant name must be at least 3 characters long' }),
|
||||
/**
|
||||
* An analytics event name (e.g. AnalyticsController.mjs's recordEvent
|
||||
* param, or AnalyticsManager.recordEventForSession/recordEventForUser's
|
||||
* `event` argument in web), forwarded as-is from web's AnalyticsManager,
|
||||
* through the analytics service's events queue, to each downstream sink
|
||||
* (Mixpanel, Customer.io, Postgres, BigQuery). The charset isn't an
|
||||
* arbitrary narrowing: every event name a real user can trigger is one of
|
||||
* the small set of literal strings the frontend/backend code emits (never
|
||||
* user-supplied text), and every one of those literals -- across every
|
||||
* call site of recordEventForSession/recordEventForUser/
|
||||
* recordEventForMongoUser in web's app/, modules/ and scripts/ -- is
|
||||
* already kebab-case or snake_case, i.e. already matches
|
||||
* `^[a-z0-9-_]+$`. Capped at 240 characters, well above the longest of
|
||||
* those literals. AnalyticsManager's own _isAttributeValid applies a
|
||||
* wider regex, but that's a separate, looser backend-only guard for call
|
||||
* sites that don't go through one of these schemas -- not evidence that a
|
||||
* wider charset is actually needed here.
|
||||
*/
|
||||
eventName: () =>
|
||||
z
|
||||
.string()
|
||||
.min(1, { message: 'invalid event name' })
|
||||
.regex(/^[a-z0-9-_]+$/, { message: 'invalid event name' })
|
||||
.max(240, { message: 'event name is too long' }),
|
||||
/**
|
||||
* For endpoints that consume the request as a raw stream (e.g. blob
|
||||
* uploads): asserts that no body parser consumed the stream before the
|
||||
* handler. Accepts only undefined or the empty object that a skipping
|
||||
* body parser leaves behind, and parses to undefined. If a body parser is
|
||||
* later mounted on such a route, validation fails loudly instead of the
|
||||
* handler silently reading an already-consumed stream.
|
||||
*/
|
||||
streamedBody: () =>
|
||||
z
|
||||
.custom(
|
||||
val => {
|
||||
if (val === undefined) return true
|
||||
return (
|
||||
typeof val === 'object' &&
|
||||
val !== null &&
|
||||
!Buffer.isBuffer(val) &&
|
||||
Object.keys(val).length === 0
|
||||
)
|
||||
},
|
||||
{ message: 'unexpected parsed request body on streamed endpoint' }
|
||||
)
|
||||
.transform(() => undefined),
|
||||
/**
|
||||
* A multer file object (disk storage — the only storage backend we use),
|
||||
* for validating req.file / req.files on multipart routes. The
|
||||
* client-supplied originalname is held to the same traversal rules as
|
||||
* zz.filepath().
|
||||
*/
|
||||
uploadedFile: () =>
|
||||
z.strictObject({
|
||||
fieldname: z.string(),
|
||||
originalname: zz.filepath(),
|
||||
encoding: z.string().optional(),
|
||||
mimetype: z.string(),
|
||||
size: z.number().int().nonnegative(),
|
||||
destination: z.string(),
|
||||
filename: z.string(),
|
||||
path: z.string(),
|
||||
}),
|
||||
}
|
||||
|
||||
module.exports = { zz }
|
||||
|
||||
+3
-2
@@ -70,7 +70,7 @@
|
||||
"react-dom": "18.3.1",
|
||||
"i18next-scanner/i18next": "23.16.8",
|
||||
"downshift": "9.0.9",
|
||||
"body-parser": "patch:body-parser@npm%3A1.20.4#~/.yarn/patches/body-parser-npm-1.20.4.patch",
|
||||
"body-parser": "patch:body-parser@npm%3A1.20.4#~/.yarn/patches/body-parser-npm-1.20.4-805a6a51d0.patch",
|
||||
"cypress-multi-reporters": "patch:cypress-multi-reporters@npm%3A2.0.5#~/.yarn/patches/cypress-multi-reporters-npm-2.0.5.patch",
|
||||
"forwarded@npm:0.2.0": "patch:forwarded@npm%3A0.2.0#~/.yarn/patches/forwarded-npm-0.2.0.patch",
|
||||
"multer@npm:2.2.0": "patch:multer@npm%3A2.2.0#~/.yarn/patches/multer-npm-2.2.0-4ed181c78f.patch",
|
||||
@@ -176,7 +176,8 @@
|
||||
"@babel/plugin-transform-modules-systemjs": "7.29.4",
|
||||
"fast-xml-builder": "1.1.7",
|
||||
"fast-xml-parser": "5.7.3",
|
||||
"ip-address": "10.1.1"
|
||||
"ip-address": "10.1.1",
|
||||
"express": "patch:express@npm%3A4.22.1#~/.yarn/patches/express-npm-4.22.1-0ea5210390.patch"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --cache --cache-location ./.cache/prettier/.prettier-cache --check",
|
||||
|
||||
@@ -7358,6 +7358,7 @@ __metadata:
|
||||
"@google-cloud/logging-bunyan": "npm:^5.1.0"
|
||||
"@overleaf/fetch-utils": "workspace:*"
|
||||
"@overleaf/o-error": "workspace:*"
|
||||
"@overleaf/validation-tools": "workspace:*"
|
||||
"@types/node": "npm:^24.5.2"
|
||||
bunyan: "npm:^1.8.14"
|
||||
chai: "npm:^4.3.6"
|
||||
@@ -14097,9 +14098,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"body-parser@patch:body-parser@npm%3A1.20.4#~/.yarn/patches/body-parser-npm-1.20.4.patch":
|
||||
"body-parser@patch:body-parser@npm%3A1.20.4#~/.yarn/patches/body-parser-npm-1.20.4-805a6a51d0.patch":
|
||||
version: 1.20.4
|
||||
resolution: "body-parser@patch:body-parser@npm%3A1.20.4#~/.yarn/patches/body-parser-npm-1.20.4.patch::version=1.20.4&hash=83d8bd"
|
||||
resolution: "body-parser@patch:body-parser@npm%3A1.20.4#~/.yarn/patches/body-parser-npm-1.20.4-805a6a51d0.patch::version=1.20.4&hash=b5edbe"
|
||||
dependencies:
|
||||
bytes: "npm:~3.1.2"
|
||||
content-type: "npm:~1.0.5"
|
||||
@@ -14113,7 +14114,7 @@ __metadata:
|
||||
raw-body: "npm:~2.5.3"
|
||||
type-is: "npm:~1.6.18"
|
||||
unpipe: "npm:~1.0.0"
|
||||
checksum: 10c0/b1c96b5ad2facd9b5d125aff21b7cacdcd763d7ffd31852fbd94c96a944e0ccb86089091813170fa7f4217f06f34b58e8cabefcf402b95c980ab9d004625982d
|
||||
checksum: 10c0/f9b40b232c187a3dc3c30056655384f3ecf483a486b914c60f70f19671234024ad07b794637c1a2c8f658cde7e834916a671cdf3fe251c9b11e7865f04279aff
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -18438,7 +18439,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"express@npm:4.22.1, express@npm:^4.17.1, express@npm:^4.22.1":
|
||||
"express@npm:4.22.1":
|
||||
version: 4.22.1
|
||||
resolution: "express@npm:4.22.1"
|
||||
dependencies:
|
||||
@@ -18477,6 +18478,45 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"express@patch:express@npm%3A4.22.1#~/.yarn/patches/express-npm-4.22.1-0ea5210390.patch":
|
||||
version: 4.22.1
|
||||
resolution: "express@patch:express@npm%3A4.22.1#~/.yarn/patches/express-npm-4.22.1-0ea5210390.patch::version=4.22.1&hash=1a79ae"
|
||||
dependencies:
|
||||
accepts: "npm:~1.3.8"
|
||||
array-flatten: "npm:1.1.1"
|
||||
body-parser: "npm:~1.20.3"
|
||||
content-disposition: "npm:~0.5.4"
|
||||
content-type: "npm:~1.0.4"
|
||||
cookie: "npm:~0.7.1"
|
||||
cookie-signature: "npm:~1.0.6"
|
||||
debug: "npm:2.6.9"
|
||||
depd: "npm:2.0.0"
|
||||
encodeurl: "npm:~2.0.0"
|
||||
escape-html: "npm:~1.0.3"
|
||||
etag: "npm:~1.8.1"
|
||||
finalhandler: "npm:~1.3.1"
|
||||
fresh: "npm:~0.5.2"
|
||||
http-errors: "npm:~2.0.0"
|
||||
merge-descriptors: "npm:1.0.3"
|
||||
methods: "npm:~1.1.2"
|
||||
on-finished: "npm:~2.4.1"
|
||||
parseurl: "npm:~1.3.3"
|
||||
path-to-regexp: "npm:~0.1.12"
|
||||
proxy-addr: "npm:~2.0.7"
|
||||
qs: "npm:~6.14.0"
|
||||
range-parser: "npm:~1.2.1"
|
||||
safe-buffer: "npm:5.2.1"
|
||||
send: "npm:~0.19.0"
|
||||
serve-static: "npm:~1.16.2"
|
||||
setprototypeof: "npm:1.2.0"
|
||||
statuses: "npm:~2.0.1"
|
||||
type-is: "npm:~1.6.18"
|
||||
utils-merge: "npm:1.0.1"
|
||||
vary: "npm:~1.1.2"
|
||||
checksum: 10c0/92c800c627cd0fdcff980dc7111ced6621f622d18ea117739ee79213a70a020eeab359c9f650ae7b71ee0ee3233ee4dd003589e2ce52831a551f4b7ef4fb5f54
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"expressionify@npm:0.9.3":
|
||||
version: 0.9.3
|
||||
resolution: "expressionify@npm:0.9.3"
|
||||
@@ -24972,13 +25012,13 @@ __metadata:
|
||||
|
||||
"multer@patch:multer@npm%3A2.2.0#~/.yarn/patches/multer-npm-2.2.0-4ed181c78f.patch":
|
||||
version: 2.2.0
|
||||
resolution: "multer@patch:multer@npm%3A2.2.0#~/.yarn/patches/multer-npm-2.2.0-4ed181c78f.patch::version=2.2.0&hash=cf9e1c"
|
||||
resolution: "multer@patch:multer@npm%3A2.2.0#~/.yarn/patches/multer-npm-2.2.0-4ed181c78f.patch::version=2.2.0&hash=f480e2"
|
||||
dependencies:
|
||||
append-field: "npm:^1.0.0"
|
||||
busboy: "npm:^1.6.0"
|
||||
concat-stream: "npm:^2.0.0"
|
||||
type-is: "npm:^1.6.18"
|
||||
checksum: 10c0/9aa266ba976e6ee0d292d4b838d7e957086bcfb53fa170b62ca26d8737cc5eb2fc066fba0045895cbce6981a467adc0f801685b5528e8d1d09e00ca1544163dc
|
||||
checksum: 10c0/c704a7c804e026efa6b9f6902febded9d9df64a061d0dc86f8fb014df07666240f39685f36a35bb620388bf3a4717a78af558bb5a25c1e2aefe548c13724e829
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user