Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ The following emojis are used to highlight certain changes:

### Fixed

- `routing/http/server`: `GET /routing/v1/ipns/{name}` no longer gives a cache a window that outlasts the record. It caps `max-age` to the record's remaining validity and sizes the stale window (`stale-while-revalidate`/`stale-if-error`) to the time left after it, so the two never cross the record's EOL. An expired record, or one whose `ValidityType` is not EOL (unknown expiration), returns `Cache-Control: no-store`, and a negative TTL no longer yields a negative `max-age`. [#1166](https://github.com/ipfs/boxo/pull/1166)
- `gateway`: serving a raw IPNS record (`GET /ipns/{name}?format=ipns-record`) now caps `max-age` to the record's remaining validity and never lets it go negative, so a cache cannot reuse the record past its EOL. [#1166](https://github.com/ipfs/boxo/pull/1166)
- `namesys`: the IPNS resolver now floors a negative record TTL at zero, so a malformed record can no longer surface a negative TTL through `Result.TTL`. [#1166](https://github.com/ipfs/boxo/pull/1166)
- `namesys`: a cache hit now reports the TTL remaining in the cache entry rather than the record's original TTL, so a late hit near a record's EOL can no longer advertise a freshness lifetime that outlives the record. [#1166](https://github.com/ipfs/boxo/pull/1166)
- `ipns`: `NewRecord` floors a negative TTL at zero and `Validate` rejects records carrying one. [#1166](https://github.com/ipfs/boxo/pull/1166)

### Security

- `tracing`: bumped OpenTelemetry OTLP exporters to [v1.43.0](https://github.com/open-telemetry/opentelemetry-go/releases/tag/v1.43.0), which caps the HTTP exporter's response body at 4 MiB. A hostile or man-in-the-middle collector could otherwise exhaust its memory ([CVE-2026-39882](https://github.com/open-telemetry/opentelemetry-go/security/advisories/GHSA-w8rr-5gcm-pp58)). The gRPC exporter is unaffected.
Expand Down
24 changes: 22 additions & 2 deletions gateway/handler_ipns_record.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ func (i *handler) serveIpnsRecord(ctx context.Context, w http.ResponseWriter, r
return false
}

if ttl, err := record.TTL(); err == nil {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(ttl.Seconds())))
if maxAge, ok := ipnsRecordMaxAge(record); ok {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
} else {
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
}
Expand Down Expand Up @@ -98,3 +98,23 @@ func (i *handler) serveIpnsRecord(ctx context.Context, w http.ResponseWriter, r
"error", err)
return false
}

// ipnsRecordMaxAge returns the Cache-Control max-age (in seconds) for a raw IPNS
// record response. It is the record TTL clamped to the record's remaining EOL
// validity, so a cache never reuses the record past the point its signature
// expires (an expired record fails validation), and floored at zero, as a
// record may report a negative TTL. ok is false when the record carries no TTL,
// leaving the caller to fall back to Last-Modified.
func ipnsRecordMaxAge(record *ipns.Record) (seconds int, ok bool) {
ttl, err := record.TTL()
if err != nil {
return 0, false
}
maxAge := int(ttl.Seconds())
if eol, err := record.Validity(); err == nil {
if remaining := int(time.Until(eol).Seconds()); remaining < maxAge {
maxAge = remaining
}
}
return max(0, maxAge), true
}
65 changes: 65 additions & 0 deletions gateway/handler_ipns_record_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package gateway

import (
"crypto/rand"
"testing"
"time"

ipnstest "github.com/ipfs/boxo/internal/ipnstest"
"github.com/ipfs/boxo/ipns"
"github.com/ipfs/boxo/path"
ci "github.com/libp2p/go-libp2p/core/crypto"
"github.com/stretchr/testify/require"
)

func TestIPNSRecordMaxAge(t *testing.T) {
t.Parallel()

sk, _, err := ci.GenerateKeyPairWithReader(ci.Ed25519, 2048, rand.Reader)
require.NoError(t, err)

value, err := path.NewPath("/ipfs/bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4")
require.NoError(t, err)

makeRecord := func(t *testing.T, ttl time.Duration, eol time.Time) *ipns.Record {
rec, err := ipns.NewRecord(sk, value, 1, eol, ttl)
require.NoError(t, err)
return rec
}

t.Run("ttl below remaining validity is used as-is", func(t *testing.T) {
t.Parallel()
rec := makeRecord(t, time.Minute, time.Now().Add(time.Hour))
maxAge, ok := ipnsRecordMaxAge(rec)
require.True(t, ok)
require.Equal(t, 60, maxAge)
})

t.Run("ttl above remaining validity is clamped to EOL", func(t *testing.T) {
t.Parallel()
rec := makeRecord(t, time.Hour, time.Now().Add(30*time.Second))
maxAge, ok := ipnsRecordMaxAge(rec)
require.True(t, ok)
require.Greater(t, maxAge, 0)
require.LessOrEqual(t, maxAge, 30)
})

t.Run("expired record yields max-age=0", func(t *testing.T) {
t.Parallel()
rec := makeRecord(t, time.Hour, time.Now().Add(-time.Hour))
maxAge, ok := ipnsRecordMaxAge(rec)
require.True(t, ok)
require.Equal(t, 0, maxAge)
})

t.Run("negative ttl is floored to max-age=0", func(t *testing.T) {
t.Parallel()
// Built at the wire level so the record carries a genuinely negative TTL
// that ipns.NewRecord would otherwise floor.
rec, err := ipnstest.RawRecordWithTTL(value, time.Now().Add(time.Hour), -time.Minute)
require.NoError(t, err)
maxAge, ok := ipnsRecordMaxAge(rec)
require.True(t, ok)
require.Equal(t, 0, maxAge)
})
}
49 changes: 49 additions & 0 deletions internal/ipnstest/ipnstest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Package ipnstest builds IPNS records at the wire level for tests, bypassing
// [ipns.NewRecord] so callers can construct records that record creation would
// sanitize or that verification would reject (for example a negative TTL).
package ipnstest

import (
"bytes"
"time"

ipns "github.com/ipfs/boxo/ipns"
ipns_pb "github.com/ipfs/boxo/ipns/pb"
"github.com/ipfs/boxo/path"
"github.com/ipfs/boxo/util"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
basicnode "github.com/ipld/go-ipld-prime/node/basic"
"google.golang.org/protobuf/proto"
)

// RawRecordWithTTL builds an unsigned EOL IPNS record whose DAG-CBOR data carries
// exactly the given TTL, without going through [ipns.NewRecord]. This lets a test
// produce a record that NewRecord would floor or that [ipns.Validate] would
// reject (such as a negative TTL). The record is unsigned and must only be fed to
// code paths that do not verify signatures.
func RawRecordWithTTL(value path.Path, eol time.Time, ttl time.Duration) (*ipns.Record, error) {
// Keys are assembled in canonical DAG-CBOR order (by length, then bytewise).
node, err := qp.BuildMap(basicnode.Prototype.Map, 5, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, "TTL", qp.Int(int64(ttl)))
qp.MapEntry(ma, "Value", qp.Bytes([]byte(value.String())))
qp.MapEntry(ma, "Sequence", qp.Int(1))
qp.MapEntry(ma, "Validity", qp.Bytes([]byte(util.FormatRFC3339(eol))))
qp.MapEntry(ma, "ValidityType", qp.Int(0))
})
if err != nil {
return nil, err
}

var buf bytes.Buffer
if err := dagcbor.Encode(node, &buf); err != nil {
return nil, err
}

raw, err := proto.Marshal(&ipns_pb.IpnsRecord{Data: buf.Bytes()})
if err != nil {
return nil, err
}
return ipns.UnmarshalRecord(raw)
}
4 changes: 4 additions & 0 deletions ipns/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,10 @@ func NewRecord(sk ic.PrivKey, value path.Path, seq uint64, eol time.Time, ttl ti
func newRecord(sk ic.PrivKey, value []byte, seq uint64, eol time.Time, ttl time.Duration, opts ...Option) (*Record, error) {
options := processOptions(opts...)

// TTL is a non-negative cache hint per the IPNS spec; floor it so a negative
// duration is never stored, and so the v1 uint64 TTL field cannot underflow.
ttl = max(0, ttl)

node, err := createNode(value, seq, eol, ttl, options.metadata)
if err != nil {
return nil, err
Expand Down
15 changes: 15 additions & 0 deletions ipns/record_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ func mustNewRawRecord(t *testing.T, sk ic.PrivKey, value []byte, seq uint64, eol
return rec
}

func TestNewRecordFloorsNegativeTTL(t *testing.T) {
t.Parallel()

sk, pk, _ := mustKeyPair(t, ic.Ed25519)
rec, err := NewRecord(sk, testPath, 1, time.Now().Add(time.Hour), -time.Minute)
require.NoError(t, err)

ttl, err := rec.TTL()
require.NoError(t, err)
require.Equal(t, time.Duration(0), ttl)
require.Equal(t, uint64(0), rec.pb.GetTtl()) // v1 protobuf TTL must not underflow

require.NoError(t, Validate(rec, pk))
}

func mustMarshal(t *testing.T, entry *Record) []byte {
data, err := MarshalRecord(entry)
require.NoError(t, err)
Expand Down
5 changes: 5 additions & 0 deletions ipns/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ func Validate(rec *Record, pk ic.PubKey) error {
return ErrExpiredRecord
}

// The TTL is a non-negative cache hint per the spec; reject a negative one.
if ttl, err := rec.TTL(); err == nil && ttl < 0 {
return fmt.Errorf("%w: negative TTL", ErrInvalidRecord)
}

return nil
}

Expand Down
29 changes: 29 additions & 0 deletions ipns/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import (
"testing"
"time"

ipns_pb "github.com/ipfs/boxo/ipns/pb"
"github.com/ipfs/boxo/path"
ic "github.com/libp2p/go-libp2p/core/crypto"
"github.com/libp2p/go-libp2p/core/peerstore"
"github.com/libp2p/go-libp2p/p2p/host/peerstore/pstoremem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
)

func shuffle[T any](a []T) {
Expand Down Expand Up @@ -249,3 +251,30 @@ func TestValidateWithName(t *testing.T) {
assert.ErrorIs(t, err, ErrSignature)
})
}

func TestValidateRejectsNegativeTTL(t *testing.T) {
t.Parallel()

sk, pk, _ := mustKeyPair(t, ic.Ed25519)

// Build a validly-signed record whose CBOR TTL is negative, bypassing the
// floor in newRecord, and confirm verification rejects it.
node, err := createNode([]byte(testPath.String()), 1, time.Now().Add(time.Hour), -time.Minute, nil)
require.NoError(t, err)
cborData, err := nodeToCBOR(node)
require.NoError(t, err)
sigData, err := recordDataForSignatureV2(cborData)
require.NoError(t, err)
sig, err := sk.Sign(sigData)
require.NoError(t, err)
raw, err := proto.Marshal(&ipns_pb.IpnsRecord{Data: cborData, SignatureV2: sig})
require.NoError(t, err)
rec, err := UnmarshalRecord(raw)
require.NoError(t, err)

ttl, err := rec.TTL()
require.NoError(t, err)
require.Less(t, ttl, time.Duration(0)) // sanity: the record really carries a negative TTL

require.ErrorIs(t, Validate(rec, pk), ErrInvalidRecord)
}
6 changes: 4 additions & 2 deletions namesys/ipns_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
// IPNSResolver implements [Resolver] for IPNS Records. This resolver always returns
// a TTL if the record is still valid. It happens as follows:
//
// 1. Provisory TTL is chosen: record TTL if it exists, otherwise `ipns.DefaultRecordTTL`.
// 1. Provisory TTL is chosen: record TTL (floored at 0) if it exists, otherwise `ipns.DefaultRecordTTL`.
// 2. If provisory TTL expires before EOL, then returned TTL is duration between EOL and now.
// 3. If record is expired, 0 is returned as TTL.
type IPNSResolver struct {
Expand Down Expand Up @@ -135,7 +135,9 @@ func (r *IPNSResolver) resolveOnceAsync(ctx context.Context, p path.Path, option
func calculateBestTTL(rec *ipns.Record) (time.Duration, error) {
ttl := DefaultResolverCacheTTL
if recordTTL, err := rec.TTL(); err == nil {
ttl = recordTTL
// A record may report a negative TTL; floor it at zero rather than
// letting a negative duration propagate to callers.
ttl = max(0, recordTTL)
}

switch eol, err := rec.Validity(); err {
Expand Down
47 changes: 47 additions & 0 deletions namesys/ipns_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"
"time"

ipnstest "github.com/ipfs/boxo/internal/ipnstest"
ipns "github.com/ipfs/boxo/ipns"
"github.com/ipfs/boxo/path"
"github.com/ipfs/boxo/routing/offline"
Expand Down Expand Up @@ -129,3 +130,49 @@ func TestResolver(t *testing.T) {
require.Equal(t, pathDog, res.Path)
})
}

func TestCalculateBestTTL(t *testing.T) {
t.Parallel()

id := tnet.RandIdentityOrFatal(t)
value := path.FromCid(cid.MustParse("bafkqabddmf2au"))

makeRecord := func(t *testing.T, ttl time.Duration, eol time.Time) *ipns.Record {
rec, err := ipns.NewRecord(id.PrivateKey(), value, 1, eol, ttl)
require.NoError(t, err)
return rec
}

t.Run("record ttl below remaining validity is used as-is", func(t *testing.T) {
t.Parallel()
got, err := calculateBestTTL(makeRecord(t, time.Minute, time.Now().Add(time.Hour)))
require.NoError(t, err)
require.Equal(t, time.Minute, got)
})

t.Run("record ttl above remaining validity is clamped to EOL", func(t *testing.T) {
t.Parallel()
got, err := calculateBestTTL(makeRecord(t, time.Hour, time.Now().Add(30*time.Second)))
require.NoError(t, err)
require.Greater(t, got, time.Duration(0))
require.LessOrEqual(t, got, 30*time.Second)
})

t.Run("expired record yields zero ttl", func(t *testing.T) {
t.Parallel()
got, err := calculateBestTTL(makeRecord(t, time.Hour, time.Now().Add(-time.Hour)))
require.NoError(t, err)
require.Equal(t, time.Duration(0), got)
})

t.Run("negative record ttl is floored to zero with valid EOL", func(t *testing.T) {
t.Parallel()
// Built at the wire level so the record carries a genuinely negative TTL
// that ipns.NewRecord would otherwise floor.
rec, err := ipnstest.RawRecordWithTTL(value, time.Now().Add(time.Hour), -time.Minute)
require.NoError(t, err)
got, err := calculateBestTTL(rec)
require.NoError(t, err)
require.Equal(t, time.Duration(0), got)
})
}
6 changes: 5 additions & 1 deletion namesys/namesys_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ func (ns *namesys) cacheGet(name string) (path.Path, time.Duration, time.Time, b
}

if time.Now().Before(entry.cacheEOL) {
return entry.val, entry.ttl, entry.lastMod, true
// Cap the returned TTL to the entry's remaining cache lifetime, which is
// bounded by the record's EOL (see cacheSet). Without this, a late cache
// hit re-advertises the full original TTL and could let a downstream
// cache outlive the record.
return entry.val, min(entry.ttl, time.Until(entry.cacheEOL)), entry.lastMod, true
}

// We do not delete the entry from the cache. Removals are handled by the
Expand Down
39 changes: 39 additions & 0 deletions namesys/namesys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"
"time"

lru "github.com/hashicorp/golang-lru/v2"
"github.com/ipfs/boxo/ipns"
"github.com/ipfs/boxo/path"
offroute "github.com/ipfs/boxo/routing/offline"
Expand Down Expand Up @@ -181,3 +182,41 @@ func TestPublishWithTTL(t *testing.T) {
require.LessOrEqual(t, time.Until(entry.cacheEOL), cacheTTL)
})
}

func TestCacheGetClampsTTLToCacheEOL(t *testing.T) {
t.Parallel()

cache, err := lru.New[string, cacheEntry](8)
require.NoError(t, err)
ns := &namesys{cache: cache}

p, err := path.NewPath("/ipfs/QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn")
require.NoError(t, err)

t.Run("late hit clamps ttl to remaining cache lifetime", func(t *testing.T) {
// Original TTL is an hour, but the cache entry is about to expire: the
// returned TTL must not outlive the (EOL-bounded) cache lifetime.
ns.cache.Add("/ipns/late", cacheEntry{
val: p,
ttl: time.Hour,
cacheEOL: time.Now().Add(2 * time.Second),
lastMod: time.Now(),
})
_, ttl, _, ok := ns.cacheGet("/ipns/late")
require.True(t, ok)
require.Greater(t, ttl, time.Duration(0))
require.LessOrEqual(t, ttl, 2*time.Second)
})

t.Run("early hit returns full ttl", func(t *testing.T) {
ns.cache.Add("/ipns/early", cacheEntry{
val: p,
ttl: 30 * time.Second,
cacheEOL: time.Now().Add(time.Hour),
lastMod: time.Now(),
})
_, ttl, _, ok := ns.cacheGet("/ipns/early")
require.True(t, ok)
require.Equal(t, 30*time.Second, ttl)
})
}
Loading
Loading