diff --git a/ts/services/backups/export.preload.ts b/ts/services/backups/export.preload.ts index b9fffb8da8..35cd081db6 100644 --- a/ts/services/backups/export.preload.ts +++ b/ts/services/backups/export.preload.ts @@ -32,7 +32,10 @@ import { type AciString, type ServiceIdString, } from '../../types/ServiceId.std.js'; -import type { RawBodyRange } from '../../types/BodyRange.std.js'; +import { + bodyRangeSchema, + type RawBodyRange, +} from '../../types/BodyRange.std.js'; import { PaymentEventKind } from '../../types/Payment.std.js'; import { MessageRequestResponseEvent } from '../../types/MessageRequestResponseEvent.std.js'; import type { @@ -183,6 +186,7 @@ import { ChatFolderType } from '../../types/ChatFolder.std.js'; import { expiresTooSoonForBackup } from './util/expiration.std.js'; import type { PinnedMessage } from '../../types/PinnedMessage.std.js'; import type { ThemeType } from '../../util/preload.preload.js'; +import { safeParseStrict } from '../../util/schemas.std.js'; const { isNumber } = lodash; @@ -1675,7 +1679,7 @@ export class BackupExportStream extends Readable { state, }; } - } else if (message.storyReplyContext) { + } else if (message.storyReplyContext || message.storyReaction) { result.directStoryReplyMessage = await this.#toDirectStoryReplyMessage({ message, }); @@ -2787,9 +2791,7 @@ export class BackupExportStream extends Readable { quote.text != null ? { body: trimBody(quote.text, BACKUP_QUOTE_BODY_LIMIT), - bodyRanges: quote.bodyRanges?.map(range => - this.#toBodyRange(range) - ), + bodyRanges: this.#toBodyRanges(quote.bodyRanges), } : null, attachments: await Promise.all( @@ -2814,22 +2816,45 @@ export class BackupExportStream extends Readable { }; } - #toBodyRange(range: RawBodyRange): Backups.IBodyRange { - return { - start: range.start, - length: range.length, + #toBodyRange(range: RawBodyRange): Backups.IBodyRange | null { + const { data: parsedRange, error } = safeParseStrict( + bodyRangeSchema, + range + ); - ...('mentionAci' in range + if (error) { + log.warn('toBodyRange: Dropping invalid body range', toLogFormat(error)); + return null; + } + + return { + start: parsedRange.start, + length: parsedRange.length, + + ...('mentionAci' in parsedRange ? { - mentionAci: this.#aciToBytes(range.mentionAci), + mentionAci: this.#aciToBytes(parsedRange.mentionAci), } : { // Numeric values are compatible between backup and message protos - style: range.style, + style: parsedRange.style, }), }; } + #toBodyRanges( + ranges: ReadonlyArray | undefined + ): Array | undefined { + if (!ranges?.length) { + return undefined; + } + + const result = ranges + .map(range => this.#toBodyRange(range)) + .filter(isNotNil); + return result.length > 0 ? result : undefined; + } + #getMessageAttachmentFlag( message: Pick, attachment: AttachmentType @@ -3194,8 +3219,8 @@ export class BackupExportStream extends Readable { }> { const includeLongTextAttachment = message.bodyAttachment && !isDownloaded(message.bodyAttachment); - const includeText = - Boolean(message.body) || Boolean(message.bodyRanges?.length); + const bodyRanges = this.#toBodyRanges(message.bodyRanges); + const includeText = Boolean(message.body) || Boolean(bodyRanges?.length); return { longText: @@ -3217,9 +3242,7 @@ export class BackupExportStream extends Readable { : MAX_BACKUP_MESSAGE_BODY_BYTE_LENGTH ) : undefined, - bodyRanges: message.bodyRanges?.map(range => - this.#toBodyRange(range) - ), + bodyRanges, } : undefined, }; diff --git a/ts/test-electron/backup/bubble_test.preload.ts b/ts/test-electron/backup/bubble_test.preload.ts index 37ce2e05ad..507e83b139 100644 --- a/ts/test-electron/backup/bubble_test.preload.ts +++ b/ts/test-electron/backup/bubble_test.preload.ts @@ -28,6 +28,7 @@ import { IMAGE_PNG, TEXT_ATTACHMENT } from '../../types/MIME.std.js'; import { MY_STORY_ID } from '../../types/Stories.std.js'; import { generateAttachmentKeys } from '../../AttachmentCrypto.node.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; +import { BodyRange } from '../../types/BodyRange.std.js'; const CONTACT_A = generateAci(); const CONTACT_B = generateAci(); @@ -607,7 +608,7 @@ describe('backup/bubble messages', () => { await asymmetricRoundtripHarness( [ { - conversationId: gv1.id, + conversationId: contactA.id, id: generateGuid(), type: 'incoming', received_at: 3, @@ -625,6 +626,62 @@ describe('backup/bubble messages', () => { [] ); }); + it('drops invalid body ranges', async () => { + await asymmetricRoundtripHarness( + [ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 3, + received_at_ms: 3, + sent_at: 3, + timestamp: 3, + sourceServiceId: CONTACT_A, + body: 'd', + bodyRanges: [ + { + start: 0, + length: 1, + // @ts-expect-error invalid data + style: undefined, + }, + { + start: 1, + length: 0, + style: BodyRange.Style.BOLD, + }, + ], + readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, + }, + ], + [ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 3, + received_at_ms: 3, + sent_at: 3, + timestamp: 3, + sourceServiceId: CONTACT_A, + body: 'd', + bodyRanges: [ + { + start: 1, + length: 0, + style: BodyRange.Style.BOLD, + }, + ], + readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, + }, + ] + ); + }); it('drops messages that expire soon', async () => { await asymmetricRoundtripHarness( diff --git a/ts/types/BodyRange.std.ts b/ts/types/BodyRange.std.ts index eac51ac1b7..6db4b95725 100644 --- a/ts/types/BodyRange.std.ts +++ b/ts/types/BodyRange.std.ts @@ -4,6 +4,7 @@ /* eslint-disable @typescript-eslint/no-namespace */ import lodash from 'lodash'; +import * as z from 'zod'; import { SignalService as Proto } from '../protobuf/index.std.js'; import { createLogger } from '../logging/log.std.js'; @@ -15,7 +16,8 @@ import { SNIPPET_TRUNCATION_PLACEHOLDER, } from '../util/search.std.js'; import { assertDev } from '../util/assert.std.js'; -import type { AciString } from './ServiceId.std.js'; +import { aciSchema, type AciString } from './ServiceId.std.js'; +import { signalservice } from '../protobuf/compiled.std.js'; const { isEqual, isNumber, omit, orderBy, partition } = lodash; @@ -869,3 +871,24 @@ export function areBodyRangesEqual( return isEqual(normalizedLeft, sortedRight); } + +const bodyRangeOffsetSchema = z.number().int().min(0); +const bodyRangeStyleSchema = z.nativeEnum(signalservice.BodyRange.Style); + +export const bodyRangeSchema = z.union([ + z + .object({ + start: bodyRangeOffsetSchema, + length: bodyRangeOffsetSchema, + mentionAci: aciSchema, + }) + .strict(), + z + .object({ + start: bodyRangeOffsetSchema, + length: bodyRangeOffsetSchema, + style: bodyRangeStyleSchema, + spoilerId: z.number().optional(), + }) + .strict(), +]);