Skip to content

Commit 01cc5ea

Browse files
hacdiaslidel
andauthored
feat(rpc): Opt-in HTTP RPC API Authorization (#10218)
Context: ipfs/kubo#10187 Co-authored-by: Marcin Rataj <[email protected]>
1 parent 0770702 commit 01cc5ea

File tree

12 files changed

+463
-9
lines changed

12 files changed

+463
-9
lines changed

Diff for: client/rpc/auth/auth.go

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package auth
2+
3+
import "net/http"
4+
5+
var _ http.RoundTripper = &AuthorizedRoundTripper{}
6+
7+
type AuthorizedRoundTripper struct {
8+
authorization string
9+
roundTripper http.RoundTripper
10+
}
11+
12+
// NewAuthorizedRoundTripper creates a new [http.RoundTripper] that will set the
13+
// Authorization HTTP header with the value of [authorization]. The given [roundTripper] is
14+
// the base [http.RoundTripper]. If it is nil, [http.DefaultTransport] is used.
15+
func NewAuthorizedRoundTripper(authorization string, roundTripper http.RoundTripper) http.RoundTripper {
16+
if roundTripper == nil {
17+
roundTripper = http.DefaultTransport
18+
}
19+
20+
return &AuthorizedRoundTripper{
21+
authorization: authorization,
22+
roundTripper: roundTripper,
23+
}
24+
}
25+
26+
func (tp *AuthorizedRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
27+
r.Header.Set("Authorization", tp.authorization)
28+
return tp.roundTripper.RoundTrip(r)
29+
}

Diff for: cmd/ipfs/daemon.go

+4
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,10 @@ func serveHTTPApi(req *cmds.Request, cctx *oldcmds.Context) (<-chan error, error
676676
listeners = append(listeners, apiLis)
677677
}
678678

679+
if len(cfg.API.Authorizations) > 0 && len(listeners) > 0 {
680+
fmt.Printf("RPC API access is limited by the rules defined in API.Authorizations\n")
681+
}
682+
679683
for _, listener := range listeners {
680684
// we might have listened to /tcp/0 - let's see what we are listing on
681685
fmt.Printf("RPC API server listening on %s\n", listener.Multiaddr())

Diff for: cmd/ipfs/main.go

+8
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import (
2323
cmdhttp "github.com/ipfs/go-ipfs-cmds/http"
2424
logging "github.com/ipfs/go-log"
2525
ipfs "github.com/ipfs/kubo"
26+
"github.com/ipfs/kubo/client/rpc/auth"
2627
"github.com/ipfs/kubo/cmd/ipfs/util"
2728
oldcmds "github.com/ipfs/kubo/commands"
29+
config "github.com/ipfs/kubo/config"
2830
"github.com/ipfs/kubo/core"
2931
corecmds "github.com/ipfs/kubo/core/commands"
3032
"github.com/ipfs/kubo/core/corehttp"
@@ -325,6 +327,12 @@ func makeExecutor(req *cmds.Request, env interface{}) (cmds.Executor, error) {
325327
return nil, fmt.Errorf("unsupported API address: %s", apiAddr)
326328
}
327329

330+
apiAuth, specified := req.Options[corecmds.ApiAuthOption].(string)
331+
if specified {
332+
authorization := config.ConvertAuthSecret(apiAuth)
333+
tpt = auth.NewAuthorizedRoundTripper(authorization, tpt)
334+
}
335+
328336
httpClient := &http.Client{
329337
Transport: otelhttp.NewTransport(tpt),
330338
}

Diff for: config/api.go

+59-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,63 @@
11
package config
22

3+
import (
4+
"encoding/base64"
5+
"strings"
6+
)
7+
8+
const (
9+
APITag = "API"
10+
AuthorizationTag = "Authorizations"
11+
)
12+
13+
type RPCAuthScope struct {
14+
// AuthSecret is the secret that will be compared to the HTTP "Authorization".
15+
// header. A secret is in the format "type:value". Check the documentation for
16+
// supported types.
17+
AuthSecret string
18+
19+
// AllowedPaths is an explicit list of RPC path prefixes to allow.
20+
// By default, none are allowed. ["/api/v0"] exposes all RPCs.
21+
AllowedPaths []string
22+
}
23+
324
type API struct {
4-
HTTPHeaders map[string][]string // HTTP headers to return with the API.
25+
// HTTPHeaders are the HTTP headers to return with the API.
26+
HTTPHeaders map[string][]string
27+
28+
// Authorization is a map of authorizations used to authenticate in the API.
29+
// If the map is empty, then the RPC API is exposed to everyone. Check the
30+
// documentation for more details.
31+
Authorizations map[string]*RPCAuthScope `json:",omitempty"`
32+
}
33+
34+
// ConvertAuthSecret converts the given secret in the format "type:value" into an
35+
// HTTP Authorization header value. It can handle 'bearer' and 'basic' as type.
36+
// If type exists and is not known, an empty string is returned. If type does not
37+
// exist, 'bearer' type is assumed.
38+
func ConvertAuthSecret(secret string) string {
39+
if secret == "" {
40+
return secret
41+
}
42+
43+
split := strings.SplitN(secret, ":", 2)
44+
if len(split) < 2 {
45+
// No prefix: assume bearer token.
46+
return "Bearer " + secret
47+
}
48+
49+
if strings.HasPrefix(secret, "basic:") {
50+
if strings.Contains(split[1], ":") {
51+
// Assume basic:user:password
52+
return "Basic " + base64.StdEncoding.EncodeToString([]byte(split[1]))
53+
} else {
54+
// Assume already base64 encoded.
55+
return "Basic " + split[1]
56+
}
57+
} else if strings.HasPrefix(secret, "bearer:") {
58+
return "Bearer " + split[1]
59+
}
60+
61+
// Unknown. Type is present, but we can't handle it.
62+
return ""
563
}

Diff for: config/api_test.go

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package config
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestConvertAuthSecret(t *testing.T) {
10+
for _, testCase := range []struct {
11+
input string
12+
output string
13+
}{
14+
{"", ""},
15+
{"someToken", "Bearer someToken"},
16+
{"bearer:someToken", "Bearer someToken"},
17+
{"basic:user:pass", "Basic dXNlcjpwYXNz"},
18+
{"basic:dXNlcjpwYXNz", "Basic dXNlcjpwYXNz"},
19+
} {
20+
assert.Equal(t, testCase.output, ConvertAuthSecret(testCase.input))
21+
}
22+
}

Diff for: core/commands/config.go

+5
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,11 @@ NOTE: For security reasons, this command will omit your private key and remote s
208208
return err
209209
}
210210

211+
cfg, err = scrubValue(cfg, []string{config.APITag, config.AuthorizationTag})
212+
if err != nil {
213+
return err
214+
}
215+
211216
cfg, err = scrubOptionalValue(cfg, config.PinningConcealSelector)
212217
if err != nil {
213218
return err

Diff for: core/commands/root.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ const (
2828
DebugOption = "debug"
2929
LocalOption = "local" // DEPRECATED: use OfflineOption
3030
OfflineOption = "offline"
31-
ApiOption = "api" //nolint
31+
ApiOption = "api" //nolint
32+
ApiAuthOption = "api-auth" //nolint
3233
)
3334

3435
var Root = &cmds.Command{
@@ -110,6 +111,7 @@ The CLI will exit with one of the following values:
110111
cmds.BoolOption(LocalOption, "L", "Run the command locally, instead of using the daemon. DEPRECATED: use --offline."),
111112
cmds.BoolOption(OfflineOption, "Run the command offline."),
112113
cmds.StringOption(ApiOption, "Use a specific API instance (defaults to /ip4/127.0.0.1/tcp/5001)"),
114+
cmds.StringOption(ApiAuthOption, "Optional RPC API authorization secret (defined as AuthSecret in API.Authorizations config)"),
113115

114116
// global options, added to every command
115117
cmdenv.OptionCidBase,

Diff for: core/corehttp/commands.go

+51
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,63 @@ func commandsOption(cctx oldcmds.Context, command *cmds.Command, allowGet bool)
143143
patchCORSVars(cfg, l.Addr())
144144

145145
cmdHandler := cmdsHttp.NewHandler(&cctx, command, cfg)
146+
147+
if len(rcfg.API.Authorizations) > 0 {
148+
authorizations := convertAuthorizationsMap(rcfg.API.Authorizations)
149+
cmdHandler = withAuthSecrets(authorizations, cmdHandler)
150+
}
151+
146152
cmdHandler = otelhttp.NewHandler(cmdHandler, "corehttp.cmdsHandler")
147153
mux.Handle(APIPath+"/", cmdHandler)
148154
return mux, nil
149155
}
150156
}
151157

158+
type rpcAuthScopeWithUser struct {
159+
config.RPCAuthScope
160+
User string
161+
}
162+
163+
func convertAuthorizationsMap(authScopes map[string]*config.RPCAuthScope) map[string]rpcAuthScopeWithUser {
164+
// authorizations is a map where we can just check for the header value to match.
165+
authorizations := map[string]rpcAuthScopeWithUser{}
166+
for user, authScope := range authScopes {
167+
expectedHeader := config.ConvertAuthSecret(authScope.AuthSecret)
168+
if expectedHeader != "" {
169+
authorizations[expectedHeader] = rpcAuthScopeWithUser{
170+
RPCAuthScope: *authScopes[user],
171+
User: user,
172+
}
173+
}
174+
}
175+
176+
return authorizations
177+
}
178+
179+
func withAuthSecrets(authorizations map[string]rpcAuthScopeWithUser, next http.Handler) http.Handler {
180+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
181+
authorizationHeader := r.Header.Get("Authorization")
182+
auth, ok := authorizations[authorizationHeader]
183+
184+
if ok {
185+
// version check is implicitly allowed
186+
if r.URL.Path == "/api/v0/version" {
187+
next.ServeHTTP(w, r)
188+
return
189+
}
190+
// everything else has to be safelisted via AllowedPaths
191+
for _, prefix := range auth.AllowedPaths {
192+
if strings.HasPrefix(r.URL.Path, prefix) {
193+
next.ServeHTTP(w, r)
194+
return
195+
}
196+
}
197+
}
198+
199+
http.Error(w, "Kubo RPC Access Denied: Please provide a valid authorization token as defined in the API.Authorizations configuration.", http.StatusForbidden)
200+
})
201+
}
202+
152203
// CommandsOption constructs a ServerOption for hooking the commands into the
153204
// HTTP server. It will NOT allow GET requests.
154205
func CommandsOption(cctx oldcmds.Context) ServeOption {

Diff for: docs/changelogs/v0.25.md

+14
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,27 @@
66

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

1213
### Overview
1314

1415
### 🔦 Highlights
1516

17+
#### RPC `API.Authorizations`
18+
19+
Kubo RPC API now supports optional HTTP Authorization.
20+
21+
Granular control over user access to the RPC can be defined in the
22+
[`API.Authorizations`](https://github.com/ipfs/kubo/blob/master/docs/config.md#apiauthorizations)
23+
map in the configuration file, allowing different users or apps to have unique
24+
access secrets and allowed paths.
25+
26+
This feature is opt-in. By default, no authorization is set up.
27+
For configuration instructions,
28+
refer to the [documentation](https://github.com/ipfs/kubo/blob/master/docs/config.md#apiauthorizations).
29+
1630
### 📝 Changelog
1731

1832
### 👨‍👩‍👧‍👦 Contributors

Diff for: docs/config.md

+84
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ config file at runtime.
2828
- [`Addresses.NoAnnounce`](#addressesnoannounce)
2929
- [`API`](#api)
3030
- [`API.HTTPHeaders`](#apihttpheaders)
31+
- [`API.Authorizations`](#apiauthorizations)
32+
- [`API.Authorizations: AuthSecret`](#apiauthorizations-authsecret)
33+
- [`API.Authorizations: AllowedPaths`](#apiauthorizations-allowedpaths)
3134
- [`AutoNAT`](#autonat)
3235
- [`AutoNAT.ServiceMode`](#autonatservicemode)
3336
- [`AutoNAT.Throttle`](#autonatthrottle)
@@ -438,6 +441,87 @@ Default: `null`
438441

439442
Type: `object[string -> array[string]]` (header names -> array of header values)
440443

444+
### `API.Authorizations`
445+
446+
The `API.Authorizations` field defines user-based access restrictions for the
447+
[Kubo RPC API](https://docs.ipfs.tech/reference/kubo/rpc/), which is located at
448+
`Addresses.API` under `/api/v0` paths.
449+
450+
By default, the RPC API is accessible without restrictions as it is only
451+
exposed on `127.0.0.1` and safeguarded with Origin check and implicit
452+
[CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) headers that
453+
block random websites from accessing the RPC.
454+
455+
When entries are defined in `API.Authorizations`, RPC requests will be declined
456+
unless a corresponding secret is present in the HTTP [`Authorization` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization),
457+
and the requested path is included in the `AllowedPaths` list for that specific
458+
secret.
459+
460+
Default: `null`
461+
462+
Type: `object[string -> object]` (user name -> authorization object, see bellow)
463+
464+
For example, to limit RPC access to Alice (access `id` and MFS `files` commands with HTTP Basic Auth)
465+
and Bob (full access with Bearer token):
466+
467+
```json
468+
{
469+
"API": {
470+
"Authorizations": {
471+
"Alice": {
472+
"AuthSecret": "basic:alice:password123",
473+
"AllowedPaths": ["/api/v0/id", "/api/v0/files"]
474+
},
475+
"Bob": {
476+
"AuthSecret": "bearer:secret-token123",
477+
"AllowedPaths": ["/api/v0"]
478+
}
479+
}
480+
}
481+
}
482+
483+
```
484+
485+
#### `API.Authorizations: AuthSecret`
486+
487+
The `AuthSecret` field denotes the secret used by a user to authenticate,
488+
usually via HTTP [`Authorization` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization).
489+
490+
Field format is `type:value`, and the following types are supported:
491+
492+
- `bearer:` For secret Bearer tokens, set as `bearer:token`.
493+
- If no known `type:` prefix is present, `bearer:` is assumed.
494+
- `basic`: For HTTP Basic Auth introduced in [RFC7617](https://datatracker.ietf.org/doc/html/rfc7617). Value can be:
495+
- `basic:user:pass`
496+
- `basic:base64EncodedBasicAuth`
497+
498+
One can use the config value for authentication via the command line:
499+
500+
```
501+
ipfs id --api-auth basic:user:pass
502+
```
503+
504+
Type: `string`
505+
506+
#### `API.Authorizations: AllowedPaths`
507+
508+
The `AllowedPaths` field is an array of strings containing allowed RPC path
509+
prefixes. Users authorized with the related `AuthSecret` will only be able to
510+
access paths prefixed by the specified prefixes.
511+
512+
For instance:
513+
514+
- If set to `["/api/v0"]`, the user will have access to the complete RPC API.
515+
- If set to `["/api/v0/id", "/api/v0/files"]`, the user will only have access
516+
to the `id` command and all MFS commands under `files`.
517+
518+
Note that `/api/v0/version` is always permitted access to allow version check
519+
to ensure compatibility.
520+
521+
Default: `[]`
522+
523+
Type: `array[string]`
524+
441525
## `AutoNAT`
442526

443527
Contains the configuration options for the AutoNAT service. The AutoNAT service

0 commit comments

Comments
 (0)