Skip to content

Commit 47b5b08

Browse files
committed
feat(IDN): add support for international domains
Utilize idna python package to support international domains Requires router#76ea246
1 parent d01a778 commit 47b5b08

4 files changed

Lines changed: 169 additions & 26 deletions

File tree

rootfs/api/serializers.py

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import re
77
import jsonschema
8+
import idna
89
from urllib.parse import urlparse
910

1011
from django.contrib.auth.models import User
@@ -376,35 +377,47 @@ def validate_domain(self, value):
376377
"""
377378
Check that the hostname is valid
378379
"""
379-
if len(value) > 255:
380-
raise serializers.ValidationError('Hostname must be 255 characters or less.')
381-
382380
if value[-1:] == ".":
383381
value = value[:-1] # strip exactly one dot from the right, if present
384382

383+
if value == "*":
384+
raise serializers.ValidationError("Hostname can't only be a wildcard")
385+
385386
labels = value.split('.')
386-
if 'xip.io' in value:
387-
return value
388387

389388
# Let wildcards through by not trying to validate it
390-
if labels[0] == '*':
389+
wildcard = True if labels[0] == '*' else False
390+
if wildcard:
391391
labels.pop(0)
392-
if len(labels) == 0:
393-
raise serializers.ValidationError("Hostname can't only be a wildcard")
394-
395-
# TODO this doesn't support IDN domains
396-
allowed = re.compile("^(?!-)[a-z0-9-]{1,63}(?<!-)$", re.IGNORECASE)
397-
for label in labels:
398-
match = allowed.match(label)
399-
if not match or '--' in label or label.isdigit() or \
400-
len(labels) == 1 and any(char.isdigit() for char in label):
401-
raise serializers.ValidationError('Hostname does not look valid.')
402-
403-
if models.Domain.objects.filter(domain=value).exists():
392+
393+
try:
394+
# IDN domain labels to ACE (IDNA2008)
395+
def ToACE(x): return idna.alabel(x).decode("utf-8", "strict")
396+
labels = list(map(ToACE, labels))
397+
except idna.IDNAError as e:
398+
raise serializers.ValidationError(
399+
"Hostname does not look valid, could not convert to ACE {}: {}"
400+
.format(value, e))
401+
402+
# TLD must not only contain digits according to RFC 3696
403+
if labels[-1].isdigit():
404+
raise serializers.ValidationError('Hostname does not look valid.')
405+
406+
# prepend wildcard 'label' again if removed before
407+
if wildcard:
408+
labels.insert(0, '*')
409+
410+
# recreate value using ACE'd labels
411+
aceValue = '.'.join(labels)
412+
413+
if len(aceValue) > 253:
414+
raise serializers.ValidationError('Hostname must be 253 characters or less.')
415+
416+
if models.Domain.objects.filter(domain=aceValue).exists():
404417
raise serializers.ValidationError(
405-
"The domain {} is already in use by another app".format(value))
418+
"The domain {} is already in use by another app".format(value))
406419

407-
return value
420+
return aceValue
408421

409422

410423
class CertificateSerializer(serializers.ModelSerializer):

rootfs/api/tests/test_domain.py

Lines changed: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from api.models import Domain
1414
from scheduler import KubeException
1515

16+
import idna
17+
1618

1719
class DomainTest(APITestCase):
1820

@@ -73,18 +75,136 @@ def test_strip_dot(self):
7375
expected = [data['domain'] for data in response.data['results']]
7476
self.assertEqual([self.app_id, domain], expected, msg)
7577

78+
def test_manage_idn_domain(self):
79+
url = '/v2/apps/{app_id}/domains'.format(app_id=self.app_id)
80+
test_domains = [
81+
'ドメイン.テスト',
82+
'xn--eckwd4c7c.xn--zckzah',
83+
'xn--80ahd1agd.ru',
84+
'домена.ru',
85+
'*.домена.испытание',
86+
'täst.königsgäßchen.de',
87+
'xn--tst-qla.xn--knigsgsschen-lcb0w.de',
88+
'ドメイン.xn--zckzah',
89+
'xn--eckwd4c7c.テスト',
90+
'täst.xn--knigsgsschen-lcb0w.de',
91+
'*.xn--tst-qla.königsgäßchen.de'
92+
]
93+
for domain in test_domains:
94+
msg = "failed on '{}'".format(domain)
95+
96+
# Generate ACE and Unicode variant for domain
97+
if domain.startswith("*."):
98+
ace_domain = "*." + idna.encode(domain[2:]).decode("utf-8", "strict")
99+
unicode_domain = "*." + idna.decode(ace_domain[2:])
100+
else:
101+
ace_domain = idna.encode(domain).decode("utf-8", "strict")
102+
unicode_domain = idna.decode(ace_domain)
103+
104+
# Create
105+
response = self.client.post(url, {'domain': domain})
106+
self.assertEqual(response.status_code, 201, msg)
107+
108+
# Fetch
109+
url = '/v2/apps/{app_id}/domains'.format(app_id=self.app_id)
110+
response = self.client.get(url)
111+
expected = [data['domain'] for data in response.data['results']]
112+
self.assertEqual([self.app_id, ace_domain], expected, msg)
113+
114+
# Verify creation failure for same domain with different encoding
115+
if ace_domain != domain:
116+
response = self.client.post(url, {'domain': ace_domain})
117+
self.assertEqual(response.status_code, 400, msg)
118+
119+
# Verify creation failure for same domain with different encoding
120+
if unicode_domain != domain:
121+
response = self.client.post(url, {'domain': unicode_domain})
122+
self.assertEqual(response.status_code, 400, msg)
123+
124+
# Delete
125+
url = '/v2/apps/{app_id}/domains/{hostname}'.format(hostname=domain,
126+
app_id=self.app_id)
127+
response = self.client.delete(url)
128+
self.assertEqual(response.status_code, 204, msg)
129+
130+
# Verify removal
131+
url = '/v2/apps/{app_id}/domains'.format(app_id=self.app_id)
132+
response = self.client.get(url)
133+
self.assertEqual(1, response.data['count'], msg)
134+
135+
# verify only app domain is left
136+
expected = [data['domain'] for data in response.data['results']]
137+
self.assertEqual([self.app_id], expected, msg)
138+
139+
# Use different encoding for creating and deleting (ACE)
140+
if ace_domain != domain:
141+
# Create
142+
response = self.client.post(url, {'domain': domain})
143+
self.assertEqual(response.status_code, 201, msg)
144+
145+
# Fetch
146+
url = '/v2/apps/{app_id}/domains'.format(app_id=self.app_id)
147+
response = self.client.get(url)
148+
expected = [data['domain'] for data in response.data['results']]
149+
self.assertEqual([self.app_id, ace_domain], expected, msg)
150+
151+
# Delete
152+
url = '/v2/apps/{app_id}/domains/{hostname}'.format(hostname=ace_domain,
153+
app_id=self.app_id)
154+
response = self.client.delete(url)
155+
self.assertEqual(response.status_code, 204, msg)
156+
157+
# Verify removal
158+
url = '/v2/apps/{app_id}/domains'.format(app_id=self.app_id)
159+
response = self.client.get(url)
160+
self.assertEqual(1, response.data['count'], msg)
161+
162+
# verify only app domain is left
163+
expected = [data['domain'] for data in response.data['results']]
164+
self.assertEqual([self.app_id], expected, msg)
165+
166+
# Use different encoding for creating and deleting (Unicode)
167+
if unicode_domain != domain:
168+
# Create
169+
response = self.client.post(url, {'domain': domain})
170+
self.assertEqual(response.status_code, 201, msg)
171+
172+
# Fetch
173+
url = '/v2/apps/{app_id}/domains'.format(app_id=self.app_id)
174+
response = self.client.get(url)
175+
expected = [data['domain'] for data in response.data['results']]
176+
self.assertEqual([self.app_id, ace_domain], expected, msg)
177+
178+
# Delete
179+
url = '/v2/apps/{app_id}/domains/{hostname}'.format(hostname=unicode_domain,
180+
app_id=self.app_id)
181+
response = self.client.delete(url)
182+
self.assertEqual(response.status_code, 204, msg)
183+
184+
# Verify removal
185+
url = '/v2/apps/{app_id}/domains'.format(app_id=self.app_id)
186+
response = self.client.get(url)
187+
self.assertEqual(1, response.data['count'], msg)
188+
189+
# verify only app domain is left
190+
expected = [data['domain'] for data in response.data['results']]
191+
self.assertEqual([self.app_id], expected, msg)
192+
76193
def test_manage_domain(self):
77194
url = '/v2/apps/{app_id}/domains'.format(app_id=self.app_id)
78195
test_domains = [
79196
'test-domain.example.com',
80197
'django.paas-sandbox',
198+
'django.paas--sandbox',
81199
'domain',
82200
'not.too.loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong',
83201
'3com.com',
202+
'domain1',
203+
'3333.xyz',
84204
'w3.example.com',
85205
'MYDOMAIN.NET',
86206
'autotest.127.0.0.1.xip.io',
87-
'*.deis.example.com',
207+
'*.deis.example.com'
88208
]
89209

90210
for domain in test_domains:
@@ -161,13 +281,12 @@ def test_manage_domain_invalid_domain(self):
161281
test_domains = [
162282
'this_is_an.invalid.domain',
163283
'this-is-an.invalid.1',
164-
'django.pass--sandbox',
165-
'domain1',
166-
'3333.com',
284+
'django.pa--assandbox',
167285
'too.looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong',
168286
'foo.*.bar.com',
169287
'*',
170-
'a' * 300
288+
'a' * 300,
289+
'.'.join(['a'] * 128)
171290
]
172291
for domain in test_domains:
173292
msg = "failed on \"{}\"".format(domain)

rootfs/api/views.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,17 @@ class DomainViewSet(AppResourceViewSet):
291291

292292
def get_object(self, **kwargs):
293293
qs = self.get_queryset(**kwargs)
294-
return get_object_or_404(qs, domain=self.kwargs['domain'])
294+
domain = self.kwargs['domain']
295+
# support IDN domains, i.e. accept Unicode encoding too
296+
try:
297+
import idna
298+
if domain.startswith("*."):
299+
ace_domain = "*." + idna.encode(domain[2:]).decode("utf-8", "strict")
300+
else:
301+
ace_domain = idna.encode(domain).decode("utf-8", "strict")
302+
except:
303+
ace_domain = domain
304+
return get_object_or_404(qs, domain=ace_domain)
295305

296306

297307
class CertificateViewSet(BaseDeisViewSet):

rootfs/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ requests==2.10.0
1717
requests-toolbelt==0.6.2
1818
simpleflock==0.0.3
1919
jsonschema==2.5.1
20+
idna==2.1

0 commit comments

Comments
 (0)