mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
* update zod safePath validation to match existing behaviour in web * test SafePath from web against zod validation GitOrigin-RevId: a159f5078b5bf75eac4365c97e34e21e716290eb
252 lines
11 KiB
JavaScript
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 }
|