diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a31c0da..155ea4882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/gateway/handler_ipns_record.go b/gateway/handler_ipns_record.go index 56a5a91ee..c2e779b61 100644 --- a/gateway/handler_ipns_record.go +++ b/gateway/handler_ipns_record.go @@ -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)) } @@ -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 +} diff --git a/gateway/handler_ipns_record_test.go b/gateway/handler_ipns_record_test.go new file mode 100644 index 000000000..ea6b81979 --- /dev/null +++ b/gateway/handler_ipns_record_test.go @@ -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) + }) +} diff --git a/internal/ipnstest/ipnstest.go b/internal/ipnstest/ipnstest.go new file mode 100644 index 000000000..ec00b0c6e --- /dev/null +++ b/internal/ipnstest/ipnstest.go @@ -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) +} diff --git a/ipns/record.go b/ipns/record.go index 584d169b2..a1d1739ff 100644 --- a/ipns/record.go +++ b/ipns/record.go @@ -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 diff --git a/ipns/record_test.go b/ipns/record_test.go index 22fb49594..e6d71dc5a 100644 --- a/ipns/record_test.go +++ b/ipns/record_test.go @@ -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) diff --git a/ipns/validation.go b/ipns/validation.go index f8ad5f9f0..b4403807c 100644 --- a/ipns/validation.go +++ b/ipns/validation.go @@ -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 } diff --git a/ipns/validation_test.go b/ipns/validation_test.go index 337e0fd0f..a288616ee 100644 --- a/ipns/validation_test.go +++ b/ipns/validation_test.go @@ -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) { @@ -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) +} diff --git a/namesys/ipns_resolver.go b/namesys/ipns_resolver.go index c566aac93..96a16a09b 100644 --- a/namesys/ipns_resolver.go +++ b/namesys/ipns_resolver.go @@ -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 { @@ -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 { diff --git a/namesys/ipns_resolver_test.go b/namesys/ipns_resolver_test.go index c7dcd3b97..50fa39e0c 100644 --- a/namesys/ipns_resolver_test.go +++ b/namesys/ipns_resolver_test.go @@ -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" @@ -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) + }) +} diff --git a/namesys/namesys_cache.go b/namesys/namesys_cache.go index 531220b23..f42ed8a08 100644 --- a/namesys/namesys_cache.go +++ b/namesys/namesys_cache.go @@ -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 diff --git a/namesys/namesys_test.go b/namesys/namesys_test.go index 48310f025..01ab869fd 100644 --- a/namesys/namesys_test.go +++ b/namesys/namesys_test.go @@ -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" @@ -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) + }) +} diff --git a/routing/http/server/server.go b/routing/http/server/server.go index b96ac7588..1c590677a 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -553,13 +553,16 @@ func (s *server) GetIPNS(w http.ResponseWriter, r *http.Request) { remainingValidity = int(time.Until(validity).Seconds()) } } else { - remainingValidity = int(ipns.DefaultRecordLifetime.Seconds()) + // Unrecognized or unsupported ValidityType: the record's expiration is + // unknown, so it must not be cached (setIPNSCacheControl emits no-store + // for a non-positive remaining validity). + remainingValidity = 0 } - if ttl, err := record.TTL(); err == nil { - setCacheControl(w, int(ttl.Seconds()), remainingValidity) - } else { - setCacheControl(w, int(ipns.DefaultRecordTTL.Seconds()), remainingValidity) + ttl := int(ipns.DefaultRecordTTL.Seconds()) + if recordTTL, err := record.TTL(); err == nil { + ttl = int(recordTTL.Seconds()) } + setIPNSCacheControl(w, ttl, remainingValidity) w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat)) w.Header().Set("Etag", fmt.Sprintf(`"%x"`, xxhash.Sum64(rawRecord))) @@ -749,6 +752,24 @@ func setCacheControl(w http.ResponseWriter, maxAge int, stale int) { w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d, stale-if-error=%d", maxAge, stale, stale)) } +// setIPNSCacheControl sets Cache-Control for an IPNS record response. An IPNS +// record is cryptographically valid only until its EOL, so no cache may reuse +// the response past that point: doing so hands clients a record that fails +// validation. Both the freshness lifetime (max-age) and the stale-serving +// window (stale-while-revalidate, stale-if-error) are therefore bounded by the +// remaining validity. max-age is the record TTL capped to the remaining +// validity (and floored at zero, as a record may report a negative TTL), and +// the stale window covers whatever validity is left after it, so max-age+stale +// never exceeds EOL. An already-expired record is not cacheable. +func setIPNSCacheControl(w http.ResponseWriter, ttl int, remainingValidity int) { + if remainingValidity <= 0 { + w.Header().Set("Cache-Control", "no-store") + return + } + maxAge := min(max(0, ttl), remainingValidity) + setCacheControl(w, maxAge, remainingValidity-maxAge) +} + func writeJSONResult(w http.ResponseWriter, method string, val interface{ Length() int }) { w.Header().Add("Content-Type", mediaTypeJSON) w.Header().Add("Vary", "Accept") diff --git a/routing/http/server/server_test.go b/routing/http/server/server_test.go index 35fa25001..d995d8eea 100644 --- a/routing/http/server/server_test.go +++ b/routing/http/server/server_test.go @@ -14,12 +14,16 @@ import ( "testing" "time" + ipnstest "github.com/ipfs/boxo/internal/ipnstest" "github.com/ipfs/boxo/ipns" + ipns_pb "github.com/ipfs/boxo/ipns/pb" "github.com/ipfs/boxo/path" "github.com/ipfs/boxo/routing/http/filters" "github.com/ipfs/boxo/routing/http/types" "github.com/ipfs/boxo/routing/http/types/iter" "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime/codec/dagcbor" + basicnode "github.com/ipld/go-ipld-prime/node/basic" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/core/routing" @@ -27,6 +31,7 @@ import ( "github.com/multiformats/go-multiaddr" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" ) func TestHeaders(t *testing.T) { @@ -1258,6 +1263,50 @@ func makeIPNSRecord(t *testing.T, cid cid.Cid, eol time.Time, ttl time.Duration, return record, rawRecord } +// makeIPNSRecordWithValidityType returns a copy of base whose signed CBOR data +// carries the given ValidityType, used to exercise records the server cannot +// interpret (anything other than EOL). The signature carried over from base is +// left intact and is therefore invalid, which is fine here: the GetIPNS handler +// is a passthrough that does not verify records. +func makeIPNSRecordWithValidityType(t *testing.T, base *ipns.Record, validityType int64) *ipns.Record { + raw, err := ipns.MarshalRecord(base) + require.NoError(t, err) + + var pb ipns_pb.IpnsRecord + require.NoError(t, proto.Unmarshal(raw, &pb)) + + nb := basicnode.Prototype.Any.NewBuilder() + require.NoError(t, dagcbor.Decode(nb, bytes.NewReader(pb.Data))) + orig := nb.Build() + + ob := basicnode.Prototype.Map.NewBuilder() + ma, err := ob.BeginMap(orig.Length()) + require.NoError(t, err) + for it := orig.MapIterator(); !it.Done(); { + k, v, err := it.Next() + require.NoError(t, err) + ks, err := k.AsString() + require.NoError(t, err) + require.NoError(t, ma.AssembleKey().AssignString(ks)) + if ks == "ValidityType" { + require.NoError(t, ma.AssembleValue().AssignInt(validityType)) + } else { + require.NoError(t, ma.AssembleValue().AssignNode(v)) + } + } + require.NoError(t, ma.Finish()) + + var buf bytes.Buffer + require.NoError(t, dagcbor.Encode(ob.Build(), &buf)) + pb.Data = buf.Bytes() + + raw2, err := proto.Marshal(&pb) + require.NoError(t, err) + rec, err := ipns.UnmarshalRecord(raw2) + require.NoError(t, err) + return rec +} + func TestIPNS(t *testing.T) { cid1, err := cid.Decode("bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4") require.NoError(t, err) @@ -1333,6 +1382,92 @@ func TestIPNS(t *testing.T) { require.Equal(t, body, rawRecord1) }) + t.Run("GET /routing/v1/ipns/{cid-peer-id} caps Cache-Control to remaining validity", func(t *testing.T) { + t.Parallel() + + // TTL is longer than the remaining validity, so neither max-age nor + // the stale window may let a cache serve the record past its EOL. + eol := time.Now().Add(30 * time.Second) + _, rawRecord := makeIPNSRecord(t, cid1, eol, time.Hour, sk, opts...) + rec, err := ipns.UnmarshalRecord(rawRecord) + require.NoError(t, err) + + router := &mockContentRouter{} + router.On("GetIPNS", mock.Anything, name1).Return(rec, nil) + + resp := makeRequest(t, router, "/routing/v1/ipns/"+name1.String(), mediaTypeIPNSRecord) + require.Equal(t, http.StatusOK, resp.StatusCode) + + re := regexp.MustCompile(`(?:^|,\s*)(max-age|stale-while-revalidate|stale-if-error)=(\d+)`) + matches := re.FindAllStringSubmatch(resp.Header.Get("Cache-Control"), -1) + require.Len(t, matches, 3) + maxAge := stringToDuration(matches[0][2]) + staleWhileRevalidate := stringToDuration(matches[1][2]) + staleIfError := stringToDuration(matches[2][2]) + + // max-age must not exceed the remaining validity + require.LessOrEqual(t, maxAge, 30*time.Second) + // the full stale window must end at EOL, not after it (allow drift) + require.WithinDuration(t, eol, time.Now().Add(maxAge+staleWhileRevalidate), 1*time.Minute) + require.WithinDuration(t, eol, time.Now().Add(maxAge+staleIfError), 1*time.Minute) + }) + + t.Run("GET /routing/v1/ipns/{cid-peer-id} is not cacheable when expired", func(t *testing.T) { + t.Parallel() + + // An expired record must not be cached by anyone: serving it later + // hands the client a record that fails validation. + eol := time.Now().Add(-time.Hour) + _, rawRecord := makeIPNSRecord(t, cid1, eol, time.Hour, sk, opts...) + rec, err := ipns.UnmarshalRecord(rawRecord) + require.NoError(t, err) + + router := &mockContentRouter{} + router.On("GetIPNS", mock.Anything, name1).Return(rec, nil) + + resp := makeRequest(t, router, "/routing/v1/ipns/"+name1.String(), mediaTypeIPNSRecord) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "no-store", resp.Header.Get("Cache-Control")) + }) + + t.Run("GET /routing/v1/ipns/{cid-peer-id} floors a negative TTL to max-age=0", func(t *testing.T) { + t.Parallel() + + // A record may report a negative TTL; max-age must never go negative. + // Built at the wire level so the record carries a genuinely negative + // TTL that ipns.NewRecord would otherwise floor. + eol := time.Now().Add(time.Hour) + rec, err := ipnstest.RawRecordWithTTL(path.FromCid(cid1), eol, -time.Minute) + require.NoError(t, err) + + router := &mockContentRouter{} + router.On("GetIPNS", mock.Anything, name1).Return(rec, nil) + + resp := makeRequest(t, router, "/routing/v1/ipns/"+name1.String(), mediaTypeIPNSRecord) + require.Equal(t, http.StatusOK, resp.StatusCode) + cc := resp.Header.Get("Cache-Control") + require.Contains(t, cc, "public, max-age=0,") + require.NotContains(t, cc, "=-") // no negative directive values + }) + + t.Run("GET /routing/v1/ipns/{cid-peer-id} is not cacheable when validity type is unrecognized", func(t *testing.T) { + t.Parallel() + + // A record whose ValidityType is not EOL has an unknown expiration, + // so the server must not advertise a cache window for it. + eol := time.Now().Add(time.Hour) + base, _ := makeIPNSRecord(t, cid1, eol, time.Hour, sk, opts...) + rec := makeIPNSRecordWithValidityType(t, base, 1) + + router := &mockContentRouter{} + router.On("GetIPNS", mock.Anything, name1).Return(rec, nil) + + resp := makeRequest(t, router, "/routing/v1/ipns/"+name1.String(), mediaTypeIPNSRecord) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "no-store", resp.Header.Get("Cache-Control")) + require.Empty(t, resp.Header.Get("Expires")) + }) + t.Run("GET /routing/v1/ipns/{cid-peer-id} returns 200 (Accept header missing)", func(t *testing.T) { t.Parallel()