Files
overleaf-cep/libraries/validation-tools/zodHelpers.js
T
Brian Gough 3e8f2ebe31 update zod safePath validation to match existing behaviour in web (#36357)
* update zod safePath validation to match existing behaviour in web

* test SafePath from web against zod validation

GitOrigin-RevId: a159f5078b5bf75eac4365c97e34e21e716290eb
2026-09-17 11:51:30 +00:00

252 lines
11 KiB
JavaScript

const { z } = require('zod')
const mongodb = require('mongodb')
const { ObjectId } = mongodb
/**
* @import { DatetimeSchemaOptions } from './types'
*/
/**
* @param {DatetimeSchemaOptions} options
*/
const datetimeSchema = ({ allowNull, allowUndefined, ...zodOptions } = {}) => {
const union = [z.date(), z.iso.datetime(zodOptions)]
if (allowNull) union.push(z.null())
if (allowUndefined) union.push(z.undefined())
return z.union(union).transform(dt => {
if (allowNull && !dt) return dt === null ? null : undefined
return dt instanceof Date ? dt : new Date(dt)
})
}
const zz = {
objectId: () =>
z.string().refine(ObjectId.isValid, { message: 'invalid Mongo ObjectId' }),
coercedObjectId: () =>
z
.string()
.refine(ObjectId.isValid, { message: 'invalid Mongo ObjectId' })
.transform(val => new ObjectId(val)),
hex: () => z.string().regex(/^[0-9a-f]*$/),
datetime: options => datetimeSchema(options),
datetimeNullable: options => datetimeSchema({ ...options, allowNull: true }),
datetimeNullish: options =>
datetimeSchema({ ...options, allowNull: true, allowUndefined: true }),
buildId: () =>
z.string().regex(/^[0-9a-f]+-[0-9a-f]+$/, { message: 'invalid buildId' }),
editorBuildId: () =>
z.string().regex(/^[a-f0-9-]{36}-[0-9a-f]+-[0-9a-f]+$/, {
message: 'invalid editorId-buildId',
}),
clsiServerId: () =>
z.string().regex(/^[a-z0-9-]+$/, { message: 'invalid clsiServerId' }),
compileBackendClass: () =>
z
.string()
.regex(/^[a-z0-9-]+$/, { message: 'invalid compileBackendClass' }),
compileGroup: () =>
z.enum(['alpha', 'gvisor', 'standard', 'priority'], {
message: 'invalid compileGroup',
}),
submissionId: () => z.string().regex(/^[a-zA-Z0-9_-]+$/),
filepath: () =>
z
.string()
.nonempty({ message: 'path is empty' })
.refine(s => !s.startsWith('/'), { message: 'path is absolute' })
.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). Parity with
* isCleanPath is asserted directly in zodHelpers.test.ts, which imports
* SafePath.mjs and checks it against every safePath() test case; the only
* remaining difference from that reference is deliberate: BADCHAR_RX's
* leading "/" is not ported here, since "/" 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. The unsafe-property-name check (BLOCKEDFILE_RX) matches
* isCleanPath exactly -- only the top-level path, not nested segments --
* since web already allows a reserved name below the top level (e.g. a
* folder literally named "prototype" is permitted), and this schema
* shouldn't reject paths web itself considers valid.
*/
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 => !UNSAFE_SEGMENT_RX.test(s.replace(/^\//, '')), {
message: 'path 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-_]+$/i`. 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-zA-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 }