| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "testing" |
| 6 | |
| 7 | "github.com/ipfs/kubo/client/rpc/auth" |
| 8 | "github.com/ipfs/kubo/config" |
| 9 | "github.com/ipfs/kubo/test/cli/harness" |
| 10 | "github.com/stretchr/testify/assert" |
| 11 | "github.com/stretchr/testify/require" |
| 12 | ) |
| 13 | |
| 14 | const rpcDeniedMsg = "Kubo RPC Access Denied: Please provide a valid authorization token as defined in the API.Authorizations configuration." |
| 15 | |
| 16 | func TestRPCAuth(t *testing.T) { |
| 17 | t.Parallel() |
| 18 | |
| 19 | makeAndStartProtectedNode := func(t *testing.T, authorizations map[string]*config.RPCAuthScope) *harness.Node { |
| 20 | authorizations["test-node-starter"] = &config.RPCAuthScope{ |
| 21 | AuthSecret: "bearer:test-node-starter", |
| 22 | AllowedPaths: []string{"/api/v0"}, |
| 23 | } |
| 24 | |
| 25 | node := harness.NewT(t).NewNode().Init() |
| 26 | node.UpdateConfig(func(cfg *config.Config) { |
| 27 | cfg.API.Authorizations = authorizations |
| 28 | }) |
| 29 | node.StartDaemonWithAuthorization("Bearer test-node-starter") |
| 30 | return node |
| 31 | } |
| 32 | |
| 33 | makeHTTPTest := func(authSecret, header string) func(t *testing.T) { |
| 34 | return func(t *testing.T) { |
| 35 | t.Parallel() |
| 36 | t.Log(authSecret, header) |
| 37 | |
| 38 | node := makeAndStartProtectedNode(t, map[string]*config.RPCAuthScope{ |
| 39 | "userA": { |
| 40 | AuthSecret: authSecret, |
| 41 | AllowedPaths: []string{"/api/v0/id"}, |
| 42 | }, |
| 43 | }) |
| 44 | |
| 45 | apiClient := node.APIClient() |
| 46 | apiClient.Client = &http.Client{ |
| 47 | Transport: auth.NewAuthorizedRoundTripper(header, http.DefaultTransport), |
| 48 | } |
| 49 | |
| 50 | // Can access /id with valid token |
| 51 | resp := apiClient.Post("/api/v0/id", nil) |
| 52 | assert.Equal(t, 200, resp.StatusCode) |
| 53 | |
| 54 | // But not /config/show |
| 55 | resp = apiClient.Post("/api/v0/config/show", nil) |
| 56 | assert.Equal(t, 403, resp.StatusCode) |
| 57 | |
| 58 | // create client which sends invalid access token |
| 59 | invalidApiClient := node.APIClient() |
| 60 | invalidApiClient.Client = &http.Client{ |
| 61 | Transport: auth.NewAuthorizedRoundTripper("Bearer invalid", http.DefaultTransport), |
| 62 | } |
| 63 | |
| 64 | // Can't access /id with invalid token |
| 65 | errResp := invalidApiClient.Post("/api/v0/id", nil) |
| 66 | assert.Equal(t, 403, errResp.StatusCode) |
| 67 | |
| 68 | node.StopDaemon() |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | makeCLITest := func(authSecret string) func(t *testing.T) { |
| 73 | return func(t *testing.T) { |
| 74 | t.Parallel() |
| 75 | |
| 76 | node := makeAndStartProtectedNode(t, map[string]*config.RPCAuthScope{ |
| 77 | "userA": { |
| 78 | AuthSecret: authSecret, |
| 79 | AllowedPaths: []string{"/api/v0/id"}, |
| 80 | }, |
| 81 | }) |
| 82 | |
| 83 | // Can access 'ipfs id' |
| 84 | resp := node.RunIPFS("id", "--api-auth", authSecret) |
| 85 | require.NoError(t, resp.Err) |
| 86 | |
| 87 | // But not 'ipfs config show' |
| 88 | resp = node.RunIPFS("config", "show", "--api-auth", authSecret) |
| 89 | require.Error(t, resp.Err) |
| 90 | require.Contains(t, resp.Stderr.String(), rpcDeniedMsg) |
| 91 | |
| 92 | node.StopDaemon() |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | for _, testCase := range []struct { |
| 97 | name string |
| 98 | authSecret string |
| 99 | header string |
| 100 | }{ |
| 101 | {"Bearer (no type)", "myToken", "Bearer myToken"}, |
| 102 | {"Bearer", "bearer:myToken", "Bearer myToken"}, |
| 103 | {"Basic (user:pass)", "basic:user:pass", "Basic dXNlcjpwYXNz"}, |
| 104 | {"Basic (encoded)", "basic:dXNlcjpwYXNz", "Basic dXNlcjpwYXNz"}, |
| 105 | } { |
| 106 | t.Run("AllowedPaths on CLI "+testCase.name, makeCLITest(testCase.authSecret)) |
| 107 | t.Run("AllowedPaths on HTTP "+testCase.name, makeHTTPTest(testCase.authSecret, testCase.header)) |
| 108 | } |
| 109 | |
| 110 | t.Run("AllowedPaths set to /api/v0 Gives Full Access", func(t *testing.T) { |
| 111 | t.Parallel() |
| 112 | |
| 113 | node := makeAndStartProtectedNode(t, map[string]*config.RPCAuthScope{ |
| 114 | "userA": { |
| 115 | AuthSecret: "bearer:userAToken", |
| 116 | AllowedPaths: []string{"/api/v0"}, |
| 117 | }, |
| 118 | }) |
| 119 | |
| 120 | apiClient := node.APIClient() |
| 121 | apiClient.Client = &http.Client{ |
| 122 | Transport: auth.NewAuthorizedRoundTripper("Bearer userAToken", http.DefaultTransport), |
| 123 | } |
| 124 | |
| 125 | resp := apiClient.Post("/api/v0/id", nil) |
| 126 | assert.Equal(t, 200, resp.StatusCode) |
| 127 | |
| 128 | node.StopDaemon() |
| 129 | }) |
| 130 | |
| 131 | t.Run("API.Authorizations set to nil disables Authorization header check", func(t *testing.T) { |
| 132 | t.Parallel() |
| 133 | |
| 134 | node := harness.NewT(t).NewNode().Init() |
| 135 | node.UpdateConfig(func(cfg *config.Config) { |
| 136 | cfg.API.Authorizations = nil |
| 137 | }) |
| 138 | node.StartDaemon() |
| 139 | |
| 140 | apiClient := node.APIClient() |
| 141 | resp := apiClient.Post("/api/v0/id", nil) |
| 142 | assert.Equal(t, 200, resp.StatusCode) |
| 143 | |
| 144 | node.StopDaemon() |
| 145 | }) |
| 146 | |
| 147 | t.Run("API.Authorizations set to empty map disables Authorization header check", func(t *testing.T) { |
| 148 | t.Parallel() |
| 149 | |
| 150 | node := harness.NewT(t).NewNode().Init() |
| 151 | node.UpdateConfig(func(cfg *config.Config) { |
| 152 | cfg.API.Authorizations = map[string]*config.RPCAuthScope{} |
| 153 | }) |
| 154 | node.StartDaemon() |
| 155 | |
| 156 | apiClient := node.APIClient() |
| 157 | resp := apiClient.Post("/api/v0/id", nil) |
| 158 | assert.Equal(t, 200, resp.StatusCode) |
| 159 | |
| 160 | node.StopDaemon() |
| 161 | }) |
| 162 | |
| 163 | t.Run("Requests without Authorization header are rejected when auth is enabled", func(t *testing.T) { |
| 164 | t.Parallel() |
| 165 | |
| 166 | node := makeAndStartProtectedNode(t, map[string]*config.RPCAuthScope{ |
| 167 | "userA": { |
| 168 | AuthSecret: "bearer:mytoken", |
| 169 | AllowedPaths: []string{"/api/v0"}, |
| 170 | }, |
| 171 | }) |
| 172 | |
| 173 | // Create client with NO auth |
| 174 | apiClient := node.APIClient() // Uses http.DefaultClient with no auth headers |
| 175 | |
| 176 | // Should be denied without auth header |
| 177 | resp := apiClient.Post("/api/v0/id", nil) |
| 178 | assert.Equal(t, 403, resp.StatusCode) |
| 179 | |
| 180 | // Should contain denial message |
| 181 | assert.Contains(t, resp.Body, rpcDeniedMsg) |
| 182 | |
| 183 | node.StopDaemon() |
| 184 | }) |
| 185 | |
| 186 | t.Run("Version endpoint is always accessible even with limited AllowedPaths", func(t *testing.T) { |
| 187 | t.Parallel() |
| 188 | |
| 189 | node := makeAndStartProtectedNode(t, map[string]*config.RPCAuthScope{ |
| 190 | "userA": { |
| 191 | AuthSecret: "bearer:mytoken", |
| 192 | AllowedPaths: []string{"/api/v0/id"}, // Only /id allowed |
| 193 | }, |
| 194 | }) |
| 195 | |
| 196 | apiClient := node.APIClient() |
| 197 | apiClient.Client = &http.Client{ |
| 198 | Transport: auth.NewAuthorizedRoundTripper("Bearer mytoken", http.DefaultTransport), |
| 199 | } |
| 200 | |
| 201 | // Can access /version even though not in AllowedPaths |
| 202 | resp := apiClient.Post("/api/v0/version", nil) |
| 203 | assert.Equal(t, 200, resp.StatusCode) |
| 204 | |
| 205 | node.StopDaemon() |
| 206 | }) |
| 207 | |
| 208 | t.Run("User cannot access API with another user's secret", func(t *testing.T) { |
| 209 | t.Parallel() |
| 210 | |
| 211 | node := makeAndStartProtectedNode(t, map[string]*config.RPCAuthScope{ |
| 212 | "alice": { |
| 213 | AuthSecret: "bearer:alice-secret", |
| 214 | AllowedPaths: []string{"/api/v0/id"}, |
| 215 | }, |
| 216 | "bob": { |
| 217 | AuthSecret: "bearer:bob-secret", |
| 218 | AllowedPaths: []string{"/api/v0/config"}, |
| 219 | }, |
| 220 | }) |
| 221 | |
| 222 | // Alice tries to use Bob's secret |
| 223 | apiClient := node.APIClient() |
| 224 | apiClient.Client = &http.Client{ |
| 225 | Transport: auth.NewAuthorizedRoundTripper("Bearer bob-secret", http.DefaultTransport), |
| 226 | } |
| 227 | |
| 228 | // Bob's secret should work for Bob's paths |
| 229 | resp := apiClient.Post("/api/v0/config/show", nil) |
| 230 | assert.Equal(t, 200, resp.StatusCode) |
| 231 | |
| 232 | // But not for Alice's paths (Bob doesn't have access to /id) |
| 233 | resp = apiClient.Post("/api/v0/id", nil) |
| 234 | assert.Equal(t, 403, resp.StatusCode) |
| 235 | |
| 236 | node.StopDaemon() |
| 237 | }) |
| 238 | |
| 239 | t.Run("Empty AllowedPaths denies all access except version", func(t *testing.T) { |
| 240 | t.Parallel() |
| 241 | |
| 242 | node := makeAndStartProtectedNode(t, map[string]*config.RPCAuthScope{ |
| 243 | "userA": { |
| 244 | AuthSecret: "bearer:mytoken", |
| 245 | AllowedPaths: []string{}, // Empty! |
| 246 | }, |
| 247 | }) |
| 248 | |
| 249 | apiClient := node.APIClient() |
| 250 | apiClient.Client = &http.Client{ |
| 251 | Transport: auth.NewAuthorizedRoundTripper("Bearer mytoken", http.DefaultTransport), |
| 252 | } |
| 253 | |
| 254 | // Should deny everything |
| 255 | resp := apiClient.Post("/api/v0/id", nil) |
| 256 | assert.Equal(t, 403, resp.StatusCode) |
| 257 | |
| 258 | resp = apiClient.Post("/api/v0/config/show", nil) |
| 259 | assert.Equal(t, 403, resp.StatusCode) |
| 260 | |
| 261 | // Except version |
| 262 | resp = apiClient.Post("/api/v0/version", nil) |
| 263 | assert.Equal(t, 200, resp.StatusCode) |
| 264 | |
| 265 | node.StopDaemon() |
| 266 | }) |
| 267 | |
| 268 | t.Run("CLI commands fail without --api-auth when auth is enabled", func(t *testing.T) { |
| 269 | t.Parallel() |
| 270 | |
| 271 | node := makeAndStartProtectedNode(t, map[string]*config.RPCAuthScope{ |
| 272 | "userA": { |
| 273 | AuthSecret: "bearer:mytoken", |
| 274 | AllowedPaths: []string{"/api/v0"}, |
| 275 | }, |
| 276 | }) |
| 277 | |
| 278 | // Try to run command without --api-auth flag |
| 279 | resp := node.RunIPFS("id") // No --api-auth flag |
| 280 | require.Error(t, resp.Err) |
| 281 | require.Contains(t, resp.Stderr.String(), rpcDeniedMsg) |
| 282 | |
| 283 | node.StopDaemon() |
| 284 | }) |
| 285 | } |