[monorepo] prepare for zod everywhere epic (#36027)

GitOrigin-RevId: f082ae7fc7857fa72b2c1823645050bd4f6df305
This commit is contained in:
Jakob Ackermann
2026-09-17 11:42:46 +00:00
committed by Copybot
parent bff27fc3cc
commit 81df324bcc
27 changed files with 2588 additions and 70 deletions
+12 -1
View File
@@ -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,
}
+70
View File
@@ -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,
}
+280 -10
View File
@@ -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)
})
})
})
+188
View File
@@ -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 }