couchdb icon indicating copy to clipboard operation
couchdb copied to clipboard

Server Admin Access Via JWT Claims vs Explicit Configuration

Open ronnievsmith opened this issue 2 years ago • 8 comments

Introduction

I have implemented JWT authentication. I noticed that:

(1) Users configured as server administrators are not granted server administrator privileges when authenticating via JWT unless "_admin" is included in the JWT payload roles parameter.

(2) JWT authenticated users having "_admin" included in the JWT payload roles parameter are allowed server admin privileges even though this user, or sub in JWT parlance, is not configured as a server admin.

When I say "configured as a server admin" I mean that the user will be listed in CouchDB's response to the /_node/{node-name}/_config/admins endpoint.

Abstract

Server admin privileges should be subject to highest security measures. Double checking a JWT claim of a users _admin role against the actual configured system admins seems worth consideration. Perhaps an even better solution is to ignore JWT claims of _admin altogether.

Requirements Language

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.

Terminology

JWT, JSON Web Token Server admin, user is listed in response to the _node/_local/_config/admins/ endpoint


Detailed Description

I can think of two possible enhancements:

  1. CouchDB SHALL validate any JWT claim of sub having role including _admin against configured system administrators.
  2. CouchDB SHALL ignore JWT claims of role:["_admin"] but grant server administrator privileges if the user, sub, is a configured server administrator. This solution seems best since JWT payloads can be viewed by anyone and tokens including admin access are particularly interesting to attackers.

Advantages and Disadvantages

The advantage of this additional security check is that a compromised JWT, while not probable, would disallow an attacker server admin status.

Key Changes

This existing behavior of trusting _admin role claims is not explicitly documented therefore users may not notice the change. A meaningful HTTP error message such as User is not configured as a server admin. would enable adoption.

Applications and Modules affected

[chttpd] [admins]

HTTP API additions

A meaningful HTTP error message such as User is not configured as a server admin.

HTTP API deprecations

None.

Security Considerations

CouchDB security would be enhanced by implementing this additional validation of _admin claims, or even better by ignoring (and perhaps discouraging via documentation) this JWT role claim altogether.

References

https://docs.couchdb.org/en/stable/api/server/authn.html#jwt-authentication https://docs.couchdb.org/en/stable/intro/security.html#creating-a-new-admin-user

Acknowledgements

Thanks to the CouchDB team for creating a great product!

ronnievsmith avatar Jul 03 '23 17:07 ronnievsmith

The JWT handler (and the much older proxy handler) exists to externalise authentication/authorization decisions, including whether a user has admin privileges. The CouchDB administrator needs to make several privileged changes to enable this behaviour. Enabling the handler itself, and then populating [jwt_keys] they trust to issue JWT tokens.

I'd be happy to merge an enhancement to the handler that rejects JWT tokens with the _admin role as an option (off by default for backward compatibility reasons), if you were so minded.

I think what won't work is 'validating' the token against the [admins] in the .ini file as that only contains a (salted) password hash; the JWT obviously doesn't include the password that we'd hash to compare it against. Perhaps you have some other idea in mind for this double-check? (though it can't be solely on the name of the admin).

rnewson avatar Jul 03 '23 18:07 rnewson

There is value in validating that the sub is configured as a server admin. The particular server admin password can exist even if not used/read for JWT authentication.

As an aside, understood regarding the provenance of the JWT handler. Authorization, or role based access, is configured on CouchDB no matter what, e.g. each database's _security endpoint, or permissions. Mapping users to CouchDB roles seems like it should always exist on CouchDB and developers can architect in this fashion as-is via design documents as far as I understand.

ronnievsmith avatar Jul 03 '23 19:07 ronnievsmith

There is value in validating that the sub is configured as a server admin

How do you propose doing so? (usernames are not secrets).

I do not understand your aside.

rnewson avatar Jul 03 '23 19:07 rnewson

Server admin usernames are only available to server admins.

Upon HTTP request,

  1. read/validate JWT
  2. if sub role exists, splice/remove _admin from roles
  3. for each configured server admin 3a. If sub equals userID push _admin to sub roles array

ronnievsmith avatar Jul 03 '23 19:07 ronnievsmith

hm, nope, I can't go for that. usernames are not secrets (even if the list of usernames is access controlled).

That leaves us with an option to simply reject JWT tokens with the _admin role as an option.

rnewson avatar Jul 03 '23 19:07 rnewson

The JWT signature acts as the password. Couch verifying that the sub is configured as an _admin does not make a username a secret or a password but adds additional security in that server admins must be explicitly configured.

The option to reject _admin claims is valuable.

ronnievsmith avatar Jul 03 '23 19:07 ronnievsmith

we added a admin_roles config. feel free to use/adapt the below (needs rebase)

diff --git a/src/couch/src/couch_httpd_auth.erl b/src/couch/src/couch_httpd_auth.erl
index 24a0c15ed..07d4df4f3 100644
--- a/src/couch/src/couch_httpd_auth.erl
+++ b/src/couch/src/couch_httpd_auth.erl
@@ -230,21 +230,10 @@ jwt_authentication_handler(Req) ->
                     case lists:keyfind(<<"sub">>, 1, Claims) of
                         false ->
                             throw({unauthorized, <<"Token missing sub claim.">>});
-                        {_, User} ->
-                            Req#httpd{
-                                user_ctx = #user_ctx{
-                                    name = User,
-                                    roles = couch_util:get_value(
-                                        ?l2b(
-                                            config:get(
-                                                "jwt_auth", "roles_claim_name", "_couchdb.roles"
-                                            )
-                                        ),
-                                        Claims,
-                                        []
-                                    )
-                                }
-                            }
+                        {_, User} -> Req#httpd{user_ctx=#user_ctx{
+                            name = User,
+                            roles = jwt_roles(Claims)
+                        }}
                     end;
                 {error, Reason} ->
                     throw(Reason)
@@ -253,6 +242,31 @@ jwt_authentication_handler(Req) ->
             Req
     end.
 
+jwt_roles(Claims) ->
+    RolesClaimName = ?l2b(config:get("jwt_auth", "roles_claim_name", "_couchdb.roles")),
+    JWTRoles = couch_util:get_value(RolesClaimName, Claims, []),
+    case jwt_admin_roles() of
+        [] -> lists:usort(JWTRoles);
+        Roles -> jwt_verify_admin_roles(JWTRoles, Roles)
+    end.
+
+jwt_verify_admin_roles(JWTRoles, Roles) ->
+    VerifyFun = fun(JWTRole) -> lists:member(JWTRole, Roles) end,
+    case lists:any(VerifyFun, JWTRoles) of
+        true -> lists:usort([<<"_admin">> | JWTRoles]);
+        false -> lists:usort(JWTRoles)
+    end.
+
+jwt_admin_roles() ->
+    AdminRoles = string:split(config:get("jwt_auth", "admin_roles", ""), ",", all),
+    lists:filtermap(fun jwt_filter_map_admin_role/1, AdminRoles).
+
+jwt_filter_map_admin_role(Role) ->
+    case string:is_empty(Role) of
+        true -> false;
+        false -> {true, ?l2b(Role)}
+    end.
+
 get_configured_claims() ->
     Claims = config:get("jwt_auth", "required_claims", ""),
     Re = "((?<key1>[a-z]+)|{(?<key2>[a-z]+)\s*,\s*\"(?<val>[^\"]+)\"})",

lazedo avatar Jul 11 '23 07:07 lazedo

another alternative would be to use JMESPath to evaluate the _admins https://github.com/thehangedman/jmespath-erlang

the below was added to a POC of grafana

admin_attribute_path = contains(groups[*], 'myawesomeorg:admins')
role_attribute_path = contains(groups[*], 'myawesomeorg:admins') && 'Admin' || contains(groups[*], 'myawesomeorg:eks') && 'Editor' || 'Viewer'

lazedo avatar Jul 11 '23 08:07 lazedo