Skip to content

Commit 0883d3b

Browse files
committed
feat(gateway)!: new trustless mode, and by default
1 parent 5e94b9d commit 0883d3b

File tree

9 files changed

+393
-85
lines changed

9 files changed

+393
-85
lines changed

examples/gateway/common/handler.go

+36-24
Original file line numberDiff line numberDiff line change
@@ -10,34 +10,46 @@ import (
1010
)
1111

1212
func NewHandler(gwAPI gateway.IPFSBackend) http.Handler {
13-
// Initialize the headers and gateway configuration. For this example, we do
14-
// not add any special headers, but the required ones.
15-
headers := map[string][]string{}
16-
gateway.AddAccessControlHeaders(headers)
1713
conf := gateway.Config{
18-
Headers: headers,
19-
}
14+
// Initialize the headers. For this example, we do not add any special headers,
15+
// only the required ones via gateway.AddAccessControlHeaders.
16+
Headers: map[string][]string{},
2017

21-
// Initialize the public gateways that we will want to have available through
22-
// Host header rewriting. This step is optional and only required if you're
23-
// running multiple public gateways and want different settings and support
24-
// for DNSLink and Subdomain Gateways.
25-
noDNSLink := false // If you set DNSLink to point at the CID from CAR, you can load it!
26-
publicGateways := map[string]*gateway.Specification{
27-
// Support public requests with Host: CID.ipfs.example.net and ID.ipns.example.net
28-
"example.net": {
29-
Paths: []string{"/ipfs", "/ipns"},
30-
NoDNSLink: noDNSLink,
31-
UseSubdomains: true,
32-
},
33-
// Support local requests
34-
"localhost": {
35-
Paths: []string{"/ipfs", "/ipns"},
36-
NoDNSLink: noDNSLink,
37-
UseSubdomains: true,
18+
// If you set DNSLink to point at the CID from CAR, you can load it!
19+
NoDNSLink: false,
20+
21+
// For these examples we have the trusted mode enabled by default. That is,
22+
// all types of requests will be accepted. By default, only Trustless Gateway
23+
// requests work: https://specs.ipfs.tech/http-gateways/trustless-gateway/
24+
DeserializedResponses: true,
25+
26+
// Initialize the public gateways that we will want to have available through
27+
// Host header rewriting. This step is optional and only required if you're
28+
// running multiple public gateways and want different settings and support
29+
// for DNSLink and Subdomain Gateways.
30+
PublicGateways: map[string]*gateway.Specification{
31+
// Support public requests with Host: CID.ipfs.example.net and ID.ipns.example.net
32+
"example.net": {
33+
Paths: []string{"/ipfs", "/ipns"},
34+
NoDNSLink: false,
35+
UseSubdomains: true,
36+
// This gateway is used for testing and therefore we make non-trustless
37+
// requests. Thus, we have to manually turn on the trusted mode.
38+
DeserializedResponses: true,
39+
},
40+
// Support local requests
41+
"localhost": {
42+
Paths: []string{"/ipfs", "/ipns"},
43+
NoDNSLink: false,
44+
UseSubdomains: true,
45+
DeserializedResponses: true,
46+
},
3847
},
3948
}
4049

50+
// Add required access control headers to the configuration.
51+
gateway.AddAccessControlHeaders(conf.Headers)
52+
4153
// Creates a mux to serve the gateway paths. This is not strictly necessary
4254
// and gwHandler could be used directly. However, on the next step we also want
4355
// to add prometheus metrics, hence needing the mux.
@@ -57,7 +69,7 @@ func NewHandler(gwAPI gateway.IPFSBackend) http.Handler {
5769
// or example.net. If you want to expose the metrics on such gateways,
5870
// you will have to add the path "/debug" to the variable Paths.
5971
var handler http.Handler
60-
handler = gateway.WithHostname(mux, gwAPI, publicGateways, noDNSLink)
72+
handler = gateway.WithHostname(conf, gwAPI, mux)
6173

6274
// Then, wrap with the withConnect middleware. This is required since we use
6375
// http.ServeMux which does not support CONNECT by default.

gateway/gateway.go

+66-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,61 @@ import (
1515

1616
// Config is the configuration used when creating a new gateway handler.
1717
type Config struct {
18+
// Headers is a map containing all the headers that should be sent by default
19+
// in all requests. You can define custom headers, as well as add the recommended
20+
// headers via AddAccessControlHeaders.
1821
Headers map[string][]string
22+
23+
// DeserializedResponses configures this gateway to support returning data
24+
// in deserialized format. By default, the gateway will only provide raw responses,
25+
// operating as a trustless gateway, as defined in the specification:
26+
// https://specs.ipfs.tech/http-gateways/trustless-gateway/. This flag can be
27+
// overridden per FQDN in PublicGateways.
28+
DeserializedResponses bool
29+
30+
// NoDNSLink configures the gateway to _not_ perform DNS TXT record lookups in
31+
// response to requests with values in `Host` HTTP header. This flag can be
32+
// overridden per FQDN in PublicGateways. To be used with WithHostname.
33+
NoDNSLink bool
34+
35+
// PublicGateways configures the behavior of known public gateways. Each key is
36+
// a fully qualified domain name (FQDN). To be used with WithHostname.
37+
PublicGateways map[string]*Specification
38+
}
39+
40+
// Specification is the specification of an IPFS Public Gateway.
41+
type Specification struct {
42+
// Paths is explicit list of path prefixes that should be handled by
43+
// this gateway. Example: `["/ipfs", "/ipns"]`
44+
// Useful if you only want to support immutable `/ipfs`.
45+
Paths []string
46+
47+
// UseSubdomains indicates whether or not this gateway uses subdomains
48+
// for IPFS resources instead of paths. That is: http://CID.ipfs.GATEWAY/...
49+
//
50+
// If this flag is set, any /ipns/$id and/or /ipfs/$id paths in Paths
51+
// will be permanently redirected to http://$id.[ipns|ipfs].$gateway/.
52+
//
53+
// We do not support using both paths and subdomains for a single domain
54+
// for security reasons (Origin isolation).
55+
UseSubdomains bool
56+
57+
// NoDNSLink configures this gateway to _not_ resolve DNSLink for the
58+
// specific FQDN provided in `Host` HTTP header. Useful when you want to
59+
// explicitly allow or refuse hosting a single hostname. To refuse all
60+
// DNSLinks in `Host` processing, set NoDNSLink in Config instead. This setting
61+
// overrides the global setting.
62+
NoDNSLink bool
63+
64+
// InlineDNSLink configures this gateway to always inline DNSLink names
65+
// (FQDN) into a single DNS label in order to interop with wildcard TLS certs
66+
// and Origin per CID isolation provided by rules like https://publicsuffix.org
67+
// This should be set to true if you use HTTPS.
68+
InlineDNSLink bool
69+
70+
// DeserializedResponses configures this gateway to support returning data
71+
// in deserialized format. This setting overrides the global setting.
72+
DeserializedResponses bool
1973
}
2074

2175
// TODO: Is this what we want for ImmutablePath?
@@ -221,7 +275,17 @@ func AddAccessControlHeaders(headers map[string][]string) {
221275
type RequestContextKey string
222276

223277
const (
224-
DNSLinkHostnameKey RequestContextKey = "dnslink-hostname"
278+
// GatewayHostnameKey is the key for the hostname at which the gateway is
279+
// operating. It may be a DNSLink, Subdomain or Regular gateway.
225280
GatewayHostnameKey RequestContextKey = "gw-hostname"
226-
ContentPathKey RequestContextKey = "content-path"
281+
282+
// DNSLinkHostnameKey is the key for the hostname of a DNSLink Gateway:
283+
// https://specs.ipfs.tech/http-gateways/dnslink-gateway/
284+
DNSLinkHostnameKey RequestContextKey = "dnslink-hostname"
285+
286+
// SubdomainHostnameKey is the key for the hostname of a Subdomain Gateway:
287+
// https://specs.ipfs.tech/http-gateways/subdomain-gateway/
288+
SubdomainHostnameKey RequestContextKey = "subdomain-hostname"
289+
290+
ContentPathKey RequestContextKey = "content-path"
227291
)

gateway/gateway_test.go

+133-2
Original file line numberDiff line numberDiff line change
@@ -198,14 +198,20 @@ func newTestServerAndNode(t *testing.T, ns mockNamesys) (*httptest.Server, *mock
198198
}
199199

200200
func newTestServer(t *testing.T, api IPFSBackend) *httptest.Server {
201-
config := Config{Headers: map[string][]string{}}
201+
return newTestServerWithConfig(t, api, Config{
202+
Headers: map[string][]string{},
203+
DeserializedResponses: true,
204+
})
205+
}
206+
207+
func newTestServerWithConfig(t *testing.T, api IPFSBackend, config Config) *httptest.Server {
202208
AddAccessControlHeaders(config.Headers)
203209

204210
handler := NewHandler(config, api)
205211
mux := http.NewServeMux()
206212
mux.Handle("/ipfs/", handler)
207213
mux.Handle("/ipns/", handler)
208-
handler = WithHostname(mux, api, map[string]*Specification{}, false)
214+
handler = WithHostname(config, api, mux)
209215

210216
ts := httptest.NewServer(handler)
211217
t.Cleanup(func() { ts.Close() })
@@ -573,3 +579,128 @@ func TestIpnsBase58MultihashRedirect(t *testing.T) {
573579
assert.Equal(t, "/ipns/k2k4r8ol4m8kkcqz509c1rcjwunebj02gcnm5excpx842u736nja8ger?keep=query", res.Header.Get("Location"))
574580
})
575581
}
582+
583+
func TestIpfsTrustlessMode(t *testing.T) {
584+
api, root := newMockAPI(t)
585+
586+
ts := newTestServerWithConfig(t, api, Config{
587+
Headers: map[string][]string{},
588+
NoDNSLink: false,
589+
PublicGateways: map[string]*Specification{
590+
"trustless.com": {
591+
Paths: []string{"/ipfs", "/ipns"},
592+
},
593+
"trusted.com": {
594+
Paths: []string{"/ipfs", "/ipns"},
595+
DeserializedResponses: true,
596+
},
597+
},
598+
})
599+
t.Logf("test server url: %s", ts.URL)
600+
601+
trustedFormats := []string{"", "dag-json", "dag-cbor", "tar", "json", "cbor"}
602+
trustlessFormats := []string{"raw", "car"}
603+
604+
doRequest := func(t *testing.T, path, host string, expectedStatus int) {
605+
req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
606+
assert.Nil(t, err)
607+
608+
if host != "" {
609+
req.Host = host
610+
}
611+
612+
res, err := doWithoutRedirect(req)
613+
assert.Nil(t, err)
614+
defer res.Body.Close()
615+
assert.Equal(t, expectedStatus, res.StatusCode)
616+
}
617+
618+
doIpfsCidRequests := func(t *testing.T, formats []string, host string, expectedStatus int) {
619+
for _, format := range formats {
620+
doRequest(t, "/ipfs/"+root.String()+"/?format="+format, host, expectedStatus)
621+
}
622+
}
623+
624+
doIpfsCidPathRequests := func(t *testing.T, formats []string, host string, expectedStatus int) {
625+
for _, format := range formats {
626+
doRequest(t, "/ipfs/"+root.String()+"/EmptyDir/?format="+format, host, expectedStatus)
627+
}
628+
}
629+
630+
trustedTests := func(t *testing.T, host string) {
631+
doIpfsCidRequests(t, trustlessFormats, host, http.StatusOK)
632+
doIpfsCidRequests(t, trustedFormats, host, http.StatusOK)
633+
doIpfsCidPathRequests(t, trustlessFormats, host, http.StatusOK)
634+
doIpfsCidPathRequests(t, trustedFormats, host, http.StatusOK)
635+
}
636+
637+
trustlessTests := func(t *testing.T, host string) {
638+
doIpfsCidRequests(t, trustlessFormats, host, http.StatusOK)
639+
doIpfsCidRequests(t, trustedFormats, host, http.StatusNotImplemented)
640+
doIpfsCidPathRequests(t, trustlessFormats, host, http.StatusNotImplemented)
641+
doIpfsCidPathRequests(t, trustedFormats, host, http.StatusNotImplemented)
642+
}
643+
644+
t.Run("Explicit Trustless Gateway", func(t *testing.T) {
645+
t.Parallel()
646+
trustlessTests(t, "trustless.com")
647+
})
648+
649+
t.Run("Explicit Trusted Gateway", func(t *testing.T) {
650+
t.Parallel()
651+
trustedTests(t, "trusted.com")
652+
})
653+
654+
t.Run("Implicit Default Trustless Gateway", func(t *testing.T) {
655+
t.Parallel()
656+
trustlessTests(t, "not.configured.com")
657+
trustlessTests(t, "localhost")
658+
trustlessTests(t, "127.0.0.1")
659+
trustlessTests(t, "::1")
660+
})
661+
}
662+
663+
func TestIpnsTrustlessMode(t *testing.T) {
664+
api, root := newMockAPI(t)
665+
api.namesys["/ipns/trustless.com"] = path.FromCid(root)
666+
api.namesys["/ipns/trusted.com"] = path.FromCid(root)
667+
668+
ts := newTestServerWithConfig(t, api, Config{
669+
Headers: map[string][]string{},
670+
NoDNSLink: false,
671+
PublicGateways: map[string]*Specification{
672+
"trustless.com": {
673+
Paths: []string{"/ipfs", "/ipns"},
674+
},
675+
"trusted.com": {
676+
Paths: []string{"/ipfs", "/ipns"},
677+
DeserializedResponses: true,
678+
},
679+
},
680+
})
681+
t.Logf("test server url: %s", ts.URL)
682+
683+
doRequest := func(t *testing.T, path, host string, expectedStatus int) {
684+
req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
685+
assert.Nil(t, err)
686+
687+
if host != "" {
688+
req.Host = host
689+
}
690+
691+
res, err := doWithoutRedirect(req)
692+
assert.Nil(t, err)
693+
defer res.Body.Close()
694+
assert.Equal(t, expectedStatus, res.StatusCode)
695+
}
696+
697+
// DNSLink only. Not supported for trustless. Supported for trusted, except
698+
// format=ipns-record which is unavailable for DNSLink.
699+
doRequest(t, "/", "trustless.com", http.StatusNotImplemented)
700+
doRequest(t, "/EmptyDir/", "trustless.com", http.StatusNotImplemented)
701+
doRequest(t, "/?format=ipns-record", "trustless.com", http.StatusNotImplemented)
702+
703+
doRequest(t, "/", "trusted.com", http.StatusOK)
704+
doRequest(t, "/EmptyDir/", "trusted.com", http.StatusOK)
705+
doRequest(t, "/?format=ipns-record", "trusted.com", http.StatusBadRequest)
706+
}

gateway/handler.go

+64
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"time"
1818

1919
ipath "github.com/ipfs/boxo/coreiface/path"
20+
"github.com/ipfs/boxo/ipns"
2021
cid "github.com/ipfs/go-cid"
2122
logging "github.com/ipfs/go-log"
2223
"github.com/libp2p/go-libp2p/core/peer"
@@ -238,6 +239,13 @@ func (i *handler) getOrHeadHandler(w http.ResponseWriter, r *http.Request) {
238239
i.addUserHeaders(w) // ok, _now_ write user's headers.
239240
w.Header().Set("X-Ipfs-Path", contentPath.String())
240241

242+
// Trustless gateway.
243+
if !i.onlyDeserializedResponses(r) && !i.isSerializedRequest(contentPath, responseFormat) {
244+
err := errors.New("only trustless requests are accepted: https://specs.ipfs.tech/http-gateways/trustless-gateway/")
245+
webError(w, err, http.StatusNotImplemented)
246+
return
247+
}
248+
241249
// TODO: Why did the previous code do path resolution, was that a bug?
242250
// TODO: Does If-None-Match apply here?
243251
if responseFormat == "application/vnd.ipfs.ipns-record" {
@@ -321,6 +329,62 @@ func (i *handler) addUserHeaders(w http.ResponseWriter) {
321329
}
322330
}
323331

332+
func (i *handler) onlyDeserializedResponses(r *http.Request) bool {
333+
// Get the host, by default the request's Host. If this request went through
334+
// WithHostname, also check for the key in the context. If that is not present,
335+
// also check X-Forwarded-Host to support reverse proxies.
336+
host := r.Host
337+
if h, ok := r.Context().Value(GatewayHostnameKey).(string); ok {
338+
host = h
339+
} else if xHost := r.Header.Get("X-Forwarded-Host"); xHost != "" {
340+
host = xHost
341+
}
342+
343+
// If the gateway is defined, return whatever is set.
344+
if gw, ok := i.config.PublicGateways[host]; ok {
345+
return gw.DeserializedResponses
346+
}
347+
348+
// Otherwise, the default.
349+
return i.config.DeserializedResponses
350+
}
351+
352+
func (i *handler) isSerializedRequest(contentPath ipath.Path, responseFormat string) bool {
353+
// Only allow "/{#1}/{#2}"-like paths.
354+
trimmedPath := strings.Trim(contentPath.String(), "/")
355+
pathComponents := strings.Split(trimmedPath, "/")
356+
if len(pathComponents) != 2 {
357+
return false
358+
}
359+
360+
if contentPath.Namespace() == "ipns" {
361+
// Only ipns records allowed until https://github.com/ipfs/specs/issues/369 is resolved
362+
if responseFormat != "application/vnd.ipfs.ipns-record" {
363+
return false
364+
}
365+
366+
// Only valid IPNS names, no DNSLink.
367+
if _, err := ipns.Decode(pathComponents[1]); err != nil {
368+
return false
369+
}
370+
371+
return true
372+
}
373+
374+
// Only valid CIDs.
375+
if _, err := cid.Decode(pathComponents[1]); err != nil {
376+
return false
377+
}
378+
379+
switch responseFormat {
380+
case "application/vnd.ipld.raw",
381+
"application/vnd.ipld.car":
382+
return true
383+
default:
384+
return false
385+
}
386+
}
387+
324388
func panicHandler(w http.ResponseWriter) {
325389
if r := recover(); r != nil {
326390
log.Error("A panic occurred in the gateway handler!")

0 commit comments

Comments
 (0)