From bd309219f893b8aa38ef29e1407ac69742ffd950 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 3 Jun 2026 17:32:55 +0200 Subject: [PATCH 1/7] fix: bound ipns cache-control to record eol max-age plus stale-while-revalidate could extend past an IPNS record's EOL, letting caches serve an expired record that then fails validation. Cap max-age to the remaining validity and size the stale window to whatever validity is left, so max-age+stale never crosses EOL. An already-expired record is returned with Cache-Control: no-store. --- CHANGELOG.md | 2 ++ routing/http/server/server.go | 25 +++++++++++++--- routing/http/server/server_test.go | 48 ++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a31c0da..abf17c4fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ The following emojis are used to highlight certain changes: ### Fixed +- `routing/http/server`: `GET /routing/v1/ipns/{name}` no longer advertises a cache window that outlives the record. `max-age` is capped to the record's remaining validity and `stale-while-revalidate`/`stale-if-error` cover only the validity left after it, so `max-age`+stale never crosses the record's EOL. An already-expired record is returned with `Cache-Control: no-store`. Previously the stale window could extend past EOL, letting caches serve an expired record that then fails validation. + ### 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/routing/http/server/server.go b/routing/http/server/server.go index b96ac7588..4fc56b119 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -555,11 +555,11 @@ func (s *server) GetIPNS(w http.ResponseWriter, r *http.Request) { } else { remainingValidity = int(ipns.DefaultRecordLifetime.Seconds()) } - 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 +749,23 @@ 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 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(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..243c6c1eb 100644 --- a/routing/http/server/server_test.go +++ b/routing/http/server/server_test.go @@ -1333,6 +1333,54 @@ 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} returns 200 (Accept header missing)", func(t *testing.T) { t.Parallel() From 94dc86a6447cf19bec3d002e0f09a8deba77d7ed Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 5 Jun 2026 14:59:06 +0200 Subject: [PATCH 2/7] fix: floor ipns cache-control max-age at zero A record's TTL is read from CBOR without a sign check, so a negative value would make min(ttl, remainingValidity) emit a malformed Cache-Control: max-age=-N. Floor ttl at zero before capping. --- CHANGELOG.md | 2 +- routing/http/server/server.go | 7 ++++--- routing/http/server/server_test.go | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abf17c4fb..568b4a531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ The following emojis are used to highlight certain changes: ### Fixed -- `routing/http/server`: `GET /routing/v1/ipns/{name}` no longer advertises a cache window that outlives the record. `max-age` is capped to the record's remaining validity and `stale-while-revalidate`/`stale-if-error` cover only the validity left after it, so `max-age`+stale never crosses the record's EOL. An already-expired record is returned with `Cache-Control: no-store`. Previously the stale window could extend past EOL, letting caches serve an expired record that then fails validation. +- `routing/http/server`: `GET /routing/v1/ipns/{name}` no longer advertises a cache window that outlives the record. `max-age` is capped to the record's remaining validity and `stale-while-revalidate`/`stale-if-error` cover only the validity left after it, so `max-age`+stale never crosses the record's EOL. An already-expired record is returned with `Cache-Control: no-store`, and a record reporting a negative TTL no longer yields a negative `max-age`. Previously the stale window could extend past EOL, letting caches serve an expired record that then fails validation. ### Security diff --git a/routing/http/server/server.go b/routing/http/server/server.go index 4fc56b119..a0aa75e00 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -755,14 +755,15 @@ func setCacheControl(w http.ResponseWriter, maxAge int, stale int) { // 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 the stale window covers whatever validity is left after it, so -// max-age+stale never exceeds EOL. An already-expired record is not cacheable. +// 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(ttl, remainingValidity) + maxAge := min(max(0, ttl), remainingValidity) setCacheControl(w, maxAge, remainingValidity-maxAge) } diff --git a/routing/http/server/server_test.go b/routing/http/server/server_test.go index 243c6c1eb..e978f6e36 100644 --- a/routing/http/server/server_test.go +++ b/routing/http/server/server_test.go @@ -1381,6 +1381,25 @@ func TestIPNS(t *testing.T) { 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. + eol := time.Now().Add(time.Hour) + _, rawRecord := makeIPNSRecord(t, cid1, eol, -time.Minute, 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) + 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} returns 200 (Accept header missing)", func(t *testing.T) { t.Parallel() From 766b2c00bed1ca7cac851f5af9867f18840b2027 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 5 Jun 2026 15:37:49 +0200 Subject: [PATCH 3/7] fix: clamp gateway ipns-record max-age to eol The raw IPNS record handler set max-age straight from record.TTL(), bypassing the resolver's EOL clamp, so a cache could reuse the record past its signature expiry; a negative TTL also yielded a negative max-age. Cap max-age to the remaining validity and floor it at zero. --- CHANGELOG.md | 3 +- gateway/handler_ipns_record.go | 24 +++++++++++- gateway/handler_ipns_record_test.go | 61 +++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 gateway/handler_ipns_record_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 568b4a531..d52faa92a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,8 @@ The following emojis are used to highlight certain changes: ### Fixed -- `routing/http/server`: `GET /routing/v1/ipns/{name}` no longer advertises a cache window that outlives the record. `max-age` is capped to the record's remaining validity and `stale-while-revalidate`/`stale-if-error` cover only the validity left after it, so `max-age`+stale never crosses the record's EOL. An already-expired record is returned with `Cache-Control: no-store`, and a record reporting a negative TTL no longer yields a negative `max-age`. Previously the stale window could extend past EOL, letting caches serve an expired record that then fails validation. +- `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 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) ### Security 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..d4a49566c --- /dev/null +++ b/gateway/handler_ipns_record_test.go @@ -0,0 +1,61 @@ +package gateway + +import ( + "crypto/rand" + "testing" + "time" + + "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() + rec := makeRecord(t, -time.Minute, time.Now().Add(time.Hour)) + maxAge, ok := ipnsRecordMaxAge(rec) + require.True(t, ok) + require.Equal(t, 0, maxAge) + }) +} From 6f25dcfcf2221f5af593971e1e341eaaa9460acf Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 5 Jun 2026 16:35:35 +0200 Subject: [PATCH 4/7] fix: floor negative ipns ttl in resolver calculateBestTTL clamped a record TTL down to the remaining EOL validity but never floored a negative record TTL, so a malformed record with a future EOL leaked a negative duration through namesys.Result.TTL. Floor the record TTL at zero. --- CHANGELOG.md | 1 + namesys/ipns_resolver.go | 6 +++-- namesys/ipns_resolver_test.go | 42 +++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d52faa92a..5c605f5bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ The following emojis are used to highlight certain changes: - `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 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) ### Security 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..dd3dfb6e1 100644 --- a/namesys/ipns_resolver_test.go +++ b/namesys/ipns_resolver_test.go @@ -129,3 +129,45 @@ 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() + got, err := calculateBestTTL(makeRecord(t, -time.Minute, time.Now().Add(time.Hour))) + require.NoError(t, err) + require.Equal(t, time.Duration(0), got) + }) +} From 9aa626e30cc80a86a37033f51a63544d1d5a0764 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 5 Jun 2026 16:38:39 +0200 Subject: [PATCH 5/7] fix: no-store ipns records with unknown validity GET /routing/v1/ipns/{name} assumed the default 48h record lifetime for any record whose ValidityType was not EOL (or could not be parsed), advertising a stale-serving window for a record whose expiration is unknown. Treat such records as uncacheable instead. --- CHANGELOG.md | 2 +- routing/http/server/server.go | 5 ++- routing/http/server/server_test.go | 66 ++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c605f5bd..9d6e3ad84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ 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 returns `Cache-Control: no-store`, and a negative TTL no longer yields a negative `max-age`. [#1166](https://github.com/ipfs/boxo/pull/1166) +- `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) diff --git a/routing/http/server/server.go b/routing/http/server/server.go index a0aa75e00..1c590677a 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -553,7 +553,10 @@ 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 } ttl := int(ipns.DefaultRecordTTL.Seconds()) if recordTTL, err := record.TTL(); err == nil { diff --git a/routing/http/server/server_test.go b/routing/http/server/server_test.go index e978f6e36..1a2f601ca 100644 --- a/routing/http/server/server_test.go +++ b/routing/http/server/server_test.go @@ -15,11 +15,14 @@ import ( "time" "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 +30,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 +1262,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) @@ -1400,6 +1448,24 @@ func TestIPNS(t *testing.T) { 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() From 420d48ab32c71c41ff0b1876cdf527fbec6f7fbb Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 5 Jun 2026 16:50:59 +0200 Subject: [PATCH 6/7] fix: clamp cached ipns ttl to cache lifetime On a cache hit the resolver returned the record's original TTL, so a hit late in the cache window re-advertised the full TTL and could let a downstream cache outlive the record's EOL. Return the cache entry's remaining lifetime instead, which is already bounded by the EOL. --- CHANGELOG.md | 1 + namesys/namesys_cache.go | 6 +++++- namesys/namesys_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6e3ad84..2a3a084b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ The following emojis are used to highlight certain changes: - `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) ### Security 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) + }) +} From 55fd621d1872ce2685e7bd53b352feef2577741d Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 5 Jun 2026 17:50:29 +0200 Subject: [PATCH 7/7] fix: floor and reject negative ipns ttl A negative TTL was stored verbatim: the CBOR field went negative and the v1 uint64 protobuf field underflowed to a huge value. NewRecord now floors the TTL at zero, and Validate rejects a record whose CBOR TTL is negative. Tests that need a genuinely negative TTL build records at the wire level via internal/ipnstest, since NewRecord no longer mints them. --- CHANGELOG.md | 1 + gateway/handler_ipns_record_test.go | 6 +++- internal/ipnstest/ipnstest.go | 49 +++++++++++++++++++++++++++++ ipns/record.go | 4 +++ ipns/record_test.go | 15 +++++++++ ipns/validation.go | 5 +++ ipns/validation_test.go | 29 +++++++++++++++++ namesys/ipns_resolver_test.go | 7 ++++- routing/http/server/server_test.go | 6 ++-- 9 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 internal/ipnstest/ipnstest.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a3a084b7..155ea4882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The following emojis are used to highlight certain changes: - `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 diff --git a/gateway/handler_ipns_record_test.go b/gateway/handler_ipns_record_test.go index d4a49566c..ea6b81979 100644 --- a/gateway/handler_ipns_record_test.go +++ b/gateway/handler_ipns_record_test.go @@ -5,6 +5,7 @@ import ( "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" @@ -53,7 +54,10 @@ func TestIPNSRecordMaxAge(t *testing.T) { t.Run("negative ttl is floored to max-age=0", func(t *testing.T) { t.Parallel() - rec := makeRecord(t, -time.Minute, time.Now().Add(time.Hour)) + // 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_test.go b/namesys/ipns_resolver_test.go index dd3dfb6e1..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" @@ -166,7 +167,11 @@ func TestCalculateBestTTL(t *testing.T) { t.Run("negative record ttl is floored to zero with valid EOL", func(t *testing.T) { t.Parallel() - got, err := calculateBestTTL(makeRecord(t, -time.Minute, time.Now().Add(time.Hour))) + // 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/routing/http/server/server_test.go b/routing/http/server/server_test.go index 1a2f601ca..d995d8eea 100644 --- a/routing/http/server/server_test.go +++ b/routing/http/server/server_test.go @@ -14,6 +14,7 @@ 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" @@ -1433,9 +1434,10 @@ func TestIPNS(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) - _, rawRecord := makeIPNSRecord(t, cid1, eol, -time.Minute, sk, opts...) - rec, err := ipns.UnmarshalRecord(rawRecord) + rec, err := ipnstest.RawRecordWithTTL(path.FromCid(cid1), eol, -time.Minute) require.NoError(t, err) router := &mockContentRouter{}