Skip to content

Commit

Permalink
security: fix CVE 2024 52289 (#12113)
Browse files Browse the repository at this point in the history
* initial migration

Signed-off-by: Jens Langhammer <[email protected]>

* migrate tests

Signed-off-by: Jens Langhammer <[email protected]>

* fix loading

Signed-off-by: Jens Langhammer <[email protected]>

* fix

Signed-off-by: Jens Langhammer <[email protected]>

* start dynamic ui

Signed-off-by: Jens Langhammer <[email protected]>

* initial ui

Signed-off-by: Jens Langhammer <[email protected]>

* add serialize

Signed-off-by: Jens Langhammer <[email protected]>

* add error message handling

Signed-off-by: Jens Langhammer <[email protected]>

* fix/add tests

Signed-off-by: Jens Langhammer <[email protected]>

* prepare docs

Signed-off-by: Jens Langhammer <[email protected]>

* migrate to new input

Signed-off-by: Jens Langhammer <[email protected]>

* fix tests

Signed-off-by: Jens Langhammer <[email protected]>

---------

Signed-off-by: Jens Langhammer <[email protected]>
# Conflicts:
#	authentik/core/tests/test_transactional_applications_api.py
  • Loading branch information
BeryJu committed Nov 21, 2024
1 parent 11b013d commit fabacc5
Show file tree
Hide file tree
Showing 37 changed files with 687 additions and 199 deletions.
4 changes: 2 additions & 2 deletions authentik/core/tests/test_applications_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from authentik.lib.generators import generate_id
from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import OAuth2Provider
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
from authentik.providers.proxy.models import ProxyProvider
from authentik.providers.saml.models import SAMLProvider

Expand All @@ -24,7 +24,7 @@ def setUp(self) -> None:
self.user = create_test_admin_user()
self.provider = OAuth2Provider.objects.create(
name="test",
redirect_uris="http://some-other-domain",
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://some-other-domain")],
authorization_flow=create_test_flow(),
)
self.allowed: Application = Application.objects.create(
Expand Down
2 changes: 2 additions & 0 deletions authentik/core/tests/test_transactional_applications_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def test_create_transactional(self):
"name": uid,
"authorization_flow": str(create_test_flow().pk),
"invalidation_flow": str(create_test_flow().pk),
"redirect_uris": [],
},
},
)
Expand All @@ -57,6 +58,7 @@ def test_create_transactional_invalid(self):
"name": uid,
"authorization_flow": "",
"invalidation_flow": "",
"redirect_uris": [],
},
},
)
Expand Down
6 changes: 3 additions & 3 deletions authentik/crypto/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id, generate_key
from authentik.providers.oauth2.models import OAuth2Provider
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode


class TestCrypto(APITestCase):
Expand Down Expand Up @@ -274,7 +274,7 @@ def test_used_by(self):
client_id="test",
client_secret=generate_key(),
authorization_flow=create_test_flow(),
redirect_uris="http://localhost",
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=keypair,
)
response = self.client.get(
Expand Down Expand Up @@ -306,7 +306,7 @@ def test_used_by_denied(self):
client_id="test",
client_secret=generate_key(),
authorization_flow=create_test_flow(),
redirect_uris="http://localhost",
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=keypair,
)
response = self.client.get(
Expand Down
34 changes: 31 additions & 3 deletions authentik/providers/oauth2/api/providers.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
"""OAuth2Provider API Views"""

from copy import copy
from re import compile
from re import error as RegexError

from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField
from rest_framework.fields import CharField, ChoiceField
from rest_framework.generics import get_object_or_404
from rest_framework.request import Request
from rest_framework.response import Response
Expand All @@ -20,13 +23,39 @@
from authentik.core.api.utils import PassiveSerializer, PropertyMappingPreviewSerializer
from authentik.core.models import Provider
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.models import (
AccessToken,
OAuth2Provider,
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.rbac.decorators import permission_required


class RedirectURISerializer(PassiveSerializer):
"""A single allowed redirect URI entry"""

matching_mode = ChoiceField(choices=RedirectURIMatchingMode.choices)
url = CharField()


class OAuth2ProviderSerializer(ProviderSerializer):
"""OAuth2Provider Serializer"""

redirect_uris = RedirectURISerializer(many=True, source="_redirect_uris")

def validate_redirect_uris(self, data: list) -> list:
for entry in data:
if entry.get("matching_mode") == RedirectURIMatchingMode.REGEX:
url = entry.get("url")
try:
compile(url)
except RegexError:
raise ValidationError(
_("Invalid Regex Pattern: {url}".format(url=url))
) from None
return data

class Meta:
model = OAuth2Provider
fields = ProviderSerializer.Meta.fields + [
Expand Down Expand Up @@ -79,7 +108,6 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
"refresh_token_validity",
"include_claims_in_id_token",
"signing_key",
"redirect_uris",
"sub_mode",
"property_mappings",
"issuer_mode",
Expand Down
6 changes: 3 additions & 3 deletions authentik/providers/oauth2/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from authentik.events.models import Event, EventAction
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.views import bad_request_message
from authentik.providers.oauth2.models import GrantTypes
from authentik.providers.oauth2.models import GrantTypes, RedirectURI


class OAuth2Error(SentryIgnoredException):
Expand Down Expand Up @@ -46,9 +46,9 @@ class RedirectUriError(OAuth2Error):
)

provided_uri: str
allowed_uris: list[str]
allowed_uris: list[RedirectURI]

def __init__(self, provided_uri: str, allowed_uris: list[str]) -> None:
def __init__(self, provided_uri: str, allowed_uris: list[RedirectURI]) -> None:
super().__init__()
self.provided_uri = provided_uri
self.allowed_uris = allowed_uris
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 5.0.9 on 2024-11-04 12:56
from django.apps.registry import Apps

from django.db.backends.base.schema import BaseDatabaseSchemaEditor

from django.db import migrations, models


def migrate_redirect_uris(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.providers.oauth2.models import RedirectURI, RedirectURIMatchingMode

OAuth2Provider = apps.get_model("authentik_providers_oauth2", "oauth2provider")

db_alias = schema_editor.connection.alias
for provider in OAuth2Provider.objects.using(db_alias).all():
uris = []
for old in provider.old_redirect_uris.split("\n"):
mode = RedirectURIMatchingMode.STRICT
if old == "*" or old == ".*":
mode = RedirectURIMatchingMode.REGEX
uris.append(RedirectURI(mode, url=old))
provider.redirect_uris = uris
provider.save()


class Migration(migrations.Migration):

dependencies = [
("authentik_providers_oauth2", "0023_alter_accesstoken_refreshtoken_use_hash_index"),
]

operations = [
migrations.RenameField(
model_name="oauth2provider",
old_name="redirect_uris",
new_name="old_redirect_uris",
),
migrations.AddField(
model_name="oauth2provider",
name="_redirect_uris",
field=models.JSONField(default=dict, verbose_name="Redirect URIs"),
),
migrations.RunPython(migrate_redirect_uris, lambda *args: ...),
migrations.RemoveField(
model_name="oauth2provider",
name="old_redirect_uris",
),
]
52 changes: 43 additions & 9 deletions authentik/providers/oauth2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import base64
import binascii
import json
from dataclasses import asdict
from dataclasses import asdict, dataclass
from functools import cached_property
from hashlib import sha256
from typing import Any
Expand All @@ -12,6 +12,7 @@
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
from dacite import Config
from dacite.core import from_dict
from django.contrib.postgres.indexes import HashIndex
from django.db import models
Expand Down Expand Up @@ -77,11 +78,25 @@ class IssuerMode(models.TextChoices):
"""Configure how the `iss` field is created."""

GLOBAL = "global", _("Same identifier is used for all providers")
PER_PROVIDER = "per_provider", _(
"Each provider has a different issuer, based on the application slug."
PER_PROVIDER = (
"per_provider",
_("Each provider has a different issuer, based on the application slug."),
)


class RedirectURIMatchingMode(models.TextChoices):
STRICT = "strict", _("Strict URL comparison")
REGEX = "regex", _("Regular Expression URL matching")


@dataclass
class RedirectURI:
"""A single redirect URI entry"""

matching_mode: RedirectURIMatchingMode
url: str


class ResponseTypes(models.TextChoices):
"""Response Type required by the client."""

Expand Down Expand Up @@ -156,11 +171,9 @@ class OAuth2Provider(WebfingerProvider, Provider):
verbose_name=_("Client Secret"),
default=generate_client_secret,
)
redirect_uris = models.TextField(
default="",
blank=True,
_redirect_uris = models.JSONField(
default=dict,
verbose_name=_("Redirect URIs"),
help_text=_("Enter each URI on a new line."),
)

include_claims_in_id_token = models.BooleanField(
Expand Down Expand Up @@ -271,12 +284,33 @@ def get_issuer(self, request: HttpRequest) -> str | None:
except Provider.application.RelatedObjectDoesNotExist:
return None

@property
def redirect_uris(self) -> list[RedirectURI]:
uris = []
for entry in self._redirect_uris:
uris.append(
from_dict(
RedirectURI,
entry,
config=Config(type_hooks={RedirectURIMatchingMode: RedirectURIMatchingMode}),
)
)
return uris

@redirect_uris.setter
def redirect_uris(self, value: list[RedirectURI]):
cleansed = []
for entry in value:
cleansed.append(asdict(entry))
self._redirect_uris = cleansed

@property
def launch_url(self) -> str | None:
"""Guess launch_url based on first redirect_uri"""
if self.redirect_uris == "":
redirects = self.redirect_uris
if len(redirects) < 1:
return None
main_url = self.redirect_uris.split("\n", maxsplit=1)[0]
main_url = redirects[0].url
try:
launch_url = urlparse(main_url)._replace(path="")
return urlunparse(launch_url)
Expand Down
36 changes: 31 additions & 5 deletions authentik/providers/oauth2/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import (
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ScopeMapping,
)


class TestAPI(APITestCase):
Expand All @@ -21,7 +27,7 @@ def setUp(self) -> None:
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
name="test",
authorization_flow=create_test_flow(),
redirect_uris="http://testserver",
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
)
self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
Expand Down Expand Up @@ -50,9 +56,29 @@ def test_setup_urls(self):
@skipUnless(version_info >= (3, 11, 4), "This behaviour is only Python 3.11.4 and up")
def test_launch_url(self):
"""Test launch_url"""
self.provider.redirect_uris = (
"https://[\\d\\w]+.pr.test.goauthentik.io/source/oauth/callback/authentik/\n"
)
self.provider.redirect_uris = [
RedirectURI(
RedirectURIMatchingMode.REGEX,
"https://[\\d\\w]+.pr.test.goauthentik.io/source/oauth/callback/authentik/",
),
]
self.provider.save()
self.provider.refresh_from_db()
self.assertIsNone(self.provider.launch_url)

def test_validate_redirect_uris(self):
"""Test redirect_uris API"""
response = self.client.post(
reverse("authentik_api:oauth2provider-list"),
data={
"name": generate_id(),
"authorization_flow": create_test_flow().pk,
"invalidation_flow": create_test_flow().pk,
"redirect_uris": [
{"matching_mode": "strict", "url": "http://goauthentik.io"},
{"matching_mode": "regex", "url": "**"},
],
},
)
self.assertJSONEqual(response.content, {"redirect_uris": ["Invalid Regex Pattern: **"]})
self.assertEqual(response.status_code, 400)
Loading

0 comments on commit fabacc5

Please sign in to comment.