From be05c4698771de50e322d4ff6aedf0e66c24876d Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Wed, 28 Oct 2026 19:13:00 +0200 Subject: [PATCH 1/3] exclude preprints relationship for AVOL and return 403 on direct fetch --- api/base/serializers.py | 8 ++++++++ api/nodes/serializers.py | 11 ++++++++++- api/nodes/views.py | 2 ++ api_tests/nodes/views/test_node_detail.py | 10 ++++++++++ api_tests/nodes/views/test_node_preprints.py | 11 +++++++++++ 5 files changed, 41 insertions(+), 1 deletion(-) diff --git a/api/base/serializers.py b/api/base/serializers.py index d5b2c15a95e..273b810eddf 100644 --- a/api/base/serializers.py +++ b/api/base/serializers.py @@ -196,6 +196,14 @@ def should_be_none(self, instance): return not isinstance(self.field, RelationshipField) +class HideIfViewOnlyLink(ConditionalField): + """Hide the field when the request uses a view-only link.""" + + def should_hide(self, instance): + request = self.context.get('request') + return bool(request and request.query_params.get('view_only')) + + class HideIfDisabled(ConditionalField): """ If the user is disabled, returns None for attribute fields, or skips diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index 4a89727083b..31001d765e9 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -8,7 +8,7 @@ InvalidModelValueError, ) from api.base.serializers import ( - VersionedDateTimeField, HideIfRegistration, IDField, + VersionedDateTimeField, HideIfRegistration, HideIfViewOnlyLink, IDField, JSONAPISerializer, JSONAPIListSerializer, LinksField, NodeFileHyperLinkField, RelationshipField, ShowIfVersion, TargetTypeField, TypeField, @@ -1104,6 +1104,15 @@ class NodeDetailSerializer(NodeSerializer): """ id = IDField(source='_id', required=True) + preprints = HideIfViewOnlyLink( + HideIfRegistration( + RelationshipField( + related_view='nodes:node-preprints', + related_view_kwargs={'node_id': '<_id>'}, + ), + ), + ) + class NodeForksSerializer(NodeSerializer): diff --git a/api/nodes/views.py b/api/nodes/views.py index 4fc243ce44b..4c5e56d0518 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -2280,6 +2280,8 @@ def get_default_queryset(self): return self.preprints_queryset(node.preprints.all(), auth_user, latest_only=True) def get_queryset(self): + if self.request.query_params.get('view_only'): + raise PermissionDenied return self.get_queryset_from_request() diff --git a/api_tests/nodes/views/test_node_detail.py b/api_tests/nodes/views/test_node_detail.py index e03cff83ca9..6ab480214e2 100644 --- a/api_tests/nodes/views/test_node_detail.py +++ b/api_tests/nodes/views/test_node_detail.py @@ -572,6 +572,16 @@ def test_current_user_permissions_vol(self, app, user, url_public, project_publi res = app.get(f'{url_public}?view_only={private_link.key}', auth=user.auth) assert [permissions.READ] == res.json['data']['attributes']['current_user_permissions'] + def test_preprints_relationship_hidden_for_view_only_link(self, app, url_public, project_public): + private_link = PrivateLinkFactory(anonymous=False) + private_link.nodes.add(project_public) + private_link.save() + + response = app.get(f'{url_public}?view_only={private_link.key}') + + assert response.status_code == 200 + assert 'preprints' not in response.json['data']['relationships'] + def test_spammed_node_detail_gone(self, app, user, project_public, url_public): project_public.confirm_spam(save=True, train_spam_services=False) res = app.get(url_public, expect_errors=True) diff --git a/api_tests/nodes/views/test_node_preprints.py b/api_tests/nodes/views/test_node_preprints.py index 4c5ace85114..40e2d1aec82 100644 --- a/api_tests/nodes/views/test_node_preprints.py +++ b/api_tests/nodes/views/test_node_preprints.py @@ -9,6 +9,7 @@ AuthUserFactory, ProjectFactory, PreprintProviderFactory, + PrivateLinkFactory, ) from osf.utils import permissions @@ -104,6 +105,16 @@ def test_filter_withdrawn_preprint(self, app, url, user, project_one, provider_o actual = [preprint['id'] for preprint in res.json['data']] assert set(expected) == set(actual) + def test_view_only_link_cannot_fetch_node_preprints(self, app, project_one): + view_only_link = PrivateLinkFactory() + view_only_link.nodes.add(project_one) + view_only_link.save() + url = f'/{API_BASE}nodes/{project_one._id}/preprints/?view_only={view_only_link.key}' + + response = app.get(url, expect_errors=True) + + assert response.status_code == 403 + class TestNodePreprintIsPublishedList(PreprintIsPublishedListMixin): From 24a63bda40ba8982c8503e69a47875254a7c8019 Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Fri, 30 Oct 2026 17:00:01 +0200 Subject: [PATCH 2/3] hide preprints relationship for AVOL only --- api/base/serializers.py | 8 -------- api/nodes/serializers.py | 12 +----------- api_tests/nodes/views/test_node_detail.py | 2 +- 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/api/base/serializers.py b/api/base/serializers.py index 273b810eddf..d5b2c15a95e 100644 --- a/api/base/serializers.py +++ b/api/base/serializers.py @@ -196,14 +196,6 @@ def should_be_none(self, instance): return not isinstance(self.field, RelationshipField) -class HideIfViewOnlyLink(ConditionalField): - """Hide the field when the request uses a view-only link.""" - - def should_hide(self, instance): - request = self.context.get('request') - return bool(request and request.query_params.get('view_only')) - - class HideIfDisabled(ConditionalField): """ If the user is disabled, returns None for attribute fields, or skips diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index 31001d765e9..ab6e6fb77f3 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -8,7 +8,7 @@ InvalidModelValueError, ) from api.base.serializers import ( - VersionedDateTimeField, HideIfRegistration, HideIfViewOnlyLink, IDField, + VersionedDateTimeField, HideIfRegistration, IDField, JSONAPISerializer, JSONAPIListSerializer, LinksField, NodeFileHyperLinkField, RelationshipField, ShowIfVersion, TargetTypeField, TypeField, @@ -239,7 +239,6 @@ class NodeSerializer(TaxonomizableSerializerMixin, JSONAPISerializer): 'node_links', 'parent', 'preprint', - 'preprints', 'public', 'region', 'registration', @@ -1104,15 +1103,6 @@ class NodeDetailSerializer(NodeSerializer): """ id = IDField(source='_id', required=True) - preprints = HideIfViewOnlyLink( - HideIfRegistration( - RelationshipField( - related_view='nodes:node-preprints', - related_view_kwargs={'node_id': '<_id>'}, - ), - ), - ) - class NodeForksSerializer(NodeSerializer): diff --git a/api_tests/nodes/views/test_node_detail.py b/api_tests/nodes/views/test_node_detail.py index 6ab480214e2..06c2cd452af 100644 --- a/api_tests/nodes/views/test_node_detail.py +++ b/api_tests/nodes/views/test_node_detail.py @@ -573,7 +573,7 @@ def test_current_user_permissions_vol(self, app, user, url_public, project_publi assert [permissions.READ] == res.json['data']['attributes']['current_user_permissions'] def test_preprints_relationship_hidden_for_view_only_link(self, app, url_public, project_public): - private_link = PrivateLinkFactory(anonymous=False) + private_link = PrivateLinkFactory(anonymous=True) private_link.nodes.add(project_public) private_link.save() From e9428217122b837240a2a2c7314d3b83d7cd59b5 Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Mon, 2 Nov 2026 11:46:21 +0200 Subject: [PATCH 3/3] improvements --- api/nodes/views.py | 3 ++- api_tests/nodes/views/test_node_preprints.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/nodes/views.py b/api/nodes/views.py index 4c5e56d0518..c49bd79cdee 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -5,6 +5,7 @@ import dataclasses import waffle +from api.base.serializers import is_anonymized from api.collections.serializers import CollectionSerializer from osf import features from packaging.version import Version @@ -2280,7 +2281,7 @@ def get_default_queryset(self): return self.preprints_queryset(node.preprints.all(), auth_user, latest_only=True) def get_queryset(self): - if self.request.query_params.get('view_only'): + if is_anonymized(self.request): raise PermissionDenied return self.get_queryset_from_request() diff --git a/api_tests/nodes/views/test_node_preprints.py b/api_tests/nodes/views/test_node_preprints.py index 40e2d1aec82..dfa81b1ff73 100644 --- a/api_tests/nodes/views/test_node_preprints.py +++ b/api_tests/nodes/views/test_node_preprints.py @@ -106,7 +106,7 @@ def test_filter_withdrawn_preprint(self, app, url, user, project_one, provider_o assert set(expected) == set(actual) def test_view_only_link_cannot_fetch_node_preprints(self, app, project_one): - view_only_link = PrivateLinkFactory() + view_only_link = PrivateLinkFactory(anonymous=True) view_only_link.nodes.add(project_one) view_only_link.save() url = f'/{API_BASE}nodes/{project_one._id}/preprints/?view_only={view_only_link.key}'