auth: reject delegated tokens from token-method reauthentication
token_authenticate() unconditionally blocks trust-scoped and OAuth1-scoped tokens from creating another token via the token method, but application_credential tokens were only blocked from requesting an explicit scope -- an omitted scope fell through to the user's default-project scoping, letting an app-cred token bound to project A come back scoped to the user's default project B. ec2credential tokens matched no check at all, so they could rescope via an explicit scope to any project the underlying user has a role on, not just a default-project fallback. Both are the same root cause: application credentials and EC2 credentials are deliberately narrow, single-project grants, and the "token" method's rescoping logic never accounted for that, treating them like a normal user session that's free to move between any of its own role assignments. Block application_credential tokens from this path entirely, matching trust/OAuth1, using the same shared keystone.api._shared.delegation classification introduced by LP#2153453: any token whose methods aren't entirely primary auth methods (built-in, or operator-registered via [auth] additional_primary_auth_methods) is rejected outright, including ec2credential and any future delegated method. An empty token.methods list (e.g. an ec2credential-derived token that lost its methods on a fernet cache-miss round-trip) is also treated as delegated, since issuperset([]) is True and would otherwise be accepted as 'all primary'. Closes-Bug: #2158538 Change-Id: Iadc104ae95d6c59484f29f62d6afe4a935bd8593 Assisted-by: Claude Sonnet 5 <noreply@anthropic.com> Co-developed-by: Artem Goncharov <artem.goncharov@gmail.com> Signed-off-by: Grzegorz Grasza <xek@redhat.com> Signed-off-by: Artem Goncharov <artem.goncharov@gmail.com>
This commit is contained in:
1 parent
aeea087060
commit
49baa3f9ed
4 files changed
+220
-18
No files matched your search
@@ -16,6 +16,7 @@ import flask
|
||||
from oslo_log import log
|
||||
import typing as ty
|
||||
|
||||
from keystone.api._shared import delegation
|
||||
from keystone.auth.plugins import base
|
||||
from keystone.auth.plugins import mapped
|
||||
from keystone.common import provider_api
|
||||
@@ -57,7 +58,7 @@ class Token(base.AuthMethodHandler):
|
||||
|
||||
|
||||
def token_authenticate(token: TokenModel) -> dict[str, ty.Any]:
|
||||
response_data = {}
|
||||
response_data: dict[str, ty.Any] = {}
|
||||
try:
|
||||
# Do not allow tokens used for delegation to
|
||||
# create another token, or perform any changes of
|
||||
@@ -90,19 +91,17 @@ def token_authenticate(token: TokenModel) -> dict[str, ty.Any]:
|
||||
'or domain-scoped token is not allowed.'
|
||||
)
|
||||
)
|
||||
elif token.application_credential:
|
||||
# NOTE(gtema): when getting token from token (initially issued by
|
||||
# application credential) it is necessary to ensure scope is not
|
||||
# requested.
|
||||
if requested_scope:
|
||||
raise exception.ForbiddenAction(
|
||||
action=_(
|
||||
"Using an application credential token to create a "
|
||||
"scoped token is not allowed."
|
||||
)
|
||||
elif delegation.is_delegated_method(token):
|
||||
# A token derived from a deliberately narrow-scope credential
|
||||
# (application_credential, OAuth1 access token, ec2credential,
|
||||
# or any other non-primary method, including a future one)
|
||||
# must not be exchanged for a token with a different,
|
||||
# potentially broader, scope.
|
||||
raise exception.ForbiddenAction(
|
||||
action=_(
|
||||
'Using a delegated token to create another token is '
|
||||
'not allowed.'
|
||||
)
|
||||
response_data["application_credential_id"] = (
|
||||
token.application_credential["id"]
|
||||
)
|
||||
|
||||
if not CONF.token.allow_rescope_scoped_token:
|
||||
|
||||
@@ -300,8 +300,10 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase):
|
||||
token=token,
|
||||
expected_status=http.client.OK,
|
||||
)
|
||||
# Test reauth is also working
|
||||
resp = self.post(
|
||||
# Test reauth via the token method is rejected for EC2 credentialed
|
||||
# tokens since they are delegated and must not be exchanged for another
|
||||
# token with a different scope.
|
||||
self.post(
|
||||
'/auth/tokens',
|
||||
headers={"X-Subject-Token": ec2_token},
|
||||
body={
|
||||
@@ -313,7 +315,5 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase):
|
||||
}
|
||||
},
|
||||
token=token,
|
||||
expected_status=http.client.CREATED,
|
||||
expected_status=http.client.FORBIDDEN,
|
||||
)
|
||||
ec2_token = resp.headers['X-Subject-Token']
|
||||
self.assertIn('ec2credential', resp.json['token']['methods'])
|
||||
@@ -24,7 +24,9 @@ import uuid
|
||||
|
||||
from cryptography.hazmat.primitives.serialization import Encoding
|
||||
import fixtures
|
||||
import flask
|
||||
import freezegun
|
||||
from keystoneclient.contrib.ec2 import utils as ec2_utils
|
||||
from oslo_serialization import jsonutils as json
|
||||
from oslo_utils import fixture
|
||||
from oslo_utils import timeutils
|
||||
@@ -32,6 +34,7 @@ from testtools import matchers
|
||||
from testtools import testcase
|
||||
|
||||
from keystone import auth
|
||||
from keystone.auth.plugins import token as token_auth_plugin
|
||||
from keystone.auth.plugins import totp
|
||||
from keystone.common import authorization
|
||||
from keystone.common import provider_api
|
||||
@@ -6735,6 +6738,46 @@ class ApplicationCredentialAuth(test_v3.RestfulTestCase):
|
||||
rescope_auth, expected_status=http.client.FORBIDDEN
|
||||
)
|
||||
|
||||
def test_application_credential_token_cannot_rescope_via_token_method(
|
||||
self,
|
||||
):
|
||||
"""An app-cred token must not be exchanged for any other token (LP#2158538).
|
||||
|
||||
Previously, omitting `scope` on a token-method reauth request let
|
||||
the new token fall through to the user's default-project scoping,
|
||||
escaping the application credential's own project binding entirely.
|
||||
"""
|
||||
other_project_ref = unit.new_project_ref(domain_id=self.domain_id)
|
||||
other_project = PROVIDERS.resource_api.create_project(
|
||||
other_project_ref['id'], other_project_ref
|
||||
)
|
||||
PROVIDERS.assignment_api.add_role_to_user_and_project(
|
||||
self.user['id'], other_project['id'], self.role_id
|
||||
)
|
||||
self.patch(
|
||||
f'/users/{self.user["id"]}',
|
||||
body={'user': {'default_project_id': other_project['id']}},
|
||||
)
|
||||
|
||||
app_cred = self._make_app_cred()
|
||||
app_cred_ref = self.app_cred_api.create_application_credential(
|
||||
app_cred
|
||||
)
|
||||
auth_data = self.build_authentication_request(
|
||||
app_cred_id=app_cred_ref['id'], secret=app_cred_ref['secret']
|
||||
)
|
||||
resp = self.v3_create_token(
|
||||
auth_data, expected_status=http.client.CREATED
|
||||
)
|
||||
app_cred_token = resp.headers.get('X-Subject-Token')
|
||||
|
||||
# No explicit scope requested -- this must not silently default to
|
||||
# the user's default project.
|
||||
rescope_auth = self.build_authentication_request(token=app_cred_token)
|
||||
self.v3_create_token(
|
||||
rescope_auth, expected_status=http.client.FORBIDDEN
|
||||
)
|
||||
|
||||
def test_application_credential_with_access_rules(self):
|
||||
access_rules = [
|
||||
{
|
||||
@@ -6854,3 +6897,138 @@ class ApplicationCredentialAuth(test_v3.RestfulTestCase):
|
||||
token_data = r.result['token']
|
||||
self.assertEqual(self.user['id'], token_data['user']['id'])
|
||||
self.assertNotEqual(victim['id'], token_data['user']['id'])
|
||||
|
||||
|
||||
class Ec2CredentialTokenRescopeAuth(test_v3.RestfulTestCase):
|
||||
"""Ec2credential tokens rescoping via the token method (LP#2158538)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.useFixture(
|
||||
ksfixtures.KeyRepository(
|
||||
self.config_fixture,
|
||||
'credential',
|
||||
credential_fernet.MAX_ACTIVE_KEYS,
|
||||
)
|
||||
)
|
||||
|
||||
def _get_ec2_sig_ref(self, blob):
|
||||
signer = ec2_utils.Ec2Signer(blob['secret'])
|
||||
params = {
|
||||
'SignatureMethod': 'HmacSHA256',
|
||||
'SignatureVersion': '2',
|
||||
'AWSAccessKeyId': blob['access'],
|
||||
}
|
||||
return {
|
||||
'access': blob['access'],
|
||||
'signature': signer.generate(
|
||||
{
|
||||
'host': 'foo',
|
||||
'verb': 'GET',
|
||||
'path': '/bar',
|
||||
'params': params,
|
||||
}
|
||||
),
|
||||
'host': 'foo',
|
||||
'verb': 'GET',
|
||||
'path': '/bar',
|
||||
'params': params,
|
||||
}
|
||||
|
||||
def test_ec2credential_token_cannot_rescope_to_arbitrary_project(self):
|
||||
"""An ec2credential token must not be exchanged for another token.
|
||||
|
||||
Unlike application_credential and trust/OAuth1, a plain
|
||||
ec2credential-derived token carries no delegation marker that
|
||||
token_authenticate() recognized, so it could previously be
|
||||
exchanged via the token method for a token scoped to any project
|
||||
the underlying user has a role on -- not just the project the EC2
|
||||
credential itself was bound to.
|
||||
"""
|
||||
other_project_ref = unit.new_project_ref(domain_id=self.domain_id)
|
||||
other_project = PROVIDERS.resource_api.create_project(
|
||||
other_project_ref['id'], other_project_ref
|
||||
)
|
||||
PROVIDERS.assignment_api.add_role_to_user_and_project(
|
||||
self.user_id, other_project['id'], self.role_id
|
||||
)
|
||||
|
||||
r = self.post(
|
||||
f'/users/{self.user_id}/credentials/OS-EC2',
|
||||
body={'tenant_id': self.project_id},
|
||||
)
|
||||
ec2_cred = r.result['credential']
|
||||
blob = {'access': ec2_cred['access'], 'secret': ec2_cred['secret']}
|
||||
r = self.post(
|
||||
'/ec2tokens',
|
||||
body={'ec2Credentials': self._get_ec2_sig_ref(blob)},
|
||||
expected_status=http.client.OK,
|
||||
)
|
||||
ec2_token = r.headers.get('X-Subject-Token')
|
||||
|
||||
rescope_auth = self.build_authentication_request(
|
||||
token=ec2_token, project_id=other_project['id']
|
||||
)
|
||||
self.v3_create_token(
|
||||
rescope_auth, expected_status=http.client.FORBIDDEN
|
||||
)
|
||||
|
||||
|
||||
class TokenAuthenticateGuardUnit(unit.BaseTestCase):
|
||||
"""Unit-level tests for token_authenticate's delegation guard.
|
||||
|
||||
Calls token_authenticate directly with a mocked token, independent of
|
||||
HTTP request handling. Mirrors
|
||||
keystone.tests.unit.test_v3_trust.TestTrustGuardUnit -- same
|
||||
_PRIMARY_AUTH_METHODS.issuperset([]) == True gap, different call site.
|
||||
"""
|
||||
|
||||
def _check(self, methods):
|
||||
token = mock.Mock()
|
||||
token.oauth_scoped = False
|
||||
token.trust_scoped = False
|
||||
token.system_scoped = False
|
||||
token.application_credential = None
|
||||
token.methods = methods
|
||||
app = flask.Flask('test-token-authenticate-guard')
|
||||
with app.test_request_context('/', json={'auth': {}}):
|
||||
token_auth_plugin.token_authenticate(token)
|
||||
|
||||
def test_rejects_empty_methods(self):
|
||||
"""An empty method list must be treated as delegated, not allowed.
|
||||
|
||||
The fernet round-trip case: ec2credential/oauth2_credential have no
|
||||
bit in the method bitmask, so a token carrying only one of those
|
||||
decodes back to an empty list on any cache miss. Since
|
||||
issuperset([]) is True, such a token used to pass this guard
|
||||
entirely, allowing it to rescope via the token method.
|
||||
"""
|
||||
self.assertRaises(exception.ForbiddenAction, self._check, [])
|
||||
|
||||
def test_rejects_ec2credential_token(self):
|
||||
self.assertRaises(
|
||||
exception.ForbiddenAction, self._check, ['ec2credential']
|
||||
)
|
||||
|
||||
def test_allows_password_token(self):
|
||||
self._check(['password'])
|
||||
|
||||
def test_allows_custom_primary_method_via_config(self):
|
||||
"""A custom auth plugin can rescope via the token method.
|
||||
|
||||
E.g. a site-specific SSO integration, once listed in
|
||||
[auth] additional_primary_auth_methods -- it is not permanently
|
||||
treated as delegated just because it isn't a keystone built-in.
|
||||
"""
|
||||
CONF.set_override(
|
||||
'additional_primary_auth_methods', ['sso'], group='auth'
|
||||
)
|
||||
self.addCleanup(
|
||||
CONF.clear_override,
|
||||
'additional_primary_auth_methods',
|
||||
group='auth',
|
||||
)
|
||||
self._check(['sso'])
|
||||
|
||||
def test_unlisted_custom_method_still_rejected_by_default(self):
|
||||
self.assertRaises(exception.ForbiddenAction, self._check, ['sso'])
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
security:
|
||||
- |
|
||||
`LP#2158538 <https://bugs.launchpad.net/keystone/+bug/2158538>`_:
|
||||
Tokens issued via delegated credentials (application credentials and
|
||||
EC2 credentials) could be exchanged for a broader-scoped token using
|
||||
the token authentication method.
|
||||
|
||||
An application credential token could omit the ``scope`` parameter
|
||||
during token-method reauthentication, causing the new token to fall
|
||||
through to the user's default project, escaping the application
|
||||
credential's project binding. EC2 credential tokens had no such guard
|
||||
and could be re-scoped to any project the underlying user has a role
|
||||
on, not just the project the EC2 credential was bound to.
|
||||
|
||||
upgrade:
|
||||
- |
|
||||
`LP#2158538 <https://bugs.launchpad.net/keystone/+bug/2158538>`_:
|
||||
Authenticating with the token method using an application credential
|
||||
or EC2 credential token is now rejected with HTTP 403 (Forbidden).
|
||||
Previously, application credential tokens without an explicit scope
|
||||
would fall through to the user's default project, and EC2 credential
|
||||
tokens could be re-scoped to any project. Callers that relied on
|
||||
exchanging an application credential token via the token method must
|
||||
authenticate with the application credential directly instead.
|
||||
Reference in new issue
Block a user