From f745ea7ce5aaeceb6b38c9c792965fc6865832b6 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Mon, 19 Jun 2023 14:28:08 +0200 Subject: [PATCH 1/4] docs: prepare changelog for next release [ci skip] (cherry picked from commit 1ec05d754868ab80af629b4a9401ee575cecbe1e) --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 479f730ed..9884d18bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,20 +14,38 @@ The following emojis are used to highlight certain changes: ## [Unreleased] +### Added + +### Changed + +### Removed + +### Fixed + +### Security + ## [0.10.1] - 2023-06-19 ### Added +None. + ### Changed +None. + ### Removed +None. + ### Fixed - Allow CAR requests with a path when `DeserializedResponses` is `false`. ### Security +None. + ## [0.10.0] - 2023-06-09 ### Added From e61ca524e6e40ae592d328cff32c569315bbf9e5 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Tue, 27 Jun 2023 12:00:34 +0200 Subject: [PATCH 2/4] fix(gateway): ensure 'X-Ipfs-Root' header is valid (#337) Co-authored-by: Marcin Rataj (cherry picked from commit 72238ea9879851695ba514b8bfb35f2f8634dfae) --- gateway/errors_test.go | 39 +- gateway/gateway_test.go | 1207 +++++++++--------- gateway/handler.go | 112 +- gateway/handler_block.go | 15 +- gateway/handler_car.go | 23 +- gateway/handler_car_test.go | 27 +- gateway/handler_codec.go | 41 +- gateway/handler_codec_test.go | 80 ++ gateway/handler_defaults.go | 38 +- gateway/handler_ipns_record.go | 14 +- gateway/handler_tar.go | 18 +- gateway/handler_test.go | 201 --- gateway/handler_unixfs_dir_test.go | 87 ++ gateway/hostname_test.go | 38 +- gateway/lazyseek_test.go | 44 +- gateway/testdata/dir-special-chars.car | Bin 0 -> 554 bytes gateway/testdata/fixtures.car | Bin 2647 -> 1240 bytes gateway/testdata/headers-test.car | Bin 0 -> 85410 bytes gateway/testdata/ipns-hostname-redirects.car | Bin 0 -> 325 bytes gateway/testdata/pretty-404.car | Bin 0 -> 405 bytes gateway/utilities_test.go | 238 ++++ 21 files changed, 1212 insertions(+), 1010 deletions(-) create mode 100644 gateway/handler_codec_test.go create mode 100644 gateway/handler_unixfs_dir_test.go create mode 100644 gateway/testdata/dir-special-chars.car create mode 100644 gateway/testdata/headers-test.car create mode 100644 gateway/testdata/ipns-hostname-redirects.car create mode 100644 gateway/testdata/pretty-404.car create mode 100644 gateway/utilities_test.go diff --git a/gateway/errors_test.go b/gateway/errors_test.go index 223d80fba..4f251822e 100644 --- a/gateway/errors_test.go +++ b/gateway/errors_test.go @@ -8,32 +8,35 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestErrRetryAfterIs(t *testing.T) { + t.Parallel() var err error err = NewErrorRetryAfter(errors.New("test"), 10*time.Second) - assert.True(t, errors.Is(err, &ErrorRetryAfter{}), "pointer to error must be error") + require.True(t, errors.Is(err, &ErrorRetryAfter{}), "pointer to error must be error") err = fmt.Errorf("wrapped: %w", err) - assert.True(t, errors.Is(err, &ErrorRetryAfter{}), "wrapped pointer to error must be error") + require.True(t, errors.Is(err, &ErrorRetryAfter{}), "wrapped pointer to error must be error") } func TestErrRetryAfterAs(t *testing.T) { + t.Parallel() + var ( err error errRA *ErrorRetryAfter ) err = NewErrorRetryAfter(errors.New("test"), 25*time.Second) - assert.True(t, errors.As(err, &errRA), "pointer to error must be error") - assert.EqualValues(t, errRA.RetryAfter, 25*time.Second) + require.True(t, errors.As(err, &errRA), "pointer to error must be error") + require.EqualValues(t, errRA.RetryAfter, 25*time.Second) err = fmt.Errorf("wrapped: %w", err) - assert.True(t, errors.As(err, &errRA), "wrapped pointer to error must be error") - assert.EqualValues(t, errRA.RetryAfter, 25*time.Second) + require.True(t, errors.As(err, &errRA), "wrapped pointer to error must be error") + require.EqualValues(t, errRA.RetryAfter, 25*time.Second) } func TestWebError(t *testing.T) { @@ -43,37 +46,45 @@ func TestWebError(t *testing.T) { config := &Config{Headers: map[string][]string{}} t.Run("429 Too Many Requests", func(t *testing.T) { + t.Parallel() + err := fmt.Errorf("wrapped for testing: %w", NewErrorRetryAfter(ErrTooManyRequests, 0)) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/blah", nil) webError(w, r, config, err, http.StatusInternalServerError) - assert.Equal(t, http.StatusTooManyRequests, w.Result().StatusCode) - assert.Zero(t, len(w.Result().Header.Values("Retry-After"))) + require.Equal(t, http.StatusTooManyRequests, w.Result().StatusCode) + require.Zero(t, len(w.Result().Header.Values("Retry-After"))) }) t.Run("429 Too Many Requests with Retry-After header", func(t *testing.T) { + t.Parallel() + err := NewErrorRetryAfter(ErrTooManyRequests, 25*time.Second) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/blah", nil) webError(w, r, config, err, http.StatusInternalServerError) - assert.Equal(t, http.StatusTooManyRequests, w.Result().StatusCode) - assert.Equal(t, "25", w.Result().Header.Get("Retry-After")) + require.Equal(t, http.StatusTooManyRequests, w.Result().StatusCode) + require.Equal(t, "25", w.Result().Header.Get("Retry-After")) }) t.Run("503 Service Unavailable with Retry-After header", func(t *testing.T) { + t.Parallel() + err := NewErrorRetryAfter(ErrServiceUnavailable, 50*time.Second) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/blah", nil) webError(w, r, config, err, http.StatusInternalServerError) - assert.Equal(t, http.StatusServiceUnavailable, w.Result().StatusCode) - assert.Equal(t, "50", w.Result().Header.Get("Retry-After")) + require.Equal(t, http.StatusServiceUnavailable, w.Result().StatusCode) + require.Equal(t, "50", w.Result().Header.Get("Retry-After")) }) t.Run("ErrorStatusCode propagates HTTP Status Code", func(t *testing.T) { + t.Parallel() + err := NewErrorStatusCodeFromStatus(http.StatusTeapot) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/blah", nil) webError(w, r, config, err, http.StatusInternalServerError) - assert.Equal(t, http.StatusTeapot, w.Result().StatusCode) + require.Equal(t, http.StatusTeapot, w.Result().StatusCode) }) } diff --git a/gateway/gateway_test.go b/gateway/gateway_test.go index 46ce75113..04b80a118 100644 --- a/gateway/gateway_test.go +++ b/gateway/gateway_test.go @@ -4,236 +4,30 @@ import ( "context" "errors" "fmt" - "html" "io" "net/http" - "net/http/httptest" - "os" - "regexp" - "strings" "testing" + "time" - "github.com/ipfs/boxo/blockservice" - nsopts "github.com/ipfs/boxo/coreiface/options/namesys" ipath "github.com/ipfs/boxo/coreiface/path" - offline "github.com/ipfs/boxo/exchange/offline" "github.com/ipfs/boxo/files" - carblockstore "github.com/ipfs/boxo/ipld/car/v2/blockstore" "github.com/ipfs/boxo/namesys" path "github.com/ipfs/boxo/path" + "github.com/ipfs/boxo/path/resolver" "github.com/ipfs/go-cid" - "github.com/libp2p/go-libp2p/core/crypto" - "github.com/libp2p/go-libp2p/core/routing" + ipld "github.com/ipfs/go-ipld-format" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -type mockNamesys map[string]path.Path - -func (m mockNamesys) Resolve(ctx context.Context, name string, opts ...nsopts.ResolveOpt) (value path.Path, err error) { - cfg := nsopts.DefaultResolveOpts() - for _, o := range opts { - o(&cfg) - } - depth := cfg.Depth - if depth == nsopts.UnlimitedDepth { - // max uint - depth = ^uint(0) - } - for strings.HasPrefix(name, "/ipns/") { - if depth == 0 { - return value, namesys.ErrResolveRecursion - } - depth-- - - var ok bool - value, ok = m[name] - if !ok { - return "", namesys.ErrResolveFailed - } - name = value.String() - } - return value, nil -} - -func (m mockNamesys) ResolveAsync(ctx context.Context, name string, opts ...nsopts.ResolveOpt) <-chan namesys.Result { - out := make(chan namesys.Result, 1) - v, err := m.Resolve(ctx, name, opts...) - out <- namesys.Result{Path: v, Err: err} - close(out) - return out -} - -func (m mockNamesys) Publish(ctx context.Context, name crypto.PrivKey, value path.Path, opts ...nsopts.PublishOption) error { - return errors.New("not implemented for mockNamesys") -} - -func (m mockNamesys) GetResolver(subs string) (namesys.Resolver, bool) { - return nil, false -} - -type mockBackend struct { - gw IPFSBackend - namesys mockNamesys -} - -var _ IPFSBackend = (*mockBackend)(nil) - -func newMockBackend(t *testing.T) (*mockBackend, cid.Cid) { - r, err := os.Open("./testdata/fixtures.car") - assert.NoError(t, err) - - blockStore, err := carblockstore.NewReadOnly(r, nil) - assert.NoError(t, err) - - t.Cleanup(func() { - blockStore.Close() - r.Close() - }) - - cids, err := blockStore.Roots() - assert.NoError(t, err) - assert.Len(t, cids, 1) - - blockService := blockservice.New(blockStore, offline.Exchange(blockStore)) - - n := mockNamesys{} - backend, err := NewBlocksBackend(blockService, WithNameSystem(n)) - if err != nil { - t.Fatal(err) - } - - return &mockBackend{ - gw: backend, - namesys: n, - }, cids[0] -} - -func (mb *mockBackend) Get(ctx context.Context, immutablePath ImmutablePath, ranges ...ByteRange) (ContentPathMetadata, *GetResponse, error) { - return mb.gw.Get(ctx, immutablePath, ranges...) -} - -func (mb *mockBackend) GetAll(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, files.Node, error) { - return mb.gw.GetAll(ctx, immutablePath) -} - -func (mb *mockBackend) GetBlock(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, files.File, error) { - return mb.gw.GetBlock(ctx, immutablePath) -} - -func (mb *mockBackend) Head(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, files.Node, error) { - return mb.gw.Head(ctx, immutablePath) -} - -func (mb *mockBackend) GetCAR(ctx context.Context, immutablePath ImmutablePath, params CarParams) (ContentPathMetadata, io.ReadCloser, error) { - return mb.gw.GetCAR(ctx, immutablePath, params) -} - -func (mb *mockBackend) ResolveMutable(ctx context.Context, p ipath.Path) (ImmutablePath, error) { - return mb.gw.ResolveMutable(ctx, p) -} - -func (mb *mockBackend) GetIPNSRecord(ctx context.Context, c cid.Cid) ([]byte, error) { - return nil, routing.ErrNotSupported -} - -func (mb *mockBackend) GetDNSLinkRecord(ctx context.Context, hostname string) (ipath.Path, error) { - if mb.namesys != nil { - p, err := mb.namesys.Resolve(ctx, "/ipns/"+hostname, nsopts.Depth(1)) - if err == namesys.ErrResolveRecursion { - err = nil - } - return ipath.New(p.String()), err - } - - return nil, errors.New("not implemented") -} - -func (mb *mockBackend) IsCached(ctx context.Context, p ipath.Path) bool { - return mb.gw.IsCached(ctx, p) -} - -func (mb *mockBackend) ResolvePath(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, error) { - return mb.gw.ResolvePath(ctx, immutablePath) -} - -func (mb *mockBackend) resolvePathNoRootsReturned(ctx context.Context, ip ipath.Path) (ipath.Resolved, error) { - var imPath ImmutablePath - var err error - if ip.Mutable() { - imPath, err = mb.ResolveMutable(ctx, ip) - if err != nil { - return nil, err - } - } else { - imPath, err = NewImmutablePath(ip) - if err != nil { - return nil, err - } - } - - md, err := mb.ResolvePath(ctx, imPath) - if err != nil { - return nil, err - } - return md.LastSegment, nil -} - -func doWithoutRedirect(req *http.Request) (*http.Response, error) { - tag := "without-redirect" - c := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return errors.New(tag) - }, - } - res, err := c.Do(req) - if err != nil && !strings.Contains(err.Error(), tag) { - return nil, err - } - return res, nil -} - -func newTestServerAndNode(t *testing.T, ns mockNamesys) (*httptest.Server, *mockBackend, cid.Cid) { - backend, root := newMockBackend(t) - ts := newTestServer(t, backend) - return ts, backend, root -} - -func newTestServer(t *testing.T, backend IPFSBackend) *httptest.Server { - return newTestServerWithConfig(t, backend, Config{ - Headers: map[string][]string{}, - DeserializedResponses: true, - }) -} - -func newTestServerWithConfig(t *testing.T, backend IPFSBackend, config Config) *httptest.Server { - AddAccessControlHeaders(config.Headers) - - handler := NewHandler(config, backend) - mux := http.NewServeMux() - mux.Handle("/ipfs/", handler) - mux.Handle("/ipns/", handler) - handler = NewHostnameHandler(config, backend, mux) - - ts := httptest.NewServer(handler) - t.Cleanup(func() { ts.Close() }) - - return ts -} - -func matchPathOrBreadcrumbs(s string, expected string) bool { - matched, _ := regexp.MatchString("Index of(\n|\r\n)[\t ]*"+regexp.QuoteMeta(expected), s) - return matched -} - func TestGatewayGet(t *testing.T) { - ts, backend, root := newTestServerAndNode(t, nil) - t.Logf("test server url: %s", ts.URL) + ts, backend, root := newTestServerAndNode(t, nil, "fixtures.car") ctx, cancel := context.WithCancel(context.Background()) defer cancel() - k, err := backend.resolvePathNoRootsReturned(ctx, ipath.Join(ipath.IpfsPath(root), t.Name(), "fnord")) - assert.NoError(t, err) + k, err := backend.resolvePathNoRootsReturned(ctx, ipath.Join(ipath.IpfsPath(root), "subdir", "fnord")) + require.NoError(t, err) backend.namesys["/ipns/example.com"] = path.FromCid(k.Cid()) backend.namesys["/ipns/working.example.com"] = path.FromString(k.String()) @@ -249,7 +43,6 @@ func TestGatewayGet(t *testing.T) { // detection is platform dependent. backend.namesys["/ipns/example.man"] = path.FromString(k.String()) - t.Log(ts.URL) for _, test := range []struct { host string path string @@ -279,212 +72,25 @@ func TestGatewayGet(t *testing.T) { } { testName := "http://" + test.host + test.path t.Run(testName, func(t *testing.T) { - var c http.Client - r, err := http.NewRequest(http.MethodGet, ts.URL+test.path, nil) - assert.NoError(t, err) - r.Host = test.host - resp, err := c.Do(r) - assert.NoError(t, err) + req := mustNewRequest(t, http.MethodGet, ts.URL+test.path, nil) + req.Host = test.host + resp := mustDo(t, req) defer resp.Body.Close() - assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) + require.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) body, err := io.ReadAll(resp.Body) - assert.NoError(t, err) - assert.Equal(t, test.status, resp.StatusCode, "body", body) - assert.Equal(t, test.text, string(body)) - }) - } -} - -func TestUriQueryRedirect(t *testing.T) { - ts, _, _ := newTestServerAndNode(t, mockNamesys{}) - - cid := "QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR" - for _, test := range []struct { - path string - status int - location string - }{ - // - Browsers will send original URI in URL-escaped form - // - We expect query parameters to be persisted - // - We drop fragments, as those should not be sent by a browser - {"/ipfs/?uri=ipfs%3A%2F%2FQmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco%2Fwiki%2FFoo_%C4%85%C4%99.html%3Ffilename%3Dtest-%C4%99.html%23header-%C4%85", http.StatusMovedPermanently, "/ipfs/QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/wiki/Foo_%c4%85%c4%99.html?filename=test-%c4%99.html"}, - {"/ipfs/?uri=ipns%3A%2F%2Fexample.com%2Fwiki%2FFoo_%C4%85%C4%99.html%3Ffilename%3Dtest-%C4%99.html", http.StatusMovedPermanently, "/ipns/example.com/wiki/Foo_%c4%85%c4%99.html?filename=test-%c4%99.html"}, - {"/ipfs/?uri=ipfs://" + cid, http.StatusMovedPermanently, "/ipfs/" + cid}, - {"/ipfs?uri=ipfs://" + cid, http.StatusMovedPermanently, "/ipfs/" + cid}, - {"/ipfs/?uri=ipns://" + cid, http.StatusMovedPermanently, "/ipns/" + cid}, - {"/ipns/?uri=ipfs%3A%2F%2FQmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco%2Fwiki%2FFoo_%C4%85%C4%99.html%3Ffilename%3Dtest-%C4%99.html%23header-%C4%85", http.StatusMovedPermanently, "/ipfs/QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/wiki/Foo_%c4%85%c4%99.html?filename=test-%c4%99.html"}, - {"/ipns/?uri=ipns%3A%2F%2Fexample.com%2Fwiki%2FFoo_%C4%85%C4%99.html%3Ffilename%3Dtest-%C4%99.html", http.StatusMovedPermanently, "/ipns/example.com/wiki/Foo_%c4%85%c4%99.html?filename=test-%c4%99.html"}, - {"/ipns?uri=ipns://" + cid, http.StatusMovedPermanently, "/ipns/" + cid}, - {"/ipns/?uri=ipns://" + cid, http.StatusMovedPermanently, "/ipns/" + cid}, - {"/ipns/?uri=ipfs://" + cid, http.StatusMovedPermanently, "/ipfs/" + cid}, - {"/ipfs/?uri=unsupported://" + cid, http.StatusBadRequest, ""}, - {"/ipfs/?uri=invaliduri", http.StatusBadRequest, ""}, - {"/ipfs/?uri=" + cid, http.StatusBadRequest, ""}, - } { - testName := ts.URL + test.path - t.Run(testName, func(t *testing.T) { - r, err := http.NewRequest(http.MethodGet, ts.URL+test.path, nil) - assert.NoError(t, err) - resp, err := doWithoutRedirect(r) - assert.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, test.status, resp.StatusCode) - assert.Equal(t, test.location, resp.Header.Get("Location")) + require.NoError(t, err) + require.Equal(t, test.status, resp.StatusCode, "body", body) + require.Equal(t, test.text, string(body)) }) } } -func TestIPNSHostnameRedirect(t *testing.T) { - ts, backend, root := newTestServerAndNode(t, nil) - t.Logf("test server url: %s", ts.URL) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - k, err := backend.resolvePathNoRootsReturned(ctx, ipath.Join(ipath.IpfsPath(root), t.Name())) - assert.NoError(t, err) - - t.Logf("k: %s\n", k) - backend.namesys["/ipns/example.net"] = path.FromString(k.String()) - - // make request to directory containing index.html - req, err := http.NewRequest(http.MethodGet, ts.URL+"/foo", nil) - assert.NoError(t, err) - req.Host = "example.net" - - res, err := doWithoutRedirect(req) - assert.NoError(t, err) - - // expect 301 redirect to same path, but with trailing slash - assert.Equal(t, http.StatusMovedPermanently, res.StatusCode) - hdr := res.Header["Location"] - assert.Positive(t, len(hdr), "location header not present") - assert.Equal(t, hdr[0], "/foo/") - - // make request with prefix to directory containing index.html - req, err = http.NewRequest(http.MethodGet, ts.URL+"/foo", nil) - assert.NoError(t, err) - req.Host = "example.net" - - res, err = doWithoutRedirect(req) - assert.NoError(t, err) - // expect 301 redirect to same path, but with prefix and trailing slash - assert.Equal(t, http.StatusMovedPermanently, res.StatusCode) - - hdr = res.Header["Location"] - assert.Positive(t, len(hdr), "location header not present") - assert.Equal(t, hdr[0], "/foo/") - - // make sure /version isn't exposed - req, err = http.NewRequest(http.MethodGet, ts.URL+"/version", nil) - assert.NoError(t, err) - req.Host = "example.net" - - res, err = doWithoutRedirect(req) - assert.NoError(t, err) - assert.Equal(t, http.StatusNotFound, res.StatusCode) -} - -// Test directory listing on DNSLink website -// (scenario when Host header is the same as URL hostname) -// This is basic regression test: additional end-to-end tests -// can be found in test/sharness/t0115-gateway-dir-listing.sh -func TestIPNSHostnameBacklinks(t *testing.T) { - ts, backend, root := newTestServerAndNode(t, nil) - t.Logf("test server url: %s", ts.URL) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - k, err := backend.resolvePathNoRootsReturned(ctx, ipath.Join(ipath.IpfsPath(root), t.Name())) - assert.NoError(t, err) - - // create /ipns/example.net/foo/ - k2, err := backend.resolvePathNoRootsReturned(ctx, ipath.Join(k, "foo? #<'")) - assert.NoError(t, err) - - k3, err := backend.resolvePathNoRootsReturned(ctx, ipath.Join(k, "foo? #<'/bar")) - assert.NoError(t, err) - - t.Logf("k: %s\n", k) - backend.namesys["/ipns/example.net"] = path.FromString(k.String()) - - // make request to directory listing - req, err := http.NewRequest(http.MethodGet, ts.URL+"/foo%3F%20%23%3C%27/", nil) - assert.NoError(t, err) - req.Host = "example.net" - - res, err := doWithoutRedirect(req) - assert.NoError(t, err) - - // expect correct links - body, err := io.ReadAll(res.Body) - assert.NoError(t, err) - s := string(body) - t.Logf("body: %s\n", string(body)) - - assert.True(t, matchPathOrBreadcrumbs(s, "/ipns/example.net/foo? #<'"), "expected a path in directory listing") - // https://github.com/ipfs/dir-index-html/issues/42 - assert.Contains(t, s, "", "expected backlink in directory listing") - assert.Contains(t, s, "", "expected file in directory listing") - assert.Contains(t, s, s, k2.Cid().String(), "expected hash in directory listing") - - // make request to directory listing at root - req, err = http.NewRequest(http.MethodGet, ts.URL, nil) - assert.NoError(t, err) - req.Host = "example.net" - - res, err = doWithoutRedirect(req) - assert.NoError(t, err) - - // expect correct backlinks at root - body, err = io.ReadAll(res.Body) - assert.NoError(t, err) - - s = string(body) - t.Logf("body: %s\n", string(body)) - - assert.True(t, matchPathOrBreadcrumbs(s, "/"), "expected a path in directory listing") - assert.NotContains(t, s, "", "expected no backlink in directory listing of the root CID") - assert.Contains(t, s, "", "expected file in directory listing") - // https://github.com/ipfs/dir-index-html/issues/42 - assert.Contains(t, s, "example.net/foo? #<'/bar"), "expected a path in directory listing") - assert.Contains(t, s, "", "expected backlink in directory listing") - assert.Contains(t, s, "", "expected file in directory listing") - assert.Contains(t, s, k3.Cid().String(), "expected hash in directory listing") -} - func TestPretty404(t *testing.T) { - ts, backend, root := newTestServerAndNode(t, nil) + ts, backend, root := newTestServerAndNode(t, nil, "pretty-404.car") t.Logf("test server url: %s", ts.URL) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - k, err := backend.resolvePathNoRootsReturned(ctx, ipath.Join(ipath.IpfsPath(root), t.Name())) - assert.NoError(t, err) - host := "example.net" - backend.namesys["/ipns/"+host] = path.FromString(k.String()) + backend.namesys["/ipns/"+host] = path.FromCid(root) for _, test := range []struct { path string @@ -496,7 +102,7 @@ func TestPretty404(t *testing.T) { {"/nope", "text/html", http.StatusNotFound, "Custom 404"}, {"/nope", "text/*", http.StatusNotFound, "Custom 404"}, {"/nope", "*/*", http.StatusNotFound, "Custom 404"}, - {"/nope", "application/json", http.StatusNotFound, fmt.Sprintf("failed to resolve /ipns/example.net/nope: no link named \"nope\" under %s\n", k.Cid().String())}, + {"/nope", "application/json", http.StatusNotFound, fmt.Sprintf("failed to resolve /ipns/example.net/nope: no link named \"nope\" under %s\n", root.String())}, {"/deeper/nope", "text/html", http.StatusNotFound, "Deep custom 404"}, {"/deeper/", "text/html", http.StatusOK, ""}, {"/deeper", "text/html", http.StatusOK, ""}, @@ -504,317 +110,652 @@ func TestPretty404(t *testing.T) { } { testName := fmt.Sprintf("%s %s", test.path, test.accept) t.Run(testName, func(t *testing.T) { - var c http.Client - req, err := http.NewRequest("GET", ts.URL+test.path, nil) - assert.NoError(t, err) + req := mustNewRequest(t, "GET", ts.URL+test.path, nil) req.Header.Add("Accept", test.accept) req.Host = host - resp, err := c.Do(req) - assert.NoError(t, err) + resp := mustDo(t, req) defer resp.Body.Close() - assert.Equal(t, test.status, resp.StatusCode) + require.Equal(t, test.status, resp.StatusCode) body, err := io.ReadAll(resp.Body) - assert.NoError(t, err) + require.NoError(t, err) if test.text != "" { - assert.Equal(t, test.text, string(body)) + require.Equal(t, test.text, string(body)) } }) } } -func TestBrowserErrorHTML(t *testing.T) { - ts, _, root := newTestServerAndNode(t, nil) - t.Logf("test server url: %s", ts.URL) +func TestHeaders(t *testing.T) { + t.Parallel() - t.Run("plain error if request does not have Accept: text/html", func(t *testing.T) { - t.Parallel() + ts, _, _ := newTestServerAndNode(t, nil, "headers-test.car") - req, err := http.NewRequest(http.MethodGet, ts.URL+"/ipfs/"+root.String()+"/nonexisting-link", nil) - assert.Nil(t, err) + var ( + rootCID = "bafybeidbcy4u6y55gsemlubd64zk53xoxs73ifd6rieejxcr7xy46mjvky" - res, err := doWithoutRedirect(req) - assert.Nil(t, err) - assert.Equal(t, http.StatusNotFound, res.StatusCode) - assert.NotContains(t, res.Header.Get("Content-Type"), "text/html") + dirCID = "bafybeihta5xfgxcmyxyq6druvidc7es6ogffdd6zel22l3y4wddju5xxsu" + dirPath = "/ipfs/" + rootCID + "/subdir/" + dirRoots = rootCID + "," + dirCID - body, err := io.ReadAll(res.Body) - assert.Nil(t, err) - assert.NotContains(t, string(body), "") - }) + hamtFileCID = "bafybeigcisqd7m5nf3qmuvjdbakl5bdnh4ocrmacaqkpuh77qjvggmt2sa" + hamtFilePath = "/ipfs/" + rootCID + "/hamt/685.txt" + hamtFileRoots = rootCID + ",bafybeidbclfqleg2uojchspzd4bob56dqetqjsj27gy2cq3klkkgxtpn4i," + hamtFileCID - t.Run("html error if request has Accept: text/html", func(t *testing.T) { - t.Parallel() + fileCID = "bafkreiba3vpkcqpc6xtp3hsatzcod6iwneouzjoq7ymy4m2js6gc3czt6i" + filePath = "/ipfs/" + rootCID + "/subdir/fnord" + fileRoots = dirRoots + "," + fileCID - req, err := http.NewRequest(http.MethodGet, ts.URL+"/ipfs/"+root.String()+"/nonexisting-link", nil) - assert.Nil(t, err) - req.Header.Set("Accept", "text/html") + dagCborCID = "bafyreiaocls5bt2ha5vszv5pwz34zzcdf3axk3uqa56bgsgvlkbezw67hq" + dagCborPath = "/ipfs/" + rootCID + "/subdir/dag-cbor-document" + dagCborRoots = dirRoots + "," + dagCborCID + ) - res, err := doWithoutRedirect(req) - assert.Nil(t, err) - assert.Equal(t, http.StatusNotFound, res.StatusCode) - assert.Contains(t, res.Header.Get("Content-Type"), "text/html") + t.Run("Cache-Control is not immutable on generated /ipfs/ HTML dir listings", func(t *testing.T) { + req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipfs/"+rootCID+"/", nil) + res := mustDoWithoutRedirect(t, req) - body, err := io.ReadAll(res.Body) - assert.Nil(t, err) - assert.Contains(t, string(body), "") + // check the immutable tag isn't set + hdrs, ok := res.Header["Cache-Control"] + if ok { + for _, hdr := range hdrs { + assert.NotContains(t, hdr, "immutable", "unexpected Cache-Control: immutable on directory listing") + } + } }) -} -func TestCacheControlImmutable(t *testing.T) { - ts, _, root := newTestServerAndNode(t, nil) - t.Logf("test server url: %s", ts.URL) + t.Run("ETag is based on CID and response format", func(t *testing.T) { + test := func(responseFormat string, path string, format string, args ...any) { + t.Run(responseFormat, func(t *testing.T) { + url := ts.URL + path + req := mustNewRequest(t, http.MethodGet, url, nil) + req.Header.Add("Accept", responseFormat) + res := mustDoWithoutRedirect(t, req) + _, err := io.Copy(io.Discard, res.Body) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + require.Regexp(t, `^`+fmt.Sprintf(format, args...)+`$`, res.Header.Get("Etag")) + }) + } + test("", dirPath, `"DirIndex-(.*)_CID-%s"`, dirCID) + test("text/html", dirPath, `"DirIndex-(.*)_CID-%s"`, dirCID) + test(carResponseFormat, dirPath, `W/"%s.car.7of9u8ojv38vd"`, rootCID) // ETags of CARs on a Path have the root CID in the Etag and hashed information to derive the correct Etag of the full request. + test(rawResponseFormat, dirPath, `"%s.raw"`, dirCID) + test(tarResponseFormat, dirPath, `W/"%s.x-tar"`, dirCID) + + test("", hamtFilePath, `"%s"`, hamtFileCID) + test("text/html", hamtFilePath, `"%s"`, hamtFileCID) + test(carResponseFormat, hamtFilePath, `W/"%s.car.2uq26jdcsk50p"`, rootCID) // ETags of CARs on a Path have the root CID in the Etag and hashed information to derive the correct Etag of the full request. + test(rawResponseFormat, hamtFilePath, `"%s.raw"`, hamtFileCID) + test(tarResponseFormat, hamtFilePath, `W/"%s.x-tar"`, hamtFileCID) + + test("", filePath, `"%s"`, fileCID) + test("text/html", filePath, `"%s"`, fileCID) + test(carResponseFormat, filePath, `W/"%s.car.fgq8i0qnhsq01"`, rootCID) + test(rawResponseFormat, filePath, `"%s.raw"`, fileCID) + test(tarResponseFormat, filePath, `W/"%s.x-tar"`, fileCID) + + test("", dagCborPath, `"%s.dag-cbor"`, dagCborCID) + test("text/html", dagCborPath+"/", `"DagIndex-(.*)_CID-%s"`, dagCborCID) + test(carResponseFormat, dagCborPath, `W/"%s.car.5mg3mekeviba5"`, rootCID) + test(rawResponseFormat, dagCborPath, `"%s.raw"`, dagCborCID) + test(dagJsonResponseFormat, dagCborPath, `"%s.dag-json"`, dagCborCID) + test(dagCborResponseFormat, dagCborPath, `"%s.dag-cbor"`, dagCborCID) + }) - req, err := http.NewRequest(http.MethodGet, ts.URL+"/ipfs/"+root.String()+"/", nil) - assert.NoError(t, err) + t.Run("If-None-Match with previous Etag returns Not Modified", func(t *testing.T) { + test := func(responseFormat string, path string) { + t.Run(responseFormat, func(t *testing.T) { + url := ts.URL + path + req := mustNewRequest(t, http.MethodGet, url, nil) + req.Header.Add("Accept", responseFormat) + res := mustDoWithoutRedirect(t, req) + _, err := io.Copy(io.Discard, res.Body) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + etag := res.Header.Get("Etag") + require.NotEmpty(t, etag) + + req = mustNewRequest(t, http.MethodGet, url, nil) + req.Header.Add("Accept", responseFormat) + req.Header.Add("If-None-Match", etag) + res = mustDoWithoutRedirect(t, req) + _, err = io.Copy(io.Discard, res.Body) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusNotModified, res.StatusCode) + }) + } - res, err := doWithoutRedirect(req) - assert.NoError(t, err) + test("", dirPath) + test("text/html", dirPath) + test(carResponseFormat, dirPath) + test(rawResponseFormat, dirPath) + test(tarResponseFormat, dirPath) + + test("", hamtFilePath) + test("text/html", hamtFilePath) + test(carResponseFormat, hamtFilePath) + test(rawResponseFormat, hamtFilePath) + test(tarResponseFormat, hamtFilePath) + + test("", filePath) + test("text/html", filePath) + test(carResponseFormat, filePath) + test(rawResponseFormat, filePath) + test(tarResponseFormat, filePath) + + test("", dagCborPath) + test("text/html", dagCborPath+"/") + test(carResponseFormat, dagCborPath) + test(rawResponseFormat, dagCborPath) + test(dagJsonResponseFormat, dagCborPath) + test(dagCborResponseFormat, dagCborPath) + }) - // check the immutable tag isn't set - hdrs, ok := res.Header["Cache-Control"] - if ok { - for _, hdr := range hdrs { - assert.NotContains(t, hdr, "immutable", "unexpected Cache-Control: immutable on directory listing") + t.Run("X-Ipfs-Roots contains expected values", func(t *testing.T) { + test := func(responseFormat string, path string, roots string) { + t.Run(responseFormat, func(t *testing.T) { + url := ts.URL + path + req := mustNewRequest(t, http.MethodGet, url, nil) + req.Header.Add("Accept", responseFormat) + res := mustDoWithoutRedirect(t, req) + _, err := io.Copy(io.Discard, res.Body) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, roots, res.Header.Get("X-Ipfs-Roots")) + }) } - } + + test("", dirPath, dirRoots) + test("text/html", dirPath, dirRoots) + test(carResponseFormat, dirPath, dirRoots) + test(rawResponseFormat, dirPath, dirRoots) + test(tarResponseFormat, dirPath, dirRoots) + + test("", hamtFilePath, hamtFileRoots) + test("text/html", hamtFilePath, hamtFileRoots) + test(carResponseFormat, hamtFilePath, hamtFileRoots) + test(rawResponseFormat, hamtFilePath, hamtFileRoots) + test(tarResponseFormat, hamtFilePath, hamtFileRoots) + + test("", filePath, fileRoots) + test("text/html", filePath, fileRoots) + test(carResponseFormat, filePath, fileRoots) + test(rawResponseFormat, filePath, fileRoots) + test(tarResponseFormat, filePath, fileRoots) + + test("", dagCborPath, dagCborRoots) + test("text/html", dagCborPath+"/", dagCborRoots) + test(carResponseFormat, dagCborPath, dagCborRoots) + test(rawResponseFormat, dagCborPath, dagCborRoots) + test(dagJsonResponseFormat, dagCborPath, dagCborRoots) + test(dagCborResponseFormat, dagCborPath, dagCborRoots) + }) + + t.Run("If-None-Match with wrong value forces path resolution, but X-Ipfs-Roots is correct (regression)", func(t *testing.T) { + test := func(responseFormat string, path string, roots string) { + t.Run(responseFormat, func(t *testing.T) { + url := ts.URL + path + req := mustNewRequest(t, http.MethodGet, url, nil) + req.Header.Add("Accept", responseFormat) + req.Header.Add("If-None-Match", "just-some-gibberish") + res := mustDoWithoutRedirect(t, req) + _, err := io.Copy(io.Discard, res.Body) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, roots, res.Header.Get("X-Ipfs-Roots")) + }) + } + + test("", dirPath, dirRoots) + test("text/html", dirPath, dirRoots) + test(carResponseFormat, dirPath, dirRoots) + test(rawResponseFormat, dirPath, dirRoots) + test(tarResponseFormat, dirPath, dirRoots) + + test("", hamtFilePath, hamtFileRoots) + test("text/html", hamtFilePath, hamtFileRoots) + test(carResponseFormat, hamtFilePath, hamtFileRoots) + test(rawResponseFormat, hamtFilePath, hamtFileRoots) + test(tarResponseFormat, hamtFilePath, hamtFileRoots) + + test("", filePath, fileRoots) + test("text/html", filePath, fileRoots) + test(carResponseFormat, filePath, fileRoots) + test(rawResponseFormat, filePath, fileRoots) + test(tarResponseFormat, filePath, fileRoots) + + test("", dagCborPath, dagCborRoots) + test("text/html", dagCborPath+"/", dagCborRoots) + test(carResponseFormat, dagCborPath, dagCborRoots) + test(rawResponseFormat, dagCborPath, dagCborRoots) + test(dagJsonResponseFormat, dagCborPath, dagCborRoots) + test(dagCborResponseFormat, dagCborPath, dagCborRoots) + }) } func TestGoGetSupport(t *testing.T) { - ts, _, root := newTestServerAndNode(t, nil) - t.Logf("test server url: %s", ts.URL) + ts, _, root := newTestServerAndNode(t, nil, "fixtures.car") // mimic go-get - req, err := http.NewRequest(http.MethodGet, ts.URL+"/ipfs/"+root.String()+"?go-get=1", nil) - assert.NoError(t, err) - - res, err := doWithoutRedirect(req) - assert.NoError(t, err) - assert.Equal(t, http.StatusOK, res.StatusCode) + req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipfs/"+root.String()+"?go-get=1", nil) + res := mustDoWithoutRedirect(t, req) + require.Equal(t, http.StatusOK, res.StatusCode) } -func TestIpnsBase58MultihashRedirect(t *testing.T) { - ts, _, _ := newTestServerAndNode(t, nil) - t.Logf("test server url: %s", ts.URL) +func TestRedirects(t *testing.T) { + t.Parallel() - t.Run("ED25519 Base58-encoded key", func(t *testing.T) { - t.Parallel() + t.Run("IPNS Base58 Multihash Redirect", func(t *testing.T) { + ts, _, _ := newTestServerAndNode(t, nil, "fixtures.car") - req, err := http.NewRequest(http.MethodGet, ts.URL+"/ipns/12D3KooWRBy97UB99e3J6hiPesre1MZeuNQvfan4gBziswrRJsNK?keep=query", nil) - assert.Nil(t, err) + t.Run("ED25519 Base58-encoded key", func(t *testing.T) { + t.Parallel() - res, err := doWithoutRedirect(req) - assert.Nil(t, err) - assert.Equal(t, "/ipns/k51qzi5uqu5dlvj2baxnqndepeb86cbk3ng7n3i46uzyxzyqj2xjonzllnv0v8?keep=query", res.Header.Get("Location")) + req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipns/12D3KooWRBy97UB99e3J6hiPesre1MZeuNQvfan4gBziswrRJsNK?keep=query", nil) + res := mustDoWithoutRedirect(t, req) + require.Equal(t, "/ipns/k51qzi5uqu5dlvj2baxnqndepeb86cbk3ng7n3i46uzyxzyqj2xjonzllnv0v8?keep=query", res.Header.Get("Location")) + }) + + t.Run("RSA Base58-encoded key", func(t *testing.T) { + t.Parallel() + + req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipns/QmcJM7PRfkSbcM5cf1QugM5R37TLRKyJGgBEhXjLTB8uA2?keep=query", nil) + res := mustDoWithoutRedirect(t, req) + require.Equal(t, "/ipns/k2k4r8ol4m8kkcqz509c1rcjwunebj02gcnm5excpx842u736nja8ger?keep=query", res.Header.Get("Location")) + }) }) - t.Run("RSA Base58-encoded key", func(t *testing.T) { + t.Run("URI Query Redirects", func(t *testing.T) { t.Parallel() + ts, _, _ := newTestServerAndNode(t, mockNamesys{}, "fixtures.car") + + cid := "QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR" + for _, test := range []struct { + path string + status int + location string + }{ + // - Browsers will send original URI in URL-escaped form + // - We expect query parameters to be persisted + // - We drop fragments, as those should not be sent by a browser + {"/ipfs/?uri=ipfs%3A%2F%2FQmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco%2Fwiki%2FFoo_%C4%85%C4%99.html%3Ffilename%3Dtest-%C4%99.html%23header-%C4%85", http.StatusMovedPermanently, "/ipfs/QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/wiki/Foo_%c4%85%c4%99.html?filename=test-%c4%99.html"}, + {"/ipfs/?uri=ipns%3A%2F%2Fexample.com%2Fwiki%2FFoo_%C4%85%C4%99.html%3Ffilename%3Dtest-%C4%99.html", http.StatusMovedPermanently, "/ipns/example.com/wiki/Foo_%c4%85%c4%99.html?filename=test-%c4%99.html"}, + {"/ipfs/?uri=ipfs://" + cid, http.StatusMovedPermanently, "/ipfs/" + cid}, + {"/ipfs?uri=ipfs://" + cid, http.StatusMovedPermanently, "/ipfs/" + cid}, + {"/ipfs/?uri=ipns://" + cid, http.StatusMovedPermanently, "/ipns/" + cid}, + {"/ipns/?uri=ipfs%3A%2F%2FQmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco%2Fwiki%2FFoo_%C4%85%C4%99.html%3Ffilename%3Dtest-%C4%99.html%23header-%C4%85", http.StatusMovedPermanently, "/ipfs/QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/wiki/Foo_%c4%85%c4%99.html?filename=test-%c4%99.html"}, + {"/ipns/?uri=ipns%3A%2F%2Fexample.com%2Fwiki%2FFoo_%C4%85%C4%99.html%3Ffilename%3Dtest-%C4%99.html", http.StatusMovedPermanently, "/ipns/example.com/wiki/Foo_%c4%85%c4%99.html?filename=test-%c4%99.html"}, + {"/ipns?uri=ipns://" + cid, http.StatusMovedPermanently, "/ipns/" + cid}, + {"/ipns/?uri=ipns://" + cid, http.StatusMovedPermanently, "/ipns/" + cid}, + {"/ipns/?uri=ipfs://" + cid, http.StatusMovedPermanently, "/ipfs/" + cid}, + {"/ipfs/?uri=unsupported://" + cid, http.StatusBadRequest, ""}, + {"/ipfs/?uri=invaliduri", http.StatusBadRequest, ""}, + {"/ipfs/?uri=" + cid, http.StatusBadRequest, ""}, + } { + testName := ts.URL + test.path + t.Run(testName, func(t *testing.T) { + req := mustNewRequest(t, http.MethodGet, ts.URL+test.path, nil) + resp := mustDoWithoutRedirect(t, req) + defer resp.Body.Close() + require.Equal(t, test.status, resp.StatusCode) + require.Equal(t, test.location, resp.Header.Get("Location")) + }) + } + }) - req, err := http.NewRequest(http.MethodGet, ts.URL+"/ipns/QmcJM7PRfkSbcM5cf1QugM5R37TLRKyJGgBEhXjLTB8uA2?keep=query", nil) - assert.Nil(t, err) + t.Run("IPNS Hostname Redirects", func(t *testing.T) { + t.Parallel() - res, err := doWithoutRedirect(req) - assert.Nil(t, err) - assert.Equal(t, "/ipns/k2k4r8ol4m8kkcqz509c1rcjwunebj02gcnm5excpx842u736nja8ger?keep=query", res.Header.Get("Location")) + ts, backend, root := newTestServerAndNode(t, nil, "ipns-hostname-redirects.car") + backend.namesys["/ipns/example.net"] = path.FromCid(root) + + // make request to directory containing index.html + req := mustNewRequest(t, http.MethodGet, ts.URL+"/foo", nil) + req.Host = "example.net" + res := mustDoWithoutRedirect(t, req) + + // expect 301 redirect to same path, but with trailing slash + require.Equal(t, http.StatusMovedPermanently, res.StatusCode) + hdr := res.Header["Location"] + require.Positive(t, len(hdr), "location header not present") + require.Equal(t, hdr[0], "/foo/") + + // make request with prefix to directory containing index.html + req = mustNewRequest(t, http.MethodGet, ts.URL+"/foo", nil) + req.Host = "example.net" + res = mustDoWithoutRedirect(t, req) + // expect 301 redirect to same path, but with prefix and trailing slash + require.Equal(t, http.StatusMovedPermanently, res.StatusCode) + + hdr = res.Header["Location"] + require.Positive(t, len(hdr), "location header not present") + require.Equal(t, hdr[0], "/foo/") + + // make sure /version isn't exposed + req = mustNewRequest(t, http.MethodGet, ts.URL+"/version", nil) + req.Host = "example.net" + res = mustDoWithoutRedirect(t, req) + require.Equal(t, http.StatusNotFound, res.StatusCode) }) } -func TestIpfsTrustlessMode(t *testing.T) { - backend, root := newMockBackend(t) +func TestDeserializedResponses(t *testing.T) { + t.Parallel() - ts := newTestServerWithConfig(t, backend, Config{ - Headers: map[string][]string{}, - NoDNSLink: false, - PublicGateways: map[string]*PublicGateway{ - "trustless.com": { - Paths: []string{"/ipfs", "/ipns"}, - }, - "trusted.com": { - Paths: []string{"/ipfs", "/ipns"}, - DeserializedResponses: true, + t.Run("IPFS", func(t *testing.T) { + t.Parallel() + + backend, root := newMockBackend(t, "fixtures.car") + + ts := newTestServerWithConfig(t, backend, Config{ + Headers: map[string][]string{}, + NoDNSLink: false, + PublicGateways: map[string]*PublicGateway{ + "trustless.com": { + Paths: []string{"/ipfs", "/ipns"}, + }, + "trusted.com": { + Paths: []string{"/ipfs", "/ipns"}, + DeserializedResponses: true, + }, }, - }, - }) - t.Logf("test server url: %s", ts.URL) + }) - trustedFormats := []string{"", "dag-json", "dag-cbor", "tar", "json", "cbor"} - trustlessFormats := []string{"raw", "car"} + trustedFormats := []string{"", "dag-json", "dag-cbor", "tar", "json", "cbor"} + trustlessFormats := []string{"raw", "car"} - doRequest := func(t *testing.T, path, host string, expectedStatus int) { - req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil) - assert.Nil(t, err) + doRequest := func(t *testing.T, path, host string, expectedStatus int) { + req := mustNewRequest(t, http.MethodGet, ts.URL+path, nil) + if host != "" { + req.Host = host + } + res := mustDoWithoutRedirect(t, req) + defer res.Body.Close() + assert.Equal(t, expectedStatus, res.StatusCode) + } - if host != "" { - req.Host = host + doIpfsCidRequests := func(t *testing.T, formats []string, host string, expectedStatus int) { + for _, format := range formats { + doRequest(t, "/ipfs/"+root.String()+"/?format="+format, host, expectedStatus) + } } - res, err := doWithoutRedirect(req) - assert.Nil(t, err) - defer res.Body.Close() - assert.Equal(t, expectedStatus, res.StatusCode) - } + doIpfsCidPathRequests := func(t *testing.T, formats []string, host string, expectedStatus int) { + for _, format := range formats { + doRequest(t, "/ipfs/"+root.String()+"/empty-dir/?format="+format, host, expectedStatus) + } + } - doIpfsCidRequests := func(t *testing.T, formats []string, host string, expectedStatus int) { - for _, format := range formats { - doRequest(t, "/ipfs/"+root.String()+"/?format="+format, host, expectedStatus) + trustedTests := func(t *testing.T, host string) { + doIpfsCidRequests(t, trustlessFormats, host, http.StatusOK) + doIpfsCidRequests(t, trustedFormats, host, http.StatusOK) + doIpfsCidPathRequests(t, trustlessFormats, host, http.StatusOK) + doIpfsCidPathRequests(t, trustedFormats, host, http.StatusOK) } - } - doIpfsCidPathRequests := func(t *testing.T, formats []string, host string, expectedStatus int) { - for _, format := range formats { - doRequest(t, "/ipfs/"+root.String()+"/EmptyDir/?format="+format, host, expectedStatus) + trustlessTests := func(t *testing.T, host string) { + doIpfsCidRequests(t, trustlessFormats, host, http.StatusOK) + doIpfsCidRequests(t, trustedFormats, host, http.StatusNotAcceptable) + doIpfsCidPathRequests(t, trustedFormats, host, http.StatusNotAcceptable) + doIpfsCidPathRequests(t, []string{"raw"}, host, http.StatusNotAcceptable) + doIpfsCidPathRequests(t, []string{"car"}, host, http.StatusOK) } - } - trustedTests := func(t *testing.T, host string) { - doIpfsCidRequests(t, trustlessFormats, host, http.StatusOK) - doIpfsCidRequests(t, trustedFormats, host, http.StatusOK) - doIpfsCidPathRequests(t, trustlessFormats, host, http.StatusOK) - doIpfsCidPathRequests(t, trustedFormats, host, http.StatusOK) - } + t.Run("Explicit Trustless Gateway", func(t *testing.T) { + t.Parallel() + trustlessTests(t, "trustless.com") + }) - trustlessTests := func(t *testing.T, host string) { - doIpfsCidRequests(t, trustlessFormats, host, http.StatusOK) - doIpfsCidRequests(t, trustedFormats, host, http.StatusNotAcceptable) - doIpfsCidPathRequests(t, trustedFormats, host, http.StatusNotAcceptable) - doIpfsCidPathRequests(t, []string{"raw"}, host, http.StatusNotAcceptable) - doIpfsCidPathRequests(t, []string{"car"}, host, http.StatusOK) - } + t.Run("Explicit Trusted Gateway", func(t *testing.T) { + t.Parallel() + trustedTests(t, "trusted.com") + }) - t.Run("Explicit Trustless Gateway", func(t *testing.T) { - t.Parallel() - trustlessTests(t, "trustless.com") + t.Run("Implicit Default Trustless Gateway", func(t *testing.T) { + t.Parallel() + trustlessTests(t, "not.configured.com") + trustlessTests(t, "localhost") + trustlessTests(t, "127.0.0.1") + trustlessTests(t, "::1") + }) }) - t.Run("Explicit Trusted Gateway", func(t *testing.T) { + t.Run("IPNS", func(t *testing.T) { t.Parallel() - trustedTests(t, "trusted.com") - }) - t.Run("Implicit Default Trustless Gateway", func(t *testing.T) { - t.Parallel() - trustlessTests(t, "not.configured.com") - trustlessTests(t, "localhost") - trustlessTests(t, "127.0.0.1") - trustlessTests(t, "::1") + backend, root := newMockBackend(t, "fixtures.car") + backend.namesys["/ipns/trustless.com"] = path.FromCid(root) + backend.namesys["/ipns/trusted.com"] = path.FromCid(root) + + ts := newTestServerWithConfig(t, backend, Config{ + Headers: map[string][]string{}, + NoDNSLink: false, + PublicGateways: map[string]*PublicGateway{ + "trustless.com": { + Paths: []string{"/ipfs", "/ipns"}, + }, + "trusted.com": { + Paths: []string{"/ipfs", "/ipns"}, + DeserializedResponses: true, + }, + }, + }) + + doRequest := func(t *testing.T, path, host string, expectedStatus int) { + req := mustNewRequest(t, http.MethodGet, ts.URL+path, nil) + if host != "" { + req.Host = host + } + res := mustDoWithoutRedirect(t, req) + defer res.Body.Close() + assert.Equal(t, expectedStatus, res.StatusCode) + } + + // DNSLink only. Not supported for trustless. Supported for trusted, except + // format=ipns-record which is unavailable for DNSLink. + doRequest(t, "/", "trustless.com", http.StatusNotAcceptable) + doRequest(t, "/empty-dir/", "trustless.com", http.StatusNotAcceptable) + doRequest(t, "/?format=ipns-record", "trustless.com", http.StatusNotAcceptable) + + doRequest(t, "/", "trusted.com", http.StatusOK) + doRequest(t, "/empty-dir/", "trusted.com", http.StatusOK) + doRequest(t, "/?format=ipns-record", "trusted.com", http.StatusBadRequest) }) } -func TestIpnsTrustlessMode(t *testing.T) { - backend, root := newMockBackend(t) - backend.namesys["/ipns/trustless.com"] = path.FromCid(root) - backend.namesys["/ipns/trusted.com"] = path.FromCid(root) - - ts := newTestServerWithConfig(t, backend, Config{ - Headers: map[string][]string{}, - NoDNSLink: false, - PublicGateways: map[string]*PublicGateway{ - "trustless.com": { - Paths: []string{"/ipfs", "/ipns"}, - }, - "trusted.com": { - Paths: []string{"/ipfs", "/ipns"}, - DeserializedResponses: true, - }, - }, - }) - t.Logf("test server url: %s", ts.URL) +type errorMockBackend struct { + err error +} - doRequest := func(t *testing.T, path, host string, expectedStatus int) { - req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil) - assert.Nil(t, err) +func (mb *errorMockBackend) Get(ctx context.Context, path ImmutablePath, getRange ...ByteRange) (ContentPathMetadata, *GetResponse, error) { + return ContentPathMetadata{}, nil, mb.err +} - if host != "" { - req.Host = host - } +func (mb *errorMockBackend) GetAll(ctx context.Context, path ImmutablePath) (ContentPathMetadata, files.Node, error) { + return ContentPathMetadata{}, nil, mb.err +} - res, err := doWithoutRedirect(req) - assert.Nil(t, err) - defer res.Body.Close() - assert.Equal(t, expectedStatus, res.StatusCode) +func (mb *errorMockBackend) GetBlock(ctx context.Context, path ImmutablePath) (ContentPathMetadata, files.File, error) { + return ContentPathMetadata{}, nil, mb.err +} + +func (mb *errorMockBackend) Head(ctx context.Context, path ImmutablePath) (ContentPathMetadata, files.Node, error) { + return ContentPathMetadata{}, nil, mb.err +} + +func (mb *errorMockBackend) GetCAR(ctx context.Context, path ImmutablePath, params CarParams) (ContentPathMetadata, io.ReadCloser, error) { + return ContentPathMetadata{}, nil, mb.err +} + +func (mb *errorMockBackend) ResolveMutable(ctx context.Context, path ipath.Path) (ImmutablePath, error) { + return ImmutablePath{}, mb.err +} + +func (mb *errorMockBackend) GetIPNSRecord(ctx context.Context, c cid.Cid) ([]byte, error) { + return nil, mb.err +} + +func (mb *errorMockBackend) GetDNSLinkRecord(ctx context.Context, hostname string) (ipath.Path, error) { + return nil, mb.err +} + +func (mb *errorMockBackend) IsCached(ctx context.Context, p ipath.Path) bool { + return false +} + +func (mb *errorMockBackend) ResolvePath(ctx context.Context, path ImmutablePath) (ContentPathMetadata, error) { + return ContentPathMetadata{}, mb.err +} + +func TestErrorBubblingFromBackend(t *testing.T) { + t.Parallel() + + testError := func(name string, err error, status int) { + t.Run(name, func(t *testing.T) { + t.Parallel() + + backend := &errorMockBackend{err: fmt.Errorf("wrapped for testing purposes: %w", err)} + ts := newTestServer(t, backend) + req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipns/en.wikipedia-on-ipfs.org", nil) + res := mustDo(t, req) + require.Equal(t, status, res.StatusCode) + }) } - // DNSLink only. Not supported for trustless. Supported for trusted, except - // format=ipns-record which is unavailable for DNSLink. - doRequest(t, "/", "trustless.com", http.StatusNotAcceptable) - doRequest(t, "/EmptyDir/", "trustless.com", http.StatusNotAcceptable) - doRequest(t, "/?format=ipns-record", "trustless.com", http.StatusNotAcceptable) + testError("404 Not Found from IPLD", &ipld.ErrNotFound{}, http.StatusNotFound) + testError("404 Not Found from path resolver", resolver.ErrNoLink{}, http.StatusNotFound) + testError("502 Bad Gateway", ErrBadGateway, http.StatusBadGateway) + testError("504 Gateway Timeout", ErrGatewayTimeout, http.StatusGatewayTimeout) + + testErrorRetryAfter := func(name string, err error, status int, headerValue string, headerLength int) { + t.Run(name, func(t *testing.T) { + t.Parallel() + + backend := &errorMockBackend{err: fmt.Errorf("wrapped for testing purposes: %w", err)} + ts := newTestServer(t, backend) - doRequest(t, "/", "trusted.com", http.StatusOK) - doRequest(t, "/EmptyDir/", "trusted.com", http.StatusOK) - doRequest(t, "/?format=ipns-record", "trusted.com", http.StatusBadRequest) + req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipns/en.wikipedia-on-ipfs.org", nil) + res := mustDo(t, req) + require.Equal(t, status, res.StatusCode) + require.Equal(t, headerValue, res.Header.Get("Retry-After")) + require.Equal(t, headerLength, len(res.Header.Values("Retry-After"))) + }) + } + + testErrorRetryAfter("429 Too Many Requests without Retry-After header", ErrTooManyRequests, http.StatusTooManyRequests, "", 0) + testErrorRetryAfter("429 Too Many Requests without Retry-After header", NewErrorRetryAfter(ErrTooManyRequests, 0*time.Second), http.StatusTooManyRequests, "", 0) + testErrorRetryAfter("429 Too Many Requests with Retry-After header", NewErrorRetryAfter(ErrTooManyRequests, 3600*time.Second), http.StatusTooManyRequests, "3600", 1) } -func TestDagJsonCborPreview(t *testing.T) { - backend, root := newMockBackend(t) - - ts := newTestServerWithConfig(t, backend, Config{ - Headers: map[string][]string{}, - NoDNSLink: false, - PublicGateways: map[string]*PublicGateway{ - "example.com": { - Paths: []string{"/ipfs", "/ipns"}, - UseSubdomains: true, - DeserializedResponses: true, - }, - }, - DeserializedResponses: true, - }) - t.Logf("test server url: %s", ts.URL) +type panicMockBackend struct { + panicOnHostnameHandler bool +} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() +func (mb *panicMockBackend) Get(ctx context.Context, immutablePath ImmutablePath, ranges ...ByteRange) (ContentPathMetadata, *GetResponse, error) { + panic("i am panicking") +} + +func (mb *panicMockBackend) GetAll(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, files.Node, error) { + panic("i am panicking") +} + +func (mb *panicMockBackend) GetBlock(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, files.File, error) { + panic("i am panicking") +} + +func (mb *panicMockBackend) Head(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, files.Node, error) { + panic("i am panicking") +} + +func (mb *panicMockBackend) GetCAR(ctx context.Context, immutablePath ImmutablePath, params CarParams) (ContentPathMetadata, io.ReadCloser, error) { + panic("i am panicking") +} + +func (mb *panicMockBackend) ResolveMutable(ctx context.Context, p ipath.Path) (ImmutablePath, error) { + panic("i am panicking") +} + +func (mb *panicMockBackend) GetIPNSRecord(ctx context.Context, c cid.Cid) ([]byte, error) { + panic("i am panicking") +} + +func (mb *panicMockBackend) GetDNSLinkRecord(ctx context.Context, hostname string) (ipath.Path, error) { + // GetDNSLinkRecord is also called on the WithHostname handler. We have this option + // to disable panicking here so we can test if both the regular gateway handler + // and the hostname handler can handle panics. + if mb.panicOnHostnameHandler { + panic("i am panicking") + } - resolvedPath, err := backend.resolvePathNoRootsReturned(ctx, ipath.Join(ipath.IpfsPath(root), t.Name(), "example")) - assert.NoError(t, err) + return nil, errors.New("not implemented") +} - cidStr := resolvedPath.Cid().String() +func (mb *panicMockBackend) IsCached(ctx context.Context, p ipath.Path) bool { + panic("i am panicking") +} - t.Run("path gateway normalizes to trailing slash", func(t *testing.T) { +func (mb *panicMockBackend) ResolvePath(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, error) { + panic("i am panicking") +} + +func TestPanicStatusCode(t *testing.T) { + t.Parallel() + + t.Run("Panic on Handler", func(t *testing.T) { t.Parallel() - req, err := http.NewRequest(http.MethodGet, ts.URL+"/ipfs/"+cidStr, nil) - req.Header.Add("Accept", "text/html") - assert.NoError(t, err) + backend := &panicMockBackend{} + ts := newTestServer(t, backend) + req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipfs/bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e", nil) + res := mustDo(t, req) + require.Equal(t, http.StatusInternalServerError, res.StatusCode) + }) + + t.Run("Panic on Hostname Handler", func(t *testing.T) { + t.Parallel() - res, err := doWithoutRedirect(req) - assert.NoError(t, err) - assert.Equal(t, http.StatusMovedPermanently, res.StatusCode) - assert.Equal(t, "/ipfs/"+cidStr+"/", res.Header.Get("Location")) + backend := &panicMockBackend{panicOnHostnameHandler: true} + ts := newTestServer(t, backend) + req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipfs/bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e", nil) + res := mustDo(t, req) + require.Equal(t, http.StatusInternalServerError, res.StatusCode) }) +} - t.Run("subdomain gateway correctly redirects", func(t *testing.T) { +func TestBrowserErrorHTML(t *testing.T) { + t.Parallel() + ts, _, root := newTestServerAndNode(t, nil, "fixtures.car") + + t.Run("plain error if request does not have Accept: text/html", func(t *testing.T) { t.Parallel() - req, err := http.NewRequest(http.MethodGet, ts.URL+"/ipfs/"+cidStr, nil) - req.Header.Add("Accept", "text/html") - req.Host = "example.com" - assert.NoError(t, err) + req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipfs/"+root.String()+"/nonexisting-link", nil) + res := mustDoWithoutRedirect(t, req) + require.Equal(t, http.StatusNotFound, res.StatusCode) + require.NotContains(t, res.Header.Get("Content-Type"), "text/html") - res, err := doWithoutRedirect(req) - assert.NoError(t, err) - assert.Equal(t, http.StatusMovedPermanently, res.StatusCode) - assert.Equal(t, "http://"+cidStr+".ipfs.example.com/", res.Header.Get("Location")) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.NotContains(t, string(body), "") }) - t.Run("preview strings are correctly escaped", func(t *testing.T) { + t.Run("html error if request has Accept: text/html", func(t *testing.T) { t.Parallel() - req, err := http.NewRequest(http.MethodGet, ts.URL+resolvedPath.String()+"/", nil) - req.Header.Add("Accept", "text/html") - assert.NoError(t, err) + req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipfs/"+root.String()+"/nonexisting-link", nil) + req.Header.Set("Accept", "text/html") - res, err := doWithoutRedirect(req) - assert.NoError(t, err) - assert.Equal(t, http.StatusOK, res.StatusCode) + res := mustDoWithoutRedirect(t, req) + require.Equal(t, http.StatusNotFound, res.StatusCode) + require.Contains(t, res.Header.Get("Content-Type"), "text/html") body, err := io.ReadAll(res.Body) - assert.NoError(t, err) - - script := "window.alert('hacked')" - escaped := html.EscapeString(script) - - assert.Contains(t, string(body), escaped) - assert.NotContains(t, string(body), script) + require.NoError(t, err) + require.Contains(t, string(body), "") }) } diff --git a/gateway/handler.go b/gateway/handler.go index 4aac4fc07..e051d2006 100644 --- a/gateway/handler.go +++ b/gateway/handler.go @@ -22,6 +22,7 @@ import ( logging "github.com/ipfs/go-log/v2" "github.com/libp2p/go-libp2p/core/peer" "github.com/multiformats/go-multibase" + mc "github.com/multiformats/go-multicodec" prometheus "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -170,6 +171,37 @@ func (i *handler) optionsHandler(w http.ResponseWriter, r *http.Request) { i.addUserHeaders(w) // return all custom headers (including CORS ones, if set) } +type requestData struct { + // Defined for all requests. + begin time.Time + logger *zap.SugaredLogger + contentPath ipath.Path + responseFormat string + responseParams map[string]string + + // Defined for non IPNS Record requests. + immutablePath ImmutablePath + + // Defined if resolution has already happened. + pathMetadata *ContentPathMetadata +} + +// mostlyResolvedPath is an opportunistic optimization that returns the mostly +// resolved version of ImmutablePath available. It does not guarantee it is fully +// resolved, nor that it is the original. +func (rq *requestData) mostlyResolvedPath() ImmutablePath { + if rq.pathMetadata != nil { + imPath, err := NewImmutablePath(rq.pathMetadata.LastSegment) + if err != nil { + // This will never happen. This error has previously been checked in + // [handleIfNoneMatch] and the request will have returned 500. + panic(err) + } + return imPath + } + return rq.immutablePath +} + func (i *handler) getOrHeadHandler(w http.ResponseWriter, r *http.Request) { begin := time.Now() @@ -222,25 +254,32 @@ func (i *handler) getOrHeadHandler(w http.ResponseWriter, r *http.Request) { return } + rq := &requestData{ + begin: begin, + logger: logger, + contentPath: contentPath, + responseFormat: responseFormat, + responseParams: formatParams, + } + // IPNS Record response format can be handled now, since (1) it needs the // non-resolved mutable path, and (2) has custom If-None-Match header handling // due to custom ETag. if responseFormat == ipnsRecordResponseFormat { logger.Debugw("serving ipns record", "path", contentPath) - success = i.serveIpnsRecord(r.Context(), w, r, contentPath, begin, logger) + success = i.serveIpnsRecord(r.Context(), w, r, rq) return } - var immutableContentPath ImmutablePath if contentPath.Mutable() { - immutableContentPath, err = i.backend.ResolveMutable(r.Context(), contentPath) + rq.immutablePath, err = i.backend.ResolveMutable(r.Context(), contentPath) if err != nil { err = fmt.Errorf("failed to resolve %s: %w", debugStr(contentPath.String()), err) i.webError(w, r, err, http.StatusInternalServerError) return } } else { - immutableContentPath, err = NewImmutablePath(contentPath) + rq.immutablePath, err = NewImmutablePath(contentPath) if err != nil { err = fmt.Errorf("path was expected to be immutable, but was not %s: %w", debugStr(contentPath.String()), err) i.webError(w, r, err, http.StatusInternalServerError) @@ -253,36 +292,28 @@ func (i *handler) getOrHeadHandler(w http.ResponseWriter, r *http.Request) { // header handling due to custom ETag. if responseFormat == carResponseFormat { logger.Debugw("serving car stream", "path", contentPath) - carVersion := formatParams["version"] - success = i.serveCAR(r.Context(), w, r, immutableContentPath, contentPath, carVersion, begin) + success = i.serveCAR(r.Context(), w, r, rq) return } // Detect when If-None-Match HTTP header allows returning HTTP 304 Not Modified. - ifNoneMatchResolvedPath, handled := i.handleIfNoneMatch(w, r, responseFormat, contentPath, immutableContentPath) - if handled { + if i.handleIfNoneMatch(w, r, rq) { return } - // If we already did the path resolution no need to do it again - maybeResolvedImPath := immutableContentPath - if ifNoneMatchResolvedPath != nil { - maybeResolvedImPath = *ifNoneMatchResolvedPath - } - // Support custom response formats passed via ?format or Accept HTTP header switch responseFormat { case "", jsonResponseFormat, cborResponseFormat: - success = i.serveDefaults(r.Context(), w, r, maybeResolvedImPath, immutableContentPath, contentPath, begin, responseFormat, logger) + success = i.serveDefaults(r.Context(), w, r, rq) case rawResponseFormat: logger.Debugw("serving raw block", "path", contentPath) - success = i.serveRawBlock(r.Context(), w, r, maybeResolvedImPath, contentPath, begin) + success = i.serveRawBlock(r.Context(), w, r, rq) case tarResponseFormat: logger.Debugw("serving tar file", "path", contentPath) - success = i.serveTAR(r.Context(), w, r, maybeResolvedImPath, contentPath, begin, logger) + success = i.serveTAR(r.Context(), w, r, rq) case dagJsonResponseFormat, dagCborResponseFormat: logger.Debugw("serving codec", "path", contentPath) - success = i.serveCodec(r.Context(), w, r, maybeResolvedImPath, contentPath, begin, responseFormat) + success = i.serveCodec(r.Context(), w, r, rq) default: // catch-all for unsuported application/vnd.* err := fmt.Errorf("unsupported format %q", responseFormat) i.webError(w, r, err, http.StatusBadRequest) @@ -446,7 +477,12 @@ func setContentDispositionHeader(w http.ResponseWriter, filename string, disposi // setIpfsRootsHeader sets the X-Ipfs-Roots header with logical CID array for // efficient HTTP cache invalidation. -func setIpfsRootsHeader(w http.ResponseWriter, pathMetadata ContentPathMetadata) { +func setIpfsRootsHeader(w http.ResponseWriter, rq *requestData, md *ContentPathMetadata) { + // Update requestData with the latest ContentPathMetadata if it wasn't set yet. + if rq.pathMetadata == nil { + rq.pathMetadata = md + } + // These are logical roots where each CID represent one path segment // and resolves to either a directory or the root block of a file. // The main purpose of this header is allow HTTP caches to do smarter decisions @@ -469,10 +505,10 @@ func setIpfsRootsHeader(w http.ResponseWriter, pathMetadata ContentPathMetadata) // the last root (responsible for specific article) may not change at all. var pathRoots []string - for _, c := range pathMetadata.PathSegmentRoots { + for _, c := range rq.pathMetadata.PathSegmentRoots { pathRoots = append(pathRoots, c.String()) } - pathRoots = append(pathRoots, pathMetadata.LastSegment.Cid().String()) + pathRoots = append(pathRoots, rq.pathMetadata.LastSegment.Cid().String()) rootCidList := strings.Join(pathRoots, ",") // convention from rfc2616#sec4.2 w.Header().Set("X-Ipfs-Roots", rootCidList) @@ -552,6 +588,14 @@ func getEtag(r *http.Request, cid cid.Cid, responseFormat string) string { prefix := `"` suffix := `"` + // For Codecs, ensure that we have the right content-type. + if responseFormat == "" { + cidCodec := mc.Code(cid.Prefix().Codec) + if contentType, ok := codecToContentType[cidCodec]; ok { + responseFormat = contentType + } + } + switch responseFormat { case "": // Do nothing. @@ -645,40 +689,42 @@ func debugStr(path string) string { return q } -func (i *handler) handleIfNoneMatch(w http.ResponseWriter, r *http.Request, responseFormat string, contentPath ipath.Path, imPath ImmutablePath) (*ImmutablePath, bool) { +func (i *handler) handleIfNoneMatch(w http.ResponseWriter, r *http.Request, rq *requestData) bool { // Detect when If-None-Match HTTP header allows returning HTTP 304 Not Modified if ifNoneMatch := r.Header.Get("If-None-Match"); ifNoneMatch != "" { - pathMetadata, err := i.backend.ResolvePath(r.Context(), imPath) + pathMetadata, err := i.backend.ResolvePath(r.Context(), rq.immutablePath) if err != nil { - err = fmt.Errorf("failed to resolve %s: %w", debugStr(contentPath.String()), err) + err = fmt.Errorf("failed to resolve %s: %w", debugStr(rq.contentPath.String()), err) i.webError(w, r, err, http.StatusInternalServerError) - return nil, true + return true } - resolvedPath := pathMetadata.LastSegment - pathCid := resolvedPath.Cid() + pathCid := pathMetadata.LastSegment.Cid() // Checks against both file, dir listing, and dag index Etags. // This is an inexpensive check, and it happens before we do any I/O. - cidEtag := getEtag(r, pathCid, responseFormat) + cidEtag := getEtag(r, pathCid, rq.responseFormat) dirEtag := getDirListingEtag(pathCid) dagEtag := getDagIndexEtag(pathCid) if etagMatch(ifNoneMatch, cidEtag, dirEtag, dagEtag) { // Finish early if client already has a matching Etag w.WriteHeader(http.StatusNotModified) - return nil, true + return true } - resolvedImPath, err := NewImmutablePath(resolvedPath) + // Check if the resolvedPath is an immutable path. + _, err = NewImmutablePath(pathMetadata.LastSegment) if err != nil { i.webError(w, r, err, http.StatusInternalServerError) - return nil, true + return true } - return &resolvedImPath, true + rq.pathMetadata = &pathMetadata + return false } - return nil, false + + return false } // check if request was for one of known explicit formats, diff --git a/gateway/handler_block.go b/gateway/handler_block.go index 9708a46ef..dbff9a7ad 100644 --- a/gateway/handler_block.go +++ b/gateway/handler_block.go @@ -5,23 +5,22 @@ import ( "net/http" "time" - ipath "github.com/ipfs/boxo/coreiface/path" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // serveRawBlock returns bytes behind a raw block -func (i *handler) serveRawBlock(ctx context.Context, w http.ResponseWriter, r *http.Request, imPath ImmutablePath, contentPath ipath.Path, begin time.Time) bool { - ctx, span := spanTrace(ctx, "Handler.ServeRawBlock", trace.WithAttributes(attribute.String("path", imPath.String()))) +func (i *handler) serveRawBlock(ctx context.Context, w http.ResponseWriter, r *http.Request, rq *requestData) bool { + ctx, span := spanTrace(ctx, "Handler.ServeRawBlock", trace.WithAttributes(attribute.String("path", rq.immutablePath.String()))) defer span.End() - pathMetadata, data, err := i.backend.GetBlock(ctx, imPath) - if !i.handleRequestErrors(w, r, contentPath, err) { + pathMetadata, data, err := i.backend.GetBlock(ctx, rq.mostlyResolvedPath()) + if !i.handleRequestErrors(w, r, rq.contentPath, err) { return false } defer data.Close() - setIpfsRootsHeader(w, pathMetadata) + setIpfsRootsHeader(w, rq, &pathMetadata) blockCid := pathMetadata.LastSegment.Cid() @@ -35,7 +34,7 @@ func (i *handler) serveRawBlock(ctx context.Context, w http.ResponseWriter, r *h setContentDispositionHeader(w, name, "attachment") // Set remaining headers - modtime := addCacheControlHeaders(w, r, contentPath, blockCid, rawResponseFormat) + modtime := addCacheControlHeaders(w, r, rq.contentPath, blockCid, rawResponseFormat) w.Header().Set("Content-Type", rawResponseFormat) w.Header().Set("X-Content-Type-Options", "nosniff") // no funny business in the browsers :^) @@ -45,7 +44,7 @@ func (i *handler) serveRawBlock(ctx context.Context, w http.ResponseWriter, r *h if dataSent { // Update metrics - i.rawBlockGetMetric.WithLabelValues(contentPath.Namespace()).Observe(time.Since(begin).Seconds()) + i.rawBlockGetMetric.WithLabelValues(rq.contentPath.Namespace()).Observe(time.Since(rq.begin).Seconds()) } return dataSent diff --git a/gateway/handler_car.go b/gateway/handler_car.go index a8be3ded9..e773c920e 100644 --- a/gateway/handler_car.go +++ b/gateway/handler_car.go @@ -10,7 +10,6 @@ import ( "time" "github.com/cespare/xxhash/v2" - ipath "github.com/ipfs/boxo/coreiface/path" "github.com/ipfs/go-cid" "go.opentelemetry.io/otel/attribute" @@ -24,14 +23,14 @@ const ( ) // serveCAR returns a CAR stream for specific DAG+selector -func (i *handler) serveCAR(ctx context.Context, w http.ResponseWriter, r *http.Request, imPath ImmutablePath, contentPath ipath.Path, carVersion string, begin time.Time) bool { - ctx, span := spanTrace(ctx, "Handler.ServeCAR", trace.WithAttributes(attribute.String("path", imPath.String()))) +func (i *handler) serveCAR(ctx context.Context, w http.ResponseWriter, r *http.Request, rq *requestData) bool { + ctx, span := spanTrace(ctx, "Handler.ServeCAR", trace.WithAttributes(attribute.String("path", rq.immutablePath.String()))) defer span.End() ctx, cancel := context.WithCancel(ctx) defer cancel() - switch carVersion { + switch rq.responseParams["version"] { case "": // noop, client does not care about version case "1": // noop, we support this default: @@ -46,7 +45,7 @@ func (i *handler) serveCAR(ctx context.Context, w http.ResponseWriter, r *http.R return false } - rootCid, lastSegment, err := getCarRootCidAndLastSegment(imPath) + rootCid, lastSegment, err := getCarRootCidAndLastSegment(rq.immutablePath) if err != nil { i.webError(w, r, err, http.StatusInternalServerError) return false @@ -66,10 +65,10 @@ func (i *handler) serveCAR(ctx context.Context, w http.ResponseWriter, r *http.R setContentDispositionHeader(w, name, "attachment") // Set Cache-Control (same logic as for a regular files) - addCacheControlHeaders(w, r, contentPath, rootCid, carResponseFormat) + addCacheControlHeaders(w, r, rq.contentPath, rootCid, carResponseFormat) // Generate the CAR Etag. - etag := getCarEtag(imPath, params, rootCid) + etag := getCarEtag(rq.immutablePath, params, rootCid) w.Header().Set("Etag", etag) // Terminate early if Etag matches. We cannot rely on handleIfNoneMatch since @@ -79,12 +78,12 @@ func (i *handler) serveCAR(ctx context.Context, w http.ResponseWriter, r *http.R return false } - md, carFile, err := i.backend.GetCAR(ctx, imPath, params) - if !i.handleRequestErrors(w, r, contentPath, err) { + md, carFile, err := i.backend.GetCAR(ctx, rq.immutablePath, params) + if !i.handleRequestErrors(w, r, rq.contentPath, err) { return false } defer carFile.Close() - setIpfsRootsHeader(w, md) + setIpfsRootsHeader(w, rq, &md) // Make it clear we don't support range-requests over a car stream // Partial downloads and resumes should be handled using requests for @@ -99,7 +98,7 @@ func (i *handler) serveCAR(ctx context.Context, w http.ResponseWriter, r *http.R streamErr := multierr.Combine(carErr, copyErr) if streamErr != nil { // Update fail metric - i.carStreamFailMetric.WithLabelValues(contentPath.Namespace()).Observe(time.Since(begin).Seconds()) + i.carStreamFailMetric.WithLabelValues(rq.contentPath.Namespace()).Observe(time.Since(rq.begin).Seconds()) // We return error as a trailer, however it is not something browsers can access // (https://github.com/mdn/browser-compat-data/issues/14703) @@ -110,7 +109,7 @@ func (i *handler) serveCAR(ctx context.Context, w http.ResponseWriter, r *http.R } // Update metrics - i.carStreamGetMetric.WithLabelValues(contentPath.Namespace()).Observe(time.Since(begin).Seconds()) + i.carStreamGetMetric.WithLabelValues(rq.contentPath.Namespace()).Observe(time.Since(rq.begin).Seconds()) return true } diff --git a/gateway/handler_car_test.go b/gateway/handler_car_test.go index d603de11e..858ccb85d 100644 --- a/gateway/handler_car_test.go +++ b/gateway/handler_car_test.go @@ -7,9 +7,12 @@ import ( "github.com/ipfs/boxo/coreiface/path" "github.com/ipfs/go-cid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestCarParams(t *testing.T) { + t.Parallel() + t.Run("dag-scope parsing", func(t *testing.T) { t.Parallel() @@ -24,11 +27,8 @@ func TestCarParams(t *testing.T) { {"dag-scope=what-is-this", "", true}, } for _, test := range tests { - r, err := http.NewRequest(http.MethodGet, "http://example.com/?"+test.query, nil) - assert.NoError(t, err) - + r := mustNewRequest(t, http.MethodGet, "http://example.com/?"+test.query, nil) params, err := getCarParams(r) - if test.expectedError { assert.Error(t, err) } else { @@ -59,11 +59,8 @@ func TestCarParams(t *testing.T) { {"entity-bytes=123:bbb", true, 0, true, 0}, } for _, test := range tests { - r, err := http.NewRequest(http.MethodGet, "http://example.com/?"+test.query, nil) - assert.NoError(t, err) - + r := mustNewRequest(t, http.MethodGet, "http://example.com/?"+test.query, nil) params, err := getCarParams(r) - if test.hasError { assert.Error(t, err) } else { @@ -78,19 +75,21 @@ func TestCarParams(t *testing.T) { }) } -func TestCarEtag(t *testing.T) { +func TestGetCarEtag(t *testing.T) { + t.Parallel() + cid, err := cid.Parse("bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4") - assert.NoError(t, err) + require.NoError(t, err) imPath, err := NewImmutablePath(path.IpfsPath(cid)) - assert.NoError(t, err) + require.NoError(t, err) t.Run("Etag with entity-bytes=0:* is the same as without query param", func(t *testing.T) { t.Parallel() noRange := getCarEtag(imPath, CarParams{}, cid) withRange := getCarEtag(imPath, CarParams{Range: &DagByteRange{From: 0}}, cid) - assert.Equal(t, noRange, withRange) + require.Equal(t, noRange, withRange) }) t.Run("Etag with entity-bytes=1:* is different than without query param", func(t *testing.T) { @@ -98,7 +97,7 @@ func TestCarEtag(t *testing.T) { noRange := getCarEtag(imPath, CarParams{}, cid) withRange := getCarEtag(imPath, CarParams{Range: &DagByteRange{From: 1}}, cid) - assert.NotEqual(t, noRange, withRange) + require.NotEqual(t, noRange, withRange) }) t.Run("Etags with different dag-scope are different", func(t *testing.T) { @@ -106,6 +105,6 @@ func TestCarEtag(t *testing.T) { a := getCarEtag(imPath, CarParams{Scope: DagScopeAll}, cid) b := getCarEtag(imPath, CarParams{Scope: DagScopeEntity}, cid) - assert.NotEqual(t, a, b) + require.NotEqual(t, a, b) }) } diff --git a/gateway/handler_codec.go b/gateway/handler_codec.go index e7e5c3869..007a52fda 100644 --- a/gateway/handler_codec.go +++ b/gateway/handler_codec.go @@ -58,29 +58,28 @@ var contentTypeToExtension = map[string]string{ dagCborResponseFormat: ".cbor", } -func (i *handler) serveCodec(ctx context.Context, w http.ResponseWriter, r *http.Request, imPath ImmutablePath, contentPath ipath.Path, begin time.Time, requestedContentType string) bool { - ctx, span := spanTrace(ctx, "Handler.ServeCodec", trace.WithAttributes(attribute.String("path", imPath.String()), attribute.String("requestedContentType", requestedContentType))) +func (i *handler) serveCodec(ctx context.Context, w http.ResponseWriter, r *http.Request, rq *requestData) bool { + ctx, span := spanTrace(ctx, "Handler.ServeCodec", trace.WithAttributes(attribute.String("path", rq.immutablePath.String()), attribute.String("requestedContentType", rq.responseFormat))) defer span.End() - pathMetadata, data, err := i.backend.GetBlock(ctx, imPath) - if !i.handleRequestErrors(w, r, contentPath, err) { + pathMetadata, data, err := i.backend.GetBlock(ctx, rq.mostlyResolvedPath()) + if !i.handleRequestErrors(w, r, rq.contentPath, err) { return false } defer data.Close() - setIpfsRootsHeader(w, pathMetadata) - - resolvedPath := pathMetadata.LastSegment - return i.renderCodec(ctx, w, r, resolvedPath, data, contentPath, begin, requestedContentType) + setIpfsRootsHeader(w, rq, &pathMetadata) + return i.renderCodec(ctx, w, r, rq, data) } -func (i *handler) renderCodec(ctx context.Context, w http.ResponseWriter, r *http.Request, resolvedPath ipath.Resolved, blockData io.ReadSeekCloser, contentPath ipath.Path, begin time.Time, requestedContentType string) bool { - ctx, span := spanTrace(ctx, "Handler.RenderCodec", trace.WithAttributes(attribute.String("path", resolvedPath.String()), attribute.String("requestedContentType", requestedContentType))) +func (i *handler) renderCodec(ctx context.Context, w http.ResponseWriter, r *http.Request, rq *requestData, blockData io.ReadSeekCloser) bool { + resolvedPath := rq.pathMetadata.LastSegment + ctx, span := spanTrace(ctx, "Handler.RenderCodec", trace.WithAttributes(attribute.String("path", resolvedPath.String()), attribute.String("requestedContentType", rq.responseFormat))) defer span.End() blockCid := resolvedPath.Cid() cidCodec := mc.Code(blockCid.Prefix().Codec) - responseContentType := requestedContentType + responseContentType := rq.responseFormat // If the resolved path still has some remainder, return error for now. // TODO: handle this when we have IPLD Patch (https://ipld.io/specs/patch/) via HTTP PUT @@ -93,7 +92,7 @@ func (i *handler) renderCodec(ctx context.Context, w http.ResponseWriter, r *htt } // If no explicit content type was requested, the response will have one based on the codec from the CID - if requestedContentType == "" { + if rq.responseFormat == "" { cidContentType, ok := codecToContentType[cidCodec] if !ok { // Should not happen unless function is called with wrong parameters. @@ -105,49 +104,49 @@ func (i *handler) renderCodec(ctx context.Context, w http.ResponseWriter, r *htt } // Set HTTP headers (for caching, etc). Etag will be replaced if handled by serveCodecHTML. - modtime := addCacheControlHeaders(w, r, contentPath, resolvedPath.Cid(), responseContentType) + modtime := addCacheControlHeaders(w, r, rq.contentPath, resolvedPath.Cid(), responseContentType) name := setCodecContentDisposition(w, r, resolvedPath, responseContentType) w.Header().Set("Content-Type", responseContentType) w.Header().Set("X-Content-Type-Options", "nosniff") // No content type is specified by the user (via Accept, or format=). However, // we support this format. Let's handle it. - if requestedContentType == "" { + if rq.responseFormat == "" { isDAG := cidCodec == mc.DagJson || cidCodec == mc.DagCbor acceptsHTML := strings.Contains(r.Header.Get("Accept"), "text/html") download := r.URL.Query().Get("download") == "true" if isDAG && acceptsHTML && !download { - return i.serveCodecHTML(ctx, w, r, blockCid, blockData, resolvedPath, contentPath) + return i.serveCodecHTML(ctx, w, r, blockCid, blockData, resolvedPath, rq.contentPath) } else { // This covers CIDs with codec 'json' and 'cbor' as those do not have // an explicit requested content type. - return i.serveCodecRaw(ctx, w, r, blockData, contentPath, name, modtime, begin) + return i.serveCodecRaw(ctx, w, r, blockData, rq.contentPath, name, modtime, rq.begin) } } // If DAG-JSON or DAG-CBOR was requested using corresponding plain content type // return raw block as-is, without conversion - skipCodecs, ok := contentTypeToRaw[requestedContentType] + skipCodecs, ok := contentTypeToRaw[rq.responseFormat] if ok { for _, skipCodec := range skipCodecs { if skipCodec == cidCodec { - return i.serveCodecRaw(ctx, w, r, blockData, contentPath, name, modtime, begin) + return i.serveCodecRaw(ctx, w, r, blockData, rq.contentPath, name, modtime, rq.begin) } } } // Otherwise, the user has requested a specific content type (a DAG-* variant). // Let's first get the codecs that can be used with this content type. - toCodec, ok := contentTypeToCodec[requestedContentType] + toCodec, ok := contentTypeToCodec[rq.responseFormat] if !ok { - err := fmt.Errorf("converting from %q to %q is not supported", cidCodec.String(), requestedContentType) + err := fmt.Errorf("converting from %q to %q is not supported", cidCodec.String(), rq.responseFormat) i.webError(w, r, err, http.StatusBadRequest) return false } // This handles DAG-* conversions and validations. - return i.serveCodecConverted(ctx, w, r, blockCid, blockData, contentPath, toCodec, modtime, begin) + return i.serveCodecConverted(ctx, w, r, blockCid, blockData, rq.contentPath, toCodec, modtime, rq.begin) } func (i *handler) serveCodecHTML(ctx context.Context, w http.ResponseWriter, r *http.Request, blockCid cid.Cid, blockData io.ReadSeekCloser, resolvedPath ipath.Resolved, contentPath ipath.Path) bool { diff --git a/gateway/handler_codec_test.go b/gateway/handler_codec_test.go new file mode 100644 index 000000000..c79b07689 --- /dev/null +++ b/gateway/handler_codec_test.go @@ -0,0 +1,80 @@ +package gateway + +import ( + "context" + "html" + "io" + "net/http" + "testing" + + ipath "github.com/ipfs/boxo/coreiface/path" + "github.com/stretchr/testify/require" +) + +func TestDagJsonCborPreview(t *testing.T) { + t.Parallel() + backend, root := newMockBackend(t, "fixtures.car") + + ts := newTestServerWithConfig(t, backend, Config{ + Headers: map[string][]string{}, + NoDNSLink: false, + PublicGateways: map[string]*PublicGateway{ + "example.com": { + Paths: []string{"/ipfs", "/ipns"}, + UseSubdomains: true, + DeserializedResponses: true, + }, + }, + DeserializedResponses: true, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + resolvedPath, err := backend.resolvePathNoRootsReturned(ctx, ipath.Join(ipath.IpfsPath(root), "subdir", "dag-cbor-document")) + require.NoError(t, err) + + cidStr := resolvedPath.Cid().String() + + t.Run("path gateway normalizes to trailing slash", func(t *testing.T) { + t.Parallel() + + req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipfs/"+cidStr, nil) + req.Header.Add("Accept", "text/html") + + res := mustDoWithoutRedirect(t, req) + require.Equal(t, http.StatusMovedPermanently, res.StatusCode) + require.Equal(t, "/ipfs/"+cidStr+"/", res.Header.Get("Location")) + }) + + t.Run("subdomain gateway correctly redirects", func(t *testing.T) { + t.Parallel() + + req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipfs/"+cidStr, nil) + req.Header.Add("Accept", "text/html") + req.Host = "example.com" + + res := mustDoWithoutRedirect(t, req) + require.Equal(t, http.StatusMovedPermanently, res.StatusCode) + require.Equal(t, "http://"+cidStr+".ipfs.example.com/", res.Header.Get("Location")) + }) + + t.Run("preview strings are correctly escaped", func(t *testing.T) { + t.Parallel() + + req := mustNewRequest(t, http.MethodGet, ts.URL+resolvedPath.String()+"/", nil) + req.Header.Add("Accept", "text/html") + + res := mustDoWithoutRedirect(t, req) + require.Equal(t, http.StatusOK, res.StatusCode) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + + script := "window.alert('hacked')" + escaped := html.EscapeString(script) + + require.Contains(t, string(body), escaped) + require.NotContains(t, string(body), script) + }) +} diff --git a/gateway/handler_defaults.go b/gateway/handler_defaults.go index 5ccfed537..8e96d8b15 100644 --- a/gateway/handler_defaults.go +++ b/gateway/handler_defaults.go @@ -8,19 +8,16 @@ import ( "net/textproto" "strconv" "strings" - "time" "github.com/ipfs/boxo/files" mc "github.com/multiformats/go-multicodec" - ipath "github.com/ipfs/boxo/coreiface/path" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "go.uber.org/zap" ) -func (i *handler) serveDefaults(ctx context.Context, w http.ResponseWriter, r *http.Request, maybeResolvedImPath ImmutablePath, immutableContentPath ImmutablePath, contentPath ipath.Path, begin time.Time, requestedContentType string, logger *zap.SugaredLogger) bool { - ctx, span := spanTrace(ctx, "Handler.ServeDefaults", trace.WithAttributes(attribute.String("path", contentPath.String()))) +func (i *handler) serveDefaults(ctx context.Context, w http.ResponseWriter, r *http.Request, rq *requestData) bool { + ctx, span := spanTrace(ctx, "Handler.ServeDefaults", trace.WithAttributes(attribute.String("path", rq.contentPath.String()))) defer span.End() var ( @@ -35,8 +32,8 @@ func (i *handler) serveDefaults(ctx context.Context, w http.ResponseWriter, r *h switch r.Method { case http.MethodHead: var data files.Node - pathMetadata, data, err = i.backend.Head(ctx, maybeResolvedImPath) - if !i.handleRequestErrors(w, r, contentPath, err) { + pathMetadata, data, err = i.backend.Head(ctx, rq.mostlyResolvedPath()) + if !i.handleRequestErrors(w, r, rq.contentPath, err) { return false } defer data.Close() @@ -65,21 +62,21 @@ func (i *handler) serveDefaults(ctx context.Context, w http.ResponseWriter, r *h // allow backend to find providers for parents, even when internal // CIDs are not announced, and will provide better key for caching // related DAGs. - pathMetadata, getResp, err = i.backend.Get(ctx, maybeResolvedImPath, ranges...) + pathMetadata, getResp, err = i.backend.Get(ctx, rq.mostlyResolvedPath(), ranges...) if err != nil { - if isWebRequest(requestedContentType) { - forwardedPath, continueProcessing := i.handleWebRequestErrors(w, r, maybeResolvedImPath, immutableContentPath, contentPath, err, logger) + if isWebRequest(rq.responseFormat) { + forwardedPath, continueProcessing := i.handleWebRequestErrors(w, r, rq.mostlyResolvedPath(), rq.immutablePath, rq.contentPath, err, rq.logger) if !continueProcessing { return false } pathMetadata, getResp, err = i.backend.Get(ctx, forwardedPath, ranges...) if err != nil { - err = fmt.Errorf("failed to resolve %s: %w", debugStr(contentPath.String()), err) + err = fmt.Errorf("failed to resolve %s: %w", debugStr(rq.contentPath.String()), err) i.webError(w, r, err, http.StatusInternalServerError) return false } } else { - if !i.handleRequestErrors(w, r, contentPath, err) { + if !i.handleRequestErrors(w, r, rq.contentPath, err) { return false } } @@ -97,8 +94,7 @@ func (i *handler) serveDefaults(ctx context.Context, w http.ResponseWriter, r *h return false } - // TODO: check if we have a bug when maybeResolvedImPath is resolved and i.setIpfsRootsHeader works with pathMetadata returned by Get(maybeResolvedImPath) - setIpfsRootsHeader(w, pathMetadata) + setIpfsRootsHeader(w, rq, &pathMetadata) resolvedPath := pathMetadata.LastSegment switch mc.Code(resolvedPath.Cid().Prefix().Codec) { @@ -107,23 +103,23 @@ func (i *handler) serveDefaults(ctx context.Context, w http.ResponseWriter, r *h i.webError(w, r, fmt.Errorf("decoding error: data not usable as a file"), http.StatusInternalServerError) return false } - logger.Debugw("serving codec", "path", contentPath) - return i.renderCodec(r.Context(), w, r, resolvedPath, bytesResponse, contentPath, begin, requestedContentType) + rq.logger.Debugw("serving codec", "path", rq.contentPath) + return i.renderCodec(r.Context(), w, r, rq, bytesResponse) default: - logger.Debugw("serving unixfs", "path", contentPath) + rq.logger.Debugw("serving unixfs", "path", rq.contentPath) ctx, span := spanTrace(ctx, "Handler.ServeUnixFS", trace.WithAttributes(attribute.String("path", resolvedPath.String()))) defer span.End() // Handling Unixfs file if bytesResponse != nil { - logger.Debugw("serving unixfs file", "path", contentPath) - return i.serveFile(ctx, w, r, resolvedPath, contentPath, bytesResponse, pathMetadata.ContentType, begin) + rq.logger.Debugw("serving unixfs file", "path", rq.contentPath) + return i.serveFile(ctx, w, r, resolvedPath, rq.contentPath, bytesResponse, pathMetadata.ContentType, rq.begin) } // Handling Unixfs directory if directoryMetadata != nil || isDirectoryHeadRequest { - logger.Debugw("serving unixfs directory", "path", contentPath) - return i.serveDirectory(ctx, w, r, resolvedPath, contentPath, isDirectoryHeadRequest, directoryMetadata, ranges, begin, logger) + rq.logger.Debugw("serving unixfs directory", "path", rq.contentPath) + return i.serveDirectory(ctx, w, r, resolvedPath, rq.contentPath, isDirectoryHeadRequest, directoryMetadata, ranges, rq.begin, rq.logger) } i.webError(w, r, fmt.Errorf("unsupported UnixFS type"), http.StatusInternalServerError) diff --git a/gateway/handler_ipns_record.go b/gateway/handler_ipns_record.go index 66023f0d3..c811f0180 100644 --- a/gateway/handler_ipns_record.go +++ b/gateway/handler_ipns_record.go @@ -10,25 +10,23 @@ import ( "time" "github.com/cespare/xxhash/v2" - ipath "github.com/ipfs/boxo/coreiface/path" "github.com/ipfs/boxo/ipns" "github.com/ipfs/go-cid" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "go.uber.org/zap" ) -func (i *handler) serveIpnsRecord(ctx context.Context, w http.ResponseWriter, r *http.Request, contentPath ipath.Path, begin time.Time, logger *zap.SugaredLogger) bool { - ctx, span := spanTrace(ctx, "Handler.ServeIPNSRecord", trace.WithAttributes(attribute.String("path", contentPath.String()))) +func (i *handler) serveIpnsRecord(ctx context.Context, w http.ResponseWriter, r *http.Request, rq *requestData) bool { + ctx, span := spanTrace(ctx, "Handler.ServeIPNSRecord", trace.WithAttributes(attribute.String("path", rq.contentPath.String()))) defer span.End() - if contentPath.Namespace() != "ipns" { - err := fmt.Errorf("%s is not an IPNS link", contentPath.String()) + if rq.contentPath.Namespace() != "ipns" { + err := fmt.Errorf("%s is not an IPNS link", rq.contentPath.String()) i.webError(w, r, err, http.StatusBadRequest) return false } - key := contentPath.String() + key := rq.contentPath.String() key = strings.TrimSuffix(key, "/") key = strings.TrimPrefix(key, "/ipns/") if strings.Count(key, "/") != 0 { @@ -91,7 +89,7 @@ func (i *handler) serveIpnsRecord(ctx context.Context, w http.ResponseWriter, r _, err = w.Write(rawRecord) if err == nil { // Update metrics - i.ipnsRecordGetMetric.WithLabelValues(contentPath.Namespace()).Observe(time.Since(begin).Seconds()) + i.ipnsRecordGetMetric.WithLabelValues(rq.contentPath.Namespace()).Observe(time.Since(rq.begin).Seconds()) return true } diff --git a/gateway/handler_tar.go b/gateway/handler_tar.go index a46bb49dd..784e51993 100644 --- a/gateway/handler_tar.go +++ b/gateway/handler_tar.go @@ -6,34 +6,32 @@ import ( "net/http" "time" - ipath "github.com/ipfs/boxo/coreiface/path" "github.com/ipfs/boxo/files" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "go.uber.org/zap" ) var unixEpochTime = time.Unix(0, 0) -func (i *handler) serveTAR(ctx context.Context, w http.ResponseWriter, r *http.Request, imPath ImmutablePath, contentPath ipath.Path, begin time.Time, logger *zap.SugaredLogger) bool { - ctx, span := spanTrace(ctx, "Handler.ServeTAR", trace.WithAttributes(attribute.String("path", imPath.String()))) +func (i *handler) serveTAR(ctx context.Context, w http.ResponseWriter, r *http.Request, rq *requestData) bool { + ctx, span := spanTrace(ctx, "Handler.ServeTAR", trace.WithAttributes(attribute.String("path", rq.immutablePath.String()))) defer span.End() ctx, cancel := context.WithCancel(ctx) defer cancel() // Get Unixfs file (or directory) - pathMetadata, file, err := i.backend.GetAll(ctx, imPath) - if !i.handleRequestErrors(w, r, contentPath, err) { + pathMetadata, file, err := i.backend.GetAll(ctx, rq.mostlyResolvedPath()) + if !i.handleRequestErrors(w, r, rq.contentPath, err) { return false } defer file.Close() - setIpfsRootsHeader(w, pathMetadata) + setIpfsRootsHeader(w, rq, &pathMetadata) rootCid := pathMetadata.LastSegment.Cid() // Set Cache-Control and read optional Last-Modified time - modtime := addCacheControlHeaders(w, r, contentPath, rootCid, tarResponseFormat) + modtime := addCacheControlHeaders(w, r, rq.contentPath, rootCid, tarResponseFormat) // Set Content-Disposition var name string @@ -65,7 +63,7 @@ func (i *handler) serveTAR(ctx context.Context, w http.ResponseWriter, r *http.R // The TAR has a top-level directory (or file) named by the CID. if err := tarw.WriteFile(file, rootCid.String()); err != nil { // Update fail metric - i.tarStreamFailMetric.WithLabelValues(contentPath.Namespace()).Observe(time.Since(begin).Seconds()) + i.tarStreamFailMetric.WithLabelValues(rq.contentPath.Namespace()).Observe(time.Since(rq.begin).Seconds()) w.Header().Set("X-Stream-Error", err.Error()) // Trailer headers do not work in web browsers @@ -79,6 +77,6 @@ func (i *handler) serveTAR(ctx context.Context, w http.ResponseWriter, r *http.R } // Update metrics - i.tarStreamGetMetric.WithLabelValues(contentPath.Namespace()).Observe(time.Since(begin).Seconds()) + i.tarStreamGetMetric.WithLabelValues(rq.contentPath.Namespace()).Observe(time.Since(rq.begin).Seconds()) return true } diff --git a/gateway/handler_test.go b/gateway/handler_test.go index 28229a901..e5e8a8ecb 100644 --- a/gateway/handler_test.go +++ b/gateway/handler_test.go @@ -1,20 +1,8 @@ package gateway import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "testing" - "time" - ipath "github.com/ipfs/boxo/coreiface/path" - "github.com/ipfs/boxo/files" - "github.com/ipfs/boxo/path/resolver" - cid "github.com/ipfs/go-cid" - ipld "github.com/ipfs/go-ipld-format" "github.com/stretchr/testify/assert" ) @@ -40,192 +28,3 @@ func TestEtagMatch(t *testing.T) { assert.Equalf(t, test.expected, result, "etagMatch(%q, %q, %q)", test.header, test.cidEtag, test.dirEtag) } } - -type errorMockBackend struct { - err error -} - -func (mb *errorMockBackend) Get(ctx context.Context, path ImmutablePath, getRange ...ByteRange) (ContentPathMetadata, *GetResponse, error) { - return ContentPathMetadata{}, nil, mb.err -} - -func (mb *errorMockBackend) GetAll(ctx context.Context, path ImmutablePath) (ContentPathMetadata, files.Node, error) { - return ContentPathMetadata{}, nil, mb.err -} - -func (mb *errorMockBackend) GetBlock(ctx context.Context, path ImmutablePath) (ContentPathMetadata, files.File, error) { - return ContentPathMetadata{}, nil, mb.err -} - -func (mb *errorMockBackend) Head(ctx context.Context, path ImmutablePath) (ContentPathMetadata, files.Node, error) { - return ContentPathMetadata{}, nil, mb.err -} - -func (mb *errorMockBackend) GetCAR(ctx context.Context, path ImmutablePath, params CarParams) (ContentPathMetadata, io.ReadCloser, error) { - return ContentPathMetadata{}, nil, mb.err -} - -func (mb *errorMockBackend) ResolveMutable(ctx context.Context, path ipath.Path) (ImmutablePath, error) { - return ImmutablePath{}, mb.err -} - -func (mb *errorMockBackend) GetIPNSRecord(ctx context.Context, c cid.Cid) ([]byte, error) { - return nil, mb.err -} - -func (mb *errorMockBackend) GetDNSLinkRecord(ctx context.Context, hostname string) (ipath.Path, error) { - return nil, mb.err -} - -func (mb *errorMockBackend) IsCached(ctx context.Context, p ipath.Path) bool { - return false -} - -func (mb *errorMockBackend) ResolvePath(ctx context.Context, path ImmutablePath) (ContentPathMetadata, error) { - return ContentPathMetadata{}, mb.err -} - -func TestGatewayBadRequestInvalidPath(t *testing.T) { - backend, _ := newMockBackend(t) - ts := newTestServer(t, backend) - t.Logf("test server url: %s", ts.URL) - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/ipfs/QmInvalid/Path", nil) - assert.NoError(t, err) - - res, err := ts.Client().Do(req) - assert.NoError(t, err) - - assert.Equal(t, http.StatusBadRequest, res.StatusCode) -} - -func TestErrorBubblingFromBackend(t *testing.T) { - t.Parallel() - - for _, test := range []struct { - name string - err error - status int - }{ - {"404 Not Found from IPLD", &ipld.ErrNotFound{}, http.StatusNotFound}, - {"404 Not Found from path resolver", resolver.ErrNoLink{}, http.StatusNotFound}, - {"502 Bad Gateway", ErrBadGateway, http.StatusBadGateway}, - {"504 Gateway Timeout", ErrGatewayTimeout, http.StatusGatewayTimeout}, - } { - t.Run(test.name, func(t *testing.T) { - backend := &errorMockBackend{err: fmt.Errorf("wrapped for testing purposes: %w", test.err)} - ts := newTestServer(t, backend) - t.Logf("test server url: %s", ts.URL) - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/ipns/en.wikipedia-on-ipfs.org", nil) - assert.NoError(t, err) - - res, err := ts.Client().Do(req) - assert.NoError(t, err) - assert.Equal(t, test.status, res.StatusCode) - }) - } - - for _, test := range []struct { - name string - err error - status int - headerName string - headerValue string - headerLength int // how many times was headerName set - }{ - {"429 Too Many Requests without Retry-After header", ErrTooManyRequests, http.StatusTooManyRequests, "Retry-After", "", 0}, - {"429 Too Many Requests without Retry-After header", NewErrorRetryAfter(ErrTooManyRequests, 0*time.Second), http.StatusTooManyRequests, "Retry-After", "", 0}, - {"429 Too Many Requests with Retry-After header", NewErrorRetryAfter(ErrTooManyRequests, 3600*time.Second), http.StatusTooManyRequests, "Retry-After", "3600", 1}, - } { - backend := &errorMockBackend{err: fmt.Errorf("wrapped for testing purposes: %w", test.err)} - ts := newTestServer(t, backend) - t.Logf("test server url: %s", ts.URL) - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/ipns/en.wikipedia-on-ipfs.org", nil) - assert.NoError(t, err) - - res, err := ts.Client().Do(req) - assert.NoError(t, err) - assert.Equal(t, test.status, res.StatusCode) - assert.Equal(t, test.headerValue, res.Header.Get(test.headerName)) - assert.Equal(t, test.headerLength, len(res.Header.Values(test.headerName))) - } -} - -type panicMockBackend struct { - panicOnHostnameHandler bool -} - -func (mb *panicMockBackend) Get(ctx context.Context, immutablePath ImmutablePath, ranges ...ByteRange) (ContentPathMetadata, *GetResponse, error) { - panic("i am panicking") -} - -func (mb *panicMockBackend) GetAll(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, files.Node, error) { - panic("i am panicking") -} - -func (mb *panicMockBackend) GetBlock(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, files.File, error) { - panic("i am panicking") -} - -func (mb *panicMockBackend) Head(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, files.Node, error) { - panic("i am panicking") -} - -func (mb *panicMockBackend) GetCAR(ctx context.Context, immutablePath ImmutablePath, params CarParams) (ContentPathMetadata, io.ReadCloser, error) { - panic("i am panicking") -} - -func (mb *panicMockBackend) ResolveMutable(ctx context.Context, p ipath.Path) (ImmutablePath, error) { - panic("i am panicking") -} - -func (mb *panicMockBackend) GetIPNSRecord(ctx context.Context, c cid.Cid) ([]byte, error) { - panic("i am panicking") -} - -func (mb *panicMockBackend) GetDNSLinkRecord(ctx context.Context, hostname string) (ipath.Path, error) { - // GetDNSLinkRecord is also called on the WithHostname handler. We have this option - // to disable panicking here so we can test if both the regular gateway handler - // and the hostname handler can handle panics. - if mb.panicOnHostnameHandler { - panic("i am panicking") - } - - return nil, errors.New("not implemented") -} - -func (mb *panicMockBackend) IsCached(ctx context.Context, p ipath.Path) bool { - panic("i am panicking") -} - -func (mb *panicMockBackend) ResolvePath(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, error) { - panic("i am panicking") -} - -func TestGatewayStatusCodeOnPanic(t *testing.T) { - backend := &panicMockBackend{} - ts := newTestServer(t, backend) - t.Logf("test server url: %s", ts.URL) - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/ipfs/bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e", nil) - assert.NoError(t, err) - - res, err := ts.Client().Do(req) - assert.NoError(t, err) - assert.Equal(t, http.StatusInternalServerError, res.StatusCode) -} - -func TestGatewayStatusCodeOnHostnamePanic(t *testing.T) { - backend := &panicMockBackend{panicOnHostnameHandler: true} - ts := newTestServer(t, backend) - t.Logf("test server url: %s", ts.URL) - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/ipfs/bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e", nil) - assert.NoError(t, err) - - res, err := ts.Client().Do(req) - assert.NoError(t, err) - assert.Equal(t, http.StatusInternalServerError, res.StatusCode) -} diff --git a/gateway/handler_unixfs_dir_test.go b/gateway/handler_unixfs_dir_test.go new file mode 100644 index 000000000..a8ce04778 --- /dev/null +++ b/gateway/handler_unixfs_dir_test.go @@ -0,0 +1,87 @@ +package gateway + +import ( + "context" + "io" + "net/http" + "testing" + + ipath "github.com/ipfs/boxo/coreiface/path" + path "github.com/ipfs/boxo/path" + "github.com/stretchr/testify/require" +) + +func TestIPNSHostnameBacklinks(t *testing.T) { + // Test if directory listing on DNSLink Websites have correct backlinks. + ts, backend, root := newTestServerAndNode(t, nil, "dir-special-chars.car") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // create /ipns/example.net/foo/ + k2, err := backend.resolvePathNoRootsReturned(ctx, ipath.Join(ipath.IpfsPath(root), "foo? #<'")) + require.NoError(t, err) + + k3, err := backend.resolvePathNoRootsReturned(ctx, ipath.Join(ipath.IpfsPath(root), "foo? #<'/bar")) + require.NoError(t, err) + + backend.namesys["/ipns/example.net"] = path.FromCid(root) + + // make request to directory listing + req := mustNewRequest(t, http.MethodGet, ts.URL+"/foo%3F%20%23%3C%27/", nil) + req.Host = "example.net" + + res := mustDoWithoutRedirect(t, req) + + // expect correct links + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + s := string(body) + t.Logf("body: %s\n", string(body)) + + require.True(t, matchPathOrBreadcrumbs(s, "/ipns/example.net/foo? #<'"), "expected a path in directory listing") + // https://github.com/ipfs/dir-index-html/issues/42 + require.Contains(t, s, "", "expected backlink in directory listing") + require.Contains(t, s, "", "expected file in directory listing") + require.Contains(t, s, s, k2.Cid().String(), "expected hash in directory listing") + + // make request to directory listing at root + req = mustNewRequest(t, http.MethodGet, ts.URL, nil) + req.Host = "example.net" + + res = mustDoWithoutRedirect(t, req) + require.NoError(t, err) + + // expect correct backlinks at root + body, err = io.ReadAll(res.Body) + require.NoError(t, err) + + s = string(body) + t.Logf("body: %s\n", string(body)) + + require.True(t, matchPathOrBreadcrumbs(s, "/"), "expected a path in directory listing") + require.NotContains(t, s, "", "expected no backlink in directory listing of the root CID") + require.Contains(t, s, "", "expected file in directory listing") + // https://github.com/ipfs/dir-index-html/issues/42 + require.Contains(t, s, "example.net/foo? #<'/bar"), "expected a path in directory listing") + require.Contains(t, s, "", "expected backlink in directory listing") + require.Contains(t, s, "", "expected file in directory listing") + require.Contains(t, s, k3.Cid().String(), "expected hash in directory listing") +} diff --git a/gateway/hostname_test.go b/gateway/hostname_test.go index 272a24866..a58e0d404 100644 --- a/gateway/hostname_test.go +++ b/gateway/hostname_test.go @@ -10,12 +10,15 @@ import ( path "github.com/ipfs/boxo/path" cid "github.com/ipfs/go-cid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestToSubdomainURL(t *testing.T) { - backend, _ := newMockBackend(t) + t.Parallel() + + backend, _ := newMockBackend(t, "fixtures.car") testCID, err := cid.Decode("bafkqaglimvwgy3zakrsxg5cun5jxkyten5wwc2lokvjeycq") - assert.NoError(t, err) + require.NoError(t, err) backend.namesys["/ipns/dnslink.long-name.example.com"] = path.FromString(testCID.String()) backend.namesys["/ipns/dnslink.too-long.f1siqrebi3vir8sab33hu5vcy008djegvay6atmz91ojesyjs8lx350b7y7i1nvyw2haytfukfyu2f2x4tocdrfa0zgij6p4zpl4u5o.example.com"] = path.FromString(testCID.String()) @@ -62,13 +65,14 @@ func TestToSubdomainURL(t *testing.T) { testName := fmt.Sprintf("%s, %v, %s", test.gwHostname, test.inlineDNSLink, test.path) t.Run(testName, func(t *testing.T) { url, err := toSubdomainURL(test.gwHostname, test.path, test.request, test.inlineDNSLink, backend) - assert.Equal(t, test.url, url) - assert.Equal(t, test.err, err) + require.Equal(t, test.url, url) + require.Equal(t, test.err, err) }) } } func TestToDNSLinkDNSLabel(t *testing.T) { + t.Parallel() for _, test := range []struct { in string out string @@ -79,13 +83,14 @@ func TestToDNSLinkDNSLabel(t *testing.T) { } { t.Run(test.in, func(t *testing.T) { out, err := toDNSLinkDNSLabel(test.in) - assert.Equal(t, test.out, out) - assert.Equal(t, test.err, err) + require.Equal(t, test.out, out) + require.Equal(t, test.err, err) }) } } func TestToDNSLinkFQDN(t *testing.T) { + t.Parallel() for _, test := range []struct { in string out string @@ -96,12 +101,13 @@ func TestToDNSLinkFQDN(t *testing.T) { } { t.Run(test.in, func(t *testing.T) { out := toDNSLinkFQDN(test.in) - assert.Equal(t, test.out, out) + require.Equal(t, test.out, out) }) } } func TestIsHTTPSRequest(t *testing.T) { + t.Parallel() httpRequest := httptest.NewRequest("GET", "http://127.0.0.1:8080", nil) httpsRequest := httptest.NewRequest("GET", "https://https-request-stub.example.com", nil) httpsProxiedRequest := httptest.NewRequest("GET", "http://proxied-https-request-stub.example.com", nil) @@ -122,12 +128,13 @@ func TestIsHTTPSRequest(t *testing.T) { testName := fmt.Sprintf("%+v", test.in) t.Run(testName, func(t *testing.T) { out := isHTTPSRequest(test.in) - assert.Equal(t, test.out, out) + require.Equal(t, test.out, out) }) } } func TestHasPrefix(t *testing.T) { + t.Parallel() for _, test := range []struct { prefixes []string path string @@ -141,12 +148,13 @@ func TestHasPrefix(t *testing.T) { testName := fmt.Sprintf("%+v, %s", test.prefixes, test.path) t.Run(testName, func(t *testing.T) { out := hasPrefix(test.path, test.prefixes...) - assert.Equal(t, test.out, out) + require.Equal(t, test.out, out) }) } } func TestIsDomainNameAndNotPeerID(t *testing.T) { + t.Parallel() for _, test := range []struct { hostname string out bool @@ -160,12 +168,13 @@ func TestIsDomainNameAndNotPeerID(t *testing.T) { } { t.Run(test.hostname, func(t *testing.T) { out := isDomainNameAndNotPeerID(test.hostname) - assert.Equal(t, test.out, out) + require.Equal(t, test.out, out) }) } } func TestPortStripping(t *testing.T) { + t.Parallel() for _, test := range []struct { in string out string @@ -180,12 +189,13 @@ func TestPortStripping(t *testing.T) { } { t.Run(test.in, func(t *testing.T) { out := stripPort(test.in) - assert.Equal(t, test.out, out) + require.Equal(t, test.out, out) }) } } func TestToDNSLabel(t *testing.T) { + t.Parallel() for _, test := range []struct { in string out string @@ -203,13 +213,15 @@ func TestToDNSLabel(t *testing.T) { t.Run(test.in, func(t *testing.T) { inCID, _ := cid.Decode(test.in) out, err := toDNSLabel(test.in, inCID) - assert.Equal(t, test.out, out) - assert.Equal(t, test.err, err) + require.Equal(t, test.out, out) + require.Equal(t, test.err, err) }) } } func TestKnownSubdomainDetails(t *testing.T) { + t.Parallel() + gwLocalhost := &PublicGateway{Paths: []string{"/ipfs", "/ipns", "/api"}, UseSubdomains: true} gwDweb := &PublicGateway{Paths: []string{"/ipfs", "/ipns", "/api"}, UseSubdomains: true} gwLong := &PublicGateway{Paths: []string{"/ipfs", "/ipns", "/api"}, UseSubdomains: true} diff --git a/gateway/lazyseek_test.go b/gateway/lazyseek_test.go index ca4e57d9e..b3ed4e4e2 100644 --- a/gateway/lazyseek_test.go +++ b/gateway/lazyseek_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type badSeeker struct { @@ -30,33 +30,33 @@ func TestLazySeekerError(t *testing.T) { size: underlyingBuffer.Size(), } off, err := s.Seek(0, io.SeekEnd) - assert.NoError(t, err) - assert.Equal(t, s.size, off, "expected to seek to the end") + require.NoError(t, err) + require.Equal(t, s.size, off, "expected to seek to the end") // shouldn't have actually seeked. b, err := io.ReadAll(s) - assert.NoError(t, err) - assert.Equal(t, 0, len(b), "expected to read nothing") + require.NoError(t, err) + require.Equal(t, 0, len(b), "expected to read nothing") // shouldn't need to actually seek. off, err = s.Seek(0, io.SeekStart) - assert.NoError(t, err) - assert.Equal(t, int64(0), off, "expected to seek to the start") + require.NoError(t, err) + require.Equal(t, int64(0), off, "expected to seek to the start") b, err = io.ReadAll(s) - assert.NoError(t, err) - assert.Equal(t, "fubar", string(b), "expected to read string") + require.NoError(t, err) + require.Equal(t, "fubar", string(b), "expected to read string") // should fail the second time. off, err = s.Seek(0, io.SeekStart) - assert.NoError(t, err) - assert.Equal(t, int64(0), off, "expected to seek to the start") + require.NoError(t, err) + require.Equal(t, int64(0), off, "expected to seek to the start") // right here... b, err = io.ReadAll(s) - assert.NotNil(t, err) - assert.Equal(t, errBadSeek, err) - assert.Equal(t, 0, len(b), "expected to read nothing") + require.NotNil(t, err) + require.Equal(t, errBadSeek, err) + require.Equal(t, 0, len(b), "expected to read nothing") } func TestLazySeeker(t *testing.T) { @@ -69,25 +69,25 @@ func TestLazySeeker(t *testing.T) { t.Helper() var buf [1]byte n, err := io.ReadFull(s, buf[:]) - assert.NoError(t, err) - assert.Equal(t, 1, n, "expected to read one byte, read %d", n) - assert.Equal(t, b, buf[0]) + require.NoError(t, err) + require.Equal(t, 1, n, "expected to read one byte, read %d", n) + require.Equal(t, b, buf[0]) } expectSeek := func(whence int, off, expOff int64, expErr string) { t.Helper() n, err := s.Seek(off, whence) if expErr == "" { - assert.NoError(t, err) + require.NoError(t, err) } else { - assert.EqualError(t, err, expErr) + require.EqualError(t, err, expErr) } - assert.Equal(t, expOff, n) + require.Equal(t, expOff, n) } expectSeek(io.SeekEnd, 0, s.size, "") b, err := io.ReadAll(s) - assert.NoError(t, err) - assert.Equal(t, 0, len(b), "expected to read nothing") + require.NoError(t, err) + require.Equal(t, 0, len(b), "expected to read nothing") expectSeek(io.SeekEnd, -1, s.size-1, "") expectByte('r') expectSeek(io.SeekStart, 0, 0, "") diff --git a/gateway/testdata/dir-special-chars.car b/gateway/testdata/dir-special-chars.car new file mode 100644 index 0000000000000000000000000000000000000000..ac1ce84800e381b19a3cc7f638d9659fd35986ca GIT binary patch literal 554 zcmcColvpK*%?&#`6O>&F< zk8#y|%mM1j&$m}lwo#W@$jHUS!Kg}%O@`fIhZ8hh7vhM3FlZ3-aG$zt=M)YgK4!7n% TKZkBOI6MexG6t(6m~ zPF;~+QT|pZy)3n;I5R(waRMVT%7u)%R3_hHRB;gEOwBDQsnku$ERtXmGUQSL>Hf@~ z7aZer^dmo?$tpJeNpXc;f&Dj?zAk+)v*FmRvhP!c*osS&KsqL{O}@^kt1%sHCqZ2{ zleL-DB?VIw({+=R@{4p+@{>z*Q}aqBX0QtB1Ko3*Rb}#7R^H8tO!CZ=Z?S3t0MXNH AEdT%j literal 2647 zcmb_dYg80v7RSxS1z*4Dx`@dw~gf%AVLTtIP84OPP;p^&Wv2Vq^O_- zM=_C$B84EScsmxiL{x&3BOp?mCo)mdqvQ!xtPF`}c8SyR&+LzW&77I%dEV!JzxRDV zpCf=GNruk8<-S@pMWBO_nTKyaFRHyexVynReGCiqS(&D5Qu!Tw9Mc?eaV#&rEiD~T zG)@wN3KL`C&|=|y4wq20bD3F+8(J{}ZM)jExFx1tTh?Bz2K}?4Wy7WChaijhwK^tC zhEwn~D8Rt+xWaT-qn$Z>ivFBcX=~|S+v_1Qb1&>TTK2S8{3~JsS;qpJk)df}G)V-d zl2kMW(s7Uh_n1QO8YEzaDp4IAvB~-LnV|E7fAmEz82sR2ZqoF;p84Hz9sX_5Oin_M zG9UxZk^=_zfn)||w)BgGL)!#8M zS^~kZtHb~dr+|Wi4+{QG0@@i`P}-V5*m$N7eAf+UwW5QXNO z=Y-ry+#b<)#}_gOnW$E$0dT1)C*Evo4C{ER4j=WOK5sNuCWbKu%FrX22B(=^JI_ab z<970t6Y+KlGq#P}Y0l}k+MP;)n(R8(_6Pvrdggba0vZjux-M1)kWhjFRI~;q0E1Fl zNHDGe$f_)w0b0a0G&*>d8-mjaiqL>TNF|~mwK|eUQ#HT?(E&h-8B)wW5gkP`q=MAY zh?1lb6vHS$(>Rglfl%ZIM*o5m7{X8}K`Q}8v0qp*Ha5YCC(r;xFqAU7H)fb8o5KmGPd*+zZxijp+@(sPLlb>)c|k!_rCM|E5@l1{f`h22Mix zjG{r4P(G_^T9QzMEcykG6Cbft!Y-mr7hjs;4q!zFPGDq)1l0hFadlC#;|MSpH($?* z-#ATb0i$A(li~QBNd-n6h`#%f43g4dLQjssr)nVJIwdVk;AW3^NqMrf$Lb=tlPd%6 zy?8iQ;a?%U`_dYp8*#MfZ`(ayp(O?4e6MGasgfWm4Au{xn=uSMexHCd!MpFc1;Cn6 z=bod-yHxEg1hW-mlK$ zp6w??@a&Y%u9`iq(Wl#MTemw_MSUyW8dA=3NYW2+_H}_P^}9=COr%BmdgHv}VuOk7 zw&Zrv?!f-!HU2%bB|cC5Umm@bJ9D{Uzr4Wtd9tV)GD$@#c$xmfV)OigPWD-lZ2eom zU&8wQLu*Y0<>t5Q+nox3@=+2`=G4CP-#jO`C~u63B1W3~=cKmu<*(~Dz5L6y`=_iM z-j)_`>370rd@OhS>a*_G#r5^zBYEjje7+a&)K`u7Vp?c8?)zuk0!v>xymfGNRVG=v0$`n z;XM0@JCF%`&ae+xE`2ZYi6?W7$t&t@q*Ecg9zdo<#b`C~6wbqbeIB?ySzH7!Zn*fEGBy`Pv4LhtEy5Y(IDGj8O~U^H Dt=l0T diff --git a/gateway/testdata/headers-test.car b/gateway/testdata/headers-test.car new file mode 100644 index 0000000000000000000000000000000000000000..6d34e0a93b628dcfb13ca8bd62bc5fd00867d86a GIT binary patch literal 85410 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ɦC>Q;J!TivtQ9+|~Mx;5`lJU=h}OPgyfmf zj{@Xl4%t)CVUuV|jPYa0zH6bQ@zSEH;%4@-6-5_!{7`0Fy(Bwh;Lzo>Z&@+{vN8Md*rE-dQy(%*gPn4cJI_Yt(feTBKR65c!H zU0mN3Uz%whJn-SW$tBkE)8bCK6}xTr9j9Ml1ze+ZI9I3uYm>2z$Tu_4`Cs%no&Z|m zU5NN~fAs7Y*Za6z`1K=CcFf9{;CbD;wM**mJgu4YXDa2De0t)&p_>LHXY`|_eqQAE z7FTV^j7&B#-oR~K%>H!ri(jVnTez@C!};j4xN*;24^+qo+^E{Vy7bQs+b!WNM3OJ` z;t(HzcIzBUcNBb)@$82&dX3X;k?;wJ<`&zpcJYr_2RP?eef<8e^Xg|m?~JOM(y8vj z_52(Dw@mtec)o$2&0?vTLqHu6Lpa&x1Bc#X8|;osD0*YqRG)wjwYTi9&~3mO~wrOM5EToFB!L;$QpPDa$)2HJUY5C}+N zApvWASHUxB+rk;ahXb4pIc3;ak*6Q0dsZHwW11l8(fer0yjN}VCpJeuYVF?O89tnT zhTf2_&VG+V3IPZF1~C+jv455V062*SfGbHWnnMq2)iql~&RIk@ z1$Q{nRr1x?4{r-=W(0lrz%6&@I(iNXm0rZc(~t62h&jZ#B}y+Ny4II)muJI6!p#7+ z8T20RT3DV|@Xg#)@2=h3;9Zwpk>hzkAR%c*=K(9fdb(!n(jR8CXu$xDB{}3R07eCe zTrEhAB)2&vHBzUarA+_!&sPiW=k+?#wlOX$%)YQ7%qv*zT(Y`Ib2zQBG4PCg_oj0L zt>~8rN;^5g3d7~RG0%+JhZU(f1j?YOkA%Fi^YITiU1vz?)5||%(oUrPRvqtleda>T zyCHGgoS!)tU0v{@$ILG(6#JJvf1VrMynZx2lf()s2k#qF=Sis+?nS5HpDp*Sf!r;~ zp|oqA?Y(6{@w0OeXULDlyvW*<<6(MwpM_KXxzmSVEUaPCr4K;wN#u@xlHOp_IH-6( zAz2>!XQ`m&98qO0{jwHPFN5#I9mkkGIN8kH|7=}Q^)q?L%yV_q>m#?l6pgeSnU~*6 zR)0cwu{h}yJsD7DY7C51i?Liq)G+;{d==gt4!8<|grFMH2PBnTB7uGq0SSH^YHvVX zp*mRiU-c2%XSm7UhM<1aI{Fq_O><2Y>87VUO+4ul?T){5_kVNzmh* zLre(3!blVVQ%=ODg*d@i{q{3#IUJ?V30vD+`r_lJ_x0<~eu_?ToVs5=eNaPZkKX59 zt{yqq`7%A_d~osEpPBX1KC{L5%}J5vL!egp_e6z)3$!1$vMu z8K#vTo*WVc2-_Jx4IfCRS0UTcV6F)r9enzwwUzg_HlEAIBrh16S@HGj{5q%Izpt6} zc7^hKk{(-{d*NK{?=F>V1x;AQN@a?<(?QEkW#=A$3 zOG-%9!_fAof5!%#OQ3vPS8$%qmEH0BqWU}92TSI1!%PDlpzTb z8nreeWin3{o1o@}PEOUT6L~!YhNwc_c(JLx2wtjM%S(#pMJ6RqNJ@6DM{L-$fSf+UUX6lFCr=`MV*=&s~O*imy$G5i@ro?qIlYr2u*6VIwb{u z0)Gsit?Vpo5V^wSrzf(|qkITPa=;eIk{hTYS^OkM$C%-uLw5;dRqBFgU^X8>} z^I4Z;4yTGH9qb=ralHHCxhoT$B;*(W-~Qsp2s!m1jqoME`fr3rD9Eq=8>!NN=NNsGA=1155sapL43`mRBL?xwpL?ozFv^`y8A|m6}QLb+NJq`aHo0^oU t*2chZlE?iUWqg_iLDI)dOG-(IvhdUo__x1`2gFW@PPK5O*-&UZ{VzI_ja2{u literal 0 HcmV?d00001 diff --git a/gateway/testdata/ipns-hostname-redirects.car b/gateway/testdata/ipns-hostname-redirects.car new file mode 100644 index 0000000000000000000000000000000000000000..8e56d0fc708c190cfd7b7e219389336e73b2e55d GIT binary patch literal 325 zcmcColvTq(dogiHo%rrG4%T nF{kC{OIUF+aWE2PcpOoNnh@zyuFSlY)C#?flH42#29O&7@?D2_ literal 0 HcmV?d00001 diff --git a/gateway/testdata/pretty-404.car b/gateway/testdata/pretty-404.car new file mode 100644 index 0000000000000000000000000000000000000000..3adec2904505b235636f71f1e39424cd18559b19 GIT binary patch literal 405 zcmcColvHW(gI(VAk2&I})&3ib zyJX4;v8AM@7NizQ_z0N;b%Y8j{Hcx1xU=Td?beB&9;{|hv+r(B&nXUk(|kA~GC_2; zw}cRHWc* literal 0 HcmV?d00001 diff --git a/gateway/utilities_test.go b/gateway/utilities_test.go new file mode 100644 index 000000000..1b9f81d32 --- /dev/null +++ b/gateway/utilities_test.go @@ -0,0 +1,238 @@ +package gateway + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/ipfs/boxo/blockservice" + nsopts "github.com/ipfs/boxo/coreiface/options/namesys" + ipath "github.com/ipfs/boxo/coreiface/path" + offline "github.com/ipfs/boxo/exchange/offline" + "github.com/ipfs/boxo/files" + carblockstore "github.com/ipfs/boxo/ipld/car/v2/blockstore" + "github.com/ipfs/boxo/namesys" + path "github.com/ipfs/boxo/path" + "github.com/ipfs/go-cid" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/routing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mustNewRequest(t *testing.T, method string, path string, body io.Reader) *http.Request { + r, err := http.NewRequest(http.MethodGet, path, body) + require.NoError(t, err) + return r +} + +func mustDoWithoutRedirect(t *testing.T, req *http.Request) *http.Response { + errNoRedirect := errors.New("without-redirect") + c := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return errNoRedirect + }, + } + res, err := c.Do(req) + require.True(t, err == nil || errors.Is(err, errNoRedirect)) + return res +} + +func mustDo(t *testing.T, req *http.Request) *http.Response { + c := &http.Client{} + res, err := c.Do(req) + require.NoError(t, err) + return res +} + +type mockNamesys map[string]path.Path + +func (m mockNamesys) Resolve(ctx context.Context, name string, opts ...nsopts.ResolveOpt) (value path.Path, err error) { + cfg := nsopts.DefaultResolveOpts() + for _, o := range opts { + o(&cfg) + } + depth := cfg.Depth + if depth == nsopts.UnlimitedDepth { + // max uint + depth = ^uint(0) + } + for strings.HasPrefix(name, "/ipns/") { + if depth == 0 { + return value, namesys.ErrResolveRecursion + } + depth-- + + var ok bool + value, ok = m[name] + if !ok { + return "", namesys.ErrResolveFailed + } + name = value.String() + } + return value, nil +} + +func (m mockNamesys) ResolveAsync(ctx context.Context, name string, opts ...nsopts.ResolveOpt) <-chan namesys.Result { + out := make(chan namesys.Result, 1) + v, err := m.Resolve(ctx, name, opts...) + out <- namesys.Result{Path: v, Err: err} + close(out) + return out +} + +func (m mockNamesys) Publish(ctx context.Context, name crypto.PrivKey, value path.Path, opts ...nsopts.PublishOption) error { + return errors.New("not implemented for mockNamesys") +} + +func (m mockNamesys) GetResolver(subs string) (namesys.Resolver, bool) { + return nil, false +} + +type mockBackend struct { + gw IPFSBackend + namesys mockNamesys +} + +var _ IPFSBackend = (*mockBackend)(nil) + +func newMockBackend(t *testing.T, fixturesFile string) (*mockBackend, cid.Cid) { + r, err := os.Open(filepath.Join("./testdata", fixturesFile)) + assert.NoError(t, err) + + blockStore, err := carblockstore.NewReadOnly(r, nil) + assert.NoError(t, err) + + t.Cleanup(func() { + blockStore.Close() + r.Close() + }) + + cids, err := blockStore.Roots() + assert.NoError(t, err) + assert.Len(t, cids, 1) + + blockService := blockservice.New(blockStore, offline.Exchange(blockStore)) + + n := mockNamesys{} + backend, err := NewBlocksBackend(blockService, WithNameSystem(n)) + if err != nil { + t.Fatal(err) + } + + return &mockBackend{ + gw: backend, + namesys: n, + }, cids[0] +} + +func (mb *mockBackend) Get(ctx context.Context, immutablePath ImmutablePath, ranges ...ByteRange) (ContentPathMetadata, *GetResponse, error) { + return mb.gw.Get(ctx, immutablePath, ranges...) +} + +func (mb *mockBackend) GetAll(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, files.Node, error) { + return mb.gw.GetAll(ctx, immutablePath) +} + +func (mb *mockBackend) GetBlock(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, files.File, error) { + return mb.gw.GetBlock(ctx, immutablePath) +} + +func (mb *mockBackend) Head(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, files.Node, error) { + return mb.gw.Head(ctx, immutablePath) +} + +func (mb *mockBackend) GetCAR(ctx context.Context, immutablePath ImmutablePath, params CarParams) (ContentPathMetadata, io.ReadCloser, error) { + return mb.gw.GetCAR(ctx, immutablePath, params) +} + +func (mb *mockBackend) ResolveMutable(ctx context.Context, p ipath.Path) (ImmutablePath, error) { + return mb.gw.ResolveMutable(ctx, p) +} + +func (mb *mockBackend) GetIPNSRecord(ctx context.Context, c cid.Cid) ([]byte, error) { + return nil, routing.ErrNotSupported +} + +func (mb *mockBackend) GetDNSLinkRecord(ctx context.Context, hostname string) (ipath.Path, error) { + if mb.namesys != nil { + p, err := mb.namesys.Resolve(ctx, "/ipns/"+hostname, nsopts.Depth(1)) + if err == namesys.ErrResolveRecursion { + err = nil + } + return ipath.New(p.String()), err + } + + return nil, errors.New("not implemented") +} + +func (mb *mockBackend) IsCached(ctx context.Context, p ipath.Path) bool { + return mb.gw.IsCached(ctx, p) +} + +func (mb *mockBackend) ResolvePath(ctx context.Context, immutablePath ImmutablePath) (ContentPathMetadata, error) { + return mb.gw.ResolvePath(ctx, immutablePath) +} + +func (mb *mockBackend) resolvePathNoRootsReturned(ctx context.Context, ip ipath.Path) (ipath.Resolved, error) { + var imPath ImmutablePath + var err error + if ip.Mutable() { + imPath, err = mb.ResolveMutable(ctx, ip) + if err != nil { + return nil, err + } + } else { + imPath, err = NewImmutablePath(ip) + if err != nil { + return nil, err + } + } + + md, err := mb.ResolvePath(ctx, imPath) + if err != nil { + return nil, err + } + return md.LastSegment, nil +} + +func newTestServerAndNode(t *testing.T, ns mockNamesys, fixturesFile string) (*httptest.Server, *mockBackend, cid.Cid) { + backend, root := newMockBackend(t, fixturesFile) + ts := newTestServer(t, backend) + return ts, backend, root +} + +func newTestServer(t *testing.T, backend IPFSBackend) *httptest.Server { + return newTestServerWithConfig(t, backend, Config{ + Headers: map[string][]string{}, + DeserializedResponses: true, + }) +} + +func newTestServerWithConfig(t *testing.T, backend IPFSBackend, config Config) *httptest.Server { + AddAccessControlHeaders(config.Headers) + + handler := NewHandler(config, backend) + mux := http.NewServeMux() + mux.Handle("/ipfs/", handler) + mux.Handle("/ipns/", handler) + handler = NewHostnameHandler(config, backend, mux) + + ts := httptest.NewServer(handler) + t.Cleanup(func() { ts.Close() }) + t.Logf("test server url: %s", ts.URL) + + return ts +} + +func matchPathOrBreadcrumbs(s string, expected string) bool { + matched, _ := regexp.MatchString("Index of(\n|\r\n)[\t ]*"+regexp.QuoteMeta(expected), s) + return matched +} From 70c0d8f23e59998bb5370d450379d48974e0593a Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Tue, 27 Jun 2023 12:50:28 +0200 Subject: [PATCH 3/4] fix(gateway): include CORS on subdomain redirects (#395) (cherry picked from commit a87f9ed0b2a930034d556f1a56db4208a04db369) --- gateway/gateway.go | 22 +++++++---- gateway/gateway_test.go | 83 ++++++++++++++++++++++++++++++++++++++++- gateway/handler.go | 22 +++++++---- gateway/hostname.go | 17 +++++++-- 4 files changed, 124 insertions(+), 20 deletions(-) diff --git a/gateway/gateway.go b/gateway/gateway.go index cc0babba1..cf2ca9104 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -320,20 +320,22 @@ func cleanHeaderSet(headers []string) []string { return result } -// AddAccessControlHeaders adds default HTTP headers used for controlling -// cross-origin requests. This function adds several values to the -// [Access-Control-Allow-Headers] and [Access-Control-Expose-Headers] entries. +// AddAccessControlHeaders ensures safe default HTTP headers are used for +// controlling cross-origin requests. This function adds several values to the +// [Access-Control-Allow-Headers] and [Access-Control-Expose-Headers] entries +// to be exposed on GET and OPTIONS responses, including [CORS Preflight]. // -// If the Access-Control-Allow-Origin entry is missing a value of '*' is +// If the Access-Control-Allow-Origin entry is missing, a default value of '*' is // added, indicating that browsers should allow requesting code from any // origin to access the resource. // -// If the Access-Control-Allow-Methods entry is missing a value of 'GET' is -// added, indicating that browsers may use the GET method when issuing cross +// If the Access-Control-Allow-Methods entry is missing a value, 'GET, HEAD, +// OPTIONS' is added, indicating that browsers may use them when issuing cross // origin requests. // // [Access-Control-Allow-Headers]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers // [Access-Control-Expose-Headers]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers +// [CORS Preflight]: https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request func AddAccessControlHeaders(headers map[string][]string) { // Hard-coded headers. const ACAHeadersName = "Access-Control-Allow-Headers" @@ -346,8 +348,12 @@ func AddAccessControlHeaders(headers map[string][]string) { headers[ACAOriginName] = []string{"*"} } if _, ok := headers[ACAMethodsName]; !ok { - // Default to GET - headers[ACAMethodsName] = []string{http.MethodGet} + // Default to GET, HEAD, OPTIONS + headers[ACAMethodsName] = []string{ + http.MethodGet, + http.MethodHead, + http.MethodOptions, + } } headers[ACAHeadersName] = cleanHeaderSet( diff --git a/gateway/gateway_test.go b/gateway/gateway_test.go index 04b80a118..cc36da68f 100644 --- a/gateway/gateway_test.go +++ b/gateway/gateway_test.go @@ -128,7 +128,7 @@ func TestPretty404(t *testing.T) { func TestHeaders(t *testing.T) { t.Parallel() - ts, _, _ := newTestServerAndNode(t, nil, "headers-test.car") + ts, backend, root := newTestServerAndNode(t, nil, "headers-test.car") var ( rootCID = "bafybeidbcy4u6y55gsemlubd64zk53xoxs73ifd6rieejxcr7xy46mjvky" @@ -336,6 +336,87 @@ func TestHeaders(t *testing.T) { test(dagJsonResponseFormat, dagCborPath, dagCborRoots) test(dagCborResponseFormat, dagCborPath, dagCborRoots) }) + + // Ensures CORS headers are present in HTTP OPTIONS responses + // https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request + t.Run("CORS Preflight Headers", func(t *testing.T) { + // Expect boxo/gateway library's default CORS allowlist for Method + headerACAM := "Access-Control-Allow-Methods" + expectedACAM := []string{http.MethodGet, http.MethodHead, http.MethodOptions} + + // Set custom CORS policy to ensure we test user config end-to-end + headerACAO := "Access-Control-Allow-Origin" + expectedACAO := "https://other.example.net" + headers := map[string][]string{} + headers[headerACAO] = []string{expectedACAO} + + ts := newTestServerWithConfig(t, backend, Config{ + Headers: headers, + PublicGateways: map[string]*PublicGateway{ + "subgw.example.com": { + Paths: []string{"/ipfs", "/ipns"}, + UseSubdomains: true, + DeserializedResponses: true, + }, + }, + DeserializedResponses: true, + }) + t.Logf("test server url: %s", ts.URL) + + testCORSPreflightRequest := func(t *testing.T, path, hostHeader string, requestOriginHeader string, code int) { + req, err := http.NewRequest(http.MethodOptions, ts.URL+path, nil) + assert.Nil(t, err) + + if hostHeader != "" { + req.Host = hostHeader + } + + if requestOriginHeader != "" { + req.Header.Add("Origin", requestOriginHeader) + } + + t.Logf("test req: %+v", req) + + // Expect no redirect for OPTIONS request -- https://github.com/ipfs/kubo/issues/9983#issuecomment-1599673976 + res := mustDoWithoutRedirect(t, req) + defer res.Body.Close() + + t.Logf("test res: %+v", res) + + // Expect success + assert.Equal(t, code, res.StatusCode) + + // Expect OPTIONS response to have custom CORS header set by user + assert.Equal(t, expectedACAO, res.Header.Get(headerACAO)) + + // Expect OPTIONS response to have implicit default Allow-Methods + // set by boxo/gateway library + assert.Equal(t, expectedACAM, res.Header[headerACAM]) + + } + + cid := root.String() + + t.Run("HTTP OPTIONS response is OK and has defined headers", func(t *testing.T) { + t.Parallel() + testCORSPreflightRequest(t, "/ipfs/"+cid, "", "", http.StatusOK) + }) + + t.Run("HTTP OPTIONS response for cross-origin /ipfs/cid is OK and has CORS headers", func(t *testing.T) { + t.Parallel() + testCORSPreflightRequest(t, "/ipfs/"+cid, "", "https://other.example.net", http.StatusOK) + }) + + t.Run("HTTP OPTIONS response for cross-origin /ipfs/cid is HTTP 301 and includes CORS headers (path gw redirect on subdomain gw)", func(t *testing.T) { + t.Parallel() + testCORSPreflightRequest(t, "/ipfs/"+cid, "subgw.example.com", "https://other.example.net", http.StatusMovedPermanently) + }) + + t.Run("HTTP OPTIONS response for cross-origin is HTTP 200 and has CORS headers (host header on subdomain gw)", func(t *testing.T) { + t.Parallel() + testCORSPreflightRequest(t, "/", cid+".ipfs.subgw.example.com", "https://other.example.net", http.StatusOK) + }) + }) } func TestGoGetSupport(t *testing.T) { diff --git a/gateway/handler.go b/gateway/handler.go index e051d2006..cf553320a 100644 --- a/gateway/handler.go +++ b/gateway/handler.go @@ -156,19 +156,25 @@ func (i *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - w.Header().Add("Allow", http.MethodGet) - w.Header().Add("Allow", http.MethodHead) - w.Header().Add("Allow", http.MethodOptions) + addAllowHeader(w) errmsg := "Method " + r.Method + " not allowed: read only access" http.Error(w, errmsg, http.StatusMethodNotAllowed) } func (i *handler) optionsHandler(w http.ResponseWriter, r *http.Request) { + addAllowHeader(w) // OPTIONS is a noop request that is used by the browsers to check if server accepts // cross-site XMLHttpRequest, which is indicated by the presence of CORS headers: // https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Preflighted_requests - i.addUserHeaders(w) // return all custom headers (including CORS ones, if set) + addCustomHeaders(w, i.config.Headers) // return all custom headers (including CORS ones, if set) +} + +// addAllowHeader sets Allow header with supported HTTP methods +func addAllowHeader(w http.ResponseWriter) { + w.Header().Add("Allow", http.MethodGet) + w.Header().Add("Allow", http.MethodHead) + w.Header().Add("Allow", http.MethodOptions) } type requestData struct { @@ -244,7 +250,7 @@ func (i *handler) getOrHeadHandler(w http.ResponseWriter, r *http.Request) { trace.SpanFromContext(r.Context()).SetAttributes(attribute.String("ResponseFormat", responseFormat)) i.requestTypeMetric.WithLabelValues(contentPath.Namespace(), responseFormat).Inc() - i.addUserHeaders(w) // ok, _now_ write user's headers. + addCustomHeaders(w, i.config.Headers) // ok, _now_ write user's headers. w.Header().Set("X-Ipfs-Path", contentPath.String()) // Fail fast if unsupported request type was sent to a Trustless Gateway. @@ -320,9 +326,9 @@ func (i *handler) getOrHeadHandler(w http.ResponseWriter, r *http.Request) { } } -func (i *handler) addUserHeaders(w http.ResponseWriter) { - for k, v := range i.config.Headers { - w.Header()[k] = v +func addCustomHeaders(w http.ResponseWriter, headers map[string][]string) { + for k, v := range headers { + w.Header()[http.CanonicalHeaderKey(k)] = v } } diff --git a/gateway/hostname.go b/gateway/hostname.go index 6fb1ac8eb..4df23d22c 100644 --- a/gateway/hostname.go +++ b/gateway/hostname.go @@ -68,7 +68,7 @@ func NewHostnameHandler(c Config, backend IPFSBackend, next http.Handler) http.H return } if newURL != "" { - http.Redirect(w, r, newURL, http.StatusMovedPermanently) + httpRedirectWithHeaders(w, r, newURL, http.StatusMovedPermanently, c.Headers) return } } @@ -131,7 +131,7 @@ func NewHostnameHandler(c Config, backend IPFSBackend, next http.Handler) http.H if newURL != "" { // Redirect to deterministic CID to ensure CID // always gets the same Origin on the web - http.Redirect(w, r, newURL, http.StatusMovedPermanently) + httpRedirectWithHeaders(w, r, newURL, http.StatusMovedPermanently, c.Headers) return } } @@ -146,7 +146,7 @@ func NewHostnameHandler(c Config, backend IPFSBackend, next http.Handler) http.H } if newURL != "" { // Redirect to CID fixed inside of toSubdomainURL() - http.Redirect(w, r, newURL, http.StatusMovedPermanently) + httpRedirectWithHeaders(w, r, newURL, http.StatusMovedPermanently, c.Headers) return } } @@ -559,3 +559,14 @@ func (gws *hostnameGateways) knownSubdomainDetails(hostname string) (gw *PublicG // no match return nil, "", "", "", false } + +// httpRedirectWithHeaders applies custom headers before returning a redirect +// response to ensure consistency during transition from path to subdomain +// contexts. +func httpRedirectWithHeaders(w http.ResponseWriter, r *http.Request, url string, code int, headers map[string][]string) { + // ensure things like CORS are applied to redirect responses + // (https://github.com/ipfs/kubo/issues/9983#issuecomment-1599673976) + addCustomHeaders(w, headers) + + http.Redirect(w, r, url, code) +} From 2d3edc552442426d56db1f45dfa54a24e0e9182e Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Thu, 29 Jun 2023 16:21:14 +0200 Subject: [PATCH 4/4] chore: version 0.10.2 --- CHANGELOG.md | 7 +++++++ version.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9884d18bf..934fc75ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,13 @@ The following emojis are used to highlight certain changes: ### Security +## [0.10.2] - 2023-06-29 + +### Fixed + +- Gateway: include CORS on subdomain redirects. +- Gateway: ensure 'X-Ipfs-Root' header is valid. + ## [0.10.1] - 2023-06-19 ### Added diff --git a/version.json b/version.json index b6bb0741a..9186b8eee 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "v0.10.1" + "version": "v0.10.2" }