1
0
mirror of https://github.com/ipfs/kubo.git synced 2025-05-17 23:16:11 +08:00

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

Context: https://github.com/ipfs/kubo/issues/10187
Co-authored-by: Marcin Rataj <lidel@lidel.org>
This commit is contained in:
Henrique Dias
2023-11-17 01:29:29 +01:00
committed by GitHub
parent 0770702289
commit 01cc5eab57
12 changed files with 464 additions and 10 deletions

29
client/rpc/auth/auth.go Normal file
View File

@ -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)
}

View File

@ -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())

View File

@ -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),
}

View File

@ -1,5 +1,63 @@
package config
type API struct {
HTTPHeaders map[string][]string // HTTP headers to return with the API.
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 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
config/api_test.go Normal file
View File

@ -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))
}
}

View File

@ -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

View File

@ -29,6 +29,7 @@ const (
LocalOption = "local" // DEPRECATED: use OfflineOption
OfflineOption = "offline"
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,

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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
}

162
test/cli/rpc_auth_test.go Normal file
View File

@ -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()
})
}