mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
305 lines
11 KiB
JavaScript
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,
|
|
}
|