Skip to content

Commit a800f8f

Browse files
committed
PR previews: match repository privacy level
I was just adding a warning at the start, but then I realized that we can just take the privacy level of the repository into consideration as well. If the project was manually imported, we have no way of knowing the privacy level of the repository (well, maybe we could infer it from the repo URL and if all versions are public). Ref GHSA-pw32-ffxw-68rh
1 parent 334005e commit a800f8f

File tree

6 files changed

+123
-3
lines changed

6 files changed

+123
-3
lines changed

docs/user/guides/pull-requests.rst

+15-2
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,21 @@ Privacy levels
2626

2727
Privacy levels are only supported on :doc:`/commercial/index`.
2828

29-
By default, all docs built from pull requests are private.
30-
To change their privacy level:
29+
If you didn’t import your project manually, the privacy level of pull request previews will match your repository,
30+
otherwise it will be set to *Private*.
31+
Public pull request previews are available to anyone with the link to the preview,
32+
while private previews are only available to users with access to the Read the Docs project.
33+
34+
.. warning::
35+
36+
If you set the privacy level of pull request previews to *Private*,
37+
make sure that only trusted users can open pull requests in your repository.
38+
39+
Setting pull request previews to private on a public repository can allow a malicious user
40+
to access read-only APIs using the user's session that is reading the pull request preview.
41+
Similar to `GHSA-pw32-ffxw-68rh <https://github.com/readthedocs/readthedocs.org/security/advisories/GHSA-pw32-ffxw-68rh>`__.
42+
43+
To change the privacy level:
3144

3245
#. Go to your project dashboard
3346
#. Go to :guilabel:`Admin`, then :guilabel:`Advanced settings`

docs/user/pull-requests.rst

+26
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,29 @@ Warning banner
3434

3535
:doc:`/guides/pull-requests`
3636
A guide to configuring pull request builds on Read the Docs.
37+
38+
Security
39+
--------
40+
41+
If pull request previews are enabled for your project,
42+
anyone who can open a pull request on your repository will be able to trigger a build of your documentation.
43+
For this reason, pull request previews are served from a different domain than your main documentation
44+
(``org.readthedocs.build`` and ``com.readthedocs.build``).
45+
46+
Builds from pull requests have access to environment variables that are marked as *Public* only,
47+
if you have environment variables with private information, make sure they aren't marked as *Public*.
48+
See :ref:`environment-variables:Environment variables and build process` for more information.
49+
50+
On |com_brand| you can set pull request previews to be private or public,
51+
if you didn't import your project manually, the privacy level of pull request previews will match your repository.
52+
Public pull request previews are available to anyone with the link to the preview,
53+
while private previews are only available to users with access to the Read the Docs project.
54+
55+
.. warning::
56+
57+
If you set the privacy level of pull request previews to *Private*,
58+
make sure that only trusted users can open pull requests in your repository.
59+
60+
Setting pull request previews to private on a public repository can allow a malicious user
61+
to access read-only APIs using the user's session that is reading the pull request preview.
62+
Similar to `GHSA-pw32-ffxw-68rh <https://github.com/readthedocs/readthedocs.org/security/advisories/GHSA-pw32-ffxw-68rh>`__.

readthedocs/projects/forms.py

+12
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,18 @@ def __init__(self, *args, **kwargs):
269269

270270
def setup_external_builds_option(self):
271271
"""Disable the external builds option if the project doesn't meet the requirements."""
272+
if settings.ALLOW_PRIVATE_REPOS and self.instance.remote_repository:
273+
self.fields["external_builds_privacy_level"].disabled = True
274+
if self.instance.remote_repository.private:
275+
help_text = _(
276+
"We have detected that this project is private, pull request previews are set to private."
277+
)
278+
else:
279+
help_text = _(
280+
"We have detected that this project is public, pull request previews are set to public."
281+
)
282+
self.fields["external_builds_privacy_level"].help_text = help_text
283+
272284
integrations = list(self.instance.integrations.all())
273285
has_supported_integration = self.has_supported_integration(integrations)
274286
can_build_external_versions = self.can_build_external_versions(integrations)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Generated by Django 4.2.9 on 2024-01-22 19:16
2+
3+
from django.db import migrations, models
4+
5+
import readthedocs.projects.models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("projects", "0111_add_multiple_versions_without_translations"),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name="historicalproject",
16+
name="external_builds_privacy_level",
17+
field=models.CharField(
18+
choices=[("public", "Public"), ("private", "Private")],
19+
default=readthedocs.projects.models.default_privacy_level,
20+
help_text="Should builds from pull requests be public? <strong>If your repository is public, don't set this to private</strong>.",
21+
max_length=20,
22+
null=True,
23+
verbose_name="Privacy level of Pull Requests",
24+
),
25+
),
26+
migrations.AlterField(
27+
model_name="project",
28+
name="external_builds_privacy_level",
29+
field=models.CharField(
30+
choices=[("public", "Public"), ("private", "Private")],
31+
default=readthedocs.projects.models.default_privacy_level,
32+
help_text="Should builds from pull requests be public? <strong>If your repository is public, don't set this to private</strong>.",
33+
max_length=20,
34+
null=True,
35+
verbose_name="Privacy level of Pull Requests",
36+
),
37+
),
38+
]

readthedocs/projects/models.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@
6565
MEDIA_TYPES,
6666
MULTIPLE_VERSIONS_WITH_TRANSLATIONS,
6767
MULTIPLE_VERSIONS_WITHOUT_TRANSLATIONS,
68+
PRIVATE,
69+
PUBLIC,
6870
)
6971

7072
log = structlog.get_logger(__name__)
@@ -354,7 +356,7 @@ class Project(models.Model):
354356
choices=constants.PRIVACY_CHOICES,
355357
default=default_privacy_level,
356358
help_text=_(
357-
'Should builds from pull requests be public?',
359+
"Should builds from pull requests be public? <strong>If your repository is public, don't set this to private</strong>."
358360
),
359361
)
360362

@@ -657,6 +659,11 @@ def save(self, *args, **kwargs):
657659
raise Exception( # pylint: disable=broad-exception-raised
658660
_("Model must have slug")
659661
)
662+
663+
if self.remote_repository:
664+
privacy_level = PRIVATE if self.remote_repository.private else PUBLIC
665+
self.external_builds_privacy_level = privacy_level
666+
660667
super().save(*args, **kwargs)
661668

662669
try:

readthedocs/projects/tests/test_views.py

+24
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66

77
from readthedocs.integrations.models import Integration
88
from readthedocs.invitations.models import Invitation
9+
from readthedocs.oauth.models import RemoteRepository
910
from readthedocs.organizations.models import Organization
1011
from readthedocs.projects.constants import (
1112
DOWNLOADABLE_MEDIA_TYPES,
1213
MEDIA_TYPE_HTMLZIP,
14+
PRIVATE,
1315
PUBLIC,
1416
)
1517
from readthedocs.projects.models import Project
@@ -108,6 +110,28 @@ def test_gitlab_integration(self):
108110
)
109111
)
110112

113+
@override_settings(ALLOW_PRIVATE_REPOS=True)
114+
def test_privacy_level_pr_previews_match_remote_repository(self):
115+
remote_repository = get(RemoteRepository, private=False)
116+
self.project.remote_repository = remote_repository
117+
self.project.save()
118+
119+
resp = self.client.get(self.url)
120+
field = resp.context["form"].fields["external_builds_privacy_level"]
121+
self.assertTrue(field.disabled)
122+
self.assertIn("We have detected that this project is public", field.help_text)
123+
self.assertEqual(self.project.external_builds_privacy_level, PUBLIC)
124+
125+
remote_repository.private = True
126+
remote_repository.save()
127+
self.project.save()
128+
129+
resp = self.client.get(self.url)
130+
field = resp.context["form"].fields["external_builds_privacy_level"]
131+
self.assertTrue(field.disabled)
132+
self.assertIn("We have detected that this project is private", field.help_text)
133+
self.assertEqual(self.project.external_builds_privacy_level, PRIVATE)
134+
111135

112136
@override_settings(RTD_ALLOW_ORGANIZATIONS=True)
113137
class TestExternalBuildOptionWithOrganizations(TestExternalBuildOption):

0 commit comments

Comments
 (0)