Files
overleaf-cep/libraries/validation-tools/parseReq.js
T
Jakob Ackermann 81df324bcc [monorepo] prepare for zod everywhere epic (#36027)
GitOrigin-RevId: f082ae7fc7857fa72b2c1823645050bd4f6df305
2026-09-17 11:42:46 +00:00

305 lines
11 KiB
JavaScript

// @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
*/
/**
* @template T
* @typedef {import('zod').output<T>} output<T>
*/
/**
* 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
* @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, opts) {
const input = schemaInput(req)
const parsed = schema.safeParse(input)
if (parsed.success) {
return parsed.data
}
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,
setLogger,
setReqValidationModeForTests,
resetReqValidationLoggingForTests,
}