mirror of
https://github.com/ipfs/kubo.git
synced 2026-02-21 18:37:45 +08:00
When request is sent to http://localhost:8080/ipfs/$cid response has HTTP 301 status code and "Location" header with redirect destination at $cid.ipfs.localhost:8080 Redirect is followed by browsersi, but not by commandline tools. Status 301 is ignored by curl in default mode: it will print response and won't follow redirect, user needs to add -L for that. To fix curl, we return correct payload in body of HTTP 301 response, but set Clear-Site-Data header to ensure Origin sandbox can't be abused. This requires a surgical workaround: If Location header is present in ResponseWriter's Header map, we ensure http.ServeContent() returns HTTP 301 Context: https://github.com/ipfs/go-ipfs/pull/6982 License: MIT Signed-off-by: Marcin Rataj <lidel@lidel.org>
375 lines
11 KiB
Go
375 lines
11 KiB
Go
package corehttp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
cid "github.com/ipfs/go-cid"
|
|
core "github.com/ipfs/go-ipfs/core"
|
|
coreapi "github.com/ipfs/go-ipfs/core/coreapi"
|
|
namesys "github.com/ipfs/go-ipfs/namesys"
|
|
isd "github.com/jbenet/go-is-domain"
|
|
"github.com/libp2p/go-libp2p-core/peer"
|
|
mbase "github.com/multiformats/go-multibase"
|
|
|
|
config "github.com/ipfs/go-ipfs-config"
|
|
iface "github.com/ipfs/interface-go-ipfs-core"
|
|
options "github.com/ipfs/interface-go-ipfs-core/options"
|
|
nsopts "github.com/ipfs/interface-go-ipfs-core/options/namesys"
|
|
)
|
|
|
|
var defaultPaths = []string{"/ipfs/", "/ipns/", "/api/", "/p2p/", "/version"}
|
|
|
|
var pathGatewaySpec = config.GatewaySpec{
|
|
Paths: defaultPaths,
|
|
UseSubdomains: false,
|
|
}
|
|
|
|
var subdomainGatewaySpec = config.GatewaySpec{
|
|
Paths: defaultPaths,
|
|
UseSubdomains: true,
|
|
}
|
|
|
|
var defaultKnownGateways = map[string]config.GatewaySpec{
|
|
"localhost": subdomainGatewaySpec,
|
|
"ipfs.io": pathGatewaySpec,
|
|
"gateway.ipfs.io": pathGatewaySpec,
|
|
"dweb.link": subdomainGatewaySpec,
|
|
}
|
|
|
|
// HostnameOption rewrites an incoming request based on the Host header.
|
|
func HostnameOption() ServeOption {
|
|
return func(n *core.IpfsNode, _ net.Listener, mux *http.ServeMux) (*http.ServeMux, error) {
|
|
childMux := http.NewServeMux()
|
|
|
|
coreApi, err := coreapi.NewCoreAPI(n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cfg, err := n.Repo.Config()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
knownGateways := make(
|
|
map[string]config.GatewaySpec,
|
|
len(defaultKnownGateways)+len(cfg.Gateway.PublicGateways),
|
|
)
|
|
for hostname, gw := range defaultKnownGateways {
|
|
knownGateways[hostname] = gw
|
|
}
|
|
for hostname, gw := range cfg.Gateway.PublicGateways {
|
|
if gw == nil {
|
|
// Allows the user to remove gateways but _also_
|
|
// allows us to continuously update the list.
|
|
delete(knownGateways, hostname)
|
|
} else {
|
|
knownGateways[hostname] = *gw
|
|
}
|
|
}
|
|
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
// Unfortunately, many (well, ipfs.io) gateways use
|
|
// DNSLink so if we blindly rewrite with DNSLink, we'll
|
|
// break /ipfs links.
|
|
//
|
|
// We fix this by maintaining a list of known gateways
|
|
// and the paths that they serve "gateway" content on.
|
|
// That way, we can use DNSLink for everything else.
|
|
|
|
// HTTP Host & Path check: is this one of our "known gateways"?
|
|
if gw, ok := isKnownHostname(r.Host, knownGateways); ok {
|
|
// This is a known gateway but request is not using
|
|
// the subdomain feature.
|
|
|
|
// Does this gateway _handle_ this path?
|
|
if hasPrefix(r.URL.Path, gw.Paths...) {
|
|
// It does.
|
|
|
|
// Should this gateway use subdomains instead of paths?
|
|
if gw.UseSubdomains {
|
|
// Yes, redirect if applicable
|
|
// Example: dweb.link/ipfs/{cid} → {cid}.ipfs.dweb.link
|
|
if newURL, ok := toSubdomainURL(r.Host, r.URL.Path, r); ok {
|
|
// Just to be sure single Origin can't be abused in
|
|
// web browsers that ignored the redirect for some
|
|
// reason, Clear-Site-Data header clears browsing
|
|
// data (cookies, storage etc) associated with
|
|
// hostname's root Origin
|
|
// Note: we can't use "*" due to bug in Chromium:
|
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=898503
|
|
w.Header().Set("Clear-Site-Data", "\"cookies\", \"storage\"")
|
|
|
|
// Set "Location" header with redirect destination.
|
|
// It is ignored by curl in default mode, but will
|
|
// be respected by user agents that follow
|
|
// redirects by default, namely web browsers
|
|
w.Header().Set("Location", newURL)
|
|
|
|
// Note: we continue regular gateway processing:
|
|
// HTTP Status Code http.StatusMovedPermanently
|
|
// will be set later, in statusResponseWriter
|
|
}
|
|
}
|
|
|
|
// Not a subdomain resource, continue with path processing
|
|
// Example: 127.0.0.1:8080/ipfs/{CID}, ipfs.io/ipfs/{CID} etc
|
|
childMux.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
// Not a whitelisted path
|
|
|
|
// Try DNSLink, if it was not explicitly disabled for the hostname
|
|
if !gw.NoDNSLink && isDNSLinkRequest(n.Context(), coreApi, r) {
|
|
// rewrite path and handle as DNSLink
|
|
r.URL.Path = "/ipns/" + stripPort(r.Host) + r.URL.Path
|
|
childMux.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// If not, resource does not exist on the hostname, return 404
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// HTTP Host check: is this one of our subdomain-based "known gateways"?
|
|
// Example: {cid}.ipfs.localhost, {cid}.ipfs.dweb.link
|
|
if gw, hostname, ns, rootID, ok := knownSubdomainDetails(r.Host, knownGateways); ok {
|
|
// Looks like we're using known subdomain gateway.
|
|
|
|
// Assemble original path prefix.
|
|
pathPrefix := "/" + ns + "/" + rootID
|
|
|
|
// Does this gateway _handle_ this path?
|
|
if !(gw.UseSubdomains && hasPrefix(pathPrefix, gw.Paths...)) {
|
|
// If not, resource does not exist, return 404
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Do we need to fix multicodec in PeerID represented as CIDv1?
|
|
if isPeerIDNamespace(ns) {
|
|
keyCid, err := cid.Decode(rootID)
|
|
if err == nil && keyCid.Type() != cid.Libp2pKey {
|
|
if newURL, ok := toSubdomainURL(hostname, pathPrefix+r.URL.Path, r); ok {
|
|
// Redirect to CID fixed inside of toSubdomainURL()
|
|
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rewrite the path to not use subdomains
|
|
r.URL.Path = pathPrefix + r.URL.Path
|
|
|
|
// Serve path request
|
|
childMux.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
// We don't have a known gateway. Fallback on DNSLink lookup
|
|
|
|
// Wildcard HTTP Host check:
|
|
// 1. is wildcard DNSLink enabled (Gateway.NoDNSLink=false)?
|
|
// 2. does Host header include a fully qualified domain name (FQDN)?
|
|
// 3. does DNSLink record exist in DNS?
|
|
if !cfg.Gateway.NoDNSLink && isDNSLinkRequest(n.Context(), coreApi, r) {
|
|
// rewrite path and handle as DNSLink
|
|
r.URL.Path = "/ipns/" + stripPort(r.Host) + r.URL.Path
|
|
childMux.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// else, treat it as an old school gateway, I guess.
|
|
childMux.ServeHTTP(w, r)
|
|
})
|
|
return childMux, nil
|
|
}
|
|
}
|
|
|
|
// isKnownHostname checks Gateway.PublicGateways and returns matching
|
|
// GatewaySpec with gracefull fallback to version without port
|
|
func isKnownHostname(hostname string, knownGateways map[string]config.GatewaySpec) (gw config.GatewaySpec, ok bool) {
|
|
// Try hostname (host+optional port - value from Host header as-is)
|
|
if gw, ok := knownGateways[hostname]; ok {
|
|
return gw, ok
|
|
}
|
|
// Fallback to hostname without port
|
|
gw, ok = knownGateways[stripPort(hostname)]
|
|
return gw, ok
|
|
}
|
|
|
|
// Parses Host header and looks for a known subdomain gateway host.
|
|
// If found, returns GatewaySpec and subdomain components.
|
|
// Note: hostname is host + optional port
|
|
func knownSubdomainDetails(hostname string, knownGateways map[string]config.GatewaySpec) (gw config.GatewaySpec, knownHostname, ns, rootID string, ok bool) {
|
|
labels := strings.Split(hostname, ".")
|
|
// Look for FQDN of a known gateway hostname.
|
|
// Example: given "dist.ipfs.io.ipns.dweb.link":
|
|
// 1. Lookup "link" TLD in knownGateways: negative
|
|
// 2. Lookup "dweb.link" in knownGateways: positive
|
|
//
|
|
// Stops when we have 2 or fewer labels left as we need at least a
|
|
// rootId and a namespace.
|
|
for i := len(labels) - 1; i >= 2; i-- {
|
|
fqdn := strings.Join(labels[i:], ".")
|
|
gw, ok := isKnownHostname(fqdn, knownGateways)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
ns := labels[i-1]
|
|
if !isSubdomainNamespace(ns) {
|
|
break
|
|
}
|
|
|
|
// Merge remaining labels (could be a FQDN with DNSLink)
|
|
rootID := strings.Join(labels[:i-1], ".")
|
|
return gw, fqdn, ns, rootID, true
|
|
}
|
|
// not a known subdomain gateway
|
|
return gw, "", "", "", false
|
|
}
|
|
|
|
// isDNSLinkRequest returns bool that indicates if request
|
|
// should return data from content path listed in DNSLink record (if exists)
|
|
func isDNSLinkRequest(ctx context.Context, ipfs iface.CoreAPI, r *http.Request) bool {
|
|
fqdn := stripPort(r.Host)
|
|
if len(fqdn) == 0 && !isd.IsDomain(fqdn) {
|
|
return false
|
|
}
|
|
name := "/ipns/" + fqdn
|
|
// check if DNSLink exists
|
|
depth := options.Name.ResolveOption(nsopts.Depth(1))
|
|
_, err := ipfs.Name().Resolve(ctx, name, depth)
|
|
return err == nil || err == namesys.ErrResolveRecursion
|
|
}
|
|
|
|
func isSubdomainNamespace(ns string) bool {
|
|
switch ns {
|
|
case "ipfs", "ipns", "p2p", "ipld":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isPeerIDNamespace(ns string) bool {
|
|
switch ns {
|
|
case "ipns", "p2p":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Converts a hostname/path to a subdomain-based URL, if applicable.
|
|
func toSubdomainURL(hostname, path string, r *http.Request) (redirURL string, ok bool) {
|
|
var scheme, ns, rootID, rest string
|
|
|
|
query := r.URL.RawQuery
|
|
parts := strings.SplitN(path, "/", 4)
|
|
safeRedirectURL := func(in string) (out string, ok bool) {
|
|
safeURI, err := url.ParseRequestURI(in)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return safeURI.String(), true
|
|
}
|
|
|
|
// Support X-Forwarded-Proto if added by a reverse proxy
|
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
|
|
xproto := r.Header.Get("X-Forwarded-Proto")
|
|
if xproto == "https" {
|
|
scheme = "https:"
|
|
} else {
|
|
scheme = "http:"
|
|
}
|
|
|
|
switch len(parts) {
|
|
case 4:
|
|
rest = parts[3]
|
|
fallthrough
|
|
case 3:
|
|
ns = parts[1]
|
|
rootID = parts[2]
|
|
default:
|
|
return "", false
|
|
}
|
|
|
|
if !isSubdomainNamespace(ns) {
|
|
return "", false
|
|
}
|
|
|
|
// add prefix if query is present
|
|
if query != "" {
|
|
query = "?" + query
|
|
}
|
|
|
|
// Normalize problematic PeerIDs (eg. ed25519+identity) to CID representation
|
|
if isPeerIDNamespace(ns) && !isd.IsDomain(rootID) {
|
|
peerID, err := peer.Decode(rootID)
|
|
// Note: PeerID CIDv1 with protobuf multicodec will fail, but we fix it
|
|
// in the next block
|
|
if err == nil {
|
|
rootID = peer.ToCid(peerID).String()
|
|
}
|
|
}
|
|
|
|
// If rootID is a CID, ensure it uses DNS-friendly text representation
|
|
if rootCid, err := cid.Decode(rootID); err == nil {
|
|
multicodec := rootCid.Type()
|
|
|
|
// PeerIDs represented as CIDv1 are expected to have libp2p-key
|
|
// multicodec (https://github.com/libp2p/specs/pull/209).
|
|
// We ease the transition by fixing multicodec on the fly:
|
|
// https://github.com/ipfs/go-ipfs/issues/5287#issuecomment-492163929
|
|
if isPeerIDNamespace(ns) && multicodec != cid.Libp2pKey {
|
|
multicodec = cid.Libp2pKey
|
|
}
|
|
|
|
// if object turns out to be a valid CID,
|
|
// ensure text representation used in subdomain is CIDv1 in Base32
|
|
// https://github.com/ipfs/in-web-browsers/issues/89
|
|
rootID, err = cid.NewCidV1(multicodec, rootCid.Hash()).StringOfBase(mbase.Base32)
|
|
if err != nil {
|
|
// should not error, but if it does, its clealy not possible to
|
|
// produce a subdomain URL
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
return safeRedirectURL(fmt.Sprintf(
|
|
"%s//%s.%s.%s/%s%s",
|
|
scheme,
|
|
rootID,
|
|
ns,
|
|
hostname,
|
|
rest,
|
|
query,
|
|
))
|
|
}
|
|
|
|
func hasPrefix(path string, prefixes ...string) bool {
|
|
for _, prefix := range prefixes {
|
|
// Assume people are creative with trailing slashes in Gateway config
|
|
p := strings.TrimSuffix(prefix, "/")
|
|
// Support for both /version and /ipfs/$cid
|
|
if p == path || strings.HasPrefix(path, p+"/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func stripPort(hostname string) string {
|
|
host, _, err := net.SplitHostPort(hostname)
|
|
if err == nil {
|
|
return host
|
|
}
|
|
return hostname
|
|
}
|