| 1 | package corehttp |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "net" |
| 7 | "net/http" |
| 8 | "os" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | |
| 12 | cmds "github.com/ipfs/go-ipfs-cmds" |
| 13 | cmdsHttp "github.com/ipfs/go-ipfs-cmds/http" |
| 14 | version "github.com/ipfs/kubo" |
| 15 | oldcmds "github.com/ipfs/kubo/commands" |
| 16 | config "github.com/ipfs/kubo/config" |
| 17 | "github.com/ipfs/kubo/core" |
| 18 | corecommands "github.com/ipfs/kubo/core/commands" |
| 19 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" |
| 20 | ) |
| 21 | |
| 22 | var errAPIVersionMismatch = errors.New("api version mismatch") |
| 23 | |
| 24 | const ( |
| 25 | originEnvKey = "API_ORIGIN" |
| 26 | originEnvKeyDeprecate = `You are using the ` + originEnvKey + `ENV Variable. |
| 27 | This functionality is deprecated, and will be removed in future versions. |
| 28 | Instead, try either adding headers to the config, or passing them via |
| 29 | cli arguments: |
| 30 | |
| 31 | ipfs config API.HTTPHeaders --json '{"Access-Control-Allow-Origin": ["*"]}' |
| 32 | ipfs daemon |
| 33 | ` |
| 34 | ) |
| 35 | |
| 36 | // APIPath is the path at which the API is mounted. |
| 37 | const APIPath = "/api/v0" |
| 38 | |
| 39 | var defaultLocalhostOrigins = []string{ |
| 40 | "http://127.0.0.1:<port>", |
| 41 | "https://127.0.0.1:<port>", |
| 42 | "http://[::1]:<port>", |
| 43 | "https://[::1]:<port>", |
| 44 | "http://localhost:<port>", |
| 45 | "https://localhost:<port>", |
| 46 | } |
| 47 | |
| 48 | var companionBrowserExtensionOrigins = []string{ |
| 49 | "chrome-extension://nibjojkomfdiaoajekhjakgkdhaomnch", // ipfs-companion |
| 50 | "chrome-extension://hjoieblefckbooibpepigmacodalfndh", // ipfs-companion-beta |
| 51 | } |
| 52 | |
| 53 | func addCORSFromEnv(c *cmdsHttp.ServerConfig) { |
| 54 | origin := os.Getenv(originEnvKey) |
| 55 | if origin != "" { |
| 56 | log.Warn(originEnvKeyDeprecate) |
| 57 | c.AppendAllowedOrigins(origin) |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func addHeadersFromConfig(c *cmdsHttp.ServerConfig, nc *config.Config) { |
| 62 | log.Info("Using API.HTTPHeaders:", nc.API.HTTPHeaders) |
| 63 | |
| 64 | if acao := nc.API.HTTPHeaders[cmdsHttp.ACAOrigin]; acao != nil { |
| 65 | c.SetAllowedOrigins(acao...) |
| 66 | } |
| 67 | if acam := nc.API.HTTPHeaders[cmdsHttp.ACAMethods]; acam != nil { |
| 68 | c.SetAllowedMethods(acam...) |
| 69 | } |
| 70 | for _, v := range nc.API.HTTPHeaders[cmdsHttp.ACACredentials] { |
| 71 | c.SetAllowCredentials(strings.ToLower(v) == "true") |
| 72 | } |
| 73 | |
| 74 | c.Headers = make(map[string][]string, len(nc.API.HTTPHeaders)+1) |
| 75 | |
| 76 | // Copy these because the config is shared and this function is called |
| 77 | // in multiple places concurrently. Updating these in-place *is* racy. |
| 78 | for h, v := range nc.API.HTTPHeaders { |
| 79 | h = http.CanonicalHeaderKey(h) |
| 80 | switch h { |
| 81 | case cmdsHttp.ACAOrigin, cmdsHttp.ACAMethods, cmdsHttp.ACACredentials: |
| 82 | // these are handled by the CORs library. |
| 83 | default: |
| 84 | c.Headers[h] = v |
| 85 | } |
| 86 | } |
| 87 | c.Headers["Server"] = []string{"kubo/" + version.CurrentVersionNumber} |
| 88 | } |
| 89 | |
| 90 | func addCORSDefaults(c *cmdsHttp.ServerConfig) { |
| 91 | // always safelist certain origins |
| 92 | c.AppendAllowedOrigins(defaultLocalhostOrigins...) |
| 93 | c.AppendAllowedOrigins(companionBrowserExtensionOrigins...) |
| 94 | |
| 95 | // by default, use GET, PUT, POST |
| 96 | if len(c.AllowedMethods()) == 0 { |
| 97 | c.SetAllowedMethods(http.MethodGet, http.MethodPost, http.MethodPut) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func patchCORSVars(c *cmdsHttp.ServerConfig, addr net.Addr) { |
| 102 | // we have to grab the port from an addr, which may be an ip6 addr. |
| 103 | // TODO: this should take multiaddrs and derive port from there. |
| 104 | port := "" |
| 105 | if tcpaddr, ok := addr.(*net.TCPAddr); ok { |
| 106 | port = strconv.Itoa(tcpaddr.Port) |
| 107 | } else if udpaddr, ok := addr.(*net.UDPAddr); ok { |
| 108 | port = strconv.Itoa(udpaddr.Port) |
| 109 | } |
| 110 | |
| 111 | // we're listening on tcp/udp with ports. ("udp!?" you say? yeah... it happens...) |
| 112 | oldOrigins := c.AllowedOrigins() |
| 113 | newOrigins := make([]string, len(oldOrigins)) |
| 114 | for i, o := range oldOrigins { |
| 115 | // TODO: allow replacing <host>. tricky, ip4 and ip6 and hostnames... |
| 116 | if port != "" { |
| 117 | o = strings.Replace(o, "<port>", port, -1) |
| 118 | } |
| 119 | newOrigins[i] = o |
| 120 | } |
| 121 | c.SetAllowedOrigins(newOrigins...) |
| 122 | } |
| 123 | |
| 124 | func commandsOption(cctx oldcmds.Context, command *cmds.Command) ServeOption { |
| 125 | return func(n *core.IpfsNode, l net.Listener, mux *http.ServeMux) (*http.ServeMux, error) { |
| 126 | cfg := cmdsHttp.NewServerConfig() |
| 127 | |
| 128 | cfg.AddAllowedHeaders("Origin", "Accept", "Content-Type", "X-Requested-With") |
| 129 | cfg.SetAllowedMethods(http.MethodPost) |
| 130 | |
| 131 | cfg.APIPath = APIPath |
| 132 | rcfg, err := n.Repo.Config() |
| 133 | if err != nil { |
| 134 | return nil, err |
| 135 | } |
| 136 | |
| 137 | addHeadersFromConfig(cfg, rcfg) |
| 138 | addCORSFromEnv(cfg) |
| 139 | addCORSDefaults(cfg) |
| 140 | patchCORSVars(cfg, l.Addr()) |
| 141 | |
| 142 | cmdHandler := cmdsHttp.NewHandler(&cctx, command, cfg) |
| 143 | |
| 144 | if len(rcfg.API.Authorizations) > 0 { |
| 145 | authorizations := convertAuthorizationsMap(rcfg.API.Authorizations) |
| 146 | cmdHandler = withAuthSecrets(authorizations, cmdHandler) |
| 147 | } |
| 148 | |
| 149 | cmdHandler = otelhttp.NewHandler(withMetricLabels(cmdHandler, staticServerDomainAttrFn("api")), "corehttp.cmdsHandler") |
| 150 | mux.Handle(APIPath+"/", cmdHandler) |
| 151 | return mux, nil |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | type rpcAuthScopeWithUser struct { |
| 156 | config.RPCAuthScope |
| 157 | User string |
| 158 | } |
| 159 | |
| 160 | func convertAuthorizationsMap(authScopes map[string]*config.RPCAuthScope) map[string]rpcAuthScopeWithUser { |
| 161 | // authorizations is a map where we can just check for the header value to match. |
| 162 | authorizations := map[string]rpcAuthScopeWithUser{} |
| 163 | for user, authScope := range authScopes { |
| 164 | expectedHeader := config.ConvertAuthSecret(authScope.AuthSecret) |
| 165 | if expectedHeader != "" { |
| 166 | authorizations[expectedHeader] = rpcAuthScopeWithUser{ |
| 167 | RPCAuthScope: *authScopes[user], |
| 168 | User: user, |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | return authorizations |
| 174 | } |
| 175 | |
| 176 | func withAuthSecrets(authorizations map[string]rpcAuthScopeWithUser, next http.Handler) http.Handler { |
| 177 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 178 | authorizationHeader := r.Header.Get("Authorization") |
| 179 | auth, ok := authorizations[authorizationHeader] |
| 180 | |
| 181 | if ok { |
| 182 | // version check is implicitly allowed |
| 183 | if r.URL.Path == "/api/v0/version" { |
| 184 | next.ServeHTTP(w, r) |
| 185 | return |
| 186 | } |
| 187 | // everything else has to be safelisted via AllowedPaths |
| 188 | for _, prefix := range auth.AllowedPaths { |
| 189 | if strings.HasPrefix(r.URL.Path, prefix) { |
| 190 | next.ServeHTTP(w, r) |
| 191 | return |
| 192 | } |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | http.Error(w, "Kubo RPC Access Denied: Please provide a valid authorization token as defined in the API.Authorizations configuration.", http.StatusForbidden) |
| 197 | }) |
| 198 | } |
| 199 | |
| 200 | // CommandsOption constructs a ServerOption for hooking the commands into the |
| 201 | // HTTP server. It will NOT allow GET requests. |
| 202 | func CommandsOption(cctx oldcmds.Context) ServeOption { |
| 203 | return commandsOption(cctx, corecommands.Root) |
| 204 | } |
| 205 | |
| 206 | // CheckVersionOption returns a ServeOption that checks whether the client ipfs version matches. Does nothing when the user agent string does not contain `/kubo/` or `/go-ipfs/` |
| 207 | func CheckVersionOption() ServeOption { |
| 208 | daemonVersion := version.ApiVersion |
| 209 | |
| 210 | return func(n *core.IpfsNode, l net.Listener, parent *http.ServeMux) (*http.ServeMux, error) { |
| 211 | mux := http.NewServeMux() |
| 212 | parent.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { |
| 213 | if strings.HasPrefix(r.URL.Path, APIPath) { |
| 214 | cmdqry := r.URL.Path[len(APIPath):] |
| 215 | pth := strings.Split(cmdqry, "/") |
| 216 | |
| 217 | // backwards compatibility to previous version check |
| 218 | if len(pth) >= 2 && pth[1] != "version" { |
| 219 | clientVersion := r.UserAgent() |
| 220 | // skips check if client is not kubo (go-ipfs) |
| 221 | if (strings.Contains(clientVersion, "/go-ipfs/") || strings.Contains(clientVersion, "/kubo/")) && daemonVersion != clientVersion { |
| 222 | http.Error(w, fmt.Sprintf("%s (%s != %s)", errAPIVersionMismatch, daemonVersion, clientVersion), http.StatusBadRequest) |
| 223 | return |
| 224 | } |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | mux.ServeHTTP(w, r) |
| 229 | }) |
| 230 | |
| 231 | return mux, nil |
| 232 | } |
| 233 | } |