Skip to content

Commit 1d6dd55

Browse files
committed
feat: Update CustomDomainSettings to require CUSTOM_DOMAINS_ prefix for environment variables and add unit tests for validation
1 parent 8ec65fa commit 1d6dd55

11 files changed

Lines changed: 162 additions & 63 deletions

File tree

config.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,17 @@ class CustomDomainSettings(BaseSettings):
125125
rollout flag flips. ``enabled`` is the master switch consulted by the
126126
service layer (PR5+); the data plumbing (schema, repo, wiring) lands
127127
even when False so the rollout has a clean code path to flip.
128+
129+
All env vars must be prefixed ``CUSTOM_DOMAINS_`` so generic names
130+
like ``ENABLED`` or ``MAX_PER_USER`` set elsewhere in the deploy
131+
environment don't accidentally configure this feature.
128132
"""
129133

130-
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
134+
model_config = SettingsConfigDict(
135+
env_file=".env",
136+
extra="ignore",
137+
env_prefix="CUSTOM_DOMAINS_",
138+
)
131139

132140
# Master switch consulted by CustomDomainService. Until True, every
133141
# public method short-circuits with DomainQuotaExceededError or similar.

repositories/custom_domain_repository.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,36 @@
2121
log = get_logger(__name__)
2222

2323

24+
def _canonical(fqdn: str) -> str:
25+
"""Cheap normalisation for lookup parameters.
26+
27+
Persisted docs are validated through ``normalise_fqdn`` (strict) before
28+
insert, so they're already canonical. Lookups normalise too — different
29+
callers (DTO-validated input, raw middleware string, ops mongosh
30+
input) reach the same row regardless of case or trailing dots. Kept
31+
cheap (no regex) because lookup syntax is the caller's job, not ours.
32+
"""
33+
return str(fqdn).strip().lower().rstrip(".")
34+
35+
2436
class CustomDomainRepository(BaseRepository[CustomDomainDoc]):
2537
async def find_by_id(self, domain_id: ObjectId) -> CustomDomainDoc | None:
2638
"""Find a domain by its ObjectId."""
2739
return await self._find_one({"_id": domain_id})
2840

2941
async def find_by_fqdn(self, fqdn: str) -> CustomDomainDoc | None:
3042
"""Find a domain by fqdn (any status). Used by uniqueness checks."""
31-
return await self._find_one({"fqdn": fqdn})
43+
return await self._find_one({"fqdn": _canonical(fqdn)})
3244

3345
async def find_active_by_fqdn(self, fqdn: str) -> CustomDomainDoc | None:
3446
"""Find a domain by fqdn, scoped to ACTIVE only.
3547
3648
Used by the Caddy ask endpoint — we only mint certs for verified,
3749
currently-active domains.
3850
"""
39-
return await self._find_one({"fqdn": fqdn, "status": DomainStatus.ACTIVE})
51+
return await self._find_one(
52+
{"fqdn": _canonical(fqdn), "status": DomainStatus.ACTIVE}
53+
)
4054

4155
async def list_by_owner(
4256
self,

schemas/dto/requests/custom_domain.py

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,13 @@
22

33
from __future__ import annotations
44

5-
import re
65
from typing import Any
76

87
from pydantic import Field, field_validator
98

109
from schemas.dto.base import RequestBase
1110
from schemas.enums.domain_status import VerificationMethod
12-
13-
# Same regex as schemas.models.custom_domain — duplicated here so DTO
14-
# validation rejects bad input at the API boundary without depending on
15-
# the document model.
16-
_HOSTNAME_RE = re.compile(
17-
r"^(?=.{1,253}$)"
18-
r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+"
19-
r"[a-z]{2,63}$"
20-
)
21-
22-
23-
def _normalise_fqdn(v: Any) -> str:
24-
if v is None:
25-
raise ValueError("fqdn is required")
26-
normalised = str(v).strip().lower().rstrip(".")
27-
if not normalised:
28-
raise ValueError("fqdn is required")
29-
if re.search(r"[\x00-\x1F\x7F-\x9F<>\"'`\\]", normalised):
30-
raise ValueError("fqdn contains forbidden characters")
31-
if not _HOSTNAME_RE.match(normalised):
32-
raise ValueError("fqdn does not look like a valid hostname")
33-
return normalised
11+
from shared.url_utils import normalise_fqdn
3412

3513

3614
class CreateCustomDomainRequest(RequestBase):
@@ -55,7 +33,7 @@ class CreateCustomDomainRequest(RequestBase):
5533
@field_validator("fqdn", mode="before")
5634
@classmethod
5735
def _validate_fqdn(cls, v: Any) -> str:
58-
return _normalise_fqdn(v)
36+
return normalise_fqdn(v)
5937

6038
@field_validator("verification_method", mode="before")
6139
@classmethod

schemas/models/custom_domain.py

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,14 @@
1313

1414
from __future__ import annotations
1515

16-
import re
1716
from datetime import datetime
1817
from typing import Any
1918

2019
from pydantic import field_validator
2120

2221
from schemas.enums.domain_status import DomainStatus, VerificationMethod
2322
from schemas.models.base import MongoBaseModel, PyObjectId
24-
25-
# RFC 1035 hostname: labels of [a-z0-9-], 1-63 chars each, separated by dots,
26-
# total length ≤ 253. Trailing dot stripped before validation. Allows internal
27-
# uppercase (we lowercase ourselves) and rejects leading/trailing hyphens per
28-
# label.
29-
_HOSTNAME_RE = re.compile(
30-
r"^(?=.{1,253}$)"
31-
r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+"
32-
r"[a-z]{2,63}$"
33-
)
23+
from shared.url_utils import normalise_fqdn
3424

3525

3626
class CustomDomainDoc(MongoBaseModel):
@@ -61,19 +51,8 @@ class CustomDomainDoc(MongoBaseModel):
6151

6252
@field_validator("fqdn", mode="before")
6353
@classmethod
64-
def _normalise_fqdn(cls, v: Any) -> str:
65-
if v is None:
66-
raise ValueError("fqdn is required")
67-
normalised = str(v).strip().lower().rstrip(".")
68-
if not normalised:
69-
raise ValueError("fqdn is required")
70-
# Reject control + HTML metacharacters explicitly so a malformed input
71-
# can't sneak past the regex via Unicode tricks.
72-
if re.search(r"[\x00-\x1F\x7F-\x9F<>\"'`\\]", normalised):
73-
raise ValueError(f"fqdn contains forbidden characters: {v!r}")
74-
if not _HOSTNAME_RE.match(normalised):
75-
raise ValueError(f"fqdn does not look like a valid hostname: {v!r}")
76-
return normalised
54+
def _normalise(cls, v: Any) -> str:
55+
return normalise_fqdn(v)
7756

7857

7958
# Convenience: the set of legal state transitions, used by the service to

services/verifiers/a_record_verifier.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,8 @@
1515
import dns.exception
1616
import dns.resolver
1717

18-
from infrastructure.logging import get_logger
1918
from services.verifiers.protocol import DomainVerifier, VerificationResult
2019

21-
log = get_logger(__name__)
22-
2320
_DNS_TIMEOUT_SECS = 3.0
2421

2522

services/verifiers/cname_verifier.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,10 @@
1111

1212
import dns.asyncresolver
1313
import dns.exception
14-
import dns.rdatatype
1514
import dns.resolver
1615

17-
from infrastructure.logging import get_logger
1816
from services.verifiers.protocol import DomainVerifier, VerificationResult
1917

20-
log = get_logger(__name__)
21-
2218
# Per-query DNS timeout. Kept short so a single slow nameserver can't stall
2319
# the worker; total budget = timeout * dnspython retries (default 2).
2420
_DNS_TIMEOUT_SECS = 3.0

services/verifiers/txt_challenge_verifier.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,8 @@
1313
import dns.exception
1414
import dns.resolver
1515

16-
from infrastructure.logging import get_logger
1716
from services.verifiers.protocol import DomainVerifier, VerificationResult
1817

19-
log = get_logger(__name__)
20-
2118
_DNS_TIMEOUT_SECS = 3.0
2219
_CHALLENGE_PREFIX = "_spoo-challenge"
2320

shared/url_utils.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,21 @@
22

33
from __future__ import annotations
44

5+
import re
56
from urllib.parse import urlparse
67

8+
# RFC 1035 hostname matcher used by the custom-domains code path.
9+
# Labels: 1-63 chars, [a-z0-9-], no leading/trailing hyphen.
10+
# Total length: ≤ 253.
11+
# TLD: either ≥2 alpha chars OR an ASCII-encoded punycode label (``xn--…``)
12+
# so internationalised TLDs (.中国 → ``xn--fiqs8s``) are accepted.
13+
_HOSTNAME_RE = re.compile(
14+
r"^(?=.{1,253}$)"
15+
r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+"
16+
r"(?:[a-z]{2,63}|xn--[a-z0-9-]{1,59})$"
17+
)
18+
_FORBIDDEN_CHARS = re.compile(r"[\x00-\x1F\x7F-\x9F<>\"'`\\]")
19+
720

821
def extract_hostname(url: str | None) -> str | None:
922
"""Return hostname from URL, or None if unparseable."""
@@ -30,3 +43,26 @@ def extract_fqdn(url: str) -> str:
3043
if not host:
3144
return "localhost"
3245
return host.lower().rstrip(".")
46+
47+
48+
def normalise_fqdn(value: object) -> str:
49+
"""Strict canonical form for custom-domain fqdns.
50+
51+
Strips whitespace, lowercases, drops a trailing dot, and validates
52+
against RFC 1035 hostname syntax (with punycode TLD support). Raises
53+
``ValueError`` for empty / bad-character / malformed input.
54+
55+
Single source of truth — used by the document model, request DTO,
56+
AND the repository so a normalised lookup never misses because the
57+
persisted form drifted from the input form.
58+
"""
59+
if value is None:
60+
raise ValueError("fqdn is required")
61+
normalised = str(value).strip().lower().rstrip(".")
62+
if not normalised:
63+
raise ValueError("fqdn is required")
64+
if _FORBIDDEN_CHARS.search(normalised):
65+
raise ValueError(f"fqdn contains forbidden characters: {value!r}")
66+
if not _HOSTNAME_RE.match(normalised):
67+
raise ValueError(f"fqdn does not look like a valid hostname: {value!r}")
68+
return normalised

tests/unit/repositories/test_custom_domain_repository.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,30 @@ async def test_find_by_fqdn_returns_doc(self):
4040
assert doc.fqdn == "links.example.com"
4141
col.find_one.assert_awaited_once_with({"fqdn": "links.example.com"})
4242

43+
@pytest.mark.asyncio
44+
async def test_find_by_fqdn_normalises_lookup_input(self):
45+
# A caller passing an uppercased, trailing-dot, padded variant must
46+
# still find the canonical row — defends against input drift between
47+
# the writer (DTO-validated) and ad-hoc lookup paths.
48+
col = AsyncMock()
49+
col.find_one = AsyncMock(return_value=None)
50+
col.name = "custom_domains"
51+
repo = CustomDomainRepository(col)
52+
53+
await repo.find_by_fqdn(" LINKS.Example.COM. ")
54+
col.find_one.assert_awaited_once_with({"fqdn": "links.example.com"})
55+
56+
@pytest.mark.asyncio
57+
async def test_find_active_by_fqdn_normalises_lookup_input(self):
58+
col = AsyncMock()
59+
col.find_one = AsyncMock(return_value=None)
60+
col.name = "custom_domains"
61+
repo = CustomDomainRepository(col)
62+
63+
await repo.find_active_by_fqdn("LINKS.Example.COM.")
64+
args = col.find_one.call_args.args[0]
65+
assert args["fqdn"] == "links.example.com"
66+
4367
@pytest.mark.asyncio
4468
async def test_find_active_by_fqdn_scopes_to_active(self):
4569
col = AsyncMock()

tests/unit/shared/test_url_utils.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
"""Unit tests for shared/url_utils.py — extract_hostname + extract_fqdn."""
1+
"""Unit tests for shared/url_utils.py — extract_hostname + extract_fqdn + normalise_fqdn."""
22

3-
from shared.url_utils import extract_fqdn, extract_hostname
3+
import pytest
4+
5+
from shared.url_utils import extract_fqdn, extract_hostname, normalise_fqdn
46

57

68
class TestExtractHostname:
@@ -49,3 +51,45 @@ def test_idempotent(self):
4951
# so the cache key, the seeded custom_domains row, and the request
5052
# middleware all agree on the canonical form.
5153
assert extract_fqdn("HTTPS://Spoo.Me./") == extract_fqdn("https://spoo.me")
54+
55+
56+
class TestNormaliseFqdn:
57+
@pytest.mark.parametrize(
58+
"value, expected",
59+
[
60+
("links.acme.com", "links.acme.com"),
61+
("LINKS.ACME.COM", "links.acme.com"),
62+
(" links.acme.com ", "links.acme.com"),
63+
("links.acme.com.", "links.acme.com"),
64+
("acme.co", "acme.co"),
65+
# Punycode TLD (encoded `.中国`) — required for IDN custom domains.
66+
("links.xn--fiqs8s", "links.xn--fiqs8s"),
67+
# Multi-level subdomain
68+
("a.b.c.example.com", "a.b.c.example.com"),
69+
],
70+
)
71+
def test_accepts_valid_inputs(self, value, expected):
72+
assert normalise_fqdn(value) == expected
73+
74+
@pytest.mark.parametrize(
75+
"value",
76+
[
77+
None,
78+
"",
79+
" ",
80+
"no_underscores_allowed.com",
81+
"-leading-hyphen.com",
82+
"trailing-hyphen-.com",
83+
"single-label",
84+
"two..consecutive.dots.com",
85+
"evil<script>.com",
86+
"evil`backtick.com",
87+
"evil\\backslash.com",
88+
"evil\x00null.com",
89+
"a" * 64 + ".com", # label > 63 chars
90+
"a" * 254 + ".com", # total > 253 chars
91+
],
92+
)
93+
def test_rejects_invalid_inputs(self, value):
94+
with pytest.raises(ValueError):
95+
normalise_fqdn(value)

0 commit comments

Comments
 (0)