diff --git a/readthedocs/projects/forms.py b/readthedocs/projects/forms.py index 31e38ee81fa..fc2de9a7ad9 100644 --- a/readthedocs/projects/forms.py +++ b/readthedocs/projects/forms.py @@ -606,7 +606,15 @@ class DomainBaseForm(forms.ModelForm): class Meta: model = Domain - exclude = ['machine', 'cname', 'count'] # pylint: disable=modelform-uses-exclude + # pylint: disable=modelform-uses-exclude + exclude = [ + 'machine', + 'cname', + 'count', + 'hsts_max_age', + 'hsts_include_subdomains', + 'hsts_preload', + ] def __init__(self, *args, **kwargs): self.project = kwargs.pop('project', None) diff --git a/readthedocs/projects/migrations/0046_domain_hsts_fields.py b/readthedocs/projects/migrations/0046_domain_hsts_fields.py new file mode 100644 index 00000000000..587b9dab5f9 --- /dev/null +++ b/readthedocs/projects/migrations/0046_domain_hsts_fields.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.12 on 2020-04-23 16:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0045_project_max_concurrent_builds'), + ] + + operations = [ + migrations.AddField( + model_name='domain', + name='hsts_include_subdomains', + field=models.BooleanField(default=False, help_text='If hsts_max_age > 0, set the includeSubDomains flag with the HSTS header'), + ), + migrations.AddField( + model_name='domain', + name='hsts_max_age', + field=models.PositiveIntegerField(default=0, help_text='Set a custom max-age (eg. 31536000) for the HSTS header'), + ), + migrations.AddField( + model_name='domain', + name='hsts_preload', + field=models.BooleanField(default=False, help_text='If hsts_max_age > 0, set the preload flag with the HSTS header'), + ), + ] diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index a5e1a9f6ad7..65af1bc7688 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -1448,6 +1448,22 @@ class Domain(models.Model): help_text=_('Number of times this domain has been hit'), ) + # Strict-Transport-Security header options + # These are not exposed to users because it's easy to misconfigure things + # and hard to back out changes cleanly + hsts_max_age = models.PositiveIntegerField( + default=0, + help_text=_('Set a custom max-age (eg. 31536000) for the HSTS header') + ) + hsts_include_subdomains = models.BooleanField( + default=False, + help_text=_('If hsts_max_age > 0, set the includeSubDomains flag with the HSTS header') + ) + hsts_preload = models.BooleanField( + default=False, + help_text=_('If hsts_max_age > 0, set the preload flag with the HSTS header') + ) + objects = RelatedProjectQuerySet.as_manager() class Meta: diff --git a/readthedocs/proxito/middleware.py b/readthedocs/proxito/middleware.py index e30dc12eb99..26cc74825f3 100644 --- a/readthedocs/proxito/middleware.py +++ b/readthedocs/proxito/middleware.py @@ -82,6 +82,7 @@ def map_host_to_project_slug(request): # pylint: disable=too-many-return-statem if domain: project_slug = domain.project.slug request.cname = True + request.domain = domain log.debug('Proxito CNAME: host=%s', host) return project_slug @@ -114,3 +115,20 @@ def process_request(self, request): # noqa request.host_project_slug = request.slug = ret return None + + def process_response(self, request, response): # noqa + """Set the Strict-Transport-Security (HSTS) header for a custom domain if max-age>0.""" + if hasattr(request, 'domain'): + domain = request.domain + hsts_header_values = [] + if domain.hsts_max_age: + hsts_header_values.append(f'max-age={domain.hsts_max_age}') + # These other options don't make sense without max_age > 0 + if domain.hsts_include_subdomains: + hsts_header_values.append('includeSubDomains') + if domain.hsts_preload: + hsts_header_values.append('preload') + + # See https://tools.ietf.org/html/rfc6797 + response['Strict-Transport-Security'] = '; '.join(hsts_header_values) + return response diff --git a/readthedocs/proxito/tests/test_full.py b/readthedocs/proxito/tests/test_full.py index 2d91803e40d..f25b5e333fa 100644 --- a/readthedocs/proxito/tests/test_full.py +++ b/readthedocs/proxito/tests/test_full.py @@ -19,7 +19,7 @@ SPHINX_HTMLDIR, SPHINX_SINGLEHTML, ) -from readthedocs.projects.models import Project +from readthedocs.projects.models import Project, Domain from readthedocs.rtd_tests.storage import BuildMediaFileSystemStorageTest from .base import BaseDocServing @@ -195,6 +195,39 @@ def test_valid_project_as_invalid_subproject(self): resp = self.client.get(url, HTTP_HOST=host) self.assertEqual(resp.status_code, 404) + def test_response_hsts(self): + hostname = 'docs.random.com' + domain = fixture.get( + Domain, + project=self.project, + domain=hostname, + hsts_max_age=0, + hsts_include_subdomains=False, + hsts_preload=False, + ) + + response = self.client.get("/", HTTP_HOST=hostname) + self.assertFalse('strict-transport-security' in response) + + domain.hsts_max_age = 3600 + domain.save() + + response = self.client.get("/", HTTP_HOST=hostname) + self.assertTrue('strict-transport-security' in response) + self.assertEqual( + response['strict-transport-security'], 'max-age=3600', + ) + + domain.hsts_include_subdomains = True + domain.hsts_preload = True + domain.save() + + response = self.client.get("/", HTTP_HOST=hostname) + self.assertTrue('strict-transport-security' in response) + self.assertEqual( + response['strict-transport-security'], 'max-age=3600; includeSubDomains; preload', + ) + class TestDocServingBackends(BaseDocServing): # Test that nginx and python backends both work