[clsi] address zod violations (#36199)

* [editor-core] gracefully handle no-op TextOperation

* [web] avoid sending sync to pdf requests without a ?file

* [validation-tools] log deeper nested violations

* [editor-core] fix round trip of EditNoOperation

* [web] use custom error for sync to pdf w/o file, fix tests

* [validation-tools] include request params in log msg for violation

* Revert "[editor-core] gracefully handle no-op TextOperation"

This reverts commit c1d37261d5db72b37dbf39428c54edcfb5423f41.

* [web] gracefully handle rejected sync to pdf from context menu

GitOrigin-RevId: 21de7dc96b23575b0d67b6eb0c68ed9c2f86292b
This commit is contained in:
Jakob Ackermann
2026-09-17 11:48:12 +00:00
committed by Copybot
parent 761d4efc1f
commit 731ed50f78
14 changed files with 254 additions and 16 deletions
@@ -35,7 +35,8 @@ class Operation {
if (
'textOperation' in raw ||
'commentId' in raw ||
'deleteComment' in raw
'deleteComment' in raw ||
'noOp' in raw
) {
return EditFileOperation.fromRaw(raw)
}
@@ -78,4 +78,18 @@ describe('EditFileOperation', function () {
expect(editFileOperation1).to.deep.equal(edit('foo.tex', ['x']))
})
})
describe('fromRaw / toRaw', function () {
it('round-trips an edit no-op', function () {
const editFileOperation = Operation.editFile(
'foo.tex',
EditOperationBuilder.fromJSON({ noOp: true })
)
const raw = editFileOperation.toRaw()
expect(raw).to.eql({ pathname: 'foo.tex', noOp: true })
const roundTripped = Operation.fromRaw(raw)
expect(roundTripped).to.deep.equal(editFileOperation)
})
})
})
+1
View File
@@ -24,6 +24,7 @@
"zod-validation-error": "^4.0.1"
},
"devDependencies": {
"@overleaf/logger": "workspace:*",
"@types/express": "4.17.23",
"@types/node": "^24.5.2",
"eslint": "^10.4.0",
+7 -2
View File
@@ -179,7 +179,12 @@ function sanitizeIssues(issues, depth = 0) {
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) {
// Bounded by schema shape, not user input: each level corresponds to one
// more union nested inside a union member (e.g. rawOperation -> rawFile
// -> rawFileMetadata -> the linked-file provider discriminatedUnion), so
// raising this cannot be exploited to grow log volume from adversarial
// input the way an unbounded path/array traversal could.
if (issue.code === 'invalid_union' && depth < 5) {
sanitized.errors = issue.errors.map(nested =>
sanitizeIssues(nested, depth + 1)
)
@@ -222,7 +227,7 @@ function logSchemaFailure(req, schema, kind, issues) {
.join('\n')
logger.warn(
{ location, kind, issues: sanitizedIssues, caller },
{ location, kind, issues: sanitizedIssues, caller, req },
'req-validation: request failed schema in log-only rollout'
)
}
@@ -8,6 +8,7 @@ import {
resetReqValidationLoggingForTests,
} from '../../../parseReq'
import { zz } from '../../../zodHelpers'
import serializers from '@overleaf/logger/serializers'
const RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
const RAW_QUERY = Symbol.for('overleaf.lockdown.rawQuery')
@@ -56,6 +57,28 @@ function lockedRequest({
type WarnCall = [Record<string, any>, string]
// Mirrors what a real bunyan logger does to each field before writing a log
// line -- warnMock only records the raw ctx, so assertions on the logged
// output must run it through the same serializers to see what would actually
// reach the log (e.g. the req serializer drops req.body).
function serializedLogOutput(calls: WarnCall[]) {
return JSON.stringify(
calls.map(([ctx, msg]) => [
Object.fromEntries(
Object.entries(ctx).map(([key, value]) => [
key,
key in serializers
? (serializers as Record<string, (v: unknown) => unknown>)[key](
value
)
: value,
])
),
msg,
])
)
}
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.
@@ -296,7 +319,7 @@ describe('parseReq log-only rollout', () => {
parseReq(req, schema, { logOnly: true })
expect(JSON.stringify(warnMock.mock.calls)).not.toContain(
expect(serializedLogOutput(warnMock.mock.calls)).not.toContain(
'SECRET_VALUE_123'
)
})
@@ -311,7 +334,7 @@ describe('parseReq log-only rollout', () => {
parseReq(req, schema, { logOnly: true })
const logged = JSON.stringify(warnMock.mock.calls)
const logged = serializedLogOutput(warnMock.mock.calls)
expect(logged).toContain('extraSecretField')
expect(logged).not.toContain('TOP_SECRET_VALUE')
})
@@ -325,7 +348,7 @@ describe('parseReq log-only rollout', () => {
parseReq(req, schema, { logOnly: true })
const logged = JSON.stringify(warnMock.mock.calls)
const logged = serializedLogOutput(warnMock.mock.calls)
expect(logged).toContain(longKey.slice(0, 64))
expect(logged).not.toContain(longKey)
})
@@ -367,10 +390,50 @@ describe('parseReq log-only rollout', () => {
expect(Array.isArray(unionIssue.errors)).toBe(true)
expect(unionIssue.errors.length).toBeGreaterThan(0)
expect(JSON.stringify(warnMock.mock.calls)).not.toContain(
expect(serializedLogOutput(warnMock.mock.calls)).not.toContain(
'UNION_SECRET_VALUE'
)
})
it('expands a union nested 3 levels deep inside other unions', () => {
// Mirrors overleaf-editor-core's rawOperation -> rawFile ->
// rawFileMetadata shape: a union of objects, one of whose fields is
// itself a union of objects, one of whose fields is itself a union.
const level3 = z.union([
z.strictObject({ a: z.literal('a') }),
z.strictObject({ b: z.literal('b') }),
])
const level2 = z.union([
z.strictObject({ inner: level3 }),
z.strictObject({ other: z.string() }),
])
const level1 = z.union([
z.strictObject({ mid: level2 }),
z.strictObject({ different: z.string() }),
])
const req = {
body: { value: { mid: { inner: { c: 'nope' } } } },
} as Request
const schema = z.object({ body: z.object({ value: level1 }) })
parseReq(req, schema, { logOnly: true })
const [ctx] = warnMock.mock.calls[0] as WarnCall
// Walk down: top union (value) -> level1 branch (mid) -> level2
// union (inner) -> level3 union, whose own branch errors (unknown
// keys "a"/"b" not satisfied by "c") must have survived.
const top = ctx.issues.find((i: any) => i.code === 'invalid_union')
const level1Errors = top.errors[0] // the "mid" branch
const level2Issue = level1Errors.find(
(i: any) => i.code === 'invalid_union'
)
const level2Errors = level2Issue.errors[0] // the "inner" branch
const level3Issue = level2Errors.find(
(i: any) => i.code === 'invalid_union'
)
expect(level3Issue.errors).toBeDefined()
expect(level3Issue.errors.length).toBeGreaterThan(0)
})
})
})