fix(views): deny global view access on dedicated portals#1082
fix(views): deny global view access on dedicated portals#1082PascalRepond merged 1 commit intorero:stagingfrom
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds server-name based validation to block the Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
a16f0f3 to
df47789
Compare
dcd1373 to
005a6ce
Compare
df47789 to
65aef6c
Compare
65aef6c to
e5e9609
Compare
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
7268a29 to
3717493
Compare
This comment was marked as outdated.
This comment was marked as outdated.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
tests/ui/documents/test_documents_views.py (1)
120-138: Consider adding a test case for theext.pyhomepage guard (root path/)The existing assertions exercise the
route_converters.pyguard (via/global). Theext.pyguard — which fires when the request hits the default/route — has no explicit test. Sincemock.patchonsonar.route_converters.OrganisationSearch.get_organisation_pid_by_server_namepatches the method on the shared class object, the same mock context would also coverext.py's lazy-imported reference; only an additional request to/is needed.✅ Suggested additional assertion
with mock.patch( "sonar.route_converters.OrganisationSearch.get_organisation_pid_by_server_name", return_value="org", ): assert client.get(url_for("index", view="global")).status_code == 404 + # Also verify the homepage root path (handled by ext.py, not the converter) + assert client.get("/").status_code == 404🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/ui/documents/test_documents_views.py` around lines 120 - 138, Add an assertion in test_global_view_blocked_on_dedicated_portal that also requests the root path to exercise the ext.py homepage guard: while mocking sonar.route_converters.OrganisationSearch.get_organisation_pid_by_server_name (as already done in the second mock context) add a client.get(url_for("index", view=None) or client.get(url_for("index")) request for "/" and assert it returns 404, then in the third mock context ensure the org view still returns 200 for completeness; this ensures the ext.py lazy-imported homepage guard is tested alongside the route_converters guard.sonar/ext.py (1)
227-232: Guard logic is correct — optional: extract shared helper to remove duplicationThe same two-liner pattern appears in both
ext.py(line 230–231) androute_converters.py(line 42–43):server_name = urlparse(request.url).hostname OrganisationSearch().get_organisation_pid_by_server_name(server_name)While the two guards cover different URL patterns (
/vs/<org_code:view>), extracting this into a small helper (e.g., insonar/modules/organisations/api.pyor a dedicated utils module) would make the intent explicit and keep future changes in one place.Additionally,
urlparse(request.url).hostnamecan theoretically returnNone; passingNoneto the ES query is safe (fails open), butor ""would be a slightly more defensive choice and was mentioned in the related review onroute_converters.py.♻️ Proposed helper extraction
# In a shared location (e.g. sonar/modules/organisations/api.py or a utils module): + def is_dedicated_portal(request_url): + """Return True when the server name maps to a dedicated portal.""" + from urllib.parse import urlparse + server_name = urlparse(request_url).hostname or "" + return bool(OrganisationSearch().get_organisation_pid_by_server_name(server_name))Then in
ext.py:- server_name = urlparse(request.url).hostname - if OrganisationSearch().get_organisation_pid_by_server_name(server_name): - abort(404) + if is_dedicated_portal(request.url): + abort(404)And identically in
route_converters.py:- server_name = urlparse(request.url).hostname - if OrganisationSearch().get_organisation_pid_by_server_name(server_name): - raise ValidationError + if is_dedicated_portal(request.url): + raise ValidationError🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sonar/ext.py` around lines 227 - 232, Extract the duplicated guard into a small helper (e.g., in sonar.modules.organisations.api or a new utils module) that encapsulates the logic of resolving the request host and checking for an organisation PID; specifically, create a helper that uses urlparse(request.url).hostname or "" to get the server_name and calls OrganisationSearch().get_organisation_pid_by_server_name(server_name) and returns a boolean, then replace the two-liner in ext.py (the block around OrganisationSearch().get_organisation_pid_by_server_name and urlparse) and the identical block in route_converters.py with a call to that helper and call abort(404) when it returns true.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@sonar/ext.py`:
- Around line 227-232: Extract the duplicated guard into a small helper (e.g.,
in sonar.modules.organisations.api or a new utils module) that encapsulates the
logic of resolving the request host and checking for an organisation PID;
specifically, create a helper that uses urlparse(request.url).hostname or "" to
get the server_name and calls
OrganisationSearch().get_organisation_pid_by_server_name(server_name) and
returns a boolean, then replace the two-liner in ext.py (the block around
OrganisationSearch().get_organisation_pid_by_server_name and urlparse) and the
identical block in route_converters.py with a call to that helper and call
abort(404) when it returns true.
In `@tests/ui/documents/test_documents_views.py`:
- Around line 120-138: Add an assertion in
test_global_view_blocked_on_dedicated_portal that also requests the root path to
exercise the ext.py homepage guard: while mocking
sonar.route_converters.OrganisationSearch.get_organisation_pid_by_server_name
(as already done in the second mock context) add a client.get(url_for("index",
view=None) or client.get(url_for("index")) request for "/" and assert it returns
404, then in the third mock context ensure the org view still returns 200 for
completeness; this ensures the ext.py lazy-imported homepage guard is tested
alongside the route_converters guard.
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
uv.lockis excluded by!**/*.lock
📒 Files selected for processing (3)
sonar/ext.pysonar/route_converters.pytests/ui/documents/test_documents_views.py
There was a problem hiding this comment.
♻️ Duplicate comments (1)
tests/ui/documents/test_documents_views.py (1)
120-120: ARG001 fororganisationis intentionally not fixed.The Ruff
ARG001warning for the unusedorganisationparameter is a known pattern in this repo — the rule is disabled for test files. No action needed.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/ui/documents/test_documents_views.py` at line 120, The unused parameter warning (ARG001) for the organisation argument in the test function test_global_view_blocked_on_dedicated_portal is intentional per repository test linter rules; do not remove or modify the organisation parameter—leave the test signature as-is and make no code changes in that function regarding the unused parameter.
🧹 Nitpick comments (3)
tests/ui/documents/test_documents_views.py (1)
120-138: Theext.pyhomepage guard (root/path) has no test coverage.The mock targets
sonar.route_converters.OrganisationSearch.get_organisation_pid_by_server_name, which correctly exercises the route-converter path (/global/). However, the parallel check added insonar/ext.py(Lines 230–232) — which fires for the root/path via thedefaults={"view": ...}shortcut, bypassing the converter entirely — is never exercised with the dedicated-portal mock. The existingtest_index(Line 141) only confirms a non-mocked 200, so theabort(404)branch inext.pyremains untested.Consider adding a case to this test (or extending
test_index) that patches the class as imported insonar.ext:with mock.patch( "sonar.modules.organisations.api.OrganisationSearch.get_organisation_pid_by_server_name", return_value="org", ): assert client.get("/").status_code == 404🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/ui/documents/test_documents_views.py` around lines 120 - 138, The root-path guard in sonar/ext.py (the defaults={"view": ...} branch) is untested; extend test_global_view_blocked_on_dedicated_portal (or add to test_index) to patch the class imported in sonar.ext by mocking sonar.modules.organisations.api.OrganisationSearch.get_organisation_pid_by_server_name to return "org" and then assert that client.get("/") returns 404, ensuring the abort(404) branch in ext.py is exercised.sonar/ext.py (2)
227-232: Consider extracting the dedicated-portal guard into a shared helper to avoid duplication.The identical three-line block (
urlparse → .hostname → get_organisation_pid_by_server_name) exists in bothsonar/ext.py(Line 230–232) andsonar/route_converters.py(Lines 42–44). Extracting it (e.g., tosonar/modules/organisations/api.pyor a smallsonar/utils.py) would give a single point of maintenance.♻️ Sketch of a shared helper
# sonar/modules/organisations/api.py (or a dedicated utils module) +def is_dedicated_portal(request_url: str) -> bool: + """Return True if request_url originates from a dedicated portal.""" + from urllib.parse import urlparse + server_name = urlparse(request_url).hostname + return bool(OrganisationSearch().get_organisation_pid_by_server_name(server_name))Then in both call-sites:
- server_name = urlparse(request.url).hostname - if OrganisationSearch().get_organisation_pid_by_server_name(server_name): - abort(404) # (or raise ValidationError in route_converters.py) + if is_dedicated_portal(request.url): + abort(404)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sonar/ext.py` around lines 227 - 232, Extract the duplicate dedicated-portal guard (the urlparse(request.url).hostname lookup + OrganisationSearch().get_organisation_pid_by_server_name(...) check that leads to abort(404) when view == current_app.config.get("SONAR_APP_DEFAULT_ORGANISATION")) into a shared helper function (e.g., is_dedicated_portal_server(request) or server_owned_by_organisation(server_name)) placed in a common module (like sonar.modules.organisations.api or sonar.utils); have the helper accept the request or server_name, perform urlparse(request.url).hostname and call OrganisationSearch().get_organisation_pid_by_server_name, returning a boolean, and then replace the three-line block in both sonar/ext.py and sonar/route_converters.py with a call to the helper and abort(404) when it returns True.
227-232: New per-request Elasticsearch query on every default-org view hit.Every request to
/(or any/global/…path) now firesOrganisationSearch().get_organisation_pid_by_server_name(server_name). Because the server-name → org-pid mapping is essentially static (changes only when an org'sserverNameis edited), caching this lookup—even with a short TTL—would avoid hitting ES on every page load.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sonar/ext.py` around lines 227 - 232, The per-request ES hit comes from calling OrganisationSearch().get_organisation_pid_by_server_name(server_name) inside the default-org view check; replace that direct call with a cached lookup (e.g. a module-level helper like get_organisation_pid_by_server_name_cached(server_name) that uses functools.lru_cache or your app cache with a short TTL) and call that helper from the block that checks current_app.config.get("SONAR_APP_DEFAULT_ORGANISATION") and computes server_name; also add cache invalidation when an organisation's serverName is edited (clear the cached entry or the lru_cache) so updates are reflected.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@tests/ui/documents/test_documents_views.py`:
- Line 120: The unused parameter warning (ARG001) for the organisation argument
in the test function test_global_view_blocked_on_dedicated_portal is intentional
per repository test linter rules; do not remove or modify the organisation
parameter—leave the test signature as-is and make no code changes in that
function regarding the unused parameter.
---
Nitpick comments:
In `@sonar/ext.py`:
- Around line 227-232: Extract the duplicate dedicated-portal guard (the
urlparse(request.url).hostname lookup +
OrganisationSearch().get_organisation_pid_by_server_name(...) check that leads
to abort(404) when view ==
current_app.config.get("SONAR_APP_DEFAULT_ORGANISATION")) into a shared helper
function (e.g., is_dedicated_portal_server(request) or
server_owned_by_organisation(server_name)) placed in a common module (like
sonar.modules.organisations.api or sonar.utils); have the helper accept the
request or server_name, perform urlparse(request.url).hostname and call
OrganisationSearch().get_organisation_pid_by_server_name, returning a boolean,
and then replace the three-line block in both sonar/ext.py and
sonar/route_converters.py with a call to the helper and abort(404) when it
returns True.
- Around line 227-232: The per-request ES hit comes from calling
OrganisationSearch().get_organisation_pid_by_server_name(server_name) inside the
default-org view check; replace that direct call with a cached lookup (e.g. a
module-level helper like get_organisation_pid_by_server_name_cached(server_name)
that uses functools.lru_cache or your app cache with a short TTL) and call that
helper from the block that checks
current_app.config.get("SONAR_APP_DEFAULT_ORGANISATION") and computes
server_name; also add cache invalidation when an organisation's serverName is
edited (clear the cached entry or the lru_cache) so updates are reflected.
In `@tests/ui/documents/test_documents_views.py`:
- Around line 120-138: The root-path guard in sonar/ext.py (the
defaults={"view": ...} branch) is untested; extend
test_global_view_blocked_on_dedicated_portal (or add to test_index) to patch the
class imported in sonar.ext by mocking
sonar.modules.organisations.api.OrganisationSearch.get_organisation_pid_by_server_name
to return "org" and then assert that client.get("/") returns 404, ensuring the
abort(404) branch in ext.py is exercised.
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
uv.lockis excluded by!**/*.lock
📒 Files selected for processing (3)
sonar/ext.pysonar/route_converters.pytests/ui/documents/test_documents_views.py
50f740f to
535a6f1
Compare
605d8c2 to
1045b78
Compare
1045b78 to
4bed43e
Compare
Dedicated portals (e.g., folia.unifr.ch) could serve documents from
other organisations via the global URL path, causing search engines
to index cross-organisation content.
The route converter now checks if the request comes from a dedicated
portal and rejects the global view with a 404. The check also uses
urlparse().hostname instead of netloc.split(":")[0] to correctly
handle IPv6 addresses and URLs with explicit ports.
- Closes rero#909.
Co-Authored-by: Pascal Repond <pascal.repond@rero.ch>
4bed43e to
3a3184d
Compare
Dedicated portals (e.g., folia.unifr.ch) could serve documents from other organisations via the /global/ URL path, causing search engines to index cross-organisation content. The route converter now checks if the request comes from a dedicated portal and rejects the global view with a 404.
globalview #909.