From 405ae38eb9a3a0d5c6e968e079ce9fb2ac5275e9 Mon Sep 17 00:00:00 2001 From: Emil Date: Fri, 28 Aug 2026 12:24:17 +0300 Subject: [PATCH 1/6] tenants: fix base URL validation rejecting internal hostnames --- authentik/tenants/api/settings.py | 22 +++++++--- authentik/tenants/apps.py | 16 ++++++- .../tenants/tests/test_base_url_backfill.py | 15 +++++++ .../tenants/tests/test_base_url_settings.py | 25 +++++++++++ .../tenants/tests/test_validate_base_url.py | 43 +++++++++++++++++++ authentik/tenants/utils.py | 11 +++++ blueprints/default/flow-oobe.yaml | 8 ++-- schema.yml | 3 -- 8 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 authentik/tenants/tests/test_validate_base_url.py diff --git a/authentik/tenants/api/settings.py b/authentik/tenants/api/settings.py index e67e234a0183..04bda9970dd3 100644 --- a/authentik/tenants/api/settings.py +++ b/authentik/tenants/api/settings.py @@ -7,7 +7,7 @@ from drf_spectacular.extensions import OpenApiSerializerFieldExtension from drf_spectacular.plumbing import build_basic_type, build_object_type from rest_framework.exceptions import ValidationError -from rest_framework.fields import JSONField +from rest_framework.fields import CharField, JSONField from rest_framework.generics import RetrieveUpdateAPIView from rest_framework.permissions import SAFE_METHODS @@ -15,7 +15,7 @@ from authentik.rbac.permissions import HasPermission from authentik.tenants.flags import Flag from authentik.tenants.models import Tenant -from authentik.tenants.utils import normalize_base_url +from authentik.tenants.utils import normalize_base_url, validate_base_url class FlagJSONField(JSONDictField): @@ -73,9 +73,24 @@ def map_serializer_field(self, auto_schema, direction): return build_object_type(props, required=required) +class BaseURLField(CharField): + """Needed because 'URLValidator' from 'serializers.URLField' is too strict""" + + def to_internal_value(self, data) -> str: + value = normalize_base_url(super().to_internal_value(data)) + validate_base_url(value) + return value + + class SettingsSerializer(ModelSerializer): """Settings Serializer""" + base_url = BaseURLField( + required=False, + allow_blank=True, + max_length=200, + help_text=Tenant._meta.get_field("base_url").help_text, + ) footer_links = JSONField(required=False) flags = FlagJSONField() @@ -101,9 +116,6 @@ class Meta: "flags", ] - def validate_base_url(self, value: str) -> str: - return normalize_base_url(value) - class SettingsView(RetrieveUpdateAPIView): """Settings view""" diff --git a/authentik/tenants/apps.py b/authentik/tenants/apps.py index 6df1580fa1ca..ee035653c98b 100644 --- a/authentik/tenants/apps.py +++ b/authentik/tenants/apps.py @@ -43,10 +43,16 @@ def backfill_base_url(self): """Backfill base_url when it hasn't been set yet. Sources: AUTHENTIK_WEB__BASE_URL config value, then the embedded outpost's configured host. When neither is available, warn that the base URL must be set before it becomes required in a future release.""" + from django.core.exceptions import ValidationError + from authentik.events.models import Event from authentik.outposts.models import Outpost from authentik.tenants.models import Tenant - from authentik.tenants.utils import get_current_tenant, normalize_base_url + from authentik.tenants.utils import ( + get_current_tenant, + normalize_base_url, + validate_base_url, + ) tenant = get_current_tenant() if tenant.base_url: @@ -56,6 +62,14 @@ def backfill_base_url(self): outpost = Outpost.objects.filter(managed=MANAGED_OUTPOST).first() if outpost: base_url = normalize_base_url(outpost.config.authentik_host) + if base_url: + try: + validate_base_url(base_url) + except ValidationError: + self.logger.warning( + "Discarding invalid base_url", base_url=base_url, tenant=tenant.schema_name + ) + base_url = "" if not base_url: # No source available if Setup.get(tenant=tenant): # Only nag instances that have finished setup self.logger.warning("Base URL is not configured", tenant=tenant.schema_name) diff --git a/authentik/tenants/tests/test_base_url_backfill.py b/authentik/tenants/tests/test_base_url_backfill.py index 91cf1cbe4437..4416d8d2ccc6 100644 --- a/authentik/tenants/tests/test_base_url_backfill.py +++ b/authentik/tenants/tests/test_base_url_backfill.py @@ -68,6 +68,21 @@ def test_backfill_normalizes_trailing_slash(self): self.tenant.refresh_from_db() self.assertEqual(self.tenant.base_url, "https://outpost.example.com") + @patch_flag(Setup, True) + @reconcile_app("authentik_outposts") + def test_backfill_discards_invalid_outpost_host(self): + """An outpost host the settings API would reject is discarded rather than written, + since the backfill's `.update()` skips field validation""" + outpost = Outpost.objects.get(managed=MANAGED_OUTPOST) + outpost.config = OutpostConfig(authentik_host="outpost.example.com") + outpost.save() + with capture_logs() as logs: + apps.get_app_config("authentik_tenants").backfill_base_url() + self.tenant.refresh_from_db() + self.assertEqual(self.tenant.base_url, "") + self.assertTrue(any("Discarding invalid base_url" in log.event for log in logs)) + self.assertTrue(any("Base URL is not configured" in log.event for log in logs)) + def test_backfill_no_outpost(self): """With no embedded outpost (e.g. disable_embedded_outpost) and no config value, base_url is left empty and the backfill does not error""" diff --git a/authentik/tenants/tests/test_base_url_settings.py b/authentik/tenants/tests/test_base_url_settings.py index a45c4722e48c..794677839db3 100644 --- a/authentik/tenants/tests/test_base_url_settings.py +++ b/authentik/tenants/tests/test_base_url_settings.py @@ -35,6 +35,31 @@ def test_settings_rejects_invalid(self): ) self.assertEqual(response.status_code, 400) + def test_settings_accepts_internal_hostname(self): + """A hostname without a public-suffix shaped last label is accepted.""" + response = self.client.patch( + reverse("authentik_api:tenant_settings"), + data={"base_url": "https://auth.svr001"}, + ) + self.assertEqual(response.status_code, 200) + self.tenant.refresh_from_db() + self.assertEqual(self.tenant.base_url, "https://auth.svr001") + + def test_settings_saves_with_internal_hostname_stored(self): + """An unrelated setting can still be saved.""" + self.tenant.base_url = "https://auth.svr001" + self.tenant.save() + current = self.client.get(reverse("authentik_api:tenant_settings")).json() + response = self.client.put( + reverse("authentik_api:tenant_settings"), + data={**current, "avatars": "initials"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.tenant.refresh_from_db() + self.assertEqual(self.tenant.avatars, "initials") + self.assertEqual(self.tenant.base_url, "https://auth.svr001") + def test_settings_normalizes_trailing_slash(self): """A trailing slash is stripped when saving through the settings API""" response = self.client.patch( diff --git a/authentik/tenants/tests/test_validate_base_url.py b/authentik/tenants/tests/test_validate_base_url.py new file mode 100644 index 000000000000..04db3cd0e9c3 --- /dev/null +++ b/authentik/tenants/tests/test_validate_base_url.py @@ -0,0 +1,43 @@ +"""Tests for the validate_base_url helper""" + +from django.core.exceptions import ValidationError +from django.test import SimpleTestCase + +from authentik.tenants.utils import validate_base_url + + +class TestValidateBaseURL(SimpleTestCase): + """validate_base_url only requires an http(s) scheme followed by something""" + + def test_validate(self): + cases = { + # Empty means the base URL has not been configured, which is allowed. + "": True, + "https://authentik.company": True, + "http://authentik.company": True, + "HTTPS://authentik.company": True, + "https://authentik.company/authentik": True, + # Hostnames Django's URLValidator rejects. + "https://auth.svr001": True, + "https://auth": True, + "https://auth.s1": True, + "https://my_host.example.com": True, + "http://localhost:9000": True, + "https://192.168.1.5:9443": True, + "https://[fd00::1]:9443": True, + # Simple mistakes, which are the only thing this rejects. + "authentik.company": False, + "//authentik.company": False, + "not a url": False, + "ftp://authentik.company": False, + "javascript:alert(1)": False, + "https://": False, + "http://": False, + } + for value, valid in cases.items(): + with self.subTest(value=value): + if valid: + validate_base_url(value) + continue + with self.assertRaises(ValidationError): + validate_base_url(value) diff --git a/authentik/tenants/utils.py b/authentik/tenants/utils.py index 85a3eda9387b..b3082d2b4e25 100644 --- a/authentik/tenants/utils.py +++ b/authentik/tenants/utils.py @@ -1,6 +1,8 @@ """Tenant utils""" +from django.core.exceptions import ValidationError from django.db import connection +from django.utils.translation import gettext_lazy as _ from django_tenants.utils import get_public_schema_name from authentik.lib.config import CONFIG @@ -31,3 +33,12 @@ def get_unique_identifier() -> str: def normalize_base_url(value: str | None) -> str: """Normalize a configured base URL: strip whitespace and trailing slashes.""" return (value or "").strip().rstrip("/") + + +def validate_base_url(value: str) -> None: + """Validate a base URL: an http or https scheme, followed by something.""" + if not value: + return + scheme, separator, rest = value.partition("://") + if scheme.lower() not in ("http", "https") or not separator or not rest: + raise ValidationError(_("Enter a valid URL, for example https://authentik.company")) diff --git a/blueprints/default/flow-oobe.yaml b/blueprints/default/flow-oobe.yaml index 2c040d09faed..82e079ab8f55 100644 --- a/blueprints/default/flow-oobe.yaml +++ b/blueprints/default/flow-oobe.yaml @@ -109,16 +109,14 @@ entries: model: authentik_policies_expression.expressionpolicy - attrs: expression: | - # Validate the base URL entered during setup from django.core.exceptions import ValidationError - from django.core.validators import URLValidator + from authentik.tenants.utils import normalize_base_url, validate_base_url - base_url = ((request.context.get("prompt_data") or {}).get("base_url") or "").strip() - # Empty is handled by the field's own `required` flag; only check non-empty values. + base_url = normalize_base_url((request.context.get("prompt_data") or {}).get("base_url")) if not base_url: return True try: - URLValidator()(base_url) + validate_base_url(base_url) except ValidationError: ak_message("Enter a valid URL, for example https://authentik.company") return False diff --git a/schema.yml b/schema.yml index 21f1c7e953ba..792a7d7530db 100644 --- a/schema.yml +++ b/schema.yml @@ -53741,7 +53741,6 @@ components: description: Configure how authentik should show avatars for users. base_url: type: string - format: uri description: Configure the base URL under which this authentik instance is reachable, e.g. https://authentik.company maxLength: 200 @@ -58833,7 +58832,6 @@ components: description: Configure how authentik should show avatars for users. base_url: type: string - format: uri description: Configure the base URL under which this authentik instance is reachable, e.g. https://authentik.company maxLength: 200 @@ -58921,7 +58919,6 @@ components: description: Configure how authentik should show avatars for users. base_url: type: string - format: uri description: Configure the base URL under which this authentik instance is reachable, e.g. https://authentik.company maxLength: 200 From c997c9f31e5ccef33d6e4f579f5189223698a275 Mon Sep 17 00:00:00 2001 From: Emil Date: Fri, 28 Aug 2026 15:01:00 +0300 Subject: [PATCH 2/6] re-use DomainlessURLValidator --- authentik/tenants/api/settings.py | 5 ++--- .../tenants/tests/test_base_url_settings.py | 12 +++++++++++ .../tenants/tests/test_validate_base_url.py | 21 ++++++++++++------- authentik/tenants/utils.py | 13 +++++------- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/authentik/tenants/api/settings.py b/authentik/tenants/api/settings.py index 04bda9970dd3..a00764e024b4 100644 --- a/authentik/tenants/api/settings.py +++ b/authentik/tenants/api/settings.py @@ -77,9 +77,7 @@ class BaseURLField(CharField): """Needed because 'URLValidator' from 'serializers.URLField' is too strict""" def to_internal_value(self, data) -> str: - value = normalize_base_url(super().to_internal_value(data)) - validate_base_url(value) - return value + return normalize_base_url(super().to_internal_value(data)) class SettingsSerializer(ModelSerializer): @@ -90,6 +88,7 @@ class SettingsSerializer(ModelSerializer): allow_blank=True, max_length=200, help_text=Tenant._meta.get_field("base_url").help_text, + validators=[validate_base_url], ) footer_links = JSONField(required=False) flags = FlagJSONField() diff --git a/authentik/tenants/tests/test_base_url_settings.py b/authentik/tenants/tests/test_base_url_settings.py index 794677839db3..92b68d34f71e 100644 --- a/authentik/tenants/tests/test_base_url_settings.py +++ b/authentik/tenants/tests/test_base_url_settings.py @@ -60,6 +60,18 @@ def test_settings_saves_with_internal_hostname_stored(self): self.assertEqual(self.tenant.avatars, "initials") self.assertEqual(self.tenant.base_url, "https://auth.svr001") + def test_settings_accepts_empty(self): + """The field can be cleared, which means no base URL is configured""" + self.tenant.base_url = "https://auth.svr001" + self.tenant.save() + response = self.client.patch( + reverse("authentik_api:tenant_settings"), + data={"base_url": ""}, + ) + self.assertEqual(response.status_code, 200) + self.tenant.refresh_from_db() + self.assertEqual(self.tenant.base_url, "") + def test_settings_normalizes_trailing_slash(self): """A trailing slash is stripped when saving through the settings API""" response = self.client.patch( diff --git a/authentik/tenants/tests/test_validate_base_url.py b/authentik/tenants/tests/test_validate_base_url.py index 04db3cd0e9c3..c8a0135a2fa0 100644 --- a/authentik/tenants/tests/test_validate_base_url.py +++ b/authentik/tenants/tests/test_validate_base_url.py @@ -7,32 +7,37 @@ class TestValidateBaseURL(SimpleTestCase): - """validate_base_url only requires an http(s) scheme followed by something""" + """validate_base_url accepts an http or https URL whose host needs no domain part""" def test_validate(self): cases = { - # Empty means the base URL has not been configured, which is allowed. - "": True, "https://authentik.company": True, "http://authentik.company": True, "HTTPS://authentik.company": True, "https://authentik.company/authentik": True, - # Hostnames Django's URLValidator rejects. + # Hostnames Django's own URLValidator rejects "https://auth.svr001": True, "https://auth": True, "https://auth.s1": True, - "https://my_host.example.com": True, "http://localhost:9000": True, "https://192.168.1.5:9443": True, "https://[fd00::1]:9443": True, - # Simple mistakes, which are the only thing this rejects. + # Not a URL at all. "authentik.company": False, "//authentik.company": False, "not a url": False, - "ftp://authentik.company": False, - "javascript:alert(1)": False, "https://": False, "http://": False, + # Only http and https. + "ftp://authentik.company": False, + "javascript:alert(1)": False, + # A host that is not a host. + "http:///nohost": False, + "https://.": False, + "https://auth svr001": False, + "https://my_host.example.com": False, + "https://auth.svr001\nBcc: someone@example.com": False, + "https://auth.svr001\tfoo": False, } for value, valid in cases.items(): with self.subTest(value=value): diff --git a/authentik/tenants/utils.py b/authentik/tenants/utils.py index b3082d2b4e25..5efc81cd4163 100644 --- a/authentik/tenants/utils.py +++ b/authentik/tenants/utils.py @@ -1,11 +1,11 @@ """Tenant utils""" -from django.core.exceptions import ValidationError from django.db import connection from django.utils.translation import gettext_lazy as _ from django_tenants.utils import get_public_schema_name from authentik.lib.config import CONFIG +from authentik.lib.models import DomainlessURLValidator from authentik.root.install_id import get_install_id from authentik.tenants.models import Tenant @@ -35,10 +35,7 @@ def normalize_base_url(value: str | None) -> str: return (value or "").strip().rstrip("/") -def validate_base_url(value: str) -> None: - """Validate a base URL: an http or https scheme, followed by something.""" - if not value: - return - scheme, separator, rest = value.partition("://") - if scheme.lower() not in ("http", "https") or not separator or not rest: - raise ValidationError(_("Enter a valid URL, for example https://authentik.company")) +validate_base_url = DomainlessURLValidator( + schemes=("http", "https"), + message=_("Enter a valid URL, for example https://authentik.company"), +) From 01a1276d5a147365b90347797337a7ce79cddd65 Mon Sep 17 00:00:00 2001 From: Emil Date: Fri, 28 Aug 2026 15:01:50 +0300 Subject: [PATCH 3/6] regenerate schema --- schema.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/schema.yml b/schema.yml index 792a7d7530db..3ee2155e8d86 100644 --- a/schema.yml +++ b/schema.yml @@ -53743,6 +53743,7 @@ components: type: string description: Configure the base URL under which this authentik instance is reachable, e.g. https://authentik.company + format: uri maxLength: 200 default_user_change_name: type: boolean @@ -58834,6 +58835,7 @@ components: type: string description: Configure the base URL under which this authentik instance is reachable, e.g. https://authentik.company + format: uri maxLength: 200 default_user_change_name: type: boolean @@ -58921,6 +58923,7 @@ components: type: string description: Configure the base URL under which this authentik instance is reachable, e.g. https://authentik.company + format: uri maxLength: 200 default_user_change_name: type: boolean From f567cd84967971576b7cf907e733606c359ab1a8 Mon Sep 17 00:00:00 2001 From: Emil Date: Fri, 28 Aug 2026 15:07:20 +0300 Subject: [PATCH 4/6] cleaner --- authentik/tenants/api/settings.py | 17 +++++++---------- authentik/tenants/apps.py | 4 ++-- .../tenants/tests/test_validate_base_url.py | 8 ++++---- authentik/tenants/utils.py | 2 +- blueprints/default/flow-oobe.yaml | 4 ++-- 5 files changed, 16 insertions(+), 19 deletions(-) diff --git a/authentik/tenants/api/settings.py b/authentik/tenants/api/settings.py index a00764e024b4..04ec80b12ea6 100644 --- a/authentik/tenants/api/settings.py +++ b/authentik/tenants/api/settings.py @@ -15,7 +15,7 @@ from authentik.rbac.permissions import HasPermission from authentik.tenants.flags import Flag from authentik.tenants.models import Tenant -from authentik.tenants.utils import normalize_base_url, validate_base_url +from authentik.tenants.utils import BASE_URL_VALIDATOR, normalize_base_url class FlagJSONField(JSONDictField): @@ -73,22 +73,16 @@ def map_serializer_field(self, auto_schema, direction): return build_object_type(props, required=required) -class BaseURLField(CharField): - """Needed because 'URLValidator' from 'serializers.URLField' is too strict""" - - def to_internal_value(self, data) -> str: - return normalize_base_url(super().to_internal_value(data)) - - class SettingsSerializer(ModelSerializer): """Settings Serializer""" - base_url = BaseURLField( + # the model field is a URLField, and URLValidator rejects internal hostnames. + base_url = CharField( required=False, allow_blank=True, max_length=200, help_text=Tenant._meta.get_field("base_url").help_text, - validators=[validate_base_url], + validators=[BASE_URL_VALIDATOR], ) footer_links = JSONField(required=False) flags = FlagJSONField() @@ -115,6 +109,9 @@ class Meta: "flags", ] + def validate_base_url(self, value: str) -> str: + return normalize_base_url(value) + class SettingsView(RetrieveUpdateAPIView): """Settings view""" diff --git a/authentik/tenants/apps.py b/authentik/tenants/apps.py index ee035653c98b..e565cd9be9a8 100644 --- a/authentik/tenants/apps.py +++ b/authentik/tenants/apps.py @@ -49,9 +49,9 @@ def backfill_base_url(self): from authentik.outposts.models import Outpost from authentik.tenants.models import Tenant from authentik.tenants.utils import ( + BASE_URL_VALIDATOR, get_current_tenant, normalize_base_url, - validate_base_url, ) tenant = get_current_tenant() @@ -64,7 +64,7 @@ def backfill_base_url(self): base_url = normalize_base_url(outpost.config.authentik_host) if base_url: try: - validate_base_url(base_url) + BASE_URL_VALIDATOR(base_url) except ValidationError: self.logger.warning( "Discarding invalid base_url", base_url=base_url, tenant=tenant.schema_name diff --git a/authentik/tenants/tests/test_validate_base_url.py b/authentik/tenants/tests/test_validate_base_url.py index c8a0135a2fa0..b6a0299e411b 100644 --- a/authentik/tenants/tests/test_validate_base_url.py +++ b/authentik/tenants/tests/test_validate_base_url.py @@ -3,11 +3,11 @@ from django.core.exceptions import ValidationError from django.test import SimpleTestCase -from authentik.tenants.utils import validate_base_url +from authentik.tenants.utils import BASE_URL_VALIDATOR class TestValidateBaseURL(SimpleTestCase): - """validate_base_url accepts an http or https URL whose host needs no domain part""" + """BASE_URL_VALIDATOR accepts an http or https URL whose host needs no domain part""" def test_validate(self): cases = { @@ -42,7 +42,7 @@ def test_validate(self): for value, valid in cases.items(): with self.subTest(value=value): if valid: - validate_base_url(value) + BASE_URL_VALIDATOR(value) continue with self.assertRaises(ValidationError): - validate_base_url(value) + BASE_URL_VALIDATOR(value) diff --git a/authentik/tenants/utils.py b/authentik/tenants/utils.py index 5efc81cd4163..b611df96497f 100644 --- a/authentik/tenants/utils.py +++ b/authentik/tenants/utils.py @@ -35,7 +35,7 @@ def normalize_base_url(value: str | None) -> str: return (value or "").strip().rstrip("/") -validate_base_url = DomainlessURLValidator( +BASE_URL_VALIDATOR = DomainlessURLValidator( schemes=("http", "https"), message=_("Enter a valid URL, for example https://authentik.company"), ) diff --git a/blueprints/default/flow-oobe.yaml b/blueprints/default/flow-oobe.yaml index 82e079ab8f55..9191783725a8 100644 --- a/blueprints/default/flow-oobe.yaml +++ b/blueprints/default/flow-oobe.yaml @@ -110,13 +110,13 @@ entries: - attrs: expression: | from django.core.exceptions import ValidationError - from authentik.tenants.utils import normalize_base_url, validate_base_url + from authentik.tenants.utils import BASE_URL_VALIDATOR, normalize_base_url base_url = normalize_base_url((request.context.get("prompt_data") or {}).get("base_url")) if not base_url: return True try: - validate_base_url(base_url) + BASE_URL_VALIDATOR(base_url) except ValidationError: ak_message("Enter a valid URL, for example https://authentik.company") return False From fb1ed40a823106a63c19768f9d5a287093ee4dff Mon Sep 17 00:00:00 2001 From: Emil Date: Fri, 28 Aug 2026 18:03:55 +0300 Subject: [PATCH 5/6] switch model to CharField --- authentik/tenants/api/settings.py | 12 ++------ authentik/tenants/apps.py | 8 ++--- .../migrations/0009_alter_tenant_base_url.py | 30 +++++++++++++++++++ authentik/tenants/models.py | 17 +++++++++-- .../tenants/tests/test_validate_base_url.py | 11 +++---- authentik/tenants/utils.py | 8 ----- blueprints/default/flow-oobe.yaml | 9 +++--- 7 files changed, 60 insertions(+), 35 deletions(-) create mode 100644 authentik/tenants/migrations/0009_alter_tenant_base_url.py diff --git a/authentik/tenants/api/settings.py b/authentik/tenants/api/settings.py index 04ec80b12ea6..e67e234a0183 100644 --- a/authentik/tenants/api/settings.py +++ b/authentik/tenants/api/settings.py @@ -7,7 +7,7 @@ from drf_spectacular.extensions import OpenApiSerializerFieldExtension from drf_spectacular.plumbing import build_basic_type, build_object_type from rest_framework.exceptions import ValidationError -from rest_framework.fields import CharField, JSONField +from rest_framework.fields import JSONField from rest_framework.generics import RetrieveUpdateAPIView from rest_framework.permissions import SAFE_METHODS @@ -15,7 +15,7 @@ from authentik.rbac.permissions import HasPermission from authentik.tenants.flags import Flag from authentik.tenants.models import Tenant -from authentik.tenants.utils import BASE_URL_VALIDATOR, normalize_base_url +from authentik.tenants.utils import normalize_base_url class FlagJSONField(JSONDictField): @@ -76,14 +76,6 @@ def map_serializer_field(self, auto_schema, direction): class SettingsSerializer(ModelSerializer): """Settings Serializer""" - # the model field is a URLField, and URLValidator rejects internal hostnames. - base_url = CharField( - required=False, - allow_blank=True, - max_length=200, - help_text=Tenant._meta.get_field("base_url").help_text, - validators=[BASE_URL_VALIDATOR], - ) footer_links = JSONField(required=False) flags = FlagJSONField() diff --git a/authentik/tenants/apps.py b/authentik/tenants/apps.py index e565cd9be9a8..d07327dc0e7a 100644 --- a/authentik/tenants/apps.py +++ b/authentik/tenants/apps.py @@ -48,11 +48,7 @@ def backfill_base_url(self): from authentik.events.models import Event from authentik.outposts.models import Outpost from authentik.tenants.models import Tenant - from authentik.tenants.utils import ( - BASE_URL_VALIDATOR, - get_current_tenant, - normalize_base_url, - ) + from authentik.tenants.utils import get_current_tenant, normalize_base_url tenant = get_current_tenant() if tenant.base_url: @@ -64,7 +60,7 @@ def backfill_base_url(self): base_url = normalize_base_url(outpost.config.authentik_host) if base_url: try: - BASE_URL_VALIDATOR(base_url) + Tenant._meta.get_field("base_url").run_validators(base_url) except ValidationError: self.logger.warning( "Discarding invalid base_url", base_url=base_url, tenant=tenant.schema_name diff --git a/authentik/tenants/migrations/0009_alter_tenant_base_url.py b/authentik/tenants/migrations/0009_alter_tenant_base_url.py new file mode 100644 index 000000000000..a82ebaba714d --- /dev/null +++ b/authentik/tenants/migrations/0009_alter_tenant_base_url.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.17 on 2026-08-28 14:55 + +import authentik.lib.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_tenants", "0008_tenant_base_url"), + ] + + operations = [ + migrations.AlterField( + model_name="tenant", + name="base_url", + field=models.CharField( + blank=True, + default="", + help_text="Configure the base URL under which this authentik instance is reachable, e.g. https://authentik.company", + max_length=200, + validators=[ + authentik.lib.models.DomainlessURLValidator( + message="Enter a valid URL, for example https://authentik.company", + schemes=("http", "https"), + ) + ], + ), + ), + ] diff --git a/authentik/tenants/models.py b/authentik/tenants/models.py index fbeef9091895..d0a7d96cffd3 100644 --- a/authentik/tenants/models.py +++ b/authentik/tenants/models.py @@ -17,7 +17,11 @@ from structlog.stdlib import get_logger from authentik.blueprints.apps import ManagedAppConfig -from authentik.lib.models import InternallyManagedMixin, SerializerModel +from authentik.lib.models import ( + DomainlessURLValidator, + InternallyManagedMixin, + SerializerModel, +) from authentik.lib.utils.time import timedelta_string_validator LOGGER = get_logger() @@ -58,9 +62,18 @@ class Tenant(InternallyManagedMixin, TenantMixin, SerializerModel): help_text=_("Configure how authentik should show avatars for users."), default="gravatar,initials", ) - base_url = models.URLField( + # Not a URLField: DRF strips every URLValidator from a models.URLField and appends its + # own strict one, which rejects hostnames without a domain part. See #25546. + base_url = models.CharField( + max_length=200, default="", blank=True, + validators=[ + DomainlessURLValidator( + schemes=("http", "https"), + message=_("Enter a valid URL, for example https://authentik.company"), + ) + ], help_text=_( "Configure the base URL under which this authentik instance is " "reachable, e.g. https://authentik.company" diff --git a/authentik/tenants/tests/test_validate_base_url.py b/authentik/tenants/tests/test_validate_base_url.py index b6a0299e411b..46472fbeb80d 100644 --- a/authentik/tenants/tests/test_validate_base_url.py +++ b/authentik/tenants/tests/test_validate_base_url.py @@ -1,15 +1,16 @@ -"""Tests for the validate_base_url helper""" +"""Tests for the base_url field validators""" from django.core.exceptions import ValidationError from django.test import SimpleTestCase -from authentik.tenants.utils import BASE_URL_VALIDATOR +from authentik.tenants.models import Tenant class TestValidateBaseURL(SimpleTestCase): - """BASE_URL_VALIDATOR accepts an http or https URL whose host needs no domain part""" + """base_url accepts an http or https URL whose host needs no domain part""" def test_validate(self): + field = Tenant._meta.get_field("base_url") cases = { "https://authentik.company": True, "http://authentik.company": True, @@ -42,7 +43,7 @@ def test_validate(self): for value, valid in cases.items(): with self.subTest(value=value): if valid: - BASE_URL_VALIDATOR(value) + field.run_validators(value) continue with self.assertRaises(ValidationError): - BASE_URL_VALIDATOR(value) + field.run_validators(value) diff --git a/authentik/tenants/utils.py b/authentik/tenants/utils.py index b611df96497f..85a3eda9387b 100644 --- a/authentik/tenants/utils.py +++ b/authentik/tenants/utils.py @@ -1,11 +1,9 @@ """Tenant utils""" from django.db import connection -from django.utils.translation import gettext_lazy as _ from django_tenants.utils import get_public_schema_name from authentik.lib.config import CONFIG -from authentik.lib.models import DomainlessURLValidator from authentik.root.install_id import get_install_id from authentik.tenants.models import Tenant @@ -33,9 +31,3 @@ def get_unique_identifier() -> str: def normalize_base_url(value: str | None) -> str: """Normalize a configured base URL: strip whitespace and trailing slashes.""" return (value or "").strip().rstrip("/") - - -BASE_URL_VALIDATOR = DomainlessURLValidator( - schemes=("http", "https"), - message=_("Enter a valid URL, for example https://authentik.company"), -) diff --git a/blueprints/default/flow-oobe.yaml b/blueprints/default/flow-oobe.yaml index 9191783725a8..2da904091b58 100644 --- a/blueprints/default/flow-oobe.yaml +++ b/blueprints/default/flow-oobe.yaml @@ -110,15 +110,16 @@ entries: - attrs: expression: | from django.core.exceptions import ValidationError - from authentik.tenants.utils import BASE_URL_VALIDATOR, normalize_base_url + from authentik.tenants.models import Tenant + from authentik.tenants.utils import normalize_base_url base_url = normalize_base_url((request.context.get("prompt_data") or {}).get("base_url")) if not base_url: return True try: - BASE_URL_VALIDATOR(base_url) - except ValidationError: - ak_message("Enter a valid URL, for example https://authentik.company") + Tenant._meta.get_field("base_url").run_validators(base_url) + except ValidationError as exc: + ak_message(exc.messages[0]) return False return True id: policy-default-oobe-base-url-valid From e3a5185cf13171c2e2f7cdabe3a3e100ac105760 Mon Sep 17 00:00:00 2001 From: Emil Date: Fri, 28 Aug 2026 18:06:58 +0300 Subject: [PATCH 6/6] remove comment --- authentik/tenants/models.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/authentik/tenants/models.py b/authentik/tenants/models.py index d0a7d96cffd3..400ede1b8627 100644 --- a/authentik/tenants/models.py +++ b/authentik/tenants/models.py @@ -62,8 +62,6 @@ class Tenant(InternallyManagedMixin, TenantMixin, SerializerModel): help_text=_("Configure how authentik should show avatars for users."), default="gravatar,initials", ) - # Not a URLField: DRF strips every URLValidator from a models.URLField and appends its - # own strict one, which rejects hostnames without a domain part. See #25546. base_url = models.CharField( max_length=200, default="",