diff --git a/client/rpc/auth/auth.go b/client/rpc/auth/auth.go new file mode 100644 index 00000000000..a6ed273cd1a --- /dev/null +++ b/client/rpc/auth/auth.go @@ -0,0 +1,29 @@ +package auth + +import "net/http" + +var _ http.RoundTripper = &AuthorizedRoundTripper{} + +type AuthorizedRoundTripper struct { + authorization string + roundTripper http.RoundTripper +} + +// NewAuthorizedRoundTripper creates a new [http.RoundTripper] that will set the +// Authorization HTTP header with the value of [authorization]. The given [roundTripper] is +// the base [http.RoundTripper]. If it is nil, [http.DefaultTransport] is used. +func NewAuthorizedRoundTripper(authorization string, roundTripper http.RoundTripper) http.RoundTripper { + if roundTripper == nil { + roundTripper = http.DefaultTransport + } + + return &AuthorizedRoundTripper{ + authorization: authorization, + roundTripper: roundTripper, + } +} + +func (tp *AuthorizedRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { + r.Header.Set("Authorization", tp.authorization) + return tp.roundTripper.RoundTrip(r) +} diff --git a/cmd/ipfs/daemon.go b/cmd/ipfs/daemon.go index 62d5616c7fe..1375d464de3 100644 --- a/cmd/ipfs/daemon.go +++ b/cmd/ipfs/daemon.go @@ -676,6 +676,10 @@ func serveHTTPApi(req *cmds.Request, cctx *oldcmds.Context) (<-chan error, error listeners = append(listeners, apiLis) } + if len(cfg.API.Authorizations) > 0 && len(listeners) > 0 { + fmt.Printf("RPC API access is limited by the rules defined in API.Authorizations\n") + } + for _, listener := range listeners { // we might have listened to /tcp/0 - let's see what we are listing on fmt.Printf("RPC API server listening on %s\n", listener.Multiaddr()) diff --git a/cmd/ipfs/main.go b/cmd/ipfs/main.go index 3909e18162c..f135f28feb8 100644 --- a/cmd/ipfs/main.go +++ b/cmd/ipfs/main.go @@ -23,8 +23,10 @@ import ( cmdhttp "github.com/ipfs/go-ipfs-cmds/http" logging "github.com/ipfs/go-log" ipfs "github.com/ipfs/kubo" + "github.com/ipfs/kubo/client/rpc/auth" "github.com/ipfs/kubo/cmd/ipfs/util" oldcmds "github.com/ipfs/kubo/commands" + config "github.com/ipfs/kubo/config" "github.com/ipfs/kubo/core" corecmds "github.com/ipfs/kubo/core/commands" "github.com/ipfs/kubo/core/corehttp" @@ -325,6 +327,12 @@ func makeExecutor(req *cmds.Request, env interface{}) (cmds.Executor, error) { return nil, fmt.Errorf("unsupported API address: %s", apiAddr) } + apiAuth, specified := req.Options[corecmds.ApiAuthOption].(string) + if specified { + authorization := config.ConvertAuthSecret(apiAuth) + tpt = auth.NewAuthorizedRoundTripper(authorization, tpt) + } + httpClient := &http.Client{ Transport: otelhttp.NewTransport(tpt), } diff --git a/config/api.go b/config/api.go index b36b1080304..a626a272a49 100644 --- a/config/api.go +++ b/config/api.go @@ -1,5 +1,63 @@ package config +import ( + "encoding/base64" + "strings" +) + +const ( + APITag = "API" + AuthorizationTag = "Authorizations" +) + +type RPCAuthScope struct { + // AuthSecret is the secret that will be compared to the HTTP "Authorization". + // header. A secret is in the format "type:value". Check the documentation for + // supported types. + AuthSecret string + + // AllowedPaths is an explicit list of RPC path prefixes to allow. + // By default, none are allowed. ["/api/v0"] exposes all RPCs. + AllowedPaths []string +} + type API struct { - HTTPHeaders map[string][]string // HTTP headers to return with the API. + // HTTPHeaders are the HTTP headers to return with the API. + HTTPHeaders map[string][]string + + // Authorization is a map of authorizations used to authenticate in the API. + // If the map is empty, then the RPC API is exposed to everyone. Check the + // documentation for more details. + Authorizations map[string]*RPCAuthScope `json:",omitempty"` +} + +// ConvertAuthSecret converts the given secret in the format "type:value" into an +// HTTP Authorization header value. It can handle 'bearer' and 'basic' as type. +// If type exists and is not known, an empty string is returned. If type does not +// exist, 'bearer' type is assumed. +func ConvertAuthSecret(secret string) string { + if secret == "" { + return secret + } + + split := strings.SplitN(secret, ":", 2) + if len(split) < 2 { + // No prefix: assume bearer token. + return "Bearer " + secret + } + + if strings.HasPrefix(secret, "basic:") { + if strings.Contains(split[1], ":") { + // Assume basic:user:password + return "Basic " + base64.StdEncoding.EncodeToString([]byte(split[1])) + } else { + // Assume already base64 encoded. + return "Basic " + split[1] + } + } else if strings.HasPrefix(secret, "bearer:") { + return "Bearer " + split[1] + } + + // Unknown. Type is present, but we can't handle it. + return "" } diff --git a/config/api_test.go b/config/api_test.go new file mode 100644 index 00000000000..daf8a5375d8 --- /dev/null +++ b/config/api_test.go @@ -0,0 +1,22 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConvertAuthSecret(t *testing.T) { + for _, testCase := range []struct { + input string + output string + }{ + {"", ""}, + {"someToken", "Bearer someToken"}, + {"bearer:someToken", "Bearer someToken"}, + {"basic:user:pass", "Basic dXNlcjpwYXNz"}, + {"basic:dXNlcjpwYXNz", "Basic dXNlcjpwYXNz"}, + } { + assert.Equal(t, testCase.output, ConvertAuthSecret(testCase.input)) + } +} diff --git a/core/commands/config.go b/core/commands/config.go index b2455102747..b52c05af232 100644 --- a/core/commands/config.go +++ b/core/commands/config.go @@ -208,6 +208,11 @@ NOTE: For security reasons, this command will omit your private key and remote s return err } + cfg, err = scrubValue(cfg, []string{config.APITag, config.AuthorizationTag}) + if err != nil { + return err + } + cfg, err = scrubOptionalValue(cfg, config.PinningConcealSelector) if err != nil { return err diff --git a/core/commands/root.go b/core/commands/root.go index 0e274f0872f..b812573fc2f 100644 --- a/core/commands/root.go +++ b/core/commands/root.go @@ -28,7 +28,8 @@ const ( DebugOption = "debug" LocalOption = "local" // DEPRECATED: use OfflineOption OfflineOption = "offline" - ApiOption = "api" //nolint + ApiOption = "api" //nolint + ApiAuthOption = "api-auth" //nolint ) var Root = &cmds.Command{ @@ -110,6 +111,7 @@ The CLI will exit with one of the following values: cmds.BoolOption(LocalOption, "L", "Run the command locally, instead of using the daemon. DEPRECATED: use --offline."), cmds.BoolOption(OfflineOption, "Run the command offline."), cmds.StringOption(ApiOption, "Use a specific API instance (defaults to /ip4/127.0.0.1/tcp/5001)"), + cmds.StringOption(ApiAuthOption, "Optional RPC API authorization secret (defined as AuthSecret in API.Authorizations config)"), // global options, added to every command cmdenv.OptionCidBase, diff --git a/core/corehttp/commands.go b/core/corehttp/commands.go index 804b70a7e2c..314822ff2e7 100644 --- a/core/corehttp/commands.go +++ b/core/corehttp/commands.go @@ -143,12 +143,63 @@ func commandsOption(cctx oldcmds.Context, command *cmds.Command, allowGet bool) patchCORSVars(cfg, l.Addr()) cmdHandler := cmdsHttp.NewHandler(&cctx, command, cfg) + + if len(rcfg.API.Authorizations) > 0 { + authorizations := convertAuthorizationsMap(rcfg.API.Authorizations) + cmdHandler = withAuthSecrets(authorizations, cmdHandler) + } + cmdHandler = otelhttp.NewHandler(cmdHandler, "corehttp.cmdsHandler") mux.Handle(APIPath+"/", cmdHandler) return mux, nil } } +type rpcAuthScopeWithUser struct { + config.RPCAuthScope + User string +} + +func convertAuthorizationsMap(authScopes map[string]*config.RPCAuthScope) map[string]rpcAuthScopeWithUser { + // authorizations is a map where we can just check for the header value to match. + authorizations := map[string]rpcAuthScopeWithUser{} + for user, authScope := range authScopes { + expectedHeader := config.ConvertAuthSecret(authScope.AuthSecret) + if expectedHeader != "" { + authorizations[expectedHeader] = rpcAuthScopeWithUser{ + RPCAuthScope: *authScopes[user], + User: user, + } + } + } + + return authorizations +} + +func withAuthSecrets(authorizations map[string]rpcAuthScopeWithUser, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorizationHeader := r.Header.Get("Authorization") + auth, ok := authorizations[authorizationHeader] + + if ok { + // version check is implicitly allowed + if r.URL.Path == "/api/v0/version" { + next.ServeHTTP(w, r) + return + } + // everything else has to be safelisted via AllowedPaths + for _, prefix := range auth.AllowedPaths { + if strings.HasPrefix(r.URL.Path, prefix) { + next.ServeHTTP(w, r) + return + } + } + } + + http.Error(w, "Kubo RPC Access Denied: Please provide a valid authorization token as defined in the API.Authorizations configuration.", http.StatusForbidden) + }) +} + // CommandsOption constructs a ServerOption for hooking the commands into the // HTTP server. It will NOT allow GET requests. func CommandsOption(cctx oldcmds.Context) ServeOption { diff --git a/docs/changelogs/v0.25.md b/docs/changelogs/v0.25.md index 1388ddd90d5..1ad032ed305 100644 --- a/docs/changelogs/v0.25.md +++ b/docs/changelogs/v0.25.md @@ -6,6 +6,7 @@ - [Overview](#overview) - [๐Ÿ”ฆ Highlights](#-highlights) + - [RPC `API.Authorizations`](#rpc-apiauthorizations) - [๐Ÿ“ Changelog](#-changelog) - [๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Contributors](#-contributors) @@ -13,6 +14,19 @@ ### ๐Ÿ”ฆ Highlights +#### RPC `API.Authorizations` + +Kubo RPC API now supports optional HTTP Authorization. + +Granular control over user access to the RPC can be defined in the +[`API.Authorizations`](https://github.com/ipfs/kubo/blob/master/docs/config.md#apiauthorizations) +map in the configuration file, allowing different users or apps to have unique +access secrets and allowed paths. + +This feature is opt-in. By default, no authorization is set up. +For configuration instructions, +refer to the [documentation](https://github.com/ipfs/kubo/blob/master/docs/config.md#apiauthorizations). + ### ๐Ÿ“ Changelog ### ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Contributors diff --git a/docs/config.md b/docs/config.md index 49e761100dd..0b33fa3fdcf 100644 --- a/docs/config.md +++ b/docs/config.md @@ -28,6 +28,9 @@ config file at runtime. - [`Addresses.NoAnnounce`](#addressesnoannounce) - [`API`](#api) - [`API.HTTPHeaders`](#apihttpheaders) + - [`API.Authorizations`](#apiauthorizations) + - [`API.Authorizations: AuthSecret`](#apiauthorizations-authsecret) + - [`API.Authorizations: AllowedPaths`](#apiauthorizations-allowedpaths) - [`AutoNAT`](#autonat) - [`AutoNAT.ServiceMode`](#autonatservicemode) - [`AutoNAT.Throttle`](#autonatthrottle) @@ -438,6 +441,87 @@ Default: `null` Type: `object[string -> array[string]]` (header names -> array of header values) +### `API.Authorizations` + +The `API.Authorizations` field defines user-based access restrictions for the +[Kubo RPC API](https://docs.ipfs.tech/reference/kubo/rpc/), which is located at +`Addresses.API` under `/api/v0` paths. + +By default, the RPC API is accessible without restrictions as it is only +exposed on `127.0.0.1` and safeguarded with Origin check and implicit +[CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) headers that +block random websites from accessing the RPC. + +When entries are defined in `API.Authorizations`, RPC requests will be declined +unless a corresponding secret is present in the HTTP [`Authorization` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization), +and the requested path is included in the `AllowedPaths` list for that specific +secret. + +Default: `null` + +Type: `object[string -> object]` (user name -> authorization object, see bellow) + +For example, to limit RPC access to Alice (access `id` and MFS `files` commands with HTTP Basic Auth) +and Bob (full access with Bearer token): + +```json +{ + "API": { + "Authorizations": { + "Alice": { + "AuthSecret": "basic:alice:password123", + "AllowedPaths": ["/api/v0/id", "/api/v0/files"] + }, + "Bob": { + "AuthSecret": "bearer:secret-token123", + "AllowedPaths": ["/api/v0"] + } + } + } +} + +``` + +#### `API.Authorizations: AuthSecret` + +The `AuthSecret` field denotes the secret used by a user to authenticate, +usually via HTTP [`Authorization` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization). + +Field format is `type:value`, and the following types are supported: + +- `bearer:` For secret Bearer tokens, set as `bearer:token`. + - If no known `type:` prefix is present, `bearer:` is assumed. +- `basic`: For HTTP Basic Auth introduced in [RFC7617](https://datatracker.ietf.org/doc/html/rfc7617). Value can be: + - `basic:user:pass` + - `basic:base64EncodedBasicAuth` + +One can use the config value for authentication via the command line: + +``` +ipfs id --api-auth basic:user:pass +``` + +Type: `string` + +#### `API.Authorizations: AllowedPaths` + +The `AllowedPaths` field is an array of strings containing allowed RPC path +prefixes. Users authorized with the related `AuthSecret` will only be able to +access paths prefixed by the specified prefixes. + +For instance: + +- If set to `["/api/v0"]`, the user will have access to the complete RPC API. +- If set to `["/api/v0/id", "/api/v0/files"]`, the user will only have access + to the `id` command and all MFS commands under `files`. + +Note that `/api/v0/version` is always permitted access to allow version check +to ensure compatibility. + +Default: `[]` + +Type: `array[string]` + ## `AutoNAT` Contains the configuration options for the AutoNAT service. The AutoNAT service diff --git a/test/cli/harness/node.go b/test/cli/harness/node.go index 7db1d553824..d030c7c9404 100644 --- a/test/cli/harness/node.go +++ b/test/cli/harness/node.go @@ -223,7 +223,7 @@ func (n *Node) Init(ipfsArgs ...string) *Node { // harness.RunWithStdout(os.Stdout), // }, // }) -func (n *Node) StartDaemonWithReq(req RunRequest) *Node { +func (n *Node) StartDaemonWithReq(req RunRequest, authorization string) *Node { alive := n.IsAlive() if alive { log.Panicf("node %d is already running", n.ID) @@ -239,14 +239,20 @@ func (n *Node) StartDaemonWithReq(req RunRequest) *Node { n.Daemon = res log.Debugf("node %d started, checking API", n.ID) - n.WaitOnAPI() + n.WaitOnAPI(authorization) return n } func (n *Node) StartDaemon(ipfsArgs ...string) *Node { return n.StartDaemonWithReq(RunRequest{ Args: ipfsArgs, - }) + }, "") +} + +func (n *Node) StartDaemonWithAuthorization(secret string, ipfsArgs ...string) *Node { + return n.StartDaemonWithReq(RunRequest{ + Args: ipfsArgs, + }, secret) } func (n *Node) signalAndWait(watch <-chan struct{}, signal os.Signal, t time.Duration) bool { @@ -337,7 +343,7 @@ func (n *Node) TryAPIAddr() (multiaddr.Multiaddr, error) { return ma, nil } -func (n *Node) checkAPI() bool { +func (n *Node) checkAPI(authorization string) bool { apiAddr, err := n.TryAPIAddr() if err != nil { log.Debugf("node %d API addr not available yet: %s", n.ID, err.Error()) @@ -353,7 +359,16 @@ func (n *Node) checkAPI() bool { } url := fmt.Sprintf("http://%s:%s/api/v0/id", ip, port) log.Debugf("checking API for node %d at %s", n.ID, url) - httpResp, err := http.Post(url, "", nil) + + req, err := http.NewRequest(http.MethodPost, url, nil) + if err != nil { + panic(err) + } + if authorization != "" { + req.Header.Set("Authorization", authorization) + } + + httpResp, err := http.DefaultClient.Do(req) if err != nil { log.Debugf("node %d API check error: %s", err.Error()) return false @@ -402,10 +417,10 @@ func (n *Node) PeerID() peer.ID { return id } -func (n *Node) WaitOnAPI() *Node { +func (n *Node) WaitOnAPI(authorization string) *Node { log.Debugf("waiting on API for node %d", n.ID) for i := 0; i < 50; i++ { - if n.checkAPI() { + if n.checkAPI(authorization) { log.Debugf("daemon API found, daemon stdout: %s", n.Daemon.Stdout.String()) return n } diff --git a/test/cli/rpc_auth_test.go b/test/cli/rpc_auth_test.go new file mode 100644 index 00000000000..c30b107cf3f --- /dev/null +++ b/test/cli/rpc_auth_test.go @@ -0,0 +1,162 @@ +package cli + +import ( + "net/http" + "testing" + + "github.com/ipfs/kubo/client/rpc/auth" + "github.com/ipfs/kubo/config" + "github.com/ipfs/kubo/test/cli/harness" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const rpcDeniedMsg = "Kubo RPC Access Denied: Please provide a valid authorization token as defined in the API.Authorizations configuration." + +func TestRPCAuth(t *testing.T) { + t.Parallel() + + makeAndStartProtectedNode := func(t *testing.T, authorizations map[string]*config.RPCAuthScope) *harness.Node { + authorizations["test-node-starter"] = &config.RPCAuthScope{ + AuthSecret: "bearer:test-node-starter", + AllowedPaths: []string{"/api/v0"}, + } + + node := harness.NewT(t).NewNode().Init() + node.UpdateConfig(func(cfg *config.Config) { + cfg.API.Authorizations = authorizations + }) + node.StartDaemonWithAuthorization("Bearer test-node-starter") + return node + } + + makeHTTPTest := func(authSecret, header string) func(t *testing.T) { + return func(t *testing.T) { + t.Parallel() + t.Log(authSecret, header) + + node := makeAndStartProtectedNode(t, map[string]*config.RPCAuthScope{ + "userA": { + AuthSecret: authSecret, + AllowedPaths: []string{"/api/v0/id"}, + }, + }) + + apiClient := node.APIClient() + apiClient.Client = &http.Client{ + Transport: auth.NewAuthorizedRoundTripper(header, http.DefaultTransport), + } + + // Can access /id with valid token + resp := apiClient.Post("/api/v0/id", nil) + assert.Equal(t, 200, resp.StatusCode) + + // But not /config/show + resp = apiClient.Post("/api/v0/config/show", nil) + assert.Equal(t, 403, resp.StatusCode) + + // create client which sends invalid access token + invalidApiClient := node.APIClient() + invalidApiClient.Client = &http.Client{ + Transport: auth.NewAuthorizedRoundTripper("Bearer invalid", http.DefaultTransport), + } + + // Can't access /id with invalid token + errResp := invalidApiClient.Post("/api/v0/id", nil) + assert.Equal(t, 403, errResp.StatusCode) + + node.StopDaemon() + } + } + + makeCLITest := func(authSecret string) func(t *testing.T) { + return func(t *testing.T) { + t.Parallel() + + node := makeAndStartProtectedNode(t, map[string]*config.RPCAuthScope{ + "userA": { + AuthSecret: authSecret, + AllowedPaths: []string{"/api/v0/id"}, + }, + }) + + // Can access 'ipfs id' + resp := node.RunIPFS("id", "--api-auth", authSecret) + require.NoError(t, resp.Err) + + // But not 'ipfs config show' + resp = node.RunIPFS("config", "show", "--api-auth", authSecret) + require.Error(t, resp.Err) + require.Contains(t, resp.Stderr.String(), rpcDeniedMsg) + + node.StopDaemon() + } + } + + for _, testCase := range []struct { + name string + authSecret string + header string + }{ + {"Bearer (no type)", "myToken", "Bearer myToken"}, + {"Bearer", "bearer:myToken", "Bearer myToken"}, + {"Basic (user:pass)", "basic:user:pass", "Basic dXNlcjpwYXNz"}, + {"Basic (encoded)", "basic:dXNlcjpwYXNz", "Basic dXNlcjpwYXNz"}, + } { + t.Run("AllowedPaths on CLI "+testCase.name, makeCLITest(testCase.authSecret)) + t.Run("AllowedPaths on HTTP "+testCase.name, makeHTTPTest(testCase.authSecret, testCase.header)) + } + + t.Run("AllowedPaths set to /api/v0 Gives Full Access", func(t *testing.T) { + t.Parallel() + + node := makeAndStartProtectedNode(t, map[string]*config.RPCAuthScope{ + "userA": { + AuthSecret: "bearer:userAToken", + AllowedPaths: []string{"/api/v0"}, + }, + }) + + apiClient := node.APIClient() + apiClient.Client = &http.Client{ + Transport: auth.NewAuthorizedRoundTripper("Bearer userAToken", http.DefaultTransport), + } + + resp := apiClient.Post("/api/v0/id", nil) + assert.Equal(t, 200, resp.StatusCode) + + node.StopDaemon() + }) + + t.Run("API.Authorizations set to nil disables Authorization header check", func(t *testing.T) { + t.Parallel() + + node := harness.NewT(t).NewNode().Init() + node.UpdateConfig(func(cfg *config.Config) { + cfg.API.Authorizations = nil + }) + node.StartDaemon() + + apiClient := node.APIClient() + resp := apiClient.Post("/api/v0/id", nil) + assert.Equal(t, 200, resp.StatusCode) + + node.StopDaemon() + }) + + t.Run("API.Authorizations set to empty map disables Authorization header check", func(t *testing.T) { + t.Parallel() + + node := harness.NewT(t).NewNode().Init() + node.UpdateConfig(func(cfg *config.Config) { + cfg.API.Authorizations = map[string]*config.RPCAuthScope{} + }) + node.StartDaemon() + + apiClient := node.APIClient() + resp := apiClient.Post("/api/v0/id", nil) + assert.Equal(t, 200, resp.StatusCode) + + node.StopDaemon() + }) +}