Skip to content

Commit b902983

Browse files
authored
Merge commit from fork
- When using a form we were checking that only admin users can import projects, but that was done in the frontend, so it was easy to bypass that restriction. - When using API V3, we were not doing any checks at all. Ref GHSA-rmqq-mq6q-8hpg
1 parent 35ec392 commit b902983

File tree

7 files changed

+239
-10
lines changed

7 files changed

+239
-10
lines changed

readthedocs/api/v3/serializers.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@ def validate_name(self, value):
642642

643643
def validate(self, data): # pylint: disable=arguments-renamed
644644
repo = data.get("repo")
645+
user = self.context["request"].user
645646
try:
646647
# We are looking for an exact match of the repository URL entered
647648
# by the user and any of the known URLs (ssh, clone, html) we have
@@ -650,7 +651,9 @@ def validate(self, data): # pylint: disable=arguments-renamed
650651
# If the `RemoteRepository` is found, we save it to link with
651652
# `Project` object after performing its creating.
652653
query = Q(ssh_url=repo) | Q(clone_url=repo) | Q(html_url=repo)
653-
remote_repository = RemoteRepository.objects.get(query)
654+
remote_repository = RemoteRepository.objects.for_project_linking(user).get(
655+
query
656+
)
654657
data.update(
655658
{
656659
"remote_repository": remote_repository,

readthedocs/api/v3/tests/test_projects.py

+52-1
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import django_dynamic_fixture as fixture
44
from django.test import override_settings
55
from django.urls import reverse
6+
from django_dynamic_fixture import get
67

7-
from readthedocs.oauth.models import RemoteRepository
8+
from readthedocs.oauth.models import RemoteRepository, RemoteRepositoryRelation
89
from readthedocs.projects.constants import SINGLE_VERSION_WITHOUT_TRANSLATIONS
910
from readthedocs.projects.models import Project
1011

@@ -400,6 +401,13 @@ def test_import_project_with_remote_repository(self):
400401
clone_url="https://github.com/rtfd/template",
401402
html_url="https://github.com/rtfd/template",
402403
ssh_url="[email protected]:rtfd/template.git",
404+
private=False,
405+
)
406+
get(
407+
RemoteRepositoryRelation,
408+
remote_repository=remote_repository,
409+
user=self.me,
410+
admin=True,
403411
)
404412

405413
data = {
@@ -421,6 +429,49 @@ def test_import_project_with_remote_repository(self):
421429
self.assertIsNotNone(project.remote_repository)
422430
self.assertEqual(project.remote_repository, remote_repository)
423431

432+
def test_import_project_with_remote_repository_from_other_user(self):
433+
repo_url = "https://github.com/readthedocs/template"
434+
remote_repository = get(
435+
RemoteRepository,
436+
full_name="readthedocs/template",
437+
clone_url=repo_url,
438+
html_url="https://github.com/readthedocs/template",
439+
ssh_url="[email protected]:readthedocs/template.git",
440+
private=False,
441+
)
442+
443+
data = {
444+
"name": "Test Project",
445+
"repository": {
446+
"url": repo_url,
447+
"type": "git",
448+
},
449+
}
450+
451+
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
452+
453+
response = self.client.post(reverse("projects-list"), data)
454+
self.assertEqual(response.status_code, 201)
455+
project = Project.objects.get(slug=response.data["slug"])
456+
self.assertIsNone(project.remote_repository)
457+
458+
# The user has access to the repository but is not an admin,
459+
# and the repository is private.
460+
remote_repository.private = True
461+
remote_repository.save()
462+
get(
463+
RemoteRepositoryRelation,
464+
remote_repository=remote_repository,
465+
user=self.me,
466+
admin=False,
467+
)
468+
469+
data["name"] = "Test Project 2"
470+
response = self.client.post(reverse("projects-list"), data)
471+
self.assertEqual(response.status_code, 201)
472+
project = Project.objects.get(slug=response.data["slug"])
473+
self.assertIsNone(project.remote_repository)
474+
424475
def test_update_project(self):
425476
data = {
426477
"name": "Updated name",

readthedocs/oauth/querysets.py

+15-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Managers for OAuth models."""
22

33
from django.db import models
4+
from django.db.models import Q
45

56
from readthedocs.core.querysets import NoReprQuerySet
67

@@ -17,7 +18,20 @@ def api(self, user=None):
1718

1819

1920
class RemoteRepositoryQuerySet(RelatedUserQuerySet):
20-
pass
21+
def for_project_linking(self, user):
22+
"""
23+
Return repositories that can be linked to a project by the given user.
24+
25+
Repositories can be imported if:
26+
27+
- The user has read or adming access to the repository on the VCS service.
28+
- If the repository is private, the user must be an admin.
29+
- If the repository is public, the user doesn't need to be an admin.
30+
"""
31+
query = Q(remote_repository_relations__user=user) & (
32+
Q(private=False) | Q(private=True, remote_repository_relations__admin=True)
33+
)
34+
return self.filter(query).distinct()
2135

2236

2337
class RemoteOrganizationQuerySet(RelatedUserQuerySet):

readthedocs/oauth/tests/__init__.py

Whitespace-only changes.
+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from django.contrib.auth.models import User
2+
from django.test import TestCase
3+
from django_dynamic_fixture import get
4+
5+
from readthedocs.oauth.models import RemoteRepository, RemoteRepositoryRelation
6+
from readthedocs.projects.models import Project
7+
8+
9+
class TestRemoteRepositoryQuerysets(TestCase):
10+
def setUp(self):
11+
self.user = get(User)
12+
self.project = get(
13+
Project,
14+
users=[self.user],
15+
)
16+
17+
self.remote_repository_admin_public = get(
18+
RemoteRepository,
19+
private=False,
20+
)
21+
get(
22+
RemoteRepositoryRelation,
23+
user=self.user,
24+
remote_repository=self.remote_repository_admin_public,
25+
admin=True,
26+
)
27+
28+
self.remote_repository_admin_private = get(
29+
RemoteRepository,
30+
private=True,
31+
)
32+
get(
33+
RemoteRepositoryRelation,
34+
user=self.user,
35+
remote_repository=self.remote_repository_admin_private,
36+
admin=True,
37+
)
38+
39+
self.remote_repository_not_admin_public = get(
40+
RemoteRepository,
41+
private=False,
42+
)
43+
get(
44+
RemoteRepositoryRelation,
45+
user=self.user,
46+
remote_repository=self.remote_repository_not_admin_public,
47+
admin=False,
48+
)
49+
50+
self.remote_repository_not_admin_private = get(
51+
RemoteRepository,
52+
private=True,
53+
)
54+
get(
55+
RemoteRepositoryRelation,
56+
user=self.user,
57+
remote_repository=self.remote_repository_not_admin_private,
58+
admin=False,
59+
)
60+
61+
self.other_user = get(User)
62+
self.remote_repository_other_user = get(
63+
RemoteRepository,
64+
private=False,
65+
)
66+
get(
67+
RemoteRepositoryRelation,
68+
user=self.other_user,
69+
remote_repository=self.remote_repository_other_user,
70+
admin=True,
71+
)
72+
73+
def test_for_project_linking(self):
74+
repositories = RemoteRepository.objects.for_project_linking(user=self.user)
75+
self.assertEqual(repositories.count(), 3)
76+
self.assertIn(self.remote_repository_admin_public, repositories)
77+
self.assertIn(self.remote_repository_not_admin_public, repositories)
78+
self.assertIn(self.remote_repository_admin_private, repositories)
79+
self.assertNotIn(self.remote_repository_not_admin_private, repositories)
80+
self.assertNotIn(self.remote_repository_other_user, repositories)
81+
82+
repositories = RemoteRepository.objects.for_project_linking(
83+
user=self.other_user
84+
)
85+
self.assertEqual(repositories.count(), 1)
86+
self.assertIn(self.remote_repository_other_user, repositories)

readthedocs/projects/forms.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -362,9 +362,8 @@ def clean_remote_repository(self):
362362
if not remote_repo:
363363
return None
364364
try:
365-
return RemoteRepository.objects.get(
365+
return RemoteRepository.objects.for_project_linking(self.user).get(
366366
pk=remote_repo,
367-
users=self.user,
368367
)
369368
except RemoteRepository.DoesNotExist as exc:
370369
raise forms.ValidationError(_("Repository invalid")) from exc

readthedocs/rtd_tests/tests/test_project_views.py

+81-5
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,87 @@ def test_remote_repository_invalid_type(self):
162162
self.assertIn("remote_repository", form.errors)
163163

164164
def test_remote_repository_invalid_id(self):
165-
self.step_data["basics"]["remote_repository"] = 9
166-
resp = self.post_step("basics")
167-
self.assertEqual(resp.status_code, 200)
168-
form = resp.context_data["form"]
169-
self.assertIn("remote_repository", form.errors)
165+
remote_repository_admin_public = get(
166+
RemoteRepository,
167+
private=False,
168+
)
169+
get(
170+
RemoteRepositoryRelation,
171+
user=self.user,
172+
remote_repository=remote_repository_admin_public,
173+
admin=True,
174+
)
175+
176+
remote_repository_admin_private = get(
177+
RemoteRepository,
178+
private=True,
179+
)
180+
get(
181+
RemoteRepositoryRelation,
182+
user=self.user,
183+
remote_repository=remote_repository_admin_private,
184+
admin=True,
185+
)
186+
187+
remote_repository_not_admin_public = get(
188+
RemoteRepository,
189+
private=False,
190+
)
191+
get(
192+
RemoteRepositoryRelation,
193+
user=self.user,
194+
remote_repository=remote_repository_not_admin_public,
195+
admin=False,
196+
)
197+
198+
remote_repository_not_admin_private = get(
199+
RemoteRepository,
200+
private=True,
201+
)
202+
get(
203+
RemoteRepositoryRelation,
204+
user=self.user,
205+
remote_repository=remote_repository_not_admin_private,
206+
admin=False,
207+
)
208+
209+
other_user = get(User)
210+
remote_repository_other_user = get(
211+
RemoteRepository,
212+
private=False,
213+
)
214+
get(
215+
RemoteRepositoryRelation,
216+
user=other_user,
217+
remote_repository=remote_repository_other_user,
218+
admin=True,
219+
)
220+
221+
invalid_remote_repos_pk = [
222+
remote_repository_not_admin_private.pk,
223+
remote_repository_other_user.pk,
224+
# Doesn't exist
225+
99,
226+
]
227+
valid_remote_repos_pk = [
228+
remote_repository_admin_private.pk,
229+
remote_repository_admin_public.pk,
230+
remote_repository_not_admin_public.pk,
231+
]
232+
233+
for remote_repo_pk in invalid_remote_repos_pk:
234+
self.step_data["basics"]["remote_repository"] = remote_repo_pk
235+
resp = self.post_step("basics")
236+
self.assertEqual(resp.status_code, 200)
237+
form = resp.context_data["form"]
238+
self.assertIn("remote_repository", form.errors)
239+
240+
for remote_repo_pk in valid_remote_repos_pk:
241+
self.step_data["basics"]["remote_repository"] = remote_repo_pk
242+
resp = self.post_step("basics")
243+
self.assertEqual(resp.status_code, 200)
244+
form = resp.context_data["form"]
245+
self.assertNotIn("remote_repository", form.errors)
170246

171247
def test_remote_repository_is_not_added_for_wrong_user(self):
172248
user = get(User)

0 commit comments

Comments
 (0)