From e37b10e870d4831e984d31ab655ad1028b355b97 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:28:24 +0900 Subject: [PATCH 1/2] test(collaboration): cover local user runtime boundary Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) --- src/collaboration/awareness.test.ts | 91 +++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/src/collaboration/awareness.test.ts b/src/collaboration/awareness.test.ts index bbaa6e98..cad54296 100644 --- a/src/collaboration/awareness.test.ts +++ b/src/collaboration/awareness.test.ts @@ -61,6 +61,97 @@ describe('collaboration awareness validation', () => { expect(() => serializeCollaborationUser(user)).toThrow(expected); }); + it.each([ + ['userId', 42, 'collaboration userId must be a string'], + ['displayName', {}, 'collaboration displayName must be a string'], + ['cursorColor', null, 'collaboration cursorColor must be a string'], + ] as const)('rejects malformed %s before normalization', (field, value, message) => { + expect(() => + serializeCollaborationUser({ + userId: 'editor-alice', + displayName: 'Alice', + cursorColor: '#123456', + [field]: value, + } as never), + ).toThrowError(new Error(message)); + }); + + it('bounds normalized public identity without splitting Unicode code points', () => { + const boundedName = `${'A'.repeat(79)}😀`; + const boundedId = `${'a'.repeat(79)}😀`; + + expect( + serializeCollaborationUser({ + userId: boundedId, + displayName: `${boundedName}tail`, + cursorColor: '#123456', + }), + ).toEqual({ id: boundedId, name: boundedName, color: '#123456' }); + + const arrayFrom = vi.spyOn(Array, 'from'); + expect(() => + serializeCollaborationUser({ + userId: `editor-${'a'.repeat(74)}`, + displayName: 'Alice', + cursorColor: '#123456', + }), + ).toThrow(/userId.*80/); + expect(arrayFrom).not.toHaveBeenCalled(); + arrayFrom.mockRestore(); + }); + + it.each(['userId', 'displayName', 'cursorColor'] as const)( + 'rejects oversized %s before normalization', + (field) => { + const originalTrim = String.prototype.trim; + let oversizedTrimObserved = false; + const trimSpy = vi + .spyOn(String.prototype, 'trim') + .mockImplementation(function (this: string) { + if (this.length > 1_024) oversizedTrimObserved = true; + return originalTrim.call(this); + }); + + try { + expect(() => + serializeCollaborationUser({ + userId: 'editor-alice', + displayName: 'Alice', + cursorColor: '#123456', + [field]: ' '.repeat(1_025), + }), + ).toThrow(); + expect(oversizedTrimObserved).toBe(false); + } finally { + trimSpy.mockRestore(); + } + }, + ); + + it.each(['userId', 'displayName', 'cursorColor'] as const)( + 'redacts hostile %s property failures', + (field) => { + const privateFailure = { marker: 'private-local-user-sentinel' }; + const user = new Proxy( + { + userId: 'editor-alice', + displayName: 'Alice', + cursorColor: '#123456', + }, + { + get(target, property, receiver) { + if (property === field) throw privateFailure; + return Reflect.get(target, property, receiver); + }, + }, + ); + + expect(() => serializeCollaborationUser(user)).toThrowError( + new Error(`collaboration ${field} must be a string`), + ); + }, + ); + it('allows collaboration without an awareness provider', () => { expect(() => assertCollaborationConfiguration(undefined, undefined), From 7a7093b92616296be6b4c3c7ce990880d12f8271 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:28:57 +0900 Subject: [PATCH 2/2] fix(collaboration): validate local user fields Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) --- src/collaboration/awareness.ts | 70 ++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/src/collaboration/awareness.ts b/src/collaboration/awareness.ts index 149b8028..df3a414a 100644 --- a/src/collaboration/awareness.ts +++ b/src/collaboration/awareness.ts @@ -28,6 +28,62 @@ const CURSOR_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; const NUMERIC_IDENTIFIER_PATTERN = /^\d+$/; const FALLBACK_CURSOR_COLOR = '#475569'; const MAX_CURSOR_LABEL_LENGTH = 80; +const MAX_PUBLIC_IDENTIFIER_LENGTH = 80; +const MAX_LOCAL_FIELD_SOURCE_LENGTH = 1_024; +type CollaborationUserField = 'userId' | 'displayName' | 'cursorColor'; + +/** Reject malformed or oversized local identity fields before normalization. */ +function assertCollaborationUserStringField( + field: CollaborationUserField, + value: unknown, +): asserts value is string { + if (typeof value !== 'string') { + throw new Error(`collaboration ${field} must be a string`); + } + if (value.length > MAX_LOCAL_FIELD_SOURCE_LENGTH) { + throw new Error( + `collaboration ${field} must be at most ${MAX_LOCAL_FIELD_SOURCE_LENGTH} UTF-16 code units before normalization`, + ); + } +} + +/** Read one host-owned local identity field without leaking getter failures. */ +function readCollaborationUserStringField( + user: CollaborationUser, + field: CollaborationUserField, +): string { + let value: unknown; + try { + value = user[field]; + } catch { + throw new Error(`collaboration ${field} must be a string`); + } + assertCollaborationUserStringField(field, value); + return value; +} + +/** Trim and bound a public cursor label without splitting Unicode code points. */ +function truncateCursorLabel(value: string): string { + const trimmed = value.trim(); + let bounded = ''; + let count = 0; + for (const codePoint of trimmed) { + if (count >= MAX_CURSOR_LABEL_LENGTH) break; + bounded += codePoint; + count += 1; + } + return bounded; +} + +/** Return whether public awareness metadata exceeds its Unicode code-point bound. */ +function exceedsPublicIdentifierLength(value: string): boolean { + let count = 0; + for (const _codePoint of value) { + count += 1; + if (count > MAX_PUBLIC_IDENTIFIER_LENGTH) return true; + } + return false; +} /** Read and validate the host-owned awareness capability without leaking failures. */ function readCompatibleCollaborationAwareness( @@ -62,9 +118,12 @@ function readCompatibleCollaborationAwareness( export function serializeCollaborationUser( user: CollaborationUser, ): CollaborationCursorUser { - const id = user.userId.trim(); - const name = user.displayName.trim(); - const color = user.cursorColor.trim(); + const sourceId = readCollaborationUserStringField(user, 'userId'); + const sourceName = readCollaborationUserStringField(user, 'displayName'); + const sourceColor = readCollaborationUserStringField(user, 'cursorColor'); + const id = sourceId.trim(); + const name = truncateCursorLabel(sourceName); + const color = sourceColor.trim(); if (id === '') { throw new Error('collaboration userId must not be empty'); @@ -72,6 +131,11 @@ export function serializeCollaborationUser( if (NUMERIC_IDENTIFIER_PATTERN.test(id)) { throw new Error('collaboration userId must be descriptive and nonnumeric'); } + if (exceedsPublicIdentifierLength(id)) { + throw new Error( + 'collaboration userId must be at most 80 Unicode code points', + ); + } if (name === '') { throw new Error('collaboration displayName must not be empty'); }