Compare commits
15 Commits
version/20
...
version-20
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c03cfa906 | |||
| e2dbab5bca | |||
| 3a6c42fefb | |||
| 6bb180f94e | |||
| 03dea17519 | |||
| 49d83f11bd | |||
| 5f0af81e4d | |||
| 63591e1710 | |||
| 6503a7b048 | |||
| 7e244e0679 | |||
| c1998bf3f2 | |||
| 83372618a8 | |||
| 89a876e141 | |||
| 26d6e8bc5c | |||
| d9dc373170 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2024.2.2
|
||||
current_version = 2024.2.4
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
from os import environ
|
||||
from typing import Optional
|
||||
|
||||
__version__ = "2024.2.2"
|
||||
__version__ = "2024.2.4"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@ -68,7 +68,11 @@ class ConfigView(APIView):
|
||||
"""Get all capabilities this server instance supports"""
|
||||
caps = []
|
||||
deb_test = settings.DEBUG or settings.TEST
|
||||
if Path(settings.MEDIA_ROOT).is_mount() or deb_test:
|
||||
if (
|
||||
CONFIG.get("storage.media.backend", "file") == "s3"
|
||||
or Path(settings.STORAGES["default"]["OPTIONS"]["location"]).is_mount()
|
||||
or deb_test
|
||||
):
|
||||
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
||||
for processor in get_context_processors():
|
||||
if cap := processor.capability():
|
||||
|
||||
@ -20,7 +20,7 @@ from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.users import UserSerializer
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents
|
||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import model_to_dict
|
||||
from authentik.rbac.decorators import permission_required
|
||||
@ -36,6 +36,13 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
|
||||
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
|
||||
self.fields["key"] = CharField(required=False)
|
||||
|
||||
def validate_user(self, user: User):
|
||||
"""Ensure user of token cannot be changed"""
|
||||
if self.instance and self.instance.user_id:
|
||||
if user.pk != self.instance.user_id:
|
||||
raise ValidationError("User cannot be changed")
|
||||
return user
|
||||
|
||||
def validate(self, attrs: dict[Any, str]) -> dict[Any, str]:
|
||||
"""Ensure only API or App password tokens are created."""
|
||||
request: Request = self.context.get("request")
|
||||
|
||||
@ -611,7 +611,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
email_stage: EmailStage = stages.first()
|
||||
message = TemplateEmailMessage(
|
||||
subject=_(email_stage.subject),
|
||||
to=[for_user.email],
|
||||
to=[(for_user.name, for_user.email)],
|
||||
template_name=email_stage.template,
|
||||
language=for_user.locale(request),
|
||||
template_context={
|
||||
|
||||
@ -7,8 +7,8 @@ from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.api.tokens import TokenSerializer
|
||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_user
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ class TestTokenAPI(APITestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = User.objects.create(username="testuser")
|
||||
self.user = create_test_user()
|
||||
self.admin = create_test_admin_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
@ -76,6 +76,24 @@ class TestTokenAPI(APITestCase):
|
||||
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
||||
self.assertEqual(token.expiring, False)
|
||||
|
||||
def test_token_change_user(self):
|
||||
"""Test creating a token and then changing the user"""
|
||||
ident = generate_id()
|
||||
response = self.client.post(reverse("authentik_api:token-list"), {"identifier": ident})
|
||||
self.assertEqual(response.status_code, 201)
|
||||
token = Token.objects.get(identifier=ident)
|
||||
self.assertEqual(token.user, self.user)
|
||||
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
||||
self.assertEqual(token.expiring, True)
|
||||
self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token))
|
||||
response = self.client.put(
|
||||
reverse("authentik_api:token-detail", kwargs={"identifier": ident}),
|
||||
data={"identifier": "user_token_poc_v3", "intent": "api", "user": self.admin.pk},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
token.refresh_from_db()
|
||||
self.assertEqual(token.user, self.user)
|
||||
|
||||
def test_list(self):
|
||||
"""Test Token List (Test normal authentication)"""
|
||||
Token.objects.all().delete()
|
||||
|
||||
@ -31,7 +31,7 @@ class EnterpriseRequiredMixin:
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
"""Check that a valid license exists"""
|
||||
if not LicenseKey.cached_summary().valid:
|
||||
if not LicenseKey.cached_summary().has_license:
|
||||
raise ValidationError(_("Enterprise is required to create/update this object."))
|
||||
return super().validate(attrs)
|
||||
|
||||
|
||||
@ -11,7 +11,6 @@ from django.db.models.expressions import BaseExpression, Combinable
|
||||
from django.db.models.signals import post_init
|
||||
from django.http import HttpRequest
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.events.middleware import AuditMiddleware, should_log_model
|
||||
from authentik.events.utils import cleanse_dict, sanitize_item
|
||||
|
||||
@ -28,13 +27,10 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
|
||||
super().connect(request)
|
||||
if not self.enabled:
|
||||
return
|
||||
user = getattr(request, "user", self.anonymous_user)
|
||||
if not user.is_authenticated:
|
||||
user = self.anonymous_user
|
||||
if not hasattr(request, "request_id"):
|
||||
return
|
||||
post_init.connect(
|
||||
partial(self.post_init_handler, user=user, request=request),
|
||||
partial(self.post_init_handler, request=request),
|
||||
dispatch_uid=request.request_id,
|
||||
weak=False,
|
||||
)
|
||||
@ -76,7 +72,7 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
|
||||
diff[key] = {"previous_value": value, "new_value": after.get(key)}
|
||||
return sanitize_item(diff)
|
||||
|
||||
def post_init_handler(self, user: User, request: HttpRequest, sender, instance: Model, **_):
|
||||
def post_init_handler(self, request: HttpRequest, sender, instance: Model, **_):
|
||||
"""post_init django model handler"""
|
||||
if not should_log_model(instance):
|
||||
return
|
||||
@ -91,7 +87,6 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
|
||||
# pylint: disable=too-many-arguments
|
||||
def post_save_handler(
|
||||
self,
|
||||
user: User,
|
||||
request: HttpRequest,
|
||||
sender,
|
||||
instance: Model,
|
||||
@ -113,6 +108,4 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
|
||||
for field_set in ignored_field_sets:
|
||||
if set(diff.keys()) == set(field_set):
|
||||
return None
|
||||
return super().post_save_handler(
|
||||
user, request, sender, instance, created, thread_kwargs, **_
|
||||
)
|
||||
return super().post_save_handler(request, sender, instance, created, thread_kwargs, **_)
|
||||
|
||||
@ -6,13 +6,13 @@ from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerPermissions
|
||||
from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
|
||||
from authentik.core.api.groups import GroupMemberSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
||||
from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer
|
||||
from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
|
||||
from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint
|
||||
from authentik.enterprise.providers.rac.models import ConnectionToken
|
||||
|
||||
|
||||
class ConnectionTokenSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
@ -23,7 +23,7 @@ class ConnectionTokenSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
user = GroupMemberSerializer(source="session.user", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Endpoint
|
||||
model = ConnectionToken
|
||||
fields = [
|
||||
"pk",
|
||||
"provider",
|
||||
@ -49,5 +49,5 @@ class ConnectionTokenViewSet(
|
||||
filterset_fields = ["endpoint", "session__user", "provider"]
|
||||
search_fields = ["endpoint__name", "provider__name"]
|
||||
ordering = ["endpoint__name", "provider__name"]
|
||||
permission_classes = [OwnerPermissions]
|
||||
permission_classes = [OwnerSuperuserPermissions]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
|
||||
@ -82,26 +82,29 @@ class AuditMiddleware:
|
||||
|
||||
self.anonymous_user = get_anonymous_user()
|
||||
|
||||
def get_user(self, request: HttpRequest) -> User:
|
||||
user = getattr(request, "user", self.anonymous_user)
|
||||
if not user.is_authenticated:
|
||||
return self.anonymous_user
|
||||
return user
|
||||
|
||||
def connect(self, request: HttpRequest):
|
||||
"""Connect signal for automatic logging"""
|
||||
self._ensure_fallback_user()
|
||||
user = getattr(request, "user", self.anonymous_user)
|
||||
if not user.is_authenticated:
|
||||
user = self.anonymous_user
|
||||
if not hasattr(request, "request_id"):
|
||||
return
|
||||
post_save.connect(
|
||||
partial(self.post_save_handler, user=user, request=request),
|
||||
partial(self.post_save_handler, request=request),
|
||||
dispatch_uid=request.request_id,
|
||||
weak=False,
|
||||
)
|
||||
pre_delete.connect(
|
||||
partial(self.pre_delete_handler, user=user, request=request),
|
||||
partial(self.pre_delete_handler, request=request),
|
||||
dispatch_uid=request.request_id,
|
||||
weak=False,
|
||||
)
|
||||
m2m_changed.connect(
|
||||
partial(self.m2m_changed_handler, user=user, request=request),
|
||||
partial(self.m2m_changed_handler, request=request),
|
||||
dispatch_uid=request.request_id,
|
||||
weak=False,
|
||||
)
|
||||
@ -147,7 +150,6 @@ class AuditMiddleware:
|
||||
# pylint: disable=too-many-arguments
|
||||
def post_save_handler(
|
||||
self,
|
||||
user: User,
|
||||
request: HttpRequest,
|
||||
sender,
|
||||
instance: Model,
|
||||
@ -158,16 +160,18 @@ class AuditMiddleware:
|
||||
"""Signal handler for all object's post_save"""
|
||||
if not should_log_model(instance):
|
||||
return
|
||||
user = self.get_user(request)
|
||||
|
||||
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
|
||||
thread = EventNewThread(action, request, user=user, model=model_to_dict(instance))
|
||||
thread.kwargs.update(thread_kwargs or {})
|
||||
thread.run()
|
||||
|
||||
def pre_delete_handler(self, user: User, request: HttpRequest, sender, instance: Model, **_):
|
||||
def pre_delete_handler(self, request: HttpRequest, sender, instance: Model, **_):
|
||||
"""Signal handler for all object's pre_delete"""
|
||||
if not should_log_model(instance): # pragma: no cover
|
||||
return
|
||||
user = self.get_user(request)
|
||||
|
||||
EventNewThread(
|
||||
EventAction.MODEL_DELETED,
|
||||
@ -176,14 +180,13 @@ class AuditMiddleware:
|
||||
model=model_to_dict(instance),
|
||||
).run()
|
||||
|
||||
def m2m_changed_handler(
|
||||
self, user: User, request: HttpRequest, sender, instance: Model, action: str, **_
|
||||
):
|
||||
def m2m_changed_handler(self, request: HttpRequest, sender, instance: Model, action: str, **_):
|
||||
"""Signal handler for all object's m2m_changed"""
|
||||
if action not in ["pre_add", "pre_remove", "post_clear"]:
|
||||
return
|
||||
if not should_log_m2m(instance):
|
||||
return
|
||||
user = self.get_user(request)
|
||||
|
||||
EventNewThread(
|
||||
EventAction.MODEL_UPDATED,
|
||||
|
||||
@ -451,6 +451,13 @@ class NotificationTransport(SerializerModel):
|
||||
|
||||
def send_email(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification via global email configuration"""
|
||||
if notification.user.email.strip() == "":
|
||||
LOGGER.info(
|
||||
"Discarding notification as user has no email address",
|
||||
user=notification.user,
|
||||
notification=notification,
|
||||
)
|
||||
return None
|
||||
subject_prefix = "authentik Notification: "
|
||||
context = {
|
||||
"key_value": {
|
||||
@ -480,7 +487,7 @@ class NotificationTransport(SerializerModel):
|
||||
}
|
||||
mail = TemplateEmailMessage(
|
||||
subject=subject_prefix + context["title"],
|
||||
to=[f"{notification.user.name} <{notification.user.email}>"],
|
||||
to=[(notification.user.name, notification.user.email)],
|
||||
language=notification.user.locale(),
|
||||
template_name="email/event_notification.html",
|
||||
template_context=context,
|
||||
|
||||
@ -3,9 +3,10 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.models import Application, Token, TokenIntents
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class TestEventsMiddleware(APITestCase):
|
||||
@ -47,3 +48,30 @@ class TestEventsMiddleware(APITestCase):
|
||||
context__model__name="test-delete",
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_create_with_api(self):
|
||||
"""Test model creation event (with API token auth)"""
|
||||
self.client.logout()
|
||||
token = Token.objects.create(user=self.user, intent=TokenIntents.INTENT_API, expiring=False)
|
||||
uid = generate_id()
|
||||
self.client.post(
|
||||
reverse("authentik_api:application-list"),
|
||||
data={"name": uid, "slug": uid},
|
||||
HTTP_AUTHORIZATION=f"Bearer {token.key}",
|
||||
)
|
||||
self.assertTrue(Application.objects.filter(name=uid).exists())
|
||||
event = Event.objects.filter(
|
||||
action=EventAction.MODEL_CREATED,
|
||||
context__model__model_name="application",
|
||||
context__model__app="authentik_core",
|
||||
context__model__name=uid,
|
||||
).first()
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual(
|
||||
event.user,
|
||||
{
|
||||
"pk": self.user.pk,
|
||||
"email": self.user.email,
|
||||
"username": self.user.username,
|
||||
},
|
||||
)
|
||||
|
||||
@ -4,9 +4,10 @@ from urllib.parse import urlencode
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.models import Application, Group
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
|
||||
@ -77,3 +78,23 @@ class TesOAuth2DeviceInit(OAuthTestCase):
|
||||
+ "?"
|
||||
+ urlencode({QS_KEY_CODE: token.user_code}),
|
||||
)
|
||||
|
||||
def test_device_init_denied(self):
|
||||
"""Test device init"""
|
||||
group = Group.objects.create(name="foo")
|
||||
PolicyBinding.objects.create(
|
||||
group=group,
|
||||
target=self.application,
|
||||
order=0,
|
||||
)
|
||||
token = DeviceToken.objects.create(
|
||||
user_code="foo",
|
||||
provider=self.provider,
|
||||
)
|
||||
res = self.client.get(
|
||||
reverse("authentik_providers_oauth2_root:device-login")
|
||||
+ "?"
|
||||
+ urlencode({QS_KEY_CODE: token.user_code})
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertIn(b"Permission denied", res.content)
|
||||
|
||||
@ -12,10 +12,11 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
|
||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE, get_application
|
||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -38,7 +39,9 @@ class DeviceView(View):
|
||||
).first()
|
||||
if not provider:
|
||||
return HttpResponseBadRequest()
|
||||
if not get_application(provider):
|
||||
try:
|
||||
_ = provider.application
|
||||
except Application.DoesNotExist:
|
||||
return HttpResponseBadRequest()
|
||||
self.provider = provider
|
||||
self.client_id = client_id
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
"""Device flow views"""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views import View
|
||||
from rest_framework.exceptions import ErrorDetail
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField, IntegerField
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
@ -18,6 +17,7 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO,
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
|
||||
from authentik.providers.oauth2.views.device_finish import (
|
||||
PLAN_CONTEXT_DEVICE,
|
||||
@ -44,48 +44,52 @@ def get_application(provider: OAuth2Provider) -> Optional[Application]:
|
||||
return None
|
||||
|
||||
|
||||
def validate_code(code: int, request: HttpRequest) -> Optional[HttpResponse]:
|
||||
"""Validate user token"""
|
||||
token = DeviceToken.objects.filter(
|
||||
user_code=code,
|
||||
).first()
|
||||
if not token:
|
||||
return None
|
||||
class CodeValidatorView(PolicyAccessView):
|
||||
"""Helper to validate frontside token"""
|
||||
|
||||
app = get_application(token.provider)
|
||||
if not app:
|
||||
return None
|
||||
def __init__(self, code: str, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.code = code
|
||||
|
||||
scope_descriptions = UserInfoView().get_scope_descriptions(token.scope, token.provider)
|
||||
planner = FlowPlanner(token.provider.authorization_flow)
|
||||
planner.allow_empty_flows = True
|
||||
try:
|
||||
plan = planner.plan(
|
||||
request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: app,
|
||||
# OAuth2 related params
|
||||
PLAN_CONTEXT_DEVICE: token,
|
||||
# Consent related params
|
||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||
% {"application": app.name},
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
|
||||
},
|
||||
def resolve_provider_application(self):
|
||||
self.token = DeviceToken.objects.filter(user_code=self.code).first()
|
||||
if not self.token:
|
||||
raise Application.DoesNotExist
|
||||
self.provider = self.token.provider
|
||||
self.application = self.token.provider.application
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs):
|
||||
scope_descriptions = UserInfoView().get_scope_descriptions(self.token.scope, self.provider)
|
||||
planner = FlowPlanner(self.provider.authorization_flow)
|
||||
planner.allow_empty_flows = True
|
||||
planner.use_cache = False
|
||||
try:
|
||||
plan = planner.plan(
|
||||
request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: self.application,
|
||||
# OAuth2 related params
|
||||
PLAN_CONTEXT_DEVICE: self.token,
|
||||
# Consent related params
|
||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||
% {"application": self.application.name},
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
|
||||
},
|
||||
)
|
||||
except FlowNonApplicableException:
|
||||
LOGGER.warning("Flow not applicable to user")
|
||||
return None
|
||||
plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
request.GET,
|
||||
flow_slug=self.token.provider.authorization_flow.slug,
|
||||
)
|
||||
except FlowNonApplicableException:
|
||||
LOGGER.warning("Flow not applicable to user")
|
||||
return None
|
||||
plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
request.GET,
|
||||
flow_slug=token.provider.authorization_flow.slug,
|
||||
)
|
||||
|
||||
|
||||
class DeviceEntryView(View):
|
||||
class DeviceEntryView(PolicyAccessView):
|
||||
"""View used to initiate the device-code flow, url entered by endusers"""
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
@ -95,7 +99,9 @@ class DeviceEntryView(View):
|
||||
LOGGER.info("Brand has no device code flow configured", brand=brand)
|
||||
return HttpResponse(status=404)
|
||||
if QS_KEY_CODE in request.GET:
|
||||
validation = validate_code(request.GET[QS_KEY_CODE], request)
|
||||
validation = CodeValidatorView(request.GET[QS_KEY_CODE], request=request).dispatch(
|
||||
request
|
||||
)
|
||||
if validation:
|
||||
return validation
|
||||
LOGGER.info("Got code from query parameter but no matching token found")
|
||||
@ -130,6 +136,13 @@ class OAuthDeviceCodeChallengeResponse(ChallengeResponse):
|
||||
code = IntegerField()
|
||||
component = CharField(default="ak-provider-oauth2-device-code")
|
||||
|
||||
def validate_code(self, code: int) -> HttpResponse | None:
|
||||
"""Validate code and save the returned http response"""
|
||||
response = CodeValidatorView(code, request=self.stage.request).dispatch(self.stage.request)
|
||||
if not response:
|
||||
raise ValidationError(_("Invalid code"), "invalid")
|
||||
return response
|
||||
|
||||
|
||||
class OAuthDeviceCodeStage(ChallengeStageView):
|
||||
"""Flow challenge for users to enter device codes"""
|
||||
@ -145,12 +158,4 @@ class OAuthDeviceCodeStage(ChallengeStageView):
|
||||
)
|
||||
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
code = response.validated_data["code"]
|
||||
validation = validate_code(code, self.request)
|
||||
if not validation:
|
||||
response._errors.setdefault("code", [])
|
||||
response._errors["code"].append(ErrorDetail(_("Invalid code"), "invalid"))
|
||||
return self.challenge_invalid(response)
|
||||
# Run cancel to cleanup the current flow
|
||||
self.executor.cancel()
|
||||
return validation
|
||||
return response.validated_data["code"]
|
||||
|
||||
@ -30,7 +30,7 @@ class Command(TenantCommand):
|
||||
delete_stage = True
|
||||
message = TemplateEmailMessage(
|
||||
subject="authentik Test-Email",
|
||||
to=[options["to"]],
|
||||
to=[("", options["to"])],
|
||||
template_name="email/setup.html",
|
||||
template_context={},
|
||||
)
|
||||
|
||||
@ -111,7 +111,7 @@ class EmailStageView(ChallengeStageView):
|
||||
try:
|
||||
message = TemplateEmailMessage(
|
||||
subject=_(current_stage.subject),
|
||||
to=[f"{pending_user.name} <{email}>"],
|
||||
to=[(pending_user.name, email)],
|
||||
language=pending_user.locale(self.request),
|
||||
template_name=current_stage.template,
|
||||
template_context={
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{% load i18n %}{% translate "Welcome!" %}
|
||||
{% load i18n %}{% autoescape off %}{% translate "Welcome!" %}
|
||||
|
||||
{% translate "We're excited to have you get started. First, you need to confirm your account. Just open the link below." %}
|
||||
|
||||
@ -6,3 +6,4 @@
|
||||
|
||||
--
|
||||
Powered by goauthentik.io.
|
||||
{% endautoescape %}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{% load authentik_stages_email %}{% load i18n %}{% translate "Dear authentik user," %}
|
||||
{% load authentik_stages_email %}{% load i18n %}{% autoescape off %}{% translate "Dear authentik user," %}
|
||||
|
||||
{% translate "The following notification was created:" %}
|
||||
|
||||
@ -16,3 +16,4 @@ This email was sent from the notification transport {{ name }}.
|
||||
|
||||
--
|
||||
Powered by goauthentik.io.
|
||||
{% endautoescape %}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{% load i18n %}{% load humanize %}{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %}
|
||||
{% load i18n %}{% load humanize %}{% autoescape off %}{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %}
|
||||
|
||||
{% blocktrans %}
|
||||
You recently requested to change your password for your authentik account. Use the link below to set a new password.
|
||||
@ -10,3 +10,4 @@ If you did not request a password change, please ignore this Email. The link abo
|
||||
|
||||
--
|
||||
Powered by goauthentik.io.
|
||||
{% endautoescape %}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
{% load i18n %}authentik Test-Email
|
||||
{% load i18n %}{% autoescape off %}authentik Test-Email
|
||||
{% blocktrans %}
|
||||
This is a test email to inform you, that you've successfully configured authentik emails.
|
||||
{% endblocktrans %}
|
||||
|
||||
--
|
||||
Powered by goauthentik.io.
|
||||
{% endautoescape %}
|
||||
|
||||
@ -39,6 +39,7 @@ class TestEmailStageSending(FlowTestCase):
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
Event.objects.filter(action=EventAction.EMAIL_SENT).delete()
|
||||
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
with patch(
|
||||
|
||||
@ -9,6 +9,7 @@ from unittest.mock import PropertyMock, patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.locmem import EmailBackend
|
||||
from django.core.mail.message import sanitize_address
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
@ -19,6 +20,7 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.stages.email.models import EmailStage, get_template_choices
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
|
||||
def get_templates_setting(temp_dir: str) -> dict[str, Any]:
|
||||
@ -89,3 +91,12 @@ class TestEmailStageTemplates(FlowTestCase):
|
||||
event.context["message"], "Exception occurred while rendering E-mail template"
|
||||
)
|
||||
self.assertEqual(event.context["template"], "invalid.html")
|
||||
|
||||
def test_template_address(self):
|
||||
"""Test addresses are correctly parsed"""
|
||||
message = TemplateEmailMessage(to=[("foo@bar.baz", "foo@bar.baz")])
|
||||
[sanitize_address(addr, "utf-8") for addr in message.recipients()]
|
||||
self.assertEqual(message.recipients(), ["foo@bar.baz"])
|
||||
message = TemplateEmailMessage(to=[("some-name", "foo@bar.baz")])
|
||||
[sanitize_address(addr, "utf-8") for addr in message.recipients()]
|
||||
self.assertEqual(message.recipients(), ["some-name <foo@bar.baz>"])
|
||||
|
||||
@ -25,8 +25,19 @@ def logo_data() -> MIMEImage:
|
||||
class TemplateEmailMessage(EmailMultiAlternatives):
|
||||
"""Wrapper around EmailMultiAlternatives with integrated template rendering"""
|
||||
|
||||
def __init__(self, template_name=None, template_context=None, language="", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
def __init__(
|
||||
self, to: list[tuple[str]], template_name=None, template_context=None, language="", **kwargs
|
||||
):
|
||||
sanitized_to = []
|
||||
# Ensure that all recipients are valid
|
||||
for recipient_name, recipient_email in to:
|
||||
if recipient_name == recipient_email:
|
||||
sanitized_to.append(recipient_email)
|
||||
else:
|
||||
sanitized_to.append(f"{recipient_name} <{recipient_email}>")
|
||||
super().__init__(to=sanitized_to, **kwargs)
|
||||
if not template_name:
|
||||
return
|
||||
with translation.override(language):
|
||||
html_content = render_to_string(template_name, template_context)
|
||||
try:
|
||||
|
||||
@ -12,6 +12,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from authentik.core.middleware import SESSION_KEY_IMPERSONATE_USER
|
||||
from authentik.core.models import USER_ATTRIBUTE_SOURCES, User, UserSourceConnection, UserTypes
|
||||
from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION
|
||||
from authentik.events.utils import sanitize_item
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.flows.views.executor import FlowExecutorView
|
||||
@ -47,7 +48,7 @@ class UserWriteStageView(StageView):
|
||||
# this is just a sanity check to ensure that is removed
|
||||
if parts[0] == "attributes":
|
||||
parts = parts[1:]
|
||||
set_path_in_dict(user.attributes, ".".join(parts), value)
|
||||
set_path_in_dict(user.attributes, ".".join(parts), sanitize_item(value))
|
||||
|
||||
def ensure_user(self) -> tuple[Optional[User], bool]:
|
||||
"""Ensure a user exists"""
|
||||
|
||||
@ -87,11 +87,6 @@ class Tenant(TenantMixin, SerializerModel):
|
||||
raise IntegrityError("Cannot create schema named template")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
if self.schema_name in ("public", "template"):
|
||||
raise IntegrityError("Cannot delete schema public or template")
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def serializer(self) -> Serializer:
|
||||
from authentik.tenants.api.tenants import TenantSerializer
|
||||
|
||||
14
authentik/tenants/signals.py
Normal file
14
authentik/tenants/signals.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""authentik tenants signals"""
|
||||
|
||||
from django.db import models
|
||||
from django.db.models.signals import pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django_tenants.utils import get_public_schema_name
|
||||
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Tenant)
|
||||
def tenants_ensure_no_default_delete(sender, instance: Tenant, **kwargs):
|
||||
if instance.schema_name == get_public_schema_name():
|
||||
raise models.ProtectedError("Cannot delete schema public", instance)
|
||||
@ -32,7 +32,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.2.2}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.2.4}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -53,7 +53,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.2.2}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.2.4}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
||||
@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2024.2.2"
|
||||
const VERSION = "2024.2.4"
|
||||
|
||||
@ -64,6 +64,7 @@ def release_lock(cursor: Cursor):
|
||||
"""Release database lock"""
|
||||
if not LOCKED:
|
||||
return
|
||||
LOGGER.info("releasing database lock")
|
||||
cursor.execute("SELECT pg_advisory_unlock(%s)", (ADV_LOCK_UID,))
|
||||
|
||||
|
||||
|
||||
12
lifecycle/system_migrations/template_schema.py
Normal file
12
lifecycle/system_migrations/template_schema.py
Normal file
@ -0,0 +1,12 @@
|
||||
from lifecycle.migrate import BaseMigration
|
||||
|
||||
|
||||
class Migration(BaseMigration):
|
||||
def needs_migration(self) -> bool:
|
||||
self.cur.execute(
|
||||
"SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'template';"
|
||||
)
|
||||
return not bool(self.cur.rowcount)
|
||||
|
||||
def run(self):
|
||||
self.cur.execute("CREATE SCHEMA IF NOT EXISTS template; COMMIT;")
|
||||
@ -113,7 +113,7 @@ filterwarnings = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "authentik"
|
||||
version = "2024.2.2"
|
||||
version = "2024.2.4"
|
||||
description = ""
|
||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2024.2.2
|
||||
version: 2024.2.4
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
|
||||
@ -13,7 +13,7 @@ import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
|
||||
|
||||
import { ConnectionToken, Endpoint, RACProvider, RacApi } from "@goauthentik/api";
|
||||
import { ConnectionToken, RACProvider, RacApi } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-rac-connection-token-list")
|
||||
export class ConnectionTokenListPage extends Table<ConnectionToken> {
|
||||
@ -53,18 +53,18 @@ export class ConnectionTokenListPage extends Table<ConnectionToken> {
|
||||
return html`<ak-forms-delete-bulk
|
||||
objectLabel=${msg("Connection Token(s)")}
|
||||
.objects=${this.selectedElements}
|
||||
.metadata=${(item: Endpoint) => {
|
||||
.metadata=${(item: ConnectionToken) => {
|
||||
return [
|
||||
{ key: msg("Name"), value: item.name },
|
||||
{ key: msg("Host"), value: item.host },
|
||||
{ key: msg("Endpoint"), value: item.endpointObj.name },
|
||||
{ key: msg("User"), value: item.user.username },
|
||||
];
|
||||
}}
|
||||
.usedBy=${(item: Endpoint) => {
|
||||
.usedBy=${(item: ConnectionToken) => {
|
||||
return new RacApi(DEFAULT_CONFIG).racConnectionTokensUsedByList({
|
||||
connectionTokenUuid: item.pk,
|
||||
});
|
||||
}}
|
||||
.delete=${(item: Endpoint) => {
|
||||
.delete=${(item: ConnectionToken) => {
|
||||
return new RacApi(DEFAULT_CONFIG).racConnectionTokensDestroy({
|
||||
connectionTokenUuid: item.pk,
|
||||
});
|
||||
|
||||
@ -87,7 +87,11 @@ export class RACProviderViewPage extends AKElement {
|
||||
<section slot="page-overview" data-tab-title="${msg("Overview")}">
|
||||
${this.renderTabOverview()}
|
||||
</section>
|
||||
<section slot="page-connections" data-tab-title="${msg("Connections")}">
|
||||
<section
|
||||
slot="page-connections"
|
||||
data-tab-title="${msg("Connections")}"
|
||||
class="pf-c-page__main-section pf-m-no-padding-mobile"
|
||||
>
|
||||
<div class="pf-c-card">
|
||||
<div class="pf-c-card__body">
|
||||
<ak-rac-connection-token-list
|
||||
|
||||
@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
|
||||
export const ERROR_CLASS = "pf-m-danger";
|
||||
export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||
export const CURRENT_CLASS = "pf-m-current";
|
||||
export const VERSION = "2024.2.2";
|
||||
export const VERSION = "2024.2.4";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
|
||||
@ -180,7 +180,7 @@ export class UserSettingsFlowExecutor
|
||||
`authentik/user/flows: unsupported stage type ${this.challenge.component}`,
|
||||
);
|
||||
return html`
|
||||
<a href="/if/flow/${this.flowSlug}" class="pf-c-button pf-m-primary">
|
||||
<a href="/if/flow/${this.flowSlug}/" class="pf-c-button pf-m-primary">
|
||||
${msg("Open settings")}
|
||||
</a>
|
||||
`;
|
||||
|
||||
@ -269,59 +269,6 @@ Disable the inbuilt update-checker. Defaults to `false`.
|
||||
- Kubeconfig
|
||||
- Existence of a docker socket
|
||||
|
||||
### `AUTHENTIK_AVATARS`
|
||||
|
||||
Configure how authentik should show avatars for users. Following values can be set:
|
||||
|
||||
Default: `gravatar,initials`
|
||||
|
||||
- `none`: Disables per-user avatars and just shows a 1x1 pixel transparent picture
|
||||
- `gravatar`: Uses gravatar with the user's email address
|
||||
- `initials`: Generated avatars based on the user's name
|
||||
- Any URL: If you want to use images hosted on another server, you can set any URL.
|
||||
|
||||
Additionally, these placeholders can be used:
|
||||
|
||||
- `%(username)s`: The user's username
|
||||
- `%(mail_hash)s`: The email address, md5 hashed
|
||||
- `%(upn)s`: The user's UPN, if set (otherwise an empty string)
|
||||
|
||||
Starting with authentik 2022.8, you can also use an attribute path like `attributes.something.avatar`, which can be used in combination with the file field to allow users to upload custom avatars for themselves.
|
||||
|
||||
Starting with authentik 2023.2, multiple modes can be set, and authentik will fallback to the next mode when no avatar could be found. For example, setting this to `gravatar,initials` will attempt to get an avatar from Gravatar, and if the user has not configured on there, it will fallback to a generated avatar.
|
||||
|
||||
### `AUTHENTIK_DEFAULT_USER_CHANGE_NAME`
|
||||
|
||||
:::info
|
||||
Requires authentik 2021.12.5
|
||||
:::
|
||||
|
||||
Enable the ability for users to change their name, defaults to `true`.
|
||||
|
||||
### `AUTHENTIK_DEFAULT_USER_CHANGE_EMAIL`
|
||||
|
||||
:::info
|
||||
Requires authentik 2021.12.1
|
||||
:::
|
||||
|
||||
Enable the ability for users to change their Email address, defaults to `false`.
|
||||
|
||||
### `AUTHENTIK_DEFAULT_USER_CHANGE_USERNAME`
|
||||
|
||||
:::info
|
||||
Requires authentik 2021.12.1
|
||||
:::
|
||||
|
||||
Enable the ability for users to change their Usernames, defaults to `false`.
|
||||
|
||||
### `AUTHENTIK_GDPR_COMPLIANCE`
|
||||
|
||||
:::info
|
||||
Requires authentik 2021.12.1
|
||||
:::
|
||||
|
||||
When enabled, all the events caused by a user will be deleted upon the user's deletion. Defaults to `true`.
|
||||
|
||||
### `AUTHENTIK_DEFAULT_TOKEN_LENGTH`
|
||||
|
||||
:::info
|
||||
@ -330,28 +277,6 @@ Requires authentik 2022.4.1
|
||||
|
||||
Configure the length of generated tokens. Defaults to 60.
|
||||
|
||||
### `AUTHENTIK_IMPERSONATION`
|
||||
|
||||
:::info
|
||||
Requires authentik 2022.4.2
|
||||
:::
|
||||
|
||||
Globally enable/disable impersonation. Defaults to `true`.
|
||||
|
||||
### `AUTHENTIK_FOOTER_LINKS`
|
||||
|
||||
:::info
|
||||
Requires authentik 2021.12.1
|
||||
:::
|
||||
|
||||
This option configures the footer links on the flow executor pages.
|
||||
|
||||
The setting can be used as follows:
|
||||
|
||||
```
|
||||
AUTHENTIK_FOOTER_LINKS='[{"name": "Link Name","href":"https://goauthentik.io"}]'
|
||||
```
|
||||
|
||||
### `AUTHENTIK_LDAP__TASK_TIMEOUT_HOURS`
|
||||
|
||||
:::info
|
||||
|
||||
27
website/docs/security/CVE-2024-37905.md
Normal file
27
website/docs/security/CVE-2024-37905.md
Normal file
@ -0,0 +1,27 @@
|
||||
# CVE-2024-37905
|
||||
|
||||
_Reported by [@m2a2](https://github.com/m2a2)_
|
||||
|
||||
## Improper Authorization for Token modification
|
||||
|
||||
### Summary
|
||||
|
||||
Due to insufficient permission checks it was possible for any authenticated user to elevate their permissions to a superuser by creating an API token and changing the user the token belonged to.
|
||||
|
||||
### Patches
|
||||
|
||||
authentik 2024.6.0, 2024.4.3 and 2024.2.4 fix this issue, for other versions the workaround can be used.
|
||||
|
||||
### Details
|
||||
|
||||
By setting a token's user ID to the ID of a higher privileged user, the token will inherit the higher privileged access to the API. This can be used to change the password of the affected user or to modify the authentik configuration in a potentially malicious way.
|
||||
|
||||
### Workarounds
|
||||
|
||||
As a workaround it is possible to block any requests to `/api/v3/core/tokens*` at the reverse-proxy/load-balancer level. Doing so prevents this issue from being exploited.
|
||||
|
||||
### For more information
|
||||
|
||||
If you have any questions or comments about this advisory:
|
||||
|
||||
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io)
|
||||
23
website/docs/security/CVE-2024-38371.md
Normal file
23
website/docs/security/CVE-2024-38371.md
Normal file
@ -0,0 +1,23 @@
|
||||
# CVE-2024-38371
|
||||
|
||||
_Reported by Stefan Zwanenburg_
|
||||
|
||||
## Insufficient access control for OAuth2 Device Code flow
|
||||
|
||||
### Impact
|
||||
|
||||
Due to a bug, access restrictions assigned to an application were not checked when using the OAuth2 Device code flow. This could potentially allow users without the correct authorization to get OAuth tokens for an application, and access the application.
|
||||
|
||||
### Patches
|
||||
|
||||
authentik 2024.6.0, 2024.4.3 and 2024.2.4 fix this issue, for other versions the workaround can be used.
|
||||
|
||||
### Workarounds
|
||||
|
||||
As authentik flows are still used as part of the OAuth2 Device code flow, it is possible to add access control to the configured flows.
|
||||
|
||||
### For more information
|
||||
|
||||
If you have any questions or comments about this advisory:
|
||||
|
||||
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io)
|
||||
@ -410,6 +410,8 @@ const docsSidebar = {
|
||||
},
|
||||
items: [
|
||||
"security/policy",
|
||||
"security/CVE-2024-38371",
|
||||
"security/CVE-2024-37905",
|
||||
"security/CVE-2024-23647",
|
||||
"security/CVE-2024-21637",
|
||||
"security/CVE-2023-48228",
|
||||
|
||||
Reference in New Issue
Block a user