[real-time] add strict validation for incoming applyOtUpdate messages (#35045)

GitOrigin-RevId: 13156762e320db9c177c3cf82195baff42f68141
This commit is contained in:
Jakob Ackermann
2026-09-17 11:14:16 +00:00
committed by Copybot
parent 7932c99cfc
commit b51275ca2d
2 changed files with 284 additions and 2 deletions
+37 -1
View File
@@ -26,7 +26,43 @@ const joinDocSchema = z.object({
const applyOtUpdateSchema = z.object({
doc_id: zz.objectId(),
update: z.object(),
update: z.strictObject({
doc: zz.objectId().optional(),
dupIfSource: z.array(z.string()).optional(),
hash: z.string().optional(),
lastV: z.number().optional(),
meta: z
.strictObject({
tc: z.string(),
})
.optional(),
op: z
.array(
z
.strictObject({
i: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
})
.or(
z.strictObject({
d: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
})
)
.or(
z.strictObject({
c: z.string(),
p: z.number().int().min(0),
t: z.string(),
u: z.boolean().optional(),
})
)
)
.min(1),
v: z.number().int().min(0),
}),
})
let Router
@@ -340,7 +340,7 @@ describe('applyOtUpdate', function () {
describe('when authorized to read-only with a comment update', function () {
before(function (done) {
this.comment_update = {
op: [{ c: 'foo', p: 42 }],
op: [{ c: 'foo', p: 42, t: '1234' }],
v: 42,
}
return async.series(
@@ -765,4 +765,250 @@ describe('applyOtUpdate', function () {
return null
})
})
describe('when authorized with an empty update', function () {
before(function (done) {
this.update = {
op: [],
v: 42,
}
return async.series(
[
cb => {
return FixturesManager.setUpProject(
{
privilegeLevel: 'readOnly',
},
(e, { project_id: projectId, user_id: userId }) => {
this.project_id = projectId
this.user_id = userId
return cb(e)
}
)
},
cb => {
return FixturesManager.setUpDoc(
this.project_id,
{ lines: this.lines, version: this.version, ops: this.ops },
(e, { doc_id: docId }) => {
this.doc_id = docId
return cb(e)
}
)
},
cb => {
this.client = RealTimeClient.connect(this.project_id, cb)
},
cb => {
return this.client.emit('joinDoc', this.doc_id, cb)
},
cb => {
return this.client.emit(
'applyOtUpdate',
this.doc_id,
this.update,
error => {
this.error = error
return cb()
}
)
},
],
done
)
})
it('should return an error', function () {
return expect(this.error).to.exist
})
it('should disconnect the client', function (done) {
return setTimeout(() => {
this.client.socket.connected.should.equal(false)
return done()
}, 300)
})
return it('should not put the update in redis', function (done) {
rclient.llen(
redisSettings.documentupdater.key_schema.pendingUpdates({
doc_id: this.doc_id,
}),
(error, len) => {
if (error) return done(error)
len.should.equal(0)
return done()
}
)
return null
})
})
describe('when authorized with a mixed update', function () {
before(function (done) {
this.update = {
op: [{ c: 'foo', d: 'bar', i: 'foo', p: 42 }],
v: 42,
}
return async.series(
[
cb => {
return FixturesManager.setUpProject(
{
privilegeLevel: 'readOnly',
},
(e, { project_id: projectId, user_id: userId }) => {
this.project_id = projectId
this.user_id = userId
return cb(e)
}
)
},
cb => {
return FixturesManager.setUpDoc(
this.project_id,
{ lines: this.lines, version: this.version, ops: this.ops },
(e, { doc_id: docId }) => {
this.doc_id = docId
return cb(e)
}
)
},
cb => {
this.client = RealTimeClient.connect(this.project_id, cb)
},
cb => {
return this.client.emit('joinDoc', this.doc_id, cb)
},
cb => {
return this.client.emit(
'applyOtUpdate',
this.doc_id,
this.update,
error => {
this.error = error
return cb()
}
)
},
],
done
)
})
it('should return an error', function () {
return expect(this.error).to.exist
})
it('should disconnect the client', function (done) {
return setTimeout(() => {
this.client.socket.connected.should.equal(false)
return done()
}, 300)
})
return it('should not put the update in redis', function (done) {
rclient.llen(
redisSettings.documentupdater.key_schema.pendingUpdates({
doc_id: this.doc_id,
}),
(error, len) => {
if (error) return done(error)
len.should.equal(0)
return done()
}
)
return null
})
})
describe('when authorized with unknown fields', function () {
before(function (done) {
this.update = {
...this.update,
foo: 1,
}
return async.series(
[
cb => {
return FixturesManager.setUpProject(
{
privilegeLevel: 'readOnly',
},
(e, { project_id: projectId, user_id: userId }) => {
this.project_id = projectId
this.user_id = userId
return cb(e)
}
)
},
cb => {
return FixturesManager.setUpDoc(
this.project_id,
{ lines: this.lines, version: this.version, ops: this.ops },
(e, { doc_id: docId }) => {
this.doc_id = docId
return cb(e)
}
)
},
cb => {
this.client = RealTimeClient.connect(this.project_id, cb)
},
cb => {
return this.client.emit('joinDoc', this.doc_id, cb)
},
cb => {
return this.client.emit(
'applyOtUpdate',
this.doc_id,
this.update,
error => {
this.error = error
return cb()
}
)
},
],
done
)
})
it('should return an error', function () {
return expect(this.error).to.exist
})
it('should disconnect the client', function (done) {
return setTimeout(() => {
this.client.socket.connected.should.equal(false)
return done()
}, 300)
})
return it('should not put the update in redis', function (done) {
rclient.llen(
redisSettings.documentupdater.key_schema.pendingUpdates({
doc_id: this.doc_id,
}),
(error, len) => {
if (error) return done(error)
len.should.equal(0)
return done()
}
)
return null
})
})
})