Limit the size of incoming text to 2 kibibytes.

This commit is contained in:
Ehren Kret
2026-08-17 14:36:14 -04:00
committed by Cody Henthorne
parent cb8bcba86b
commit ec9e5e5dee
14 changed files with 186 additions and 23 deletions
@@ -28,7 +28,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.transport.RetryLaterException
import org.thoughtcrime.securesms.transport.UndeliverableMessageException
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.whispersystems.signalservice.api.SignalServiceMessageSender.IndividualSendEvents
@@ -38,6 +37,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.PaymentActivation
import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage
import org.whispersystems.signalservice.api.messages.SignalServiceMessageLimits
import org.whispersystems.signalservice.api.messages.SignalServicePreview
import org.whispersystems.signalservice.api.messages.shared.SharedContact
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException
@@ -258,8 +258,8 @@ class IndividualSendJob private constructor(parameters: Parameters, private val
@Throws(IOException::class, UnregisteredUserException::class, UntrustedIdentityException::class, UndeliverableMessageException::class)
private fun deliver(message: OutgoingMessage, originalEditedMessage: MessageRecord?): Boolean {
if (message.body.utf8Size() > MessageUtil.MAX_INLINE_BODY_SIZE_BYTES) {
throw UndeliverableMessageException("The total body size was greater than our limit of " + MessageUtil.MAX_INLINE_BODY_SIZE_BYTES + " bytes.")
if (message.body.utf8Size() > SignalServiceMessageLimits.MAX_INLINE_BODY_SIZE_BYTES) {
throw UndeliverableMessageException("The total body size was greater than our limit of " + SignalServiceMessageLimits.MAX_INLINE_BODY_SIZE_BYTES + " bytes.")
}
try {
@@ -41,7 +41,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.ratelimit.ProofRequiredExceptionHandler
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.thoughtcrime.securesms.util.isUrgent
@@ -49,6 +48,7 @@ import org.thoughtcrime.securesms.util.toDataMessage
import org.whispersystems.signalservice.api.crypto.ContentHint
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
import org.whispersystems.signalservice.api.messages.SendMessageResult
import org.whispersystems.signalservice.api.messages.SignalServiceMessageLimits
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException
import org.whispersystems.signalservice.internal.push.Content
@@ -179,8 +179,8 @@ class IndividualSendJobV2 private constructor(parameters: Parameters, private va
null
}
if (message.body.utf8Size() > MessageUtil.MAX_INLINE_BODY_SIZE_BYTES) {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Body size exceeds limit of ${MessageUtil.MAX_INLINE_BODY_SIZE_BYTES} bytes; failing.")
if (message.body.utf8Size() > SignalServiceMessageLimits.MAX_INLINE_BODY_SIZE_BYTES) {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Body size exceeds limit of ${SignalServiceMessageLimits.MAX_INLINE_BODY_SIZE_BYTES} bytes; failing.")
return Result.failure()
}
@@ -46,7 +46,6 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.RecipientAccessList;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.whispersystems.signalservice.api.crypto.ContentHint;
@@ -56,6 +55,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.SignalServiceMessageLimits;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
@@ -276,8 +276,8 @@ public final class PushGroupSendJob extends PushSendJob {
private List<SendMessageResult> deliver(OutgoingMessage message, @Nullable MessageRecord originalEditedMessage, @NonNull Recipient groupRecipient, @NonNull List<Recipient> destinations)
throws IOException, UntrustedIdentityException, UndeliverableMessageException, NoSessionException
{
if (Utf8.size(message.getBody()) > MessageUtil.MAX_INLINE_BODY_SIZE_BYTES) {
throw new UndeliverableMessageException("The total body size was greater than our limit of " + MessageUtil.MAX_INLINE_BODY_SIZE_BYTES + " bytes.");
if (Utf8.size(message.getBody()) > SignalServiceMessageLimits.MAX_INLINE_BODY_SIZE_BYTES) {
throw new UndeliverableMessageException("The total body size was greater than our limit of " + SignalServiceMessageLimits.MAX_INLINE_BODY_SIZE_BYTES + " bytes.");
}
try {
@@ -45,6 +45,7 @@ import org.signal.core.util.Base64;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.signal.core.util.Util;
import org.whispersystems.signalservice.api.messages.SignalServiceMessageLimits;
import java.util.ArrayList;
import java.util.Collection;
@@ -111,7 +112,7 @@ public final class MultiShareSender {
List<Contact> contacts = multiShareArgs.getSharedContacts();
SlideDeck slideDeck = new SlideDeck(primarySlideDeck);
boolean needsSplit = message != null && Utf8.size(message) > MessageUtil.MAX_INLINE_BODY_SIZE_BYTES;
boolean needsSplit = message != null && Utf8.size(message) > SignalServiceMessageLimits.MAX_INLINE_BODY_SIZE_BYTES;
boolean hasMmsMedia = !multiShareArgs.getMedia().isEmpty() ||
(multiShareArgs.getDataUri() != null && multiShareArgs.getDataUri() != Uri.EMPTY) ||
multiShareArgs.getStickerLocator() != null ||
@@ -5,16 +5,13 @@ import org.signal.core.util.kibiBytes
import org.signal.core.util.splitByByteLength
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.mms.TextSlide
import org.whispersystems.signalservice.api.messages.SignalServiceMessageLimits
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.Optional
object MessageUtil {
/** The maximum size of an inlined text body we'll allow in a proto. Anything larger than this will need to be a long-text attachment. */
@JvmField
val MAX_INLINE_BODY_SIZE_BYTES: Int = 2.kibiBytes.bytes.toInt()
/** The maximum total message size we'll allow ourselves to send, even as a long text attachment. */
@JvmField
val MAX_TOTAL_BODY_SIZE_BYTES = 64.kibiBytes.bytes.toInt()
@@ -25,7 +22,7 @@ object MessageUtil {
*/
@JvmStatic
fun getSplitMessage(context: Context, rawText: String): SplitResult {
val (trimmed, remainder) = rawText.splitByByteLength(MAX_INLINE_BODY_SIZE_BYTES)
val (trimmed, remainder) = rawText.splitByByteLength(SignalServiceMessageLimits.MAX_INLINE_BODY_SIZE_BYTES)
return if (remainder != null) {
val textData = rawText.toByteArray()
@@ -64,11 +64,11 @@ import org.thoughtcrime.securesms.service.ExpiringMessageManager
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
import org.thoughtcrime.securesms.testutil.MockSignalStoreRule
import org.thoughtcrime.securesms.util.DataMessageError
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.toDataMessage
import org.whispersystems.signalservice.api.crypto.ContentHint
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
import org.whispersystems.signalservice.api.messages.SignalServiceMessageLimits
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.PniSignatureMessage
@@ -293,7 +293,7 @@ class IndividualSendJobV2Test {
@Test
fun `Given body exceeds inline size limit, when run, then return failure`() {
val tooLargeBody = "x".repeat(MessageUtil.MAX_INLINE_BODY_SIZE_BYTES + 1)
val tooLargeBody = "x".repeat(SignalServiceMessageLimits.MAX_INLINE_BODY_SIZE_BYTES + 1)
every { outgoingMessage.body } returns tooLargeBody
val result = createAndRunJob()
@@ -40,3 +40,35 @@ fun OutputStream.writeUInt(value: UInt) {
// Note that casting to an int here is fine, because at the end of the day, we're just writing 4 bytes to the stream
this.write(ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(value.toInt()).array())
}
/**
* Writes [count] zero bytes to the stream, using a bounded buffer so that we never allocate the full [count] at once.
* A non-positive [count] writes nothing.
*/
fun OutputStream.writeZeros(count: Long, maxBufferSize: Int = 32 * 1024) {
writeRepeated(0, count, maxBufferSize)
}
/**
* Writes [value] to the stream [count] times, using a bounded buffer so that we never allocate the full [count] at once.
* A non-positive [count] writes nothing.
*/
fun OutputStream.writeRepeated(value: Byte, count: Long, maxBufferSize: Int = 32 * 1024) {
require(maxBufferSize > 0) { "maxBufferSize must be positive, was $maxBufferSize" }
if (count <= 0) {
return
}
val buffer = ByteArray(minOf(count, maxBufferSize.toLong()).toInt())
if (value != 0.toByte()) {
buffer.fill(value)
}
var remaining = count
while (remaining > 0) {
val chunkSize = minOf(remaining, buffer.size.toLong()).toInt()
this.write(buffer, 0, chunkSize)
remaining -= chunkSize
}
}
@@ -5,6 +5,7 @@
package org.signal.archive.stream
import org.signal.core.util.writeZeros
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import java.io.FilterOutputStream
import java.io.OutputStream
@@ -24,9 +25,8 @@ class PaddedGzipOutputStream private constructor(private val outputStream: SizeO
val totalLength = outputStream.size
val paddedSize: Long = PaddingInputStream.getPaddedSize(totalLength)
val paddingToAdd: Int = (paddedSize - totalLength).toInt()
outputStream.write(ByteArray(paddingToAdd))
outputStream.writeZeros(paddedSize - totalLength)
}
/**
@@ -62,6 +62,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStre
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.SignalServiceMessageLimits;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
@@ -1004,8 +1005,8 @@ public class SignalServiceMessageSender {
Content.Builder container = new Content.Builder();
DataMessage.Builder dataMessage = createDataMessage(message);
if (dataMessage.body != null && Utf8.size(dataMessage.body) > 2048) {
throw new ContentTooLargeException(Utf8.size(dataMessage.body), "UTF-8 size of the data message body was over 2048 bytes!");
if (dataMessage.body != null && Utf8.size(dataMessage.body) > SignalServiceMessageLimits.MAX_INLINE_BODY_SIZE_BYTES) {
throw new ContentTooLargeException(Utf8.size(dataMessage.body), "UTF-8 size of the data message body was over " + SignalServiceMessageLimits.MAX_INLINE_BODY_SIZE_BYTES + " bytes!");
}
return enforceMaxContentSize(container.dataMessage(dataMessage.build()).build());
@@ -1,6 +1,7 @@
package org.whispersystems.signalservice.api.messages
import okio.ByteString
import okio.utf8Size
import org.signal.core.models.ServiceId
import org.signal.core.models.ServiceId.ACI
import org.signal.libsignal.protocol.message.CiphertextMessage
@@ -91,6 +92,10 @@ object EnvelopeContentValidator {
return Result.Invalid("[DataMessage] Timestamps don't match! envelope: ${envelope.clientTimestamp}, content: ${dataMessage.timestamp}")
}
if (dataMessage.body.isBodyTooLarge()) {
return Result.Invalid("[DataMessage] Body exceeds ${SignalServiceMessageLimits.MAX_INLINE_BODY_SIZE_BYTES} bytes!")
}
if (dataMessage.quote != null && ACI.parseOrNull(dataMessage.quote.authorAci, dataMessage.quote.authorAciBinary).isNullOrInvalidServiceId()) {
return Result.Invalid("[DataMessage] Invalid ACI on quote!")
}
@@ -435,6 +440,10 @@ object EnvelopeContentValidator {
return Result.Invalid("[EditMessage] Timestamps don't match! envelope: ${envelope.clientTimestamp}, content: ${dataMessage.timestamp}")
}
if (dataMessage.body.isBodyTooLarge()) {
return Result.Invalid("[EditMessage] Body exceeds ${SignalServiceMessageLimits.MAX_INLINE_BODY_SIZE_BYTES} bytes!")
}
if (dataMessage.requiredProtocolVersion != null && dataMessage.requiredProtocolVersion > DataMessage.ProtocolVersion.CURRENT.value) {
return Result.UnsupportedDataMessage(
ourVersion = DataMessage.ProtocolVersion.CURRENT.value,
@@ -484,6 +493,10 @@ object EnvelopeContentValidator {
return this.attachments.any { it.contentType == LONG_TEXT_CONTENT_TYPE }
}
private fun String?.isBodyTooLarge(): Boolean {
return this != null && this.utf8Size() > SignalServiceMessageLimits.MAX_INLINE_BODY_SIZE_BYTES
}
private fun BodyRange.isStyleRangeMissingOffsets(): Boolean {
return this.style != null && (this.start == null || this.length == null)
}
@@ -0,0 +1,13 @@
package org.whispersystems.signalservice.api.messages
import org.signal.core.util.kibiBytes
/**
* Size limits that messages must respect on the wire. Shared by the send path, the receive-side
* content validation, and the app layer so that all three agree on a single set of numbers.
*/
object SignalServiceMessageLimits {
/** The maximum size of an inlined text body we'll allow in a proto. Anything larger than this will need to be a long-text attachment. */
@JvmField
val MAX_INLINE_BODY_SIZE_BYTES: Int = 2.kibiBytes.bytes.toInt()
}
@@ -56,10 +56,10 @@ public class PaddingInputStream extends FilterInputStream {
}
public static long getPaddedSize(long size) {
return (int) Math.max(541, Math.floor(Math.pow(1.05, Math.ceil(Math.log(size) / Math.log(1.05)))));
return (long) Math.max(541, Math.floor(Math.pow(1.05, Math.ceil(Math.log(size) / Math.log(1.05)))));
}
public static long getMaxUnpaddedSize(long maxPaddedSize) {
return (int) Math.floor(Math.pow(1.05, Math.floor(Math.log(maxPaddedSize) / Math.log(1.05))));
return (long) Math.floor(Math.pow(1.05, Math.floor(Math.log(maxPaddedSize) / Math.log(1.05))));
}
}
@@ -839,6 +839,81 @@ class EnvelopeContentValidatorTest {
assert(result is EnvelopeContentValidator.Result.Valid)
}
@Test
fun `validate - ensure data message body of exactly 2048 bytes is marked valid`() {
val content = Content(
dataMessage = DataMessage(
timestamp = 1234,
body = "a".repeat(2048)
)
)
val result = EnvelopeContentValidator.validate(Envelope(clientTimestamp = 1234), content, SELF_ACI, CiphertextMessage.WHISPER_TYPE)
assert(result is EnvelopeContentValidator.Result.Valid)
}
@Test
fun `validate - ensure data message body over 2048 bytes is marked invalid`() {
val content = Content(
dataMessage = DataMessage(
timestamp = 1234,
body = "a".repeat(2049)
)
)
val result = EnvelopeContentValidator.validate(Envelope(clientTimestamp = 1234), content, SELF_ACI, CiphertextMessage.WHISPER_TYPE)
assert(result is EnvelopeContentValidator.Result.Invalid)
}
@Test
fun `validate - ensure data message body over 2048 UTF-8 bytes is marked invalid`() {
val content = Content(
dataMessage = DataMessage(
timestamp = 1234,
body = "é".repeat(1025)
)
)
val result = EnvelopeContentValidator.validate(Envelope(clientTimestamp = 1234), content, SELF_ACI, CiphertextMessage.WHISPER_TYPE)
assert(result is EnvelopeContentValidator.Result.Invalid)
}
@Test
fun `validate - ensure edit message body over 2048 bytes is marked invalid`() {
val content = Content(
editMessage = EditMessage(
targetSentTimestamp = 1000,
dataMessage = DataMessage(
timestamp = 1234,
body = "a".repeat(2049)
)
)
)
val result = EnvelopeContentValidator.validate(Envelope(clientTimestamp = 1234), content, SELF_ACI, CiphertextMessage.WHISPER_TYPE)
assert(result is EnvelopeContentValidator.Result.Invalid)
}
@Test
fun `validate - ensure sync sent body over 2048 bytes is marked invalid`() {
val envelope = Envelope(sourceServiceId = SELF_ACI.toString(), clientTimestamp = 1234)
val content = Content(
syncMessage = SyncMessage(
sent = SyncMessage.Sent(
timestamp = 1234,
destinationServiceId = OTHER_ACI.toString(),
message = DataMessage(
timestamp = 1234,
body = "a".repeat(2049)
)
)
)
)
val result = EnvelopeContentValidator.validate(envelope, content, SELF_ACI, CiphertextMessage.WHISPER_TYPE)
assert(result is EnvelopeContentValidator.Result.Invalid)
}
@Test
fun `validate - ensure sync sent without a timestamp is marked invalid`() {
val envelope = Envelope(sourceServiceId = SELF_ACI.toString(), clientTimestamp = 1234)
@@ -6,6 +6,9 @@ package org.whispersystems.signalservice.internal.crypto
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isGreaterThan
import assertk.assertions.isGreaterThanOrEqualTo
import assertk.assertions.isLessThanOrEqualTo
import org.junit.Test
import org.signal.core.util.StreamUtil
import java.io.ByteArrayInputStream
@@ -37,4 +40,32 @@ class PaddingInputStreamTest {
}
}
}
/**
* Sizes above [Int.MAX_VALUE] must not be truncated. A padded size smaller than the input implies a *negative* amount of padding, which blows up
* callers that use the difference to size a buffer.
*/
@Test
fun `getPaddedSize does not truncate sizes above Int MAX_VALUE`() {
val sizes = listOf(
Int.MAX_VALUE.toLong() - 1,
Int.MAX_VALUE.toLong(),
Int.MAX_VALUE.toLong() + 1,
3L * 1024 * 1024 * 1024,
100L * 1024 * 1024 * 1024
)
sizes.forEach { size ->
assertThat(PaddingInputStream.getPaddedSize(size), "padded size of $size").isGreaterThanOrEqualTo(size)
}
}
@Test
fun `getMaxUnpaddedSize does not truncate sizes above Int MAX_VALUE`() {
val maxPaddedSize = 100L * 1024 * 1024 * 1024
val maxUnpaddedSize = PaddingInputStream.getMaxUnpaddedSize(maxPaddedSize)
assertThat(maxUnpaddedSize).isGreaterThan(Int.MAX_VALUE.toLong())
assertThat(PaddingInputStream.getPaddedSize(maxUnpaddedSize)).isLessThanOrEqualTo(maxPaddedSize)
}
}