diff --git a/.changeset/bright-students-taste.md b/.changeset/bright-students-taste.md new file mode 100644 index 00000000..1ff46dad --- /dev/null +++ b/.changeset/bright-students-taste.md @@ -0,0 +1,5 @@ +--- +'@status-im/js': patch +--- + +validate encoded data diff --git a/packages/status-js/src/utils/encode-url-data.test.ts b/packages/status-js/src/utils/encode-url-data.test.ts index ba4a460d..f7cdf6fe 100644 --- a/packages/status-js/src/utils/encode-url-data.test.ts +++ b/packages/status-js/src/utils/encode-url-data.test.ts @@ -32,7 +32,34 @@ describe('Encode URL data', () => { expect(decodedData).toEqual(data) }) - test('should encode and decode channel', () => { + test('should throw for invalid community data', () => { + expect(() => { + const encodedData = 'Ow==' + decodeCommunityURLData(encodedData) + }).toThrowError() + }) + + test('should throw for unsupported data length', () => { + expect(() => { + const encodedData = + 'G2QBQJwFdqwxrBnNb57kP0irrJpuouIjS1WZqHS6A2txojsUHidyu3evaAO3GQQku5NCQXiwAYchBIMNyptts=MD9bZAwoTasraIMkjbS1uAD7oxsAQ53OAmQWCefyBuuXlAu6J7eKQRQhgg5tan75fFp9jwGIjBLbGhnyUht2qj5GWlSBp7_OXsHxgnr21xA2HgR9VGYYikQJA4tcQHDrQzg_ARC9KiOVDD6vgTCM9_CN0HJ1zxwP3w6nzgkDTNuvDCFD3Clqo6Cf_UNY2cNRlKTqj86G4gC2dUNSApwiq72BdGTtrleiRFPUhCbTRbmEG4YwFOs4EjBdJHHRiqjS5GYGc1dAdgcGr2BQ===============================================================================================================================================' + decodeCommunityURLData(encodedData) + }).toThrowErrorMatchingInlineSnapshot(` + "[ + { + \\"code\\": \\"too_big\\", + \\"maximum\\": 500, + \\"type\\": \\"string\\", + \\"inclusive\\": true, + \\"exact\\": false, + \\"message\\": \\"String must contain at most 500 character(s)\\", + \\"path\\": [] + } + ]" + `) + }) + + test('should encode and decode channel', () => { const data = { emoji: '🏴󠁧󠁢󠁥󠁮󠁧󠁿', displayName: 'lorem-ipsum-dolore-nulla', @@ -56,6 +83,70 @@ describe('Encode URL data', () => { expect(decodedData).toEqual(data) }) + test('should throw for invalid channel data', () => { + expect(() => { + const encodedData = 'Ow==' + decodeChannelURLData(encodedData) + }).toThrowErrorMatchingInlineSnapshot(` + "[ + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"string\\", + \\"received\\": \\"undefined\\", + \\"path\\": [ + \\"displayName\\" + ], + \\"message\\": \\"Required\\" + }, + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"string\\", + \\"received\\": \\"undefined\\", + \\"path\\": [ + \\"description\\" + ], + \\"message\\": \\"Required\\" + }, + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"string\\", + \\"received\\": \\"undefined\\", + \\"path\\": [ + \\"emoji\\" + ], + \\"message\\": \\"Required\\" + }, + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"string\\", + \\"received\\": \\"undefined\\", + \\"path\\": [ + \\"color\\" + ], + \\"message\\": \\"Required\\" + }, + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"object\\", + \\"received\\": \\"undefined\\", + \\"path\\": [ + \\"community\\" + ], + \\"message\\": \\"Required\\" + }, + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"string\\", + \\"received\\": \\"undefined\\", + \\"path\\": [ + \\"uuid\\" + ], + \\"message\\": \\"Required\\" + } + ]" + `) + }) + test('should encode and decode user', () => { const data = { displayName: 'Lorem ipsum dolore nulla', @@ -72,4 +163,68 @@ describe('Encode URL data', () => { ) expect(decodedData).toEqual(data) }) + + test('should throw for invalid user data', () => { + expect(() => { + const encodedData = 'Ow==' + decodeChannelURLData(encodedData) + }).toThrowErrorMatchingInlineSnapshot(` + "[ + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"string\\", + \\"received\\": \\"undefined\\", + \\"path\\": [ + \\"displayName\\" + ], + \\"message\\": \\"Required\\" + }, + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"string\\", + \\"received\\": \\"undefined\\", + \\"path\\": [ + \\"description\\" + ], + \\"message\\": \\"Required\\" + }, + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"string\\", + \\"received\\": \\"undefined\\", + \\"path\\": [ + \\"emoji\\" + ], + \\"message\\": \\"Required\\" + }, + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"string\\", + \\"received\\": \\"undefined\\", + \\"path\\": [ + \\"color\\" + ], + \\"message\\": \\"Required\\" + }, + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"object\\", + \\"received\\": \\"undefined\\", + \\"path\\": [ + \\"community\\" + ], + \\"message\\": \\"Required\\" + }, + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"string\\", + \\"received\\": \\"undefined\\", + \\"path\\": [ + \\"uuid\\" + ], + \\"message\\": \\"Required\\" + } + ]" + `) + }) }) diff --git a/packages/status-js/src/utils/encode-url-data.ts b/packages/status-js/src/utils/encode-url-data.ts index 556fde2d..5505d54f 100644 --- a/packages/status-js/src/utils/encode-url-data.ts +++ b/packages/status-js/src/utils/encode-url-data.ts @@ -1,5 +1,6 @@ import { base64url } from '@scure/base' import { brotliCompressSync, brotliDecompressSync } from 'zlib' +import { z } from 'zod' import { Channel, Community, URLData, User } from '../protos/url_pb' @@ -7,6 +8,17 @@ import type { PlainMessage } from '@bufbuild/protobuf' export type EncodedURLData = string & { _: 'EncodedURLData' } +const colorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/) +const communityDisplayName = z.string().max(30).nonempty() + +const communitySchema = z.object({ + displayName: communityDisplayName, + description: z.string().max(140).nonempty(), + membersCount: z.number().nonnegative(), + color: colorSchema, + tagIndices: z.number().nonnegative().array(), +}) + export function encodeCommunityURLData( data: PlainMessage ): EncodedURLData { @@ -17,25 +29,48 @@ export function encodeCommunityURLData( export function decodeCommunityURLData(data: string): PlainMessage { const deserialized = decodeURLData(data) - return Community.fromBinary( - deserialized.content - ).toJson() as PlainMessage + const community = Community.fromBinary(deserialized.content).toJson() + + return communitySchema.parse(community) } +const channelSchema = z.object({ + displayName: z.string().max(24).nonempty(), + description: z.string().max(140).nonempty(), + emoji: z.string().emoji(), + color: colorSchema, + community: z.object({ + displayName: communityDisplayName, + }), + uuid: z.string().uuid(), +}) + export function encodeChannelURLData( data: PlainMessage ): EncodedURLData { return encodeURLData(new Channel(data).toBinary()) as EncodedURLData } -export function decodeChannelURLData(data: string): PlainMessage { +export function decodeChannelURLData(data: string): Omit< + PlainMessage, + 'community' +> & { + community: Pick, 'displayName'> +} { const deserialized = decodeURLData(data) - return Channel.fromBinary( - deserialized.content - ).toJson() as PlainMessage + const channel = Channel.fromBinary(deserialized.content).toJson() + + return channelSchema.parse(channel) } +const userSchema = z.object({ + displayName: z.string().max(24).nonempty(), + description: z.string().max(240).nonempty(), + // fixme: await integration in native platforms + color: colorSchema.optional().default('#ffffff'), +}) + export function encodeUserURLData(data: PlainMessage): EncodedURLData { return encodeURLData(new User(data).toBinary()) as EncodedURLData } @@ -43,7 +78,9 @@ export function encodeUserURLData(data: PlainMessage): EncodedURLData { export function decodeUserURLData(data: string): PlainMessage { const deserialized = decodeURLData(data) - return User.fromBinary(deserialized.content).toJson() as PlainMessage + const user = User.fromBinary(deserialized.content).toJson() + + return userSchema.parse(user) } function encodeURLData(data: Uint8Array): string { @@ -57,6 +94,11 @@ function encodeURLData(data: Uint8Array): string { } function decodeURLData(data: string): URLData { + // note: https://github.com/status-im/status-web/pull/345#discussion_r1113129396 observed lengths + // note?: https://docs.google.com/spreadsheets/d/1JD4kp0aUm90piUZ7FgM_c2NGe2PdN8BFB11wmt5UZIY/view#gid=1260088614 limit for url path segmets not split by ";" or "_" + // fixme: set to 301 per url path segment when the above mentioned splitting is implemented + z.string().max(500).parse(data) // default max in order not to compute arbitrary values + const decoded = base64url.decode(data) const decompressed = brotliDecompressSync(decoded) const deserialized = URLData.fromBinary(decompressed)