Skip to content

Allow setting HSTS on a per domain basis #6953

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 28, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion readthedocs/projects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions readthedocs/projects/migrations/0046_domain_hsts_fields.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
16 changes: 16 additions & 0 deletions readthedocs/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions readthedocs/proxito/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Copy link
Member

@ericholscher ericholscher Apr 23, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add support for our own domains here too. It should be as simple as checking for request.subdomain and perhaps settings.PUBLIC_DOMAIN_USES_HTTPS and setting them to what we want.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just so I'm clear, you're envisioning that we should set the HSTS header in proxito (if appropriate) exclusively in middleware whether it's for a custom domain or for the public domain (*.readthedocs.io)?

You're still planning on relying on nginx setting the HSTS header for the dashboard/production domain (readthedocs.org), correct? Because we want this header set for some domains and not others, we can't rely on the usual method of Django's SecurityMiddleware.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, just for the requests through proxito on the PUBLIC_DOMAIN and probably EXTERNAL_DOMAIN (PR builders) as well.

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
35 changes: 34 additions & 1 deletion readthedocs/proxito/tests/test_full.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down