Skip to content

Commit 57e1ad1

Browse files
authored
Custom domain: check CNAME when adding domains (#11747)
If the user owns the domain, they can delete the CNAME before adding the domain to their project. Hashed domains won't be needed anymore to prevent domain hijacking (but still be something we should do), since users trying to add a domain with an old CNAME will need to delete that CNAME first, and only domain owners can do that. It may be a little of a pain for some users that first add the CNAME before creating the domain on .org, but that's only on .org, since on .com the users doesn't know the CNAME before creating the domain. Ref readthedocs/meta#157
1 parent 2bae850 commit 57e1ad1

File tree

8 files changed

+160
-0
lines changed

8 files changed

+160
-0
lines changed

docs/user/guides/custom-domains.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,25 @@ this can cause a delay in validating because there is an exponential back-off in
112112

113113
Loading the domain details in the Read the Docs dashboard and saving the domain again will force a revalidation.
114114

115+
Disallowed DNS configurations
116+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
117+
118+
In order to prevent some common cases of domain hijacking, we disallow some DNS configurations:
119+
120+
- CNAME records pointing to another CNAME record
121+
(``doc.example.com -> docs.example.com -> readthedocs.io``).
122+
- CNAME records pointing to the APEX domain
123+
(``www.example.com -> example.com -> readthedocs.io``).
124+
125+
This prevents attackers from taking over unused domains with CNAME records pointing to domains that are on Read the Docs.
126+
127+
.. warning::
128+
129+
Users shouldn't rely on these restrictions to prevent domain hijacking.
130+
We recommend regularly reviewing your DNS records,
131+
removing any that are no longer needed or that don't exist on Read the Docs,
132+
or registering all valid domains in your project.
133+
115134
The validation process period has ended
116135
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
117136

readthedocs/projects/forms.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from re import fullmatch
77
from urllib.parse import urlparse
88

9+
import dns.name
10+
import dns.resolver
911
import pytz
1012
from allauth.socialaccount.models import SocialAccount
1113
from django import forms
@@ -1036,8 +1038,73 @@ def clean_domain(self):
10361038
if invalid_domain and domain_string.endswith(invalid_domain):
10371039
raise forms.ValidationError(f"{invalid_domain} is not a valid domain.")
10381040

1041+
self._check_for_suspicious_cname(domain_string)
1042+
10391043
return domain_string
10401044

1045+
def _check_for_suspicious_cname(self, domain):
1046+
"""
1047+
Check if a domain has a suspicious CNAME record.
1048+
1049+
The domain is suspicious if:
1050+
1051+
- Has a CNAME pointing to another CNAME.
1052+
This prevents the subdomain from being hijacked if the last subdomain is on RTD,
1053+
but the user didn't register the other subdomain.
1054+
Example: doc.example.com -> docs.example.com -> readthedocs.io,
1055+
We don't want to allow doc.example.com to be added.
1056+
- Has a CNAME pointing to the APEX domain.
1057+
This prevents a subdomain from being hijacked if the APEX domain is on RTD.
1058+
A common case is `www` pointing to the APEX domain, but users didn't register the
1059+
`www` subdomain, only the APEX domain.
1060+
Example: www.example.com -> example.com -> readthedocs.io,
1061+
we don't want to allow www.example.com to be added.
1062+
"""
1063+
cname = self._get_cname(domain)
1064+
# Doesn't have a CNAME record, we are good.
1065+
if not cname:
1066+
return
1067+
1068+
# If the domain has a CNAME pointing to the APEX domain, that's not good.
1069+
# This check isn't perfect, but it's a good enoug heuristic
1070+
# to dectect CNAMES like www.example.com -> example.com.
1071+
if f"{domain}.".endswith(f".{cname}"):
1072+
raise forms.ValidationError(
1073+
_(
1074+
"This domain has a CNAME record pointing to the APEX domain. "
1075+
"Please remove the CNAME before adding the domain.",
1076+
),
1077+
)
1078+
1079+
second_cname = self._get_cname(cname)
1080+
# The domain has a CNAME pointing to another CNAME, That's not good.
1081+
if second_cname:
1082+
raise forms.ValidationError(
1083+
_(
1084+
"This domain has a CNAME record pointing to another CNAME. "
1085+
"Please remove the CNAME before adding the domain.",
1086+
),
1087+
)
1088+
1089+
def _get_cname(self, domain):
1090+
try:
1091+
answers = dns.resolver.resolve(domain, "CNAME", lifetime=1)
1092+
# dnspython doesn't recursively resolve CNAME records.
1093+
# We always have one response or none.
1094+
return str(answers[0].target)
1095+
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
1096+
return None
1097+
except dns.resolver.LifetimeTimeout:
1098+
raise forms.ValidationError(
1099+
_(
1100+
"DNS resolution timed out. Make sure the domain is correct, or try again later."
1101+
),
1102+
)
1103+
except dns.name.EmptyLabel:
1104+
raise forms.ValidationError(
1105+
_("The domain is not valid."),
1106+
)
1107+
10411108
def clean_canonical(self):
10421109
canonical = self.cleaned_data["canonical"]
10431110
pk = self.instance.pk

readthedocs/projects/tests/test_domain_views.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from unittest import mock
2+
3+
import dns.resolver
14
from django.contrib.auth.models import User
25
from django.contrib.messages import get_messages
36
from django.test import TestCase, override_settings
@@ -109,6 +112,67 @@ def test_edit_domain_on_subproject(self):
109112
self.assertEqual(domain.domain, "test.example.com")
110113
self.assertEqual(domain.canonical, False)
111114

115+
@mock.patch("readthedocs.projects.forms.dns.resolver.resolve")
116+
def test_create_domain_with_chained_cname_record(self, dns_resolve_mock):
117+
dns_resolve_mock.side_effect = [
118+
[mock.MagicMock(target="docs.example.com.")],
119+
[mock.MagicMock(target="readthedocs.io.")],
120+
]
121+
resp = self.client.post(
122+
reverse("projects_domains_create", args=[self.project.slug]),
123+
data={"domain": "docs2.example.com"},
124+
)
125+
assert resp.status_code == 200
126+
form = resp.context_data["form"]
127+
assert not form.is_valid()
128+
assert (
129+
"This domain has a CNAME record pointing to another CNAME"
130+
in form.errors["domain"][0]
131+
)
132+
133+
@mock.patch("readthedocs.projects.forms.dns.resolver.resolve")
134+
def test_create_domain_with_cname_record_to_apex_domain(self, dns_resolve_mock):
135+
dns_resolve_mock.side_effect = [
136+
[mock.MagicMock(target="example.com.")],
137+
]
138+
resp = self.client.post(
139+
reverse("projects_domains_create", args=[self.project.slug]),
140+
data={"domain": "www.example.com"},
141+
)
142+
assert resp.status_code == 200
143+
form = resp.context_data["form"]
144+
assert not form.is_valid()
145+
assert (
146+
"This domain has a CNAME record pointing to the APEX domain"
147+
in form.errors["domain"][0]
148+
)
149+
150+
@mock.patch("readthedocs.projects.forms.dns.resolver.resolve")
151+
def test_create_domain_cname_timeout(self, dns_resolve_mock):
152+
dns_resolve_mock.side_effect = dns.resolver.LifetimeTimeout
153+
resp = self.client.post(
154+
reverse("projects_domains_create", args=[self.project.slug]),
155+
data={"domain": "docs.example.com"},
156+
)
157+
assert resp.status_code == 200
158+
form = resp.context_data["form"]
159+
assert not form.is_valid()
160+
assert "DNS resolution timed out" in form.errors["domain"][0]
161+
162+
@mock.patch("readthedocs.projects.forms.dns.resolver.resolve")
163+
def test_create_domain_with_single_cname(self, dns_resolve_mock):
164+
dns_resolve_mock.side_effect = [
165+
[mock.MagicMock(target="readthedocs.io.")],
166+
dns.resolver.NoAnswer,
167+
]
168+
resp = self.client.post(
169+
reverse("projects_domains_create", args=[self.project.slug]),
170+
data={"domain": "docs.example.com"},
171+
)
172+
assert resp.status_code == 302
173+
domain = self.project.domains.first()
174+
assert domain.domain == "docs.example.com"
175+
112176

113177
@override_settings(RTD_ALLOW_ORGANIZATIONS=True)
114178
class TestDomainViewsWithOrganizations(TestDomainViews):

requirements/deploy.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ djangorestframework-api-key==3.0.0
181181
# via -r requirements/pip.txt
182182
djangorestframework-jsonp==1.0.2
183183
# via -r requirements/pip.txt
184+
dnspython==2.7.0
185+
# via -r requirements/pip.txt
184186
docker==6.1.2
185187
# via -r requirements/pip.txt
186188
docutils==0.21.2

requirements/docker.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ djangorestframework-api-key==3.0.0
191191
# via -r requirements/pip.txt
192192
djangorestframework-jsonp==1.0.2
193193
# via -r requirements/pip.txt
194+
dnspython==2.7.0
195+
# via -r requirements/pip.txt
194196
docker==6.1.2
195197
# via -r requirements/pip.txt
196198
docutils==0.21.2

requirements/pip.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ slumber
4646
pyyaml
4747
Pygments
4848

49+
dnspython
50+
4951
# Used for Redis cache Django backend (`django.core.cache.backends.redis.RedisCache`)
5052
redis
5153

requirements/pip.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ djangorestframework-api-key==3.0.0
143143
# via -r requirements/pip.in
144144
djangorestframework-jsonp==1.0.2
145145
# via -r requirements/pip.in
146+
dnspython==2.7.0
147+
# via -r requirements/pip.in
146148
docker==6.1.2
147149
# via -r requirements/pip.in
148150
docutils==0.21.2

requirements/testing.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ djangorestframework-api-key==3.0.0
188188
# via -r requirements/pip.txt
189189
djangorestframework-jsonp==1.0.2
190190
# via -r requirements/pip.txt
191+
dnspython==2.7.0
192+
# via -r requirements/pip.txt
191193
docker==6.1.2
192194
# via -r requirements/pip.txt
193195
docutils==0.21.2

0 commit comments

Comments
 (0)