Skip to content

Commit

Permalink
feat(rpc): Opt-in HTTP RPC API Authorization (#10218)
Browse files Browse the repository at this point in the history
Context: #10187
Co-authored-by: Marcin Rataj <[email protected]>
  • Loading branch information
hacdias and lidel authored Nov 17, 2023
1 parent 0770702 commit 01cc5ea
Show file tree
Hide file tree
Showing 12 changed files with 463 additions and 9 deletions.
29 changes: 29 additions & 0 deletions client/rpc/auth/auth.go
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 4 additions & 0 deletions cmd/ipfs/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
8 changes: 8 additions & 0 deletions cmd/ipfs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
}
Expand Down
60 changes: 59 additions & 1 deletion config/api.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
22 changes: 22 additions & 0 deletions config/api_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
5 changes: 5 additions & 0 deletions core/commands/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion core/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions core/corehttp/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions docs/changelogs/v0.25.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,27 @@

- [Overview](#overview)
- [🔦 Highlights](#-highlights)
- [RPC `API.Authorizations`](#rpc-apiauthorizations)
- [📝 Changelog](#-changelog)
- [👨‍👩‍👧‍👦 Contributors](#-contributors)

### Overview

### 🔦 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
84 changes: 84 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 01cc5ea

Please sign in to comment.