Skip to content

fix: reject non-minimally encoded varints#343

Open
spokodev wants to merge 1 commit into
multiformats:masterfrom
spokodev:fix/reject-non-minimal-varint
Open

fix: reject non-minimally encoded varints#343
spokodev wants to merge 1 commit into
multiformats:masterfrom
spokodev:fix/reject-non-minimal-varint

Conversation

@spokodev

@spokodev spokodev commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

The unsigned-varint spec requires that varints be minimally encoded, but varint.decode accepts overlong encodings such as [0x81, 0x00] (a non-minimal encoding of 1).

CID.decode, CID.decodeFirst, CID.inspectBytes and Digest.decode all read their varint prefixes (version, codec, multihash code, digest length) through this function. As a result, distinct byte sequences decode to .equals()-equal CIDs and re-encode to different bytes than were supplied — content addresses become malleable, which can defeat dedup/cache-key logic that assumes a canonical binary form.

A varint is non-minimal exactly when its final (most-significant) byte is a redundant zero group, so the check is length > 1 && lastByte === 0.

import { varint } from 'multiformats'
varint.decode(Uint8Array.from([0x81, 0x00])) // returns [1, 2]; should throw

Tests added for the varint primitive and for CID.decode rejecting a non-minimal version prefix.

Per the unsigned-varint spec a varint MUST be minimally encoded, yet
varint.decode accepted overlong encodings (e.g. [0x81,0x00] for 1).
Because CID and multihash decoding read their prefixes through this
function, distinct byte sequences decoded to equal CIDs, making
content addresses malleable. Reject encodings whose final byte is a
redundant zero continuation.
@rvagg

rvagg commented Jul 9, 2026

Copy link
Copy Markdown
Member

Nice work, a couple of thoughts on this as is:

  • let's add a simple overlong 127 for a boundary case for this, [0xff, 0x00] should reject
  • also let's just protect against regressions by checking that interior zero groups still go through, so [0x80, 0x80, 0x01] might be a good case, that'd come out as [16384, 3]

The spec we work adopt here is https://github.com/multiformats/unsigned-varint/blob/master/README.md, and you'll note that even with this we don't fully comply. We should be rejecting varints longer than 9 bytes, so a number like 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x01 should reject.

Also on the encode side we have some problems:

  • We allow signed numbers
  • We allow non-integers!
  • encodeTo() has divergent behaviour because we allow negatives (encodingLength(-1) returns 1, while encode(-1) writes 5 bytes)

I'd be fine merging this with just a bit more test extension so we lock in the behaviour, you're welcome to go further as well in this PR or in a follow-up. As long as we're doing it properly, we could just consider moving varint.js out of vendor and building the validation into the decode process because it'll be more efficient that way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants