From 38d181a0ee9c62c625016811c2ffd3259d820a07 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Sat, 21 Sep 2024 12:31:27 -0500 Subject: [PATCH] Alert internal users if first message on websocket is repeated Co-authored-by: Scott Nonnenberg --- _locales/en/messages.json | 4 ++ ts/background.ts | 4 ++ ts/components/ToastManager.stories.tsx | 2 + ts/components/ToastManager.tsx | 14 ++++ ts/textsecure/SocketManager.ts | 18 +++++ ts/textsecure/WebAPI.ts | 4 ++ ts/types/Toast.tsx | 2 + ts/util/checkFirstEnvelope.ts | 96 ++++++++++++++++++++++++++ 8 files changed, 144 insertions(+) create mode 100644 ts/util/checkFirstEnvelope.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a8572d9524..7f0e4fe818 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2155,6 +2155,10 @@ "messageformat": "Yesterday {time}", "description": "Timestamp format string for displaying \"Yesterday\" and the time" }, + "icu:messageLoop": { + "messageformat": "Signal may be failing to process an incoming message.", + "description": "Shown to non-production users if the same envelope timestamp is the first three times within an hour." + }, "icu:messageBodyTooLong": { "messageformat": "Message body is too long.", "description": "Shown if the user tries to send more than 64kb of text" diff --git a/ts/background.ts b/ts/background.ts index 782754ad59..ab7ecbfe98 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -195,6 +195,7 @@ import { encryptConversationAttachments } from './util/encryptConversationAttach import { DataReader, DataWriter } from './sql/Client'; import { restoreRemoteConfigFromStorage } from './RemoteConfig'; import { getParametersForRedux, loadAll } from './services/allLoaders'; +import { checkFirstEnvelope } from './util/checkFirstEnvelope'; export function isOverHourIntoPast(timestamp: number): boolean { return isNumber(timestamp) && isOlderThan(timestamp, HOUR); @@ -481,10 +482,13 @@ export async function startApp(): Promise { first = false; restoreRemoteConfigFromStorage(); + + window.Whisper.events.on('firstEnvelope', checkFirstEnvelope); server = window.WebAPI.connect({ ...window.textsecure.storage.user.getWebAPICredentials(), hasStoriesDisabled: window.storage.get('hasStoriesDisabled', false), }); + window.textsecure.server = server; window.textsecure.messaging = new window.textsecure.MessageSender(server); diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index bb97e3c555..29abc2de99 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -122,6 +122,8 @@ function getToast(toastType: ToastType): AnyToast { return { toastType: ToastType.MaxAttachments }; case ToastType.MessageBodyTooLong: return { toastType: ToastType.MessageBodyTooLong }; + case ToastType.MessageLoop: + return { toastType: ToastType.MessageLoop }; case ToastType.OriginalMessageNotFound: return { toastType: ToastType.OriginalMessageNotFound }; case ToastType.PinnedConversationsFull: diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index eff0adb80e..b2b7c3b274 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -385,6 +385,20 @@ export function renderToast({ return {i18n('icu:messageBodyTooLong')}; } + if (toastType === ToastType.MessageLoop) { + return ( + + {i18n('icu:messageLoop')} + + ); + } + if (toastType === ToastType.OriginalMessageNotFound) { return ( {i18n('icu:originalMessageNotFound')} diff --git a/ts/textsecure/SocketManager.ts b/ts/textsecure/SocketManager.ts index afef31fda8..a9e05e492d 100644 --- a/ts/textsecure/SocketManager.ts +++ b/ts/textsecure/SocketManager.ts @@ -34,6 +34,7 @@ import type { import WebSocketResource, { connectAuthenticatedLibsignal, connectUnauthenticatedLibsignal, + ServerRequestType, TransportOption, WebSocketResourceWithShadowing, } from './WebsocketResources'; @@ -106,6 +107,8 @@ export class SocketManager extends EventListener { private reconnectController: AbortController | undefined; + private envelopeCount = 0; + constructor( private readonly libsignalNet: Net.Net, private readonly options: SocketManagerOptions @@ -298,6 +301,7 @@ export class SocketManager extends EventListener { ); window.logAuthenticatedConnect?.(); + this.envelopeCount = 0; this.backOff.reset(); authenticated.addEventListener('close', ({ code, reason }): void => { @@ -860,6 +864,12 @@ export class SocketManager extends EventListener { } private queueOrHandleRequest(req: IncomingWebSocketRequest): void { + if (req.requestType === ServerRequestType.ApiMessage) { + this.envelopeCount += 1; + if (this.envelopeCount === 1) { + this.emit('firstEnvelope', req); + } + } if (this.requestHandlers.size === 0) { this.incomingRequestQueue.push(req); log.info( @@ -924,6 +934,10 @@ export class SocketManager extends EventListener { public override on(type: 'statusChange', callback: () => void): this; public override on(type: 'online', callback: () => void): this; public override on(type: 'offline', callback: () => void): this; + public override on( + type: 'firstEnvelope', + callback: (incoming: IncomingWebSocketRequest) => void + ): this; public override on( type: string | symbol, @@ -937,6 +951,10 @@ export class SocketManager extends EventListener { public override emit(type: 'statusChange'): boolean; public override emit(type: 'online'): boolean; public override emit(type: 'offline'): boolean; + public override emit( + type: 'firstEnvelope', + incoming: IncomingWebSocketRequest + ): boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any public override emit(type: string | symbol, ...args: Array): boolean { diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 4c0fe9142a..ca3a1643fa 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -1668,6 +1668,10 @@ export function initialize({ window.Whisper.events.trigger('unlinkAndDisconnect'); }); + socketManager.on('firstEnvelope', incoming => { + window.Whisper.events.trigger('firstEnvelope', incoming); + }); + if (useWebSocket) { void socketManager.authenticate({ username, password }); } diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index 2387ea07c5..980eeae795 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -43,6 +43,7 @@ export enum ToastType { LoadingFullLogs = 'LoadingFullLogs', MaxAttachments = 'MaxAttachments', MessageBodyTooLong = 'MessageBodyTooLong', + MessageLoop = 'MessageLoop', OriginalMessageNotFound = 'OriginalMessageNotFound', PinnedConversationsFull = 'PinnedConversationsFull', ReactionFailed = 'ReactionFailed', @@ -126,6 +127,7 @@ export type AnyToast = | { toastType: ToastType.LoadingFullLogs } | { toastType: ToastType.MaxAttachments } | { toastType: ToastType.MessageBodyTooLong } + | { toastType: ToastType.MessageLoop } | { toastType: ToastType.OriginalMessageNotFound } | { toastType: ToastType.PinnedConversationsFull } | { toastType: ToastType.ReactionFailed } diff --git a/ts/util/checkFirstEnvelope.ts b/ts/util/checkFirstEnvelope.ts new file mode 100644 index 0000000000..0dbd87a827 --- /dev/null +++ b/ts/util/checkFirstEnvelope.ts @@ -0,0 +1,96 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isNumber } from 'lodash'; + +import * as log from '../logging/log'; +import { SignalService as Proto } from '../protobuf'; +import { ToastType } from '../types/Toast'; +import { HOUR, SECOND } from './durations'; +import { isOlderThan } from './timestamp'; +import { isProduction } from './version'; + +import type { IncomingWebSocketRequest } from '../textsecure/WebsocketResources'; + +const FIRST_ENVELOPE_COUNT = 5; +const FIRST_ENVELOPE_TIME = HOUR; +type FirstEnvelopeStats = { + kickoffTimestamp: number; + envelopeTimestamp: number; + count: number; +}; +let firstEnvelopeStats: FirstEnvelopeStats | undefined; + +export function checkFirstEnvelope(incoming: IncomingWebSocketRequest): void { + const { body: plaintext } = incoming; + if (!plaintext) { + log.warn('checkFirstEnvelope: body was not present!'); + return; + } + + const decoded = Proto.Envelope.decode(plaintext); + const newEnvelopeTimestamp = decoded.timestamp?.toNumber(); + if (!isNumber(newEnvelopeTimestamp)) { + log.warn('checkFirstEnvelope: timestamp is not a number!'); + return; + } + + if ( + !firstEnvelopeStats || + firstEnvelopeStats.envelopeTimestamp !== newEnvelopeTimestamp || + isOlderThan(firstEnvelopeStats.kickoffTimestamp, FIRST_ENVELOPE_TIME) + ) { + firstEnvelopeStats = { + kickoffTimestamp: Date.now(), + envelopeTimestamp: newEnvelopeTimestamp, + count: 1, + }; + return; + } + + const { count, kickoffTimestamp } = firstEnvelopeStats; + const newCount = count + 1; + + if (newCount < FIRST_ENVELOPE_COUNT) { + firstEnvelopeStats = { + ...firstEnvelopeStats, + count: newCount, + }; + return; + } + + log.warn( + `checkFirstEnvelope: Timestamp ${newEnvelopeTimestamp} has been seen ${newCount} times since ${kickoffTimestamp}` + ); + if (isProduction(window.getVersion())) { + return; + } + + firstEnvelopeStats = undefined; + + if (isReduxInitialized()) { + showToast(); + } else { + const interval = setInterval(() => { + if (isReduxInitialized()) { + clearInterval(interval); + showToast(); + } + }, 5 * SECOND); + } +} + +function isReduxInitialized() { + const result = Boolean(window.reduxActions); + log.info( + `checkFirstEnvelope: Is redux initialized? ${result ? 'Yes' : 'No'}` + ); + return result; +} + +function showToast() { + log.info('checkFirstEnvelope: Showing toast asking user to submit logs'); + window.reduxActions.toast.showToast({ + toastType: ToastType.MessageLoop, + }); +}