Skip to content

Commit 4a206c8

Browse files
authored
Merge pull request #541 from MerginMaps/dont_reveal_resource_to_anonymous
Don't reveal private project exists to anonymous user
2 parents d19d5d1 + 31518fb commit 4a206c8

File tree

6 files changed

+97
-50
lines changed

6 files changed

+97
-50
lines changed

server/.test.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ SECURITY_BEARER_SALT='bearer'
2424
SECURITY_EMAIL_SALT='email'
2525
SECURITY_PASSWORD_SALT='password'
2626
DIAGNOSTIC_LOGS_DIR=/tmp/diagnostic_logs
27+
GEVENT_WORKER=0

server/mergin/sync/permissions.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,21 @@ def require_project(ws, project_name, permission) -> Project:
209209
return project
210210

211211

212-
def require_project_by_uuid(uuid: str, permission: ProjectPermissions, scheduled=False):
212+
def require_project_by_uuid(
213+
uuid: str, permission: ProjectPermissions, scheduled=False, expose=True
214+
) -> Project:
215+
"""
216+
Retrieves a project by UUID after validating existence, workspace status, and permissions.
217+
218+
Args:
219+
uuid (str): The unique identifier of the project.
220+
permission (ProjectPermissions): The permission level required to access the project.
221+
scheduled (bool, optional): If ``True``, bypasses the check for projects marked for deletion.
222+
expose (bool, optional): Controls security disclosure behavior on permission failure.
223+
- If `True`: Returns 403 Forbidden (reveals project exists but access is denied).
224+
- If `False`: Returns 404 Not Found (hides project existence for security).
225+
Standard is that reading results in 404, while writing results in 403
226+
"""
213227
if not is_valid_uuid(uuid):
214228
abort(404)
215229

@@ -219,13 +233,18 @@ def require_project_by_uuid(uuid: str, permission: ProjectPermissions, scheduled
219233
if not scheduled:
220234
project = project.filter(Project.removed_at.is_(None))
221235
project = project.first_or_404()
236+
if not expose and current_user.is_anonymous and not project.public:
237+
# we don't want to tell anonymous user if a private project exists
238+
abort(404)
239+
222240
workspace = project.workspace
223241
if not workspace:
224242
abort(404)
225243
if not is_active_workspace(workspace):
226244
abort(404, "Workspace doesn't exist")
227245
if not permission.check(project, current_user):
228246
abort(403, "You do not have permissions for this project")
247+
229248
return project
230249

231250

server/mergin/sync/public_api_v2.yaml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,7 @@ paths:
8787
description: Include list of files at specific version
8888
required: false
8989
schema:
90-
type: string
91-
example: v3
90+
$ref: "#/components/schemas/VersionName"
9291
responses:
9392
"200":
9493
description: Success
@@ -305,9 +304,7 @@ paths:
305304
default: false
306305
example: true
307306
version:
308-
type: string
309-
pattern: '^$|^v\d+$'
310-
example: v2
307+
$ref: "#/components/schemas/VersionName"
311308
changes:
312309
type: object
313310
required:
@@ -849,3 +846,7 @@ components:
849846
- editor
850847
- writer
851848
- owner
849+
VersionName:
850+
type: string
851+
pattern: '^$|^v\d+$'
852+
example: v2

server/mergin/sync/public_api_v2_controller.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
from .public_api_controller import catch_sync_failure
4545
from .schemas import (
4646
ProjectMemberSchema,
47-
ProjectVersionSchema,
4847
UploadChunkSchema,
4948
ProjectSchema,
5049
)
@@ -167,7 +166,7 @@ def remove_project_collaborator(id, user_id):
167166

168167
def get_project(id, files_at_version=None):
169168
"""Get project info. Include list of files at specific version if requested."""
170-
project = require_project_by_uuid(id, ProjectPermissions.Read)
169+
project = require_project_by_uuid(id, ProjectPermissions.Read, expose=False)
171170
data = ProjectSchemaV2().dump(project)
172171
if files_at_version:
173172
pv = ProjectVersion.query.filter_by(

server/mergin/tests/test_middleware.py

Lines changed: 58 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import psycogreen.gevent
77
import pytest
88
import sqlalchemy
9+
from unittest.mock import patch
910

1011
from ..app import create_simple_app, GeventTimeoutMiddleware, db
1112
from ..config import Configuration
@@ -14,58 +15,74 @@
1415
@pytest.mark.parametrize("use_middleware", [True, False])
1516
def test_use_middleware(use_middleware):
1617
"""Test using middleware"""
17-
Configuration.GEVENT_WORKER = use_middleware
18-
Configuration.GEVENT_REQUEST_TIMEOUT = 1
19-
application = create_simple_app()
18+
with patch.object(
19+
Configuration,
20+
"GEVENT_WORKER",
21+
use_middleware,
22+
), patch.object(
23+
Configuration,
24+
"GEVENT_REQUEST_TIMEOUT",
25+
1,
26+
):
27+
application = create_simple_app()
2028

21-
def ping():
22-
gevent.sleep(Configuration.GEVENT_REQUEST_TIMEOUT + 1)
23-
return "pong"
29+
def ping():
30+
gevent.sleep(Configuration.GEVENT_REQUEST_TIMEOUT + 1)
31+
return "pong"
2432

25-
application.add_url_rule("/test", "ping", ping)
26-
app_context = application.app_context()
27-
app_context.push()
33+
application.add_url_rule("/test", "ping", ping)
34+
app_context = application.app_context()
35+
app_context.push()
2836

29-
assert isinstance(application.wsgi_app, GeventTimeoutMiddleware) == use_middleware
30-
# in case of gevent, dummy endpoint it set to time out
31-
assert application.test_client().get("/test").status_code == (
32-
504 if use_middleware else 200
33-
)
37+
assert (
38+
isinstance(application.wsgi_app, GeventTimeoutMiddleware) == use_middleware
39+
)
40+
# in case of gevent, dummy endpoint it set to time out
41+
assert application.test_client().get("/test").status_code == (
42+
504 if use_middleware else 200
43+
)
3444

3545

3646
def test_catch_timeout():
3747
"""Test proper handling of gevent timeout with db.session.rollback"""
3848
psycogreen.gevent.patch_psycopg()
39-
Configuration.GEVENT_WORKER = True
40-
Configuration.GEVENT_REQUEST_TIMEOUT = 1
41-
application = create_simple_app()
49+
with patch.object(
50+
Configuration,
51+
"GEVENT_WORKER",
52+
True,
53+
), patch.object(
54+
Configuration,
55+
"GEVENT_REQUEST_TIMEOUT",
56+
1,
57+
):
58+
application = create_simple_app()
4259

43-
def unhandled():
44-
try:
45-
db.session.execute("SELECT pg_sleep(1.1);")
46-
finally:
47-
db.session.execute("SELECT 1;")
48-
return ""
60+
def unhandled():
61+
try:
62+
db.session.execute("SELECT pg_sleep(1.1);")
63+
finally:
64+
db.session.execute("SELECT 1;")
65+
return ""
4966

50-
def timeout():
51-
try:
52-
db.session.execute("SELECT pg_sleep(1.1);")
53-
except gevent.timeout.Timeout:
54-
db.session.rollback()
55-
raise
56-
finally:
57-
db.session.execute("SELECT 1;")
58-
return ""
67+
def timeout():
68+
try:
69+
db.session.execute("SELECT pg_sleep(1.1);")
70+
except gevent.timeout.Timeout:
71+
db.session.rollback()
72+
raise
73+
finally:
74+
db.session.execute("SELECT 1;")
75+
return ""
5976

60-
application.add_url_rule("/unhandled", "unhandled", unhandled)
61-
application.add_url_rule("/timeout", "timeout", timeout)
62-
app_context = application.app_context()
63-
app_context.push()
77+
application.add_url_rule("/unhandled", "unhandled", unhandled)
78+
application.add_url_rule("/timeout", "timeout", timeout)
79+
app_context = application.app_context()
80+
app_context.push()
6481

65-
assert application.test_client().get("/timeout").status_code == 504
82+
assert application.test_client().get("/timeout").status_code == 504
6683

67-
# in case of missing rollback sqlalchemy would raise error
68-
with pytest.raises(sqlalchemy.exc.PendingRollbackError):
69-
application.test_client().get("/unhandled")
84+
# in case of missing rollback sqlalchemy would raise error
85+
with pytest.raises(sqlalchemy.exc.PendingRollbackError):
86+
application.test_client().get("/unhandled")
7087

71-
db.session.rollback()
88+
db.session.rollback()

server/mergin/tests/test_public_api_v2.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
create_workspace,
1212
create_project,
1313
upload_file_to_project,
14+
login,
15+
file_info,
1416
)
1517

1618
from ..auth.models import User
@@ -49,7 +51,6 @@
4951
_get_changes_with_diff_0_size,
5052
_get_changes_without_added,
5153
)
52-
from .utils import add_user, file_info
5354

5455

5556
def test_schedule_delete_project(client):
@@ -168,6 +169,7 @@ def test_project_members(client):
168169
# access provided by workspace role cannot be removed directly
169170
response = client.delete(url + f"/{user.id}")
170171
assert response.status_code == 404
172+
Configuration.GLOBAL_READ = 0
171173

172174

173175
def test_get_project(client):
@@ -176,7 +178,12 @@ def test_get_project(client):
176178
test_workspace = create_workspace()
177179
project = create_project("new_project", test_workspace, admin)
178180
logout(client)
181+
# anonymous user cannot access the private resource
182+
response = client.get(f"v2/projects/{project.id}")
183+
assert response.status_code == 404
179184
# lack of permissions
185+
user = add_user("tests", "tests")
186+
login(client, user.username, "tests")
180187
response = client.get(f"v2/projects/{project.id}")
181188
assert response.status_code == 403
182189
# access public project
@@ -235,6 +242,9 @@ def test_get_project(client):
235242
)
236243
assert len(response.json["files"]) == 3
237244
assert {f["path"] for f in response.json["files"]} == set(files)
245+
# invalid version format parameter
246+
response = client.get(f"v2/projects/{project.id}?files_at_version=3")
247+
assert response.status_code == 400
238248

239249

240250
push_data = [

0 commit comments

Comments
 (0)