Skip to content
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,32 @@ The following emojis are used to highlight certain changes:

### Changed

- `verifcid`: 🛠 Enhanced Allowlist interface with per-hash size limits ([#1018](https://github.com/ipfs/boxo/pull/1018))
- Expanded `Allowlist` interface with `MinDigestSize(code uint64)` and `MaxDigestSize(code uint64)` methods for per-hash function size validation
- Added public constants: `DefaultMinDigestSize` (20 bytes), `DefaultMaxDigestSize` (128 bytes for cryptographic hashes), and `DefaultMaxIdentityDigestSize` (128 bytes for identity CIDs)
- `DefaultAllowlist` implementation now uses these constants and supports different size limits per hash type
- Renamed errors for clarity: Added `ErrDigestTooSmall` and `ErrDigestTooLarge` as the new primary errors
- `ErrBelowMinimumHashLength` and `ErrAboveMaximumHashLength` remain as deprecated aliases pointing to the new errors
- `bitswap`: Updated to use `verifcid.DefaultMaxDigestSize` for `MaximumHashLength` constant
- The default `MaximumAllowedCid` limit for incoming CIDs can be adjusted using `bitswap.MaxCidSize` or `server.MaxCidSize` options
- 🛠 `bitswap/client`: The `RebroadcastDelay` option now takes a `time.Duration` value. This is a potentially BREAKING CHANGE. The time-varying functionality of `delay.Delay` was never used, so it was replaced with a fixed duration value. This also removes the `github.com/ipfs/go-ipfs-delay` dependency.


### Removed

### Fixed

- `ipld/unixfs/mod`:
- `DagModifier` now correctly preserves raw node codec when modifying data under the chunker threshold, instead of incorrectly forcing everything to dag-pb
- `DagModifier` prevents creation of identity CIDs exceeding `verifcid.DefaultMaxIdentityDigestSize` limit when modifying data, automatically switching to proper cryptographic hash while preserving small identity CIDs
- `DagModifier` now supports appending data to a `RawNode` by automatically converting it into a UnixFS file structure where the original `RawNode` becomes the first leaf block, fixing previously impossible append operations that would fail with "expected protobuf dag node" errors
- `mfs`: Files with identity CIDs now properly inherit full CID prefix from parent directories (version, codec, hash type, length), not just hash type

### Security

- `verifcid`: Now enforces maximum size limit of 128 bytes for identity CIDs to prevent abuse.
- 🛠 Attempts to read CIDs with identity multihash digests longer than `DefaultMaxIdentityDigestSize` will now produce `ErrDigestTooLarge` error.
- Identity CIDs can inline data directly, and without a size limit, they could embed arbitrary amounts of data. Limiting the size also protects gateways from poorly written clients that might send absurdly big data to the gateway encoded as identity CIDs only to retrieve it back. Note that identity CIDs do not provide integrity verification, making them vulnerable to bit flips. They should only be used in controlled contexts like raw leaves of a larger DAG. The limit is explicitly defined as `DefaultMaxIdentityDigestSize` (128 bytes).


## [v0.34.0]

Expand Down
32 changes: 29 additions & 3 deletions bitswap/internal/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package defaults
import (
"encoding/binary"
"time"

"github.com/ipfs/boxo/verifcid"
)

const (
Expand All @@ -27,9 +29,33 @@ const (
// Maximum size of the wantlist we are willing to keep in memory.
MaxQueuedWantlistEntiresPerPeer = 1024

// Copied from github.com/ipfs/go-verifcid#maximumHashLength
// FIXME: expose this in go-verifcid.
MaximumHashLength = 128
// MaximumHashLength is the maximum size for hash digests we accept.
// This references the default from verifcid for consistency.
MaximumHashLength = verifcid.DefaultMaxDigestSize

// MaximumAllowedCid is the maximum total CID size we accept in bitswap messages.
// Bitswap sends full CIDs (not just multihashes) on the wire, so we must
// limit the total size to prevent DoS attacks from maliciously large CIDs.
//
// The calculation is based on the CID binary format:
// - CIDv0: Just a multihash (hash type + hash length + hash digest)
// - CIDv1: <version><multicodec><multihash>
// - version: varint (usually 1 byte for version 1)
// - multicodec: varint (usually 1-2 bytes for common codecs)
// - multihash: <hash-type><hash-length><hash-digest>
// - hash-type: varint (usually 1-2 bytes)
// - hash-length: varint (usually 1-2 bytes)
// - hash-digest: up to MaximumHashLength bytes
//
// We use binary.MaxVarintLen64*4 (40 bytes) to accommodate worst-case varint encoding:
// - 1 varint for CID version (max 10 bytes)
// - 1 varint for multicodec (max 10 bytes)
// - 1 varint for multihash type (max 10 bytes)
// - 1 varint for multihash length (max 10 bytes)
// Total: 40 bytes overhead + MaximumHashLength (128) = 168 bytes max
//
// This prevents peers from sending CIDs with pathologically large varint encodings
// that could exhaust memory or cause other issues.
MaximumAllowedCid = binary.MaxVarintLen64*4 + MaximumHashLength

// RebroadcastDelay is the default delay to trigger broadcast of
Expand Down
2 changes: 1 addition & 1 deletion bitswap/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func MaxQueuedWantlistEntriesPerPeer(count uint) Option {
return Option{server.MaxQueuedWantlistEntriesPerPeer(count)}
}

// MaxCidSize only affects the server.
// MaxCidSize limits the size of incoming CIDs in requests (server only).
// If it is 0 no limit is applied.
func MaxCidSize(n uint) Option {
return Option{server.MaxCidSize(n)}
Expand Down
7 changes: 5 additions & 2 deletions bitswap/server/internal/decision/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,11 @@ func WithMaxQueuedWantlistEntriesPerPeer(count uint) Option {
}
}

// WithMaxQueuedWantlistEntriesPerPeer limits how much individual entries each peer is allowed to send.
// If a peer send us more than this we will truncate newest entries.
// WithMaxCidSize limits the size of incoming CIDs that we are willing to accept.
// We will ignore requests for CIDs whose total encoded size exceeds this limit.
// This protects against malicious peers sending CIDs with pathologically large
// varint encodings that could exhaust memory.
// It defaults to [defaults.MaximumAllowedCid]
func WithMaxCidSize(n uint) Option {
return func(e *Engine) {
e.maxCidSize = n
Expand Down
2 changes: 1 addition & 1 deletion bitswap/server/internal/decision/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1660,7 +1660,7 @@ func TestIgnoresCidsAboveLimit(t *testing.T) {

wl := warsaw.Engine.WantlistForPeer(riga.Peer)
if len(wl) != 1 {
t.Fatal("wantlist add a CID too big")
t.Fatalf("expected 1 entry in wantlist, got %d", len(wl))
}
}

Expand Down
8 changes: 5 additions & 3 deletions bitswap/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,11 @@ func MaxQueuedWantlistEntriesPerPeer(count uint) Option {
}
}

// MaxCidSize limits how big CIDs we are willing to serve.
// We will ignore CIDs over this limit.
// It defaults to [defaults.MaxCidSize].
// MaxCidSize limits the size of incoming CIDs in requests that we are willing to accept.
// We will ignore requests for CIDs whose total encoded size exceeds this limit.
// This protects against malicious peers sending CIDs with pathologically large
// varint encodings that could exhaust memory.
// It defaults to [defaults.MaximumAllowedCid].
// If it is 0 no limit is applied.
func MaxCidSize(n uint) Option {
o := decision.WithMaxCidSize(n)
Expand Down
30 changes: 30 additions & 0 deletions blockservice/blockservice_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package blockservice

import (
"bytes"
"context"
"testing"

Expand Down Expand Up @@ -288,6 +289,35 @@ func TestAllowlist(t *testing.T) {
check(NewSession(ctx, blockservice).GetBlock)
}

func TestIdentityHashSizeLimit(t *testing.T) {
a := assert.New(t)
ctx := context.Background()
bs := blockstore.NewBlockstore(dssync.MutexWrap(ds.NewMapDatastore()))
blockservice := New(bs, nil)

// Create identity CID at the DefaultMaxIdentityDigestSize limit (should be valid)
validData := bytes.Repeat([]byte("a"), verifcid.DefaultMaxIdentityDigestSize)
validHash, err := multihash.Sum(validData, multihash.IDENTITY, -1)
a.NoError(err)
validCID := cid.NewCidV1(cid.Raw, validHash)

// Create identity CID over the DefaultMaxIdentityDigestSize limit (should be rejected)
invalidData := bytes.Repeat([]byte("b"), verifcid.DefaultMaxIdentityDigestSize+1)
invalidHash, err := multihash.Sum(invalidData, multihash.IDENTITY, -1)
a.NoError(err)
invalidCID := cid.NewCidV1(cid.Raw, invalidHash)

// Valid identity CID should work (though block won't be found)
_, err = blockservice.GetBlock(ctx, validCID)
a.Error(err)
a.True(ipld.IsNotFound(err), "expected not found error for valid identity CID")

// Invalid identity CID should fail validation
_, err = blockservice.GetBlock(ctx, invalidCID)
a.Error(err)
a.ErrorIs(err, verifcid.ErrDigestTooLarge)
}

type fakeIsNewSessionCreateExchange struct {
ses exchange.Fetcher
newSessionWasCalled bool
Expand Down
33 changes: 31 additions & 2 deletions gateway/gateway_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gateway

import (
"bytes"
"context"
"errors"
"fmt"
Expand All @@ -13,8 +14,10 @@ import (
"github.com/ipfs/boxo/namesys"
"github.com/ipfs/boxo/path"
"github.com/ipfs/boxo/path/resolver"
"github.com/ipfs/boxo/verifcid"
"github.com/ipfs/go-cid"
ipld "github.com/ipfs/go-ipld-format"
mh "github.com/multiformats/go-multihash"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -51,6 +54,25 @@ func TestGatewayGet(t *testing.T) {
// detection is platform dependent.
backend.namesys["/ipns/example.man"] = newMockNamesysItem(k, 0)

// Create identity CIDs for testing
// verifcid.DefaultMaxIdentityDigestSize bytes (at the identity limit, should be valid)
validIdentityData := bytes.Repeat([]byte("a"), verifcid.DefaultMaxIdentityDigestSize)
validIdentityHash, err := mh.Sum(validIdentityData, mh.IDENTITY, -1)
require.NoError(t, err)
validIdentityCID := cid.NewCidV1(cid.Raw, validIdentityHash)

// verifcid.DefaultMaxIdentityDigestSize+1 bytes (over the identity limit, should be rejected)
invalidIdentityData := bytes.Repeat([]byte("b"), verifcid.DefaultMaxIdentityDigestSize+1)
invalidIdentityHash, err := mh.Sum(invalidIdentityData, mh.IDENTITY, -1)
require.NoError(t, err)
invalidIdentityCID := cid.NewCidV1(cid.Raw, invalidIdentityHash)

// Short identity CID (below MinDigestSize, should still be valid)
shortIdentityData := []byte("hello")
shortIdentityHash, err := mh.Sum(shortIdentityData, mh.IDENTITY, -1)
require.NoError(t, err)
shortIdentityCID := cid.NewCidV1(cid.Raw, shortIdentityHash)

for _, test := range []struct {
host string
path string
Expand All @@ -62,6 +84,9 @@ func TestGatewayGet(t *testing.T) {
{"127.0.0.1:8080", "/ipns", http.StatusBadRequest, "invalid path \"/ipns/\": path does not have enough components\n"},
{"127.0.0.1:8080", "/" + k.RootCid().String(), http.StatusNotFound, "404 page not found\n"},
{"127.0.0.1:8080", "/ipfs/this-is-not-a-cid", http.StatusBadRequest, "invalid path \"/ipfs/this-is-not-a-cid\": invalid cid: illegal base32 data at input byte 3\n"},
{"127.0.0.1:8080", "/ipfs/" + validIdentityCID.String(), http.StatusOK, string(validIdentityData)}, // Valid identity CID returns the inlined data
{"127.0.0.1:8080", "/ipfs/" + invalidIdentityCID.String(), http.StatusInternalServerError, "failed to resolve /ipfs/" + invalidIdentityCID.String() + ": digest too large: identity digest got 129 bytes, maximum 128\n"}, // Invalid identity CID, over size limit
{"127.0.0.1:8080", "/ipfs/" + shortIdentityCID.String(), http.StatusOK, "hello"}, // Short identity CID (below MinDigestSize) should work
{"127.0.0.1:8080", k.String(), http.StatusOK, "fnord"},
{"127.0.0.1:8080", "/ipns/nxdomain.example.com", http.StatusInternalServerError, "failed to resolve /ipns/nxdomain.example.com: " + namesys.ErrResolveFailed.Error() + "\n"},
{"127.0.0.1:8080", "/ipns/%0D%0A%0D%0Ahello", http.StatusInternalServerError, "failed to resolve /ipns/\\r\\n\\r\\nhello: " + namesys.ErrResolveFailed.Error() + "\n"},
Expand All @@ -87,8 +112,12 @@ func TestGatewayGet(t *testing.T) {
require.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, test.status, resp.StatusCode, "body", body)
require.Equal(t, test.text, string(body))
require.Equal(t, test.status, resp.StatusCode, "body", string(body))

// Check body content if expected text is provided
if test.text != "" {
require.Equal(t, test.text, string(body))
}
})
}
}
Expand Down
Loading
Loading