Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rpc): Opt-in HTTP RPC API Authorization #10218

Merged
merged 10 commits into from
Nov 17, 2023
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")
hacdias marked this conversation as resolved.
Show resolved Hide resolved
}

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