Skip to content

[Medium] decodeEnvelope has no payload size ceiling, oversized envelope crashes storeEnvelope as an unhandled rejection #72

Description

@konkomaji

Summary

encodeEnvelope (src/lib/protocol.ts:82-84) enforces payload.length <= MAX_ENVELOPE_LEN - HEADER_LEN (29,966 bytes). decodeEnvelope (protocol.ts:111-145) has no equivalent upper bound, it only checks internal consistency (raw.length === HEADER_LEN + payloadLen) and clamps maxHops/ttlSeconds, but a payload up to 65535 bytes (the u16 length field's ceiling) decodes cleanly.

storeEnvelope (src/lib/db.ts:445-462, the production SQLite store) unconditionally calls encodeEnvelope(e) to serialize the row, the same encoder that throws for oversized payloads. handleSealed (mesh.ts:561-684) calls await this.store.storeEnvelope(envelope) for every accepted envelope. The ingest chain is fire and forget with no .catch() anywhere (mesh.ts:214, this.transport.on('payload', (peerId, b64) => void this.ingest(peerId, b64))).

Both native BLE transports cap assembled messages above the app's own cap, leaving an exploitable window:

  • Android: MAX_MESSAGE_BYTES = 32_768 (modules/ble-mesh/android/.../BleMeshModule.kt:88)
  • iOS: maxMessageBytes = 32_768 (modules/ble-mesh/ios/BleMeshModule.swift:52)

Why it matters

A hostile phone can transmit a hand crafted envelope of 30,001 to 32,768 total bytes (payload 29,967 to 32,734 bytes), no cryptographic material needed since the wire format is fully documented in protocol.ts. Both native layers reassemble and deliver it to JS, decodeEnvelope accepts it, and storeEnvelope's encodeEnvelope(e) throws on every device that receives it directly, as an unhandled promise rejection. Each hit forces full trial decryption against every held OTK/SPK/long term secret before it fails, then the envelope is silently dropped instead of being carried or relayed, exactly the size class the protocol comments say is deliberately held well under the cap. Repeatable indefinitely from one attacker device against everyone in range.

Evidence

  • src/lib/protocol.ts:82-84 encoder bound
  • src/lib/protocol.ts:111-145 decoder, no equivalent bound
  • src/lib/db.ts:445-462 storeEnvelope re-encodes unconditionally
  • src/lib/mesh.ts:679 unconditional storeEnvelope call in handleSealed
  • src/lib/mesh.ts:214 fire and forget ingest, no catch
  • modules/ble-mesh/android/.../BleMeshModule.kt:88, modules/ble-mesh/ios/BleMeshModule.swift:52 native caps above the protocol cap

PoC steps

  1. Craft a raw envelope per the documented wire format in protocol.ts with a payload length between 29,967 and 32,734 bytes.
  2. Transmit it over BLE from a device that does not need to run the app, only speak the wire format up to the native transport's reassembly cap.
  3. On the receiving device, observe decodeEnvelope accepts it, handleSealed runs full trial decryption, then storeEnvelope throws Error('envelope payload too large: ...') as an unhandled rejection instead of persisting or relaying the envelope.

Proposed fix

decodeEnvelope should reject payloadLen > MAX_ENVELOPE_LEN - HEADER_LEN the same way it already rejects a bad TTL. Callers of storeEnvelope should also catch the encode failure rather than letting it propagate as an unhandled rejection.

Done when

  • decode rejects oversized payloads at the same boundary encode enforces
  • storeEnvelope failures cannot surface as unhandled promise rejections
  • Regression test covers an envelope sized just above the encode cap but under the native transport cap

Found during a codebase review.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions