tenants: fix base URL validation rejecting internal hostnames (cherry-pick #25560 to version-2026.8) (#25563)

tenants: fix base URL validation rejecting internal hostnames (#25560)

Co-authored-by: Emil Burzo <emil@goauthentik.io>
This commit is contained in:
authentik-cherry-pick[bot]
2026-08-28 16:42:02 +00:00
committed by GitHub
parent 27f7bdc1e6
commit ea17f12379
8 changed files with 163 additions and 12 deletions

View File

@@ -43,6 +43,8 @@ class AuthentikTenantsConfig(ManagedAppConfig):
"""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
@@ -56,6 +58,14 @@ class AuthentikTenantsConfig(ManagedAppConfig):
outpost = Outpost.objects.filter(managed=MANAGED_OUTPOST).first()
if outpost:
base_url = normalize_base_url(outpost.config.authentik_host)
if base_url:
try:
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
)
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)

View File

@@ -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"),
)
],
),
),
]

View File

@@ -17,7 +17,11 @@ from rest_framework.serializers import Serializer
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,16 @@ class Tenant(InternallyManagedMixin, TenantMixin, SerializerModel):
help_text=_("Configure how authentik should show avatars for users."),
default="gravatar,initials",
)
base_url = models.URLField(
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"

View File

@@ -68,6 +68,21 @@ class TestBaseURLBackfill(APITestCase):
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"""

View File

@@ -35,6 +35,43 @@ class TestBaseURLSettings(APITestCase):
)
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_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(

View File

@@ -0,0 +1,49 @@
"""Tests for the base_url field validators"""
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase
from authentik.tenants.models import Tenant
class TestValidateBaseURL(SimpleTestCase):
"""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,
"HTTPS://authentik.company": True,
"https://authentik.company/authentik": True,
# Hostnames Django's own URLValidator rejects
"https://auth.svr001": True,
"https://auth": True,
"https://auth.s1": True,
"http://localhost:9000": True,
"https://192.168.1.5:9443": True,
"https://[fd00::1]:9443": True,
# Not a URL at all.
"authentik.company": False,
"//authentik.company": False,
"not a url": 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):
if valid:
field.run_validators(value)
continue
with self.assertRaises(ValidationError):
field.run_validators(value)

View File

@@ -109,18 +109,17 @@ 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.models import Tenant
from authentik.tenants.utils import normalize_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)
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

View File

@@ -53723,9 +53723,9 @@ 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
format: uri
maxLength: 200
default_user_change_name:
type: boolean
@@ -58820,9 +58820,9 @@ 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
format: uri
maxLength: 200
default_user_change_name:
type: boolean
@@ -58913,9 +58913,9 @@ 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
format: uri
maxLength: 200
default_user_change_name:
type: boolean