mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-30 18:51:39 -07:00
Merge branch 'main' into dev
* main: tenants: fix base URL validation rejecting internal hostnames (#25560)
This commit is contained in:
@@ -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)
|
||||
|
||||
30
authentik/tenants/migrations/0009_alter_tenant_base_url.py
Normal file
30
authentik/tenants/migrations/0009_alter_tenant_base_url.py
Normal 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"),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
49
authentik/tenants/tests/test_validate_base_url.py
Normal file
49
authentik/tenants/tests/test_validate_base_url.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -53741,9 +53741,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
|
||||
@@ -58833,9 +58833,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
|
||||
@@ -58921,9 +58921,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
|
||||
|
||||
Reference in New Issue
Block a user