Skip to content

Commit 784e4ed

Browse files
authored
Merge branch 'main' into go-v1.26-and-go-fix
2 parents afbcdb6 + 63cae36 commit 784e4ed

11 files changed

Lines changed: 392 additions & 23 deletions

File tree

.github/workflows/gateway-conformance.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
steps:
2323
# 1. Download the gateway-conformance fixtures
2424
- name: Download gateway-conformance fixtures
25-
uses: ipfs/gateway-conformance/.github/actions/extract-fixtures@v0.13
25+
uses: ipfs/gateway-conformance/.github/actions/extract-fixtures@v0.14
2626
with:
2727
output: fixtures
2828
merged: true
@@ -47,7 +47,7 @@ jobs:
4747

4848
# 4. Run the gateway-conformance tests
4949
- name: Run gateway-conformance tests without IPNS and DNSLink
50-
uses: ipfs/gateway-conformance/.github/actions/test@v0.13
50+
uses: ipfs/gateway-conformance/.github/actions/test@v0.14
5151
with:
5252
gateway-url: http://127.0.0.1:8040
5353
subdomain-url: http://example.net:8040
@@ -84,7 +84,7 @@ jobs:
8484
steps:
8585
# 1. Download the gateway-conformance fixtures
8686
- name: Download gateway-conformance fixtures
87-
uses: ipfs/gateway-conformance/.github/actions/extract-fixtures@v0.13
87+
uses: ipfs/gateway-conformance/.github/actions/extract-fixtures@v0.14
8888
with:
8989
output: fixtures
9090
merged: true
@@ -114,7 +114,7 @@ jobs:
114114

115115
# 4. Run the gateway-conformance tests
116116
- name: Run gateway-conformance tests without IPNS and DNSLink
117-
uses: ipfs/gateway-conformance/.github/actions/test@v0.13
117+
uses: ipfs/gateway-conformance/.github/actions/test@v0.14
118118
with:
119119
gateway-url: http://127.0.0.1:8040 # we test gateway that is backed by a remote block gateway
120120
subdomain-url: http://example.net:8040
@@ -152,7 +152,7 @@ jobs:
152152
steps:
153153
# 1. Download the gateway-conformance fixtures
154154
- name: Download gateway-conformance fixtures
155-
uses: ipfs/gateway-conformance/.github/actions/extract-fixtures@v0.13
155+
uses: ipfs/gateway-conformance/.github/actions/extract-fixtures@v0.14
156156
with:
157157
output: fixtures
158158
merged: true
@@ -182,7 +182,7 @@ jobs:
182182

183183
# 4. Run the gateway-conformance tests
184184
- name: Run gateway-conformance tests without IPNS and DNSLink
185-
uses: ipfs/gateway-conformance/.github/actions/test@v0.13
185+
uses: ipfs/gateway-conformance/.github/actions/test@v0.14
186186
with:
187187
gateway-url: http://127.0.0.1:8040 # we test gateway that is backed by a remote car gateway
188188
subdomain-url: http://example.net:8040

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,20 @@ The following emojis are used to highlight certain changes:
1616

1717
### Added
1818

19+
-`gateway`: responses now include the `Ipfs-Uri` header with a canonical `ipfs://` or `ipns://` URI for the requested content path, and expose it via the default `Access-Control-Expose-Headers`. The header carries the content root in canonical form (base32 CIDv1 for `/ipfs/`, base36 CIDv1 for cryptographic `/ipns/` names, lowercase FQDN for DNSLink) with percent-encoded path segments, so clients get a value that is safe in HTTP field context regardless of bytes in the underlying path. [IPIP-548](https://github.com/ipfs/specs/pull/548) [#1209](https://github.com/ipfs/boxo/pull/1209)
20+
1921
### Changed
2022

2123
- updated Go in `go.mod` to 1.26.0
24+
- 🛠 `gateway`: the deprecated `X-Ipfs-Path` response header is no longer sent by default; its value cannot represent all UnixFS file names and it is superseded by `Ipfs-Uri`. **Action required:** consumers that read `X-Ipfs-Path` should migrate to `Ipfs-Uri`; to restore the legacy header meanwhile, set `Config.DeprecatedXIpfsPath` and call `Headers.WithDeprecatedXIpfsPath` before `Headers.ApplyCors` so it is listed in `Access-Control-Expose-Headers` again. Even with the flag set, the header is omitted for content paths with bytes that cannot appear in an HTTP field value (Section 5.5 of RFC 9110), such as raw non-ASCII UnixFS file names: gateway-conformance fails a gateway that sends such values, and only `Ipfs-Uri` carries those paths. [IPIP-548](https://github.com/ipfs/specs/pull/548) [#1209](https://github.com/ipfs/boxo/pull/1209)
25+
- `gateway`: conformance CI runs [gateway-conformance v0.14.0](https://github.com/ipfs/gateway-conformance/releases/tag/v0.14.0), the first release with the IPIP-548 `Ipfs-Uri` tests. [#1209](https://github.com/ipfs/boxo/pull/1209)
2226

2327
### Removed
2428

2529
### Fixed
2630

31+
- `gateway`: `X-Ipfs-Path` values no longer carry bytes that are invalid in an HTTP field value (Section 5.5 of RFC 9110). The header used to echo raw UnixFS file names, so non-ASCII paths arrived garbled or broke strict clients; when the header is enabled, it is now omitted for such paths, which only the percent-encoded `Ipfs-Uri` can carry. [#1209](https://github.com/ipfs/boxo/pull/1209)
32+
2733
### Security
2834

2935
## [v0.42.2]

docs/CODEOWNERS

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,3 @@
44

55
# Deafult
66
* @ipfs/kubo-maintainers
7-
8-
# HTTP Gateway
9-
gateway/ @lidel

gateway/gateway.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ type Config struct {
7979
// is being proxied by other service, which wants to use the error message.
8080
DisableHTMLErrors bool
8181

82+
// DeprecatedXIpfsPath configures the gateway to send the legacy
83+
// X-Ipfs-Path response header, deprecated by [IPIP-0548]. Disabled by
84+
// default: the legacy value cannot represent all UnixFS file names (raw
85+
// non-ASCII bytes are mangled per RFC 9110 field-value rules) and it is
86+
// superseded by the Ipfs-Uri header, which is sent whenever the content
87+
// root can be normalized. Enable only for backward compatibility with
88+
// legacy consumers that still expect X-Ipfs-Path. When enabled, pair it
89+
// with [Headers.WithDeprecatedXIpfsPath] so the header is also listed in
90+
// Access-Control-Expose-Headers. Even when enabled, the header is
91+
// omitted for content paths that contain bytes that cannot appear in an
92+
// HTTP field value (Section 5.5 of RFC 9110), per [IPIP-0548].
93+
//
94+
// [IPIP-0548]: https://github.com/ipfs/specs/pull/548
95+
DeprecatedXIpfsPath bool
96+
8297
// PublicGateways configures the behavior of known public gateways. Each key is
8398
// a fully qualified domain name (FQDN). To be used with WithHostname.
8499
PublicGateways map[string]*PublicGateway

gateway/gateway_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,101 @@ func TestHeaders(t *testing.T) {
380380
test(dagCborResponseFormat, dagCborPath, dagCborRoots)
381381
})
382382

383+
t.Run("Ipfs-Uri contains expected values", func(t *testing.T) {
384+
test := func(responseFormat string, path string, uri string) {
385+
t.Run(responseFormat+" "+path, func(t *testing.T) {
386+
url := ts.URL + path
387+
req := mustNewRequest(t, http.MethodGet, url, nil)
388+
req.Header.Add("Accept", responseFormat)
389+
res := mustDoWithoutRedirect(t, req)
390+
_, err := io.Copy(io.Discard, res.Body)
391+
require.NoError(t, err)
392+
defer res.Body.Close()
393+
require.Equal(t, http.StatusOK, res.StatusCode)
394+
require.Equal(t, uri, res.Header.Get("Ipfs-Uri"))
395+
})
396+
}
397+
398+
test("", filePath, "ipfs://"+rootCID+"/subdir/fnord")
399+
test("text/html", dirPath, "ipfs://"+rootCID+"/subdir/")
400+
test("text/html", "/ipfs/"+rootCID+"/", "ipfs://"+rootCID+"/")
401+
test("text/html", hamtFilePath, "ipfs://"+rootCID+"/hamt/685.txt")
402+
test(dagJsonResponseFormat, dagCborPath, "ipfs://"+rootCID+"/subdir/dag-cbor-document")
403+
})
404+
405+
t.Run("Ipfs-Uri is sent with 412 from Cache-Control: only-if-cached", func(t *testing.T) {
406+
// Valid CID that is missing from the fixture, so only-if-cached
407+
// returns 412 before the response format is known.
408+
missingCID := "bafkreicm2cerwpdtah2rd7rxg5jcaqsj52blfuaprkurromr5y6p3a5zlu"
409+
req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipfs/"+missingCID, nil)
410+
req.Header.Add("Cache-Control", "only-if-cached")
411+
res := mustDoWithoutRedirect(t, req)
412+
_, err := io.Copy(io.Discard, res.Body)
413+
require.NoError(t, err)
414+
defer res.Body.Close()
415+
require.Equal(t, http.StatusPreconditionFailed, res.StatusCode)
416+
require.Equal(t, "ipfs://"+missingCID, res.Header.Get("Ipfs-Uri"))
417+
})
418+
419+
t.Run("Ipfs-Uri is sent with 400 from malformed Accept header", func(t *testing.T) {
420+
req := mustNewRequest(t, http.MethodGet, ts.URL+filePath, nil)
421+
req.Header.Add("Accept", "application/vnd.ipld.car; version=1; version=2")
422+
res := mustDoWithoutRedirect(t, req)
423+
_, err := io.Copy(io.Discard, res.Body)
424+
require.NoError(t, err)
425+
defer res.Body.Close()
426+
require.Equal(t, http.StatusBadRequest, res.StatusCode)
427+
require.Equal(t, "ipfs://"+rootCID+"/subdir/fnord", res.Header.Get("Ipfs-Uri"))
428+
})
429+
430+
t.Run("X-Ipfs-Path is not sent by default", func(t *testing.T) {
431+
req := mustNewRequest(t, http.MethodGet, ts.URL+filePath, nil)
432+
res := mustDoWithoutRedirect(t, req)
433+
_, err := io.Copy(io.Discard, res.Body)
434+
require.NoError(t, err)
435+
defer res.Body.Close()
436+
require.Equal(t, http.StatusOK, res.StatusCode)
437+
require.Empty(t, res.Header.Get("X-Ipfs-Path"))
438+
require.NotContains(t, res.Header.Values("Access-Control-Expose-Headers"), "X-Ipfs-Path")
439+
require.Contains(t, res.Header.Values("Access-Control-Expose-Headers"), "Ipfs-Uri")
440+
})
441+
442+
t.Run("X-Ipfs-Path is sent when DeprecatedXIpfsPath is enabled", func(t *testing.T) {
443+
ts := newTestServerWithConfig(t, backend, Config{
444+
DeserializedResponses: true,
445+
DeprecatedXIpfsPath: true,
446+
})
447+
req := mustNewRequest(t, http.MethodGet, ts.URL+filePath, nil)
448+
res := mustDoWithoutRedirect(t, req)
449+
_, err := io.Copy(io.Discard, res.Body)
450+
require.NoError(t, err)
451+
defer res.Body.Close()
452+
require.Equal(t, http.StatusOK, res.StatusCode)
453+
require.Equal(t, filePath, res.Header.Get("X-Ipfs-Path"))
454+
require.Equal(t, "ipfs://"+rootCID+"/subdir/fnord", res.Header.Get("Ipfs-Uri"))
455+
require.Contains(t, res.Header.Values("Access-Control-Expose-Headers"), "X-Ipfs-Path")
456+
})
457+
458+
t.Run("X-Ipfs-Path is omitted for field-value-unsafe paths even when DeprecatedXIpfsPath is enabled", func(t *testing.T) {
459+
// The content path /ipfs/{cid}/łódź.txt contains raw non-ASCII
460+
// bytes, which cannot appear in an HTTP field value (Section 5.5
461+
// of RFC 9110), so the legacy header must be omitted despite the
462+
// flag; Ipfs-Uri carries the percent-encoded path instead.
463+
backend, root := newMockBackend(t, "dir-with-tricky-filenames.car")
464+
ts := newTestServerWithConfig(t, backend, Config{
465+
DeserializedResponses: true,
466+
DeprecatedXIpfsPath: true,
467+
})
468+
req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipfs/"+root.String()+"/%C5%82%C3%B3d%C5%BA.txt", nil)
469+
res := mustDoWithoutRedirect(t, req)
470+
_, err := io.Copy(io.Discard, res.Body)
471+
require.NoError(t, err)
472+
defer res.Body.Close()
473+
require.Equal(t, http.StatusOK, res.StatusCode)
474+
require.Empty(t, res.Header.Get("X-Ipfs-Path"))
475+
require.Equal(t, "ipfs://"+root.String()+"/%C5%82%C3%B3d%C5%BA.txt", res.Header.Get("Ipfs-Uri"))
476+
})
477+
383478
t.Run("If-None-Match with wrong value forces path resolution, but X-Ipfs-Roots is correct (regression)", func(t *testing.T) {
384479
test := func(responseFormat string, path string, roots string) {
385480
t.Run(responseFormat, func(t *testing.T) {

gateway/handler.go

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"go.opentelemetry.io/otel/attribute"
2929
"go.opentelemetry.io/otel/trace"
3030
"go.uber.org/zap"
31+
"golang.org/x/net/idna"
3132
)
3233

3334
var log = logging.Logger("boxo/gateway")
@@ -36,6 +37,11 @@ const (
3637
ipfsPathPrefix = "/ipfs/"
3738
ipnsPathPrefix = ipns.NamespacePrefix
3839
immutableCacheControl = "public, max-age=29030400, immutable"
40+
41+
// maxIpfsUriLength caps the value of the Ipfs-Uri response header.
42+
// Longer values are not sent (IPIP-0548 allows omission) to stay under
43+
// common per-field limits in reverse proxies.
44+
maxIpfsUriLength = 8192
3945
)
4046

4147
var (
@@ -261,6 +267,16 @@ func (i *handler) getOrHeadHandler(w http.ResponseWriter, r *http.Request) {
261267
}
262268
}()
263269

270+
// Set as soon as the content path is known, so redirect and error
271+
// responses below carry it too (IPIP-0548). The content path does not
272+
// change after this point.
273+
if i.config.DeprecatedXIpfsPath && isFieldValueSafe(contentPath.String()) {
274+
w.Header().Set("X-Ipfs-Path", contentPath.String())
275+
}
276+
if uri, ok := ipfsUriHeaderValue(contentPath); ok && len(uri) <= maxIpfsUriLength {
277+
w.Header().Set("Ipfs-Uri", uri)
278+
}
279+
264280
if i.handleOnlyIfCached(w, r, contentPath) {
265281
return
266282
}
@@ -274,8 +290,6 @@ func (i *handler) getOrHeadHandler(w http.ResponseWriter, r *http.Request) {
274290
trace.SpanFromContext(r.Context()).SetAttributes(attribute.String("ResponseFormat", responseFormat))
275291
i.requestTypeMetric.WithLabelValues(contentPath.Namespace(), responseFormat).Inc()
276292

277-
w.Header().Set("X-Ipfs-Path", contentPath.String())
278-
279293
// Fail fast if unsupported request type was sent to a Trustless Gateway.
280294
if !i.isDeserializedResponsePossible(r) && !i.isTrustlessRequest(contentPath, responseFormat) {
281295
err := errors.New("only trustless requests are accepted on this gateway: https://specs.ipfs.tech/http-gateways/trustless-gateway/")
@@ -545,6 +559,123 @@ func setIpfsRootsHeader(w http.ResponseWriter, rq *requestData, md *ContentPathM
545559
w.Header().Set("X-Ipfs-Roots", rootCidList)
546560
}
547561

562+
// isFieldValueSafe reports whether s can be carried byte-for-byte in an HTTP
563+
// field value: only HTAB (0x09), SP (0x20), and visible ASCII (0x21-0x7E)
564+
// are allowed (Section 5.5 of RFC 9110). CR, LF, and NUL are rejected or
565+
// replaced by HTTP software, other control characters are invalid, and
566+
// non-ASCII bytes arrive garbled, so the legacy X-Ipfs-Path header MUST be
567+
// omitted for such content paths (IPIP-0548); Ipfs-Uri percent-encodes them
568+
// instead.
569+
func isFieldValueSafe(s string) bool {
570+
for i := 0; i < len(s); i++ {
571+
if c := s[i]; c != '\t' && (c < 0x20 || c > 0x7E) {
572+
return false
573+
}
574+
}
575+
return true
576+
}
577+
578+
// ipfsUriHeaderValue builds the value of the Ipfs-Uri response header for the
579+
// given content path (IPIP-0548): an ipfs:// or ipns:// URI with the content
580+
// root in its canonical text form, and a URI path that mirrors the content
581+
// path remainder (everything after "/{namespace}/{root}") with every segment
582+
// percent-encoded, so a trailing slash is kept. It returns false when the
583+
// namespace has no URI scheme or the content root cannot be normalized, in
584+
// which case the header is not sent.
585+
func ipfsUriHeaderValue(contentPath path.Path) (string, bool) {
586+
segments := contentPath.Segments()
587+
if len(segments) < 2 {
588+
return "", false
589+
}
590+
591+
var authority string
592+
switch contentPath.Namespace() {
593+
case path.IPFSNamespace:
594+
// Canonical form: CIDv1 in lowercase base32.
595+
c, err := cid.Decode(segments[1])
596+
if err != nil {
597+
return "", false
598+
}
599+
authority, err = cid.NewCidV1(c.Type(), c.Hash()).StringOfBase(multibase.Base32)
600+
if err != nil {
601+
return "", false
602+
}
603+
case path.IPNSNamespace:
604+
if name, err := ipns.NameFromString(segments[1]); err == nil {
605+
// Canonical form for cryptographic IPNS names: CIDv1 in
606+
// lowercase base36, preserving the multicodec. ipns.Name
607+
// covers libp2p-key, the only codec in use today; roots with
608+
// other codecs fall through to the DNSLink branch, fail its
609+
// checks, and the header is omitted, as IPIP-0548 allows.
610+
authority = name.String()
611+
} else {
612+
// DNSLink: canonical form is the lowercase FQDN as A-labels,
613+
// without the optional trailing dot. Omit the header when the
614+
// root does not convert to A-labels or the result is not a
615+
// multi-label DNS name.
616+
host, err := idna.Lookup.ToASCII(strings.TrimSuffix(segments[1], "."))
617+
if err != nil || !strings.Contains(host, ".") ||
618+
strings.Contains(host, "..") || strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") {
619+
return "", false
620+
}
621+
authority = host
622+
}
623+
default:
624+
return "", false
625+
}
626+
627+
var b strings.Builder
628+
b.WriteString(contentPath.Namespace())
629+
b.WriteString("://")
630+
b.WriteString(authority)
631+
// The URI path mirrors the content path remainder: split on "/", each
632+
// segment percent-encoded, rejoined with "/". The content path is already
633+
// normalized (path.NewPath collapses duplicate slashes and dot segments
634+
// but keeps a trailing slash), so the remainder here is "", "/", or
635+
// "/segment..." with an optional trailing slash, all of which the header
636+
// value reproduces.
637+
remainder := strings.TrimPrefix(contentPath.String(), "/"+segments[0]+"/"+segments[1])
638+
if remainder != "" {
639+
for _, segment := range strings.Split(remainder[1:], "/") {
640+
b.WriteByte('/')
641+
b.WriteString(encodeIpfsUriSegment(segment))
642+
}
643+
}
644+
return b.String(), true
645+
}
646+
647+
// encodeIpfsUriSegment percent-encodes a single content path segment for use
648+
// in an Ipfs-Uri value: every byte outside the RFC 3986 unreserved set becomes
649+
// %XX with uppercase hex, so the output is ASCII-only and byte-identical
650+
// across implementations. This is stricter than url.PathEscape, which leaves
651+
// sub-delims like "&" and "=" unencoded. A segment that is exactly "." or
652+
// ".." is emitted fully percent-encoded (IPIP-0548), so it cannot be taken
653+
// for a relative dot segment; such segments never survive the gateway's
654+
// request path normalization, but the encoding is total for any input.
655+
func encodeIpfsUriSegment(segment string) string {
656+
switch segment {
657+
case ".":
658+
return "%2E"
659+
case "..":
660+
return "%2E%2E"
661+
}
662+
const upperhex = "0123456789ABCDEF"
663+
var b strings.Builder
664+
b.Grow(len(segment))
665+
for i := 0; i < len(segment); i++ {
666+
c := segment[i]
667+
if 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z' || '0' <= c && c <= '9' ||
668+
c == '-' || c == '.' || c == '_' || c == '~' {
669+
b.WriteByte(c)
670+
} else {
671+
b.WriteByte('%')
672+
b.WriteByte(upperhex[c>>4])
673+
b.WriteByte(upperhex[c&0xF])
674+
}
675+
}
676+
return b.String()
677+
}
678+
548679
// lastModifiedMatch returns true if we can respond with HTTP 304 Not Modified
549680
// It compares If-Modified-Since with logical modification time read from DAG
550681
// (e.g. UnixFS 1.5 modtime, if present)

0 commit comments

Comments
 (0)