From cc9bd165a1fe8b3d8f9188053f99c2a191f339f3 Mon Sep 17 00:00:00 2001 From: Jesse Salinas Date: Tue, 20 Oct 2026 06:38:15 -0700 Subject: [PATCH 1/6] refs CRASM-4315: Fix cross-organization domain access authorization --- .../xfd_django/xfd_api/api_methods/domain.py | 51 +++++++++++-------- backend/src/xfd_django/xfd_api/views.py | 6 ++- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/api_methods/domain.py b/backend/src/xfd_django/xfd_api/api_methods/domain.py index e250d9809..63ba9d6ce 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/domain.py +++ b/backend/src/xfd_django/xfd_api/api_methods/domain.py @@ -26,7 +26,28 @@ LOGGER = logging.getLogger(__name__) -def get_domain_by_id(domain_id: str): +def apply_domain_access_filter(queryset, current_user): + """Restrict a domain queryset to objects the user is allowed to view.""" + if is_global_view_admin(current_user): + return queryset + + if current_user.user_type == UserType.REGIONAL_ADMIN: + if not current_user.region_id: + return queryset.none() + + region_org_ids = Organization.objects.filter( + region_id=current_user.region_id + ).values_list("id", flat=True) + return queryset.filter(organization_id__in=region_org_ids) + + organization_ids = get_org_memberships(current_user) + if not organization_ids: + return queryset.none() + + return queryset.filter(organization_id__in=organization_ids) + + +def get_domain_by_id(domain_id: str, current_user): """ Get domain by id. @@ -35,7 +56,8 @@ def get_domain_by_id(domain_id: str): """ try: domain = ( - Domain.objects.select_related("organization") + apply_domain_access_filter(Domain.objects, current_user) + .select_related("organization") .prefetch_related( "vulnerabilities", Prefetch( @@ -54,10 +76,14 @@ def get_domain_by_id(domain_id: str): .filter(id=domain_id) .first() ) + except Exception as e: LOGGER.error("Error occurred while fetching domain by ID: %s", e) raise HTTPException(status_code=404, detail="Domain not found.") + if domain is None: + raise HTTPException(status_code=404, detail="Domain not found.") + try: # The Domain model includes related fields (e.g., organization, vulnerabilities, services) # which are Django ORM objects themselves and cannot be directly serialized into JSON. @@ -183,26 +209,7 @@ def search_domains(domain_search: DomainSearch, current_user): DomainSearchView.objects.all(), domain_search.order, domain_search.sort ) - # Apply global user permission filters - if ( - not is_global_view_admin(current_user) - and not current_user.user_type == "regionalAdmin" - ): - orgs = get_org_memberships(current_user) - if not orgs: - return [], 0 - domains = domains.filter(organization_id__in=orgs) - - # Regional Admins can only view vulnerabilities in their region - if current_user.user_type == "regionalAdmin" and current_user.region_id: - # Get all organization IDs in this region - region_org_ids = list( - Organization.objects.filter( - region_id=current_user.region_id - ).values_list("id", flat=True) - ) - - domains = domains.filter(organization_id__in=region_org_ids) + domains = apply_domain_access_filter(domains, current_user) # Apply filters if provided if domain_search.filters: diff --git a/backend/src/xfd_django/xfd_api/views.py b/backend/src/xfd_django/xfd_api/views.py index 630f8c5fa..8ab5c5e90 100644 --- a/backend/src/xfd_django/xfd_api/views.py +++ b/backend/src/xfd_django/xfd_api/views.py @@ -470,9 +470,11 @@ async def call_export_domains( response_model=GetDomainResponse, tags=["Domains"], ) -async def call_get_domain_by_id(domain_id: str): +async def call_get_domain_by_id( + domain_id: str, current_user: User = Depends(get_current_active_user) +): """Get domain by id.""" - return get_domain_by_id(domain_id) + return get_domain_by_id(domain_id, current_user) # ======================================== From b1c9d00cc144087c301e703355270ad6a3589bcb Mon Sep 17 00:00:00 2001 From: Jesse Salinas Date: Tue, 20 Oct 2026 06:55:21 -0700 Subject: [PATCH 2/6] refs CRASM-4315: Add regression tests for domain access control --- .../xfd_django/xfd_api/tests/test_domain.py | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/backend/src/xfd_django/xfd_api/tests/test_domain.py b/backend/src/xfd_django/xfd_api/tests/test_domain.py index a368e631c..a70430721 100644 --- a/backend/src/xfd_django/xfd_api/tests/test_domain.py +++ b/backend/src/xfd_django/xfd_api/tests/test_domain.py @@ -24,6 +24,7 @@ Ip, IpsSubs, Organization, + Role, Service, ShodanAssets, ShodanVulns, @@ -119,6 +120,24 @@ def domain(sample_domain_ip_vuln, refresh_vuln_views): return Domain.objects.get(name="example.crossfeed.local") +@pytest.fixture +def foreign_domain(foreign_organization, refresh_vuln_views): + """Create a domain belonging to an organization the user cannot access.""" + data_source = DataSource.objects.create( + name="Foreign Test Source", + description="Used for cross-organization access tests", + last_run=datetime.now().date(), + ) + SubDomains.objects.create( + sub_domain="foreign.crossfeed.local", + reverse_name="local.crossfeed.foreign", + organization=foreign_organization, + data_source=data_source, + ) + refresh_vuln_views() + return Domain.objects.get(name="foreign.crossfeed.local") + + @pytest.fixture def user(): """Create user fixture.""" @@ -148,6 +167,19 @@ def organization(): yield organization +@pytest.fixture +def foreign_organization(): + """Create a second organization for access-control tests.""" + organization = Organization.objects.create( + name="Foreign Test Organization", + root_domains=["foreign.crossfeed.local"], + ip_blocks=[], + is_passive=False, + ) + transaction.commit() + yield organization + + # Create the views @pytest.fixture(autouse=True, scope="session") def ensure_vuln_views_created(django_db_setup, django_db_blocker): @@ -187,6 +219,94 @@ def test_get_domain_by_id(user, domain, refresh_vuln_views): assert data["ip"] == domain.ip +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_standard_user_can_get_domain_from_member_organization(user, domain): + """Standard users can retrieve domains from their organization memberships.""" + user.user_type = UserType.STANDARD + user.save(update_fields=["user_type"]) + Role.objects.create(user=user, organization=domain.organization, role="user") + + response = client.get( + "/domain/{}".format(domain.id), + headers={"Authorization": "Bearer " + create_jwt_token(user)}, + ) + + assert response.status_code == 200 + assert response.json()["id"] == str(domain.id) + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_standard_user_cannot_get_domain_from_other_organization( + user, domain, foreign_domain +): + """Standard users cannot retrieve domains outside their memberships.""" + user.user_type = UserType.STANDARD + user.save(update_fields=["user_type"]) + Role.objects.create(user=user, organization=domain.organization, role="user") + + response = client.get( + "/domain/{}".format(foreign_domain.id), + headers={"Authorization": "Bearer " + create_jwt_token(user)}, + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "Domain not found."} + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_user_without_memberships_cannot_get_domain(user, domain): + """Users without organization memberships cannot retrieve domains.""" + user.user_type = UserType.STANDARD + user.save(update_fields=["user_type"]) + + response = client.get( + "/domain/{}".format(domain.id), + headers={"Authorization": "Bearer " + create_jwt_token(user)}, + ) + + assert response.status_code == 404 + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_regional_admin_is_limited_to_their_region(user, domain): + """Regional administrators can only retrieve domains in their region.""" + domain.organization.region_id = "7" + domain.organization.save(update_fields=["region_id"]) + user.user_type = UserType.REGIONAL_ADMIN + user.region_id = "7" + user.save(update_fields=["user_type", "region_id"]) + + response = client.get( + "/domain/{}".format(domain.id), + headers={"Authorization": "Bearer " + create_jwt_token(user)}, + ) + assert response.status_code == 200 + + domain.organization.region_id = "8" + domain.organization.save(update_fields=["region_id"]) + + response = client.get( + "/domain/{}".format(domain.id), + headers={"Authorization": "Bearer " + create_jwt_token(user)}, + ) + assert response.status_code == 404 + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_global_view_user_can_get_any_domain(user, foreign_domain): + """Global view users can retrieve domains from any organization.""" + user.user_type = UserType.GLOBAL_VIEW + user.save(update_fields=["user_type"]) + + response = client.get( + "/domain/{}".format(foreign_domain.id), + headers={"Authorization": "Bearer " + create_jwt_token(user)}, + ) + + assert response.status_code == 200 + assert response.json()["id"] == str(foreign_domain.id) + + @pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) def test_get_domain_by_id_fails_404(user, domain, refresh_vuln_views): """Test domain by id to fail.""" From 3721720817354ebf65f101b6ff47ce3f0c0d86c8 Mon Sep 17 00:00:00 2001 From: Jesse Salinas Date: Wed, 21 Oct 2026 14:14:07 -0700 Subject: [PATCH 3/6] refs CRASM-4315: Expand domain authorization coverage --- .../xfd_django/xfd_api/tests/test_domain.py | 224 +++++++++++++++++- backend/src/xfd_django/xfd_api/views.py | 1 - 2 files changed, 222 insertions(+), 3 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/tests/test_domain.py b/backend/src/xfd_django/xfd_api/tests/test_domain.py index a70430721..fb342094d 100644 --- a/backend/src/xfd_django/xfd_api/tests/test_domain.py +++ b/backend/src/xfd_django/xfd_api/tests/test_domain.py @@ -10,6 +10,7 @@ from fastapi.testclient import TestClient import pytest from xfd_api.auth import create_jwt_token +from xfd_api.api_methods.domain import apply_domain_access_filter from xfd_api.tasks.helpers.syncdb_helpers.create_db_views import ( create_domain_materialized_view, create_domain_search_mat_view, @@ -37,7 +38,7 @@ LOGGER = logging.getLogger(__name__) -bad_id = "960b7db7-f3af-411d-a247-33371" +nonexistent_domain_id = "00000000-0000-0000-0000-000000000000" search_fields = { "port": "80", "reverse_name": "local.crossfeed.quizzical-wing", @@ -292,6 +293,21 @@ def test_regional_admin_is_limited_to_their_region(user, domain): assert response.status_code == 404 +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_regional_admin_without_region_cannot_get_domain(user, domain): + """Regional administrators without a region cannot retrieve domains.""" + user.user_type = UserType.REGIONAL_ADMIN + user.region_id = None + user.save(update_fields=["user_type", "region_id"]) + + response = client.get( + "/domain/{}".format(domain.id), + headers={"Authorization": "Bearer " + create_jwt_token(user)}, + ) + + assert response.status_code == 404 + + @pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) def test_global_view_user_can_get_any_domain(user, foreign_domain): """Global view users can retrieve domains from any organization.""" @@ -312,13 +328,217 @@ def test_get_domain_by_id_fails_404(user, domain, refresh_vuln_views): """Test domain by id to fail.""" # Get domain by Id. response = client.get( - "/domain/{}".format(bad_id), + "/domain/{}".format(nonexistent_domain_id), headers={"Authorization": "Bearer " + create_jwt_token(user)}, ) assert response.status_code == 404 +def test_get_domain_by_id_unauthenticated(): + """Unauthenticated users cannot retrieve a domain.""" + response = client.get("/domain/{}".format(nonexistent_domain_id)) + + assert response.status_code == 401 + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_search_domains_standard_user_sees_member_organizations( + user, domain, foreign_domain +): + """Standard users only see domains from their organization memberships.""" + user.user_type = UserType.STANDARD + user.save(update_fields=["user_type"]) + Role.objects.create(user=user, organization=domain.organization, role="user") + + response = client.post( + "/domain/search", + json={"page": 1, "page_size": 25}, + headers={"Authorization": "Bearer " + create_jwt_token(user)}, + ) + + assert response.status_code == 200 + assert [result["id"] for result in response.json()["result"]] == [ + str(domain.id) + ] + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_search_domains_standard_user_cannot_filter_foreign_organization( + user, domain, foreign_domain +): + """Standard users cannot bypass access with a foreign organization filter.""" + user.user_type = UserType.STANDARD + user.save(update_fields=["user_type"]) + Role.objects.create(user=user, organization=domain.organization, role="user") + + response = client.post( + "/domain/search", + json={ + "page": 1, + "filters": {"organization": str(foreign_domain.organization.id)}, + "page_size": 25, + }, + headers={"Authorization": "Bearer " + create_jwt_token(user)}, + ) + + assert response.status_code == 200 + assert response.json()["result"] == [] + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_search_domains_regional_admin_sees_only_their_region( + user, domain, foreign_domain +): + """Regional administrators only see domains in their region.""" + domain.organization.region_id = "7" + domain.organization.save(update_fields=["region_id"]) + foreign_domain.organization.region_id = "8" + foreign_domain.organization.save(update_fields=["region_id"]) + user.user_type = UserType.REGIONAL_ADMIN + user.region_id = "7" + user.save(update_fields=["user_type", "region_id"]) + + response = client.post( + "/domain/search", + json={"page": 1, "page_size": 25}, + headers={"Authorization": "Bearer " + create_jwt_token(user)}, + ) + + assert response.status_code == 200 + assert [result["id"] for result in response.json()["result"]] == [ + str(domain.id) + ] + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_search_domains_regional_admin_cannot_see_other_region( + user, domain, foreign_domain +): + """Regional administrators cannot see domains outside their region.""" + domain.organization.region_id = "7" + domain.organization.save(update_fields=["region_id"]) + foreign_domain.organization.region_id = "8" + foreign_domain.organization.save(update_fields=["region_id"]) + user.user_type = UserType.REGIONAL_ADMIN + user.region_id = "7" + user.save(update_fields=["user_type", "region_id"]) + + response = client.post( + "/domain/search", + json={ + "page": 1, + "filters": {"organization": str(foreign_domain.organization.id)}, + "page_size": 25, + }, + headers={"Authorization": "Bearer " + create_jwt_token(user)}, + ) + + assert response.status_code == 200 + assert response.json()["result"] == [] + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_search_domains_global_view_user_sees_any_organization( + user, domain, foreign_domain +): + """Global-view users can see domains from every organization.""" + user.user_type = UserType.GLOBAL_VIEW + user.save(update_fields=["user_type"]) + + response = client.post( + "/domain/search", + json={"page": 1, "page_size": 25}, + headers={"Authorization": "Bearer " + create_jwt_token(user)}, + ) + + assert response.status_code == 200 + assert {result["id"] for result in response.json()["result"]} == { + str(domain.id), + str(foreign_domain.id), + } + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_search_domains_user_without_memberships_sees_no_domains(user, domain): + """Users without organization memberships see no domains.""" + user.user_type = UserType.STANDARD + user.save(update_fields=["user_type"]) + + response = client.post( + "/domain/search", + json={"page": 1, "page_size": 25}, + headers={"Authorization": "Bearer " + create_jwt_token(user)}, + ) + + assert response.status_code == 200 + assert response.json()["result"] == [] + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_apply_domain_access_filter_global_users_bypass(user, domain, foreign_domain): + """Global admin and global-view users can access every domain.""" + queryset = Domain.objects.all() + + for user_type in (UserType.GLOBAL_ADMIN, UserType.GLOBAL_VIEW): + user.user_type = user_type + user.save(update_fields=["user_type"]) + assert set( + apply_domain_access_filter(queryset, user).values_list("id", flat=True) + ) == {domain.id, foreign_domain.id} + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_apply_domain_access_filter_standard_memberships(user, domain, foreign_domain): + """Standard users are filtered to their organization memberships.""" + user.user_type = UserType.STANDARD + user.save(update_fields=["user_type"]) + Role.objects.create(user=user, organization=domain.organization, role="user") + + assert list( + apply_domain_access_filter(Domain.objects.all(), user).values_list( + "id", flat=True + ) + ) == [domain.id] + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_apply_domain_access_filter_empty_memberships(user, domain): + """Standard users without memberships receive an empty queryset.""" + user.user_type = UserType.STANDARD + user.save(update_fields=["user_type"]) + + assert not apply_domain_access_filter(Domain.objects.all(), user).exists() + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_apply_domain_access_filter_regional_membership(user, domain, foreign_domain): + """Regional administrators are filtered to organizations in their region.""" + domain.organization.region_id = "7" + domain.organization.save(update_fields=["region_id"]) + foreign_domain.organization.region_id = "8" + foreign_domain.organization.save(update_fields=["region_id"]) + user.user_type = UserType.REGIONAL_ADMIN + user.region_id = "7" + user.save(update_fields=["user_type", "region_id"]) + + assert list( + apply_domain_access_filter(Domain.objects.all(), user).values_list( + "id", flat=True + ) + ) == [domain.id] + + +@pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) +def test_apply_domain_access_filter_missing_regional_assignment(user, domain): + """Regional administrators without a region receive an empty queryset.""" + user.user_type = UserType.REGIONAL_ADMIN + user.region_id = None + user.save(update_fields=["user_type", "region_id"]) + + assert not apply_domain_access_filter(Domain.objects.all(), user).exists() + + @pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) def test_search_domain_by_ip(user, domain, refresh_vuln_views): """Test domain by ip.""" diff --git a/backend/src/xfd_django/xfd_api/views.py b/backend/src/xfd_django/xfd_api/views.py index 8ab5c5e90..bb76608ee 100644 --- a/backend/src/xfd_django/xfd_api/views.py +++ b/backend/src/xfd_django/xfd_api/views.py @@ -466,7 +466,6 @@ async def call_export_domains( @api_router.get( "/domain/{domain_id}", - dependencies=[Depends(get_current_active_user)], response_model=GetDomainResponse, tags=["Domains"], ) From 964d922b8137ff338f1d598c02f794673c4ea583 Mon Sep 17 00:00:00 2001 From: Jesse Salinas Date: Fri, 23 Oct 2026 15:58:14 -0700 Subject: [PATCH 4/6] refs CRASM-4315: Add approved domain error pages --- .../xfd_django/xfd_api/api_methods/domain.py | 11 ++++ .../xfd_django/xfd_api/tests/test_domain.py | 10 ++-- frontend/src/components/Routes/Routes.tsx | 9 ++- frontend/src/constants/routes.ts | 2 + frontend/src/pages/Domain/DomainDetails.tsx | 10 +++- frontend/src/pages/Errors/ErrorPage.tsx | 58 +++++++++++++++++++ frontend/src/pages/Errors/Forbidden.tsx | 10 ++++ frontend/src/pages/Errors/NotFound.tsx | 10 ++++ frontend/src/pages/Errors/index.ts | 3 + frontend/src/pages/index.ts | 1 + .../tests/pages/Errors/errorPages.test.tsx | 34 +++++++++++ 11 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 frontend/src/pages/Errors/ErrorPage.tsx create mode 100644 frontend/src/pages/Errors/Forbidden.tsx create mode 100644 frontend/src/pages/Errors/NotFound.tsx create mode 100644 frontend/src/pages/Errors/index.ts create mode 100644 frontend/src/tests/pages/Errors/errorPages.test.tsx diff --git a/backend/src/xfd_django/xfd_api/api_methods/domain.py b/backend/src/xfd_django/xfd_api/api_methods/domain.py index 63ba9d6ce..316e6b034 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/domain.py +++ b/backend/src/xfd_django/xfd_api/api_methods/domain.py @@ -82,6 +82,17 @@ def get_domain_by_id(domain_id: str, current_user): raise HTTPException(status_code=404, detail="Domain not found.") if domain is None: + try: + domain_exists = Domain.objects.filter(id=domain_id).exists() + except Exception as e: + LOGGER.error("Error occurred while checking domain existence: %s", e) + raise HTTPException(status_code=404, detail="Domain not found.") + + if domain_exists: + raise HTTPException( + status_code=403, detail="You do not have access to this domain." + ) + raise HTTPException(status_code=404, detail="Domain not found.") try: diff --git a/backend/src/xfd_django/xfd_api/tests/test_domain.py b/backend/src/xfd_django/xfd_api/tests/test_domain.py index fb342094d..931f80877 100644 --- a/backend/src/xfd_django/xfd_api/tests/test_domain.py +++ b/backend/src/xfd_django/xfd_api/tests/test_domain.py @@ -250,8 +250,8 @@ def test_standard_user_cannot_get_domain_from_other_organization( headers={"Authorization": "Bearer " + create_jwt_token(user)}, ) - assert response.status_code == 404 - assert response.json() == {"detail": "Domain not found."} + assert response.status_code == 403 + assert response.json() == {"detail": "You do not have access to this domain."} @pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) @@ -265,7 +265,7 @@ def test_user_without_memberships_cannot_get_domain(user, domain): headers={"Authorization": "Bearer " + create_jwt_token(user)}, ) - assert response.status_code == 404 + assert response.status_code == 403 @pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) @@ -290,7 +290,7 @@ def test_regional_admin_is_limited_to_their_region(user, domain): "/domain/{}".format(domain.id), headers={"Authorization": "Bearer " + create_jwt_token(user)}, ) - assert response.status_code == 404 + assert response.status_code == 403 @pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) @@ -305,7 +305,7 @@ def test_regional_admin_without_region_cannot_get_domain(user, domain): headers={"Authorization": "Bearer " + create_jwt_token(user)}, ) - assert response.status_code == 404 + assert response.status_code == 403 @pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) diff --git a/frontend/src/components/Routes/Routes.tsx b/frontend/src/components/Routes/Routes.tsx index 206f9adfb..156f1887c 100644 --- a/frontend/src/components/Routes/Routes.tsx +++ b/frontend/src/components/Routes/Routes.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Switch } from 'react-router-dom'; +import { Route, Switch } from 'react-router-dom'; import { RouteGuard } from 'components/Routes/RouteGuard'; import { AdminTools, @@ -13,7 +13,9 @@ import { Settings, Users, Vulnerabilities, - Vulnerability + Vulnerability, + Forbidden, + NotFound } from 'pages'; import { VulnerabilityScanWithSearch } from '../Gates/VSDashboardGate'; import { ROUTES } from '@/constants/routes'; @@ -21,6 +23,8 @@ import { ROUTES } from '@/constants/routes'; export const Routes: React.FC = () => { return ( + + { component={RegionUsers} permissions={['regionalAdmin', 'globalView']} /> + ); }; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 2b43d63a4..ccf9aa0a4 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -4,6 +4,7 @@ export const ROUTES = { DOMAIN: '/inventory/domain/:domainId', DOMAINS: '/inventory/domains', FEEDS: '/feeds', + FORBIDDEN: '/403', GLOBAL_ADMIN_DASHBOARD: '/global-admin-dashboard', GLOBAL_VIEW_DASHBOARD: '/global-view-dashboard', HOME: '/', @@ -18,6 +19,7 @@ export const ROUTES = { SCANS: '/scans', SCANS_HISTORY: '/scans/history', SETTINGS: '/settings', + NOT_FOUND: '/404', USERS: '/users', VSDASHBOARD: '/VSDashboard', VULNERABILITIES: '/inventory/vulnerabilities', diff --git a/frontend/src/pages/Domain/DomainDetails.tsx b/frontend/src/pages/Domain/DomainDetails.tsx index 5542e814c..be7f28a29 100644 --- a/frontend/src/pages/Domain/DomainDetails.tsx +++ b/frontend/src/pages/Domain/DomainDetails.tsx @@ -18,6 +18,7 @@ import ExpandMore from '@mui/icons-material/ExpandMore'; import KeyboardBackspace from '@mui/icons-material/KeyboardBackspace'; import { Domain } from 'types'; import { useDomainApi } from 'hooks'; +import { isApiError } from '@/hooks/useApi'; import { DefinitionList } from 'components/DefinitionList'; // @ts-ignore:next-line import { differenceInCalendarDays, parseISO } from 'date-fns'; @@ -159,8 +160,15 @@ export const DomainDetails: React.FC = (props) => { setDomain(result); } catch (e) { logger.error('DomainDetails.fetchDomain failed:', { error: e, domainId }); + if (isApiError(e)) { + if (e.status === 403) { + history.replace(ROUTES.FORBIDDEN); + } else if (e.status === 404) { + history.replace(ROUTES.NOT_FOUND); + } + } } - }, [domainId, getDomain]); + }, [domainId, getDomain, history]); useEffect(() => { fetchDomain(); diff --git a/frontend/src/pages/Errors/ErrorPage.tsx b/frontend/src/pages/Errors/ErrorPage.tsx new file mode 100644 index 000000000..f2d8b9dc2 --- /dev/null +++ b/frontend/src/pages/Errors/ErrorPage.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import { useHistory } from 'react-router-dom'; +import { ROUTES } from '@/constants/routes'; + +interface ErrorPageProps { + code: '403' | '404'; + title: string; + message: string; +} + +export const ErrorPage: React.FC = ({ + code, + title, + message +}) => { + const history = useHistory(); + + return ( + + + + {code} + + + {title} + + + {message} + + + + + + + + ); +}; diff --git a/frontend/src/pages/Errors/Forbidden.tsx b/frontend/src/pages/Errors/Forbidden.tsx new file mode 100644 index 000000000..0acc46974 --- /dev/null +++ b/frontend/src/pages/Errors/Forbidden.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { ErrorPage } from './ErrorPage'; + +export const Forbidden: React.FC = () => ( + +); diff --git a/frontend/src/pages/Errors/NotFound.tsx b/frontend/src/pages/Errors/NotFound.tsx new file mode 100644 index 000000000..6cc094ee1 --- /dev/null +++ b/frontend/src/pages/Errors/NotFound.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { ErrorPage } from './ErrorPage'; + +export const NotFound: React.FC = () => ( + +); diff --git a/frontend/src/pages/Errors/index.ts b/frontend/src/pages/Errors/index.ts new file mode 100644 index 000000000..cab817f0b --- /dev/null +++ b/frontend/src/pages/Errors/index.ts @@ -0,0 +1,3 @@ +export * from './ErrorPage'; +export * from './Forbidden'; +export * from './NotFound'; diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index c97b4e1c4..dea0b1ee2 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -5,6 +5,7 @@ export * from './Scans'; export * from './Search'; export * from '../components/Dialog/TermsOfUse'; export * from './Vulnerability'; +export * from './Errors'; export { default as AdminTools } from './AdminTools'; export { default as Notifications } from './Notifications'; export { default as Organization } from './Organization'; diff --git a/frontend/src/tests/pages/Errors/errorPages.test.tsx b/frontend/src/tests/pages/Errors/errorPages.test.tsx new file mode 100644 index 000000000..92948ba50 --- /dev/null +++ b/frontend/src/tests/pages/Errors/errorPages.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; +import { Forbidden, NotFound } from 'pages/Errors'; + +describe('error pages', () => { + it('renders the forbidden page', () => { + render( + + + + ); + + expect(screen.getByText('403')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Access denied' })).toBeInTheDocument(); + expect( + screen.getByText('You do not have permission to view this page.') + ).toBeInTheDocument(); + }); + + it('renders the not-found page', () => { + render( + + + + ); + + expect(screen.getByText('404')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Page not found' })).toBeInTheDocument(); + expect( + screen.getByText('The page or resource you requested could not be found.') + ).toBeInTheDocument(); + }); +}); From 9acc35db6ff615c8a892ae10eb781d27783e6173 Mon Sep 17 00:00:00 2001 From: Jesse Salinas Date: Mon, 26 Oct 2026 06:21:04 -0700 Subject: [PATCH 5/6] refs CRASM-4315: Pre-commit checks fix --- backend/src/xfd_django/xfd_api/tests/test_domain.py | 10 +++------- frontend/src/tests/pages/Errors/errorPages.test.tsx | 8 ++++++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/tests/test_domain.py b/backend/src/xfd_django/xfd_api/tests/test_domain.py index 931f80877..d07214181 100644 --- a/backend/src/xfd_django/xfd_api/tests/test_domain.py +++ b/backend/src/xfd_django/xfd_api/tests/test_domain.py @@ -9,8 +9,8 @@ from django.db import transaction from fastapi.testclient import TestClient import pytest -from xfd_api.auth import create_jwt_token from xfd_api.api_methods.domain import apply_domain_access_filter +from xfd_api.auth import create_jwt_token from xfd_api.tasks.helpers.syncdb_helpers.create_db_views import ( create_domain_materialized_view, create_domain_search_mat_view, @@ -358,9 +358,7 @@ def test_search_domains_standard_user_sees_member_organizations( ) assert response.status_code == 200 - assert [result["id"] for result in response.json()["result"]] == [ - str(domain.id) - ] + assert [result["id"] for result in response.json()["result"]] == [str(domain.id)] @pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) @@ -406,9 +404,7 @@ def test_search_domains_regional_admin_sees_only_their_region( ) assert response.status_code == 200 - assert [result["id"] for result in response.json()["result"]] == [ - str(domain.id) - ] + assert [result["id"] for result in response.json()["result"]] == [str(domain.id)] @pytest.mark.django_db(transaction=True, databases=["default", "mini_data_lake"]) diff --git a/frontend/src/tests/pages/Errors/errorPages.test.tsx b/frontend/src/tests/pages/Errors/errorPages.test.tsx index 92948ba50..3088eec25 100644 --- a/frontend/src/tests/pages/Errors/errorPages.test.tsx +++ b/frontend/src/tests/pages/Errors/errorPages.test.tsx @@ -12,7 +12,9 @@ describe('error pages', () => { ); expect(screen.getByText('403')).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Access denied' })).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Access denied' }) + ).toBeInTheDocument(); expect( screen.getByText('You do not have permission to view this page.') ).toBeInTheDocument(); @@ -26,7 +28,9 @@ describe('error pages', () => { ); expect(screen.getByText('404')).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Page not found' })).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Page not found' }) + ).toBeInTheDocument(); expect( screen.getByText('The page or resource you requested could not be found.') ).toBeInTheDocument(); From a60b42b18ab2b93738bd8c9615db5ba19ddf833e Mon Sep 17 00:00:00 2001 From: Jesse Salinas Date: Mon, 26 Oct 2026 08:18:41 -0700 Subject: [PATCH 6/6] refs CRASM-4315: Cover domain error redirects --- .../tests/pages/Domain/domainDetails.test.tsx | 114 +++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/frontend/src/tests/pages/Domain/domainDetails.test.tsx b/frontend/src/tests/pages/Domain/domainDetails.test.tsx index 9ad0048d1..3dc15c6e4 100644 --- a/frontend/src/tests/pages/Domain/domainDetails.test.tsx +++ b/frontend/src/tests/pages/Domain/domainDetails.test.tsx @@ -1,5 +1,38 @@ -import { generateWebpageTree } from 'pages/Domains/DomainDetails'; -import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ApiError } from '@/hooks/useApi'; +import { useAuthContext } from 'context'; +import { CFThemeProvider } from 'context/Theme'; +import { useDomainApi } from 'hooks'; +import { DomainDetails, generateWebpageTree } from 'pages/Domain/DomainDetails'; + +const { mockGetDomain } = vi.hoisted(() => ({ + mockGetDomain: vi.fn() +})); + +vi.mock('hooks', () => ({ + useDomainApi: vi.fn() +})); + +vi.mock('context', () => ({ + useAuthContext: vi.fn() +})); + +const mockUseDomainApi = vi.mocked(useDomainApi); +const mockUseAuthContext = vi.mocked(useAuthContext); + +beforeEach(() => { + vi.clearAllMocks(); + mockUseDomainApi.mockReturnValue({ + getDomain: mockGetDomain, + listDomains: vi.fn() + } as ReturnType); + mockUseAuthContext.mockReturnValue({ + user: { user_type: 'standard' } + } as ReturnType); +}); describe('generateWebpageTree', () => { it('no path', () => { @@ -39,3 +72,80 @@ describe('generateWebpageTree', () => { expect(tree).toMatchSnapshot(); }); }); + +describe('DomainDetails API error handling', () => { + it('redirects a forbidden response to the 403 page', async () => { + const history = createMemoryHistory({ + initialEntries: ['/inventory/domain/domain-1'] + }); + mockGetDomain.mockRejectedValueOnce( + new ApiError( + new Response(null, { status: 403, statusText: 'Forbidden' }), + { detail: 'You do not have access to this domain.' } + ) + ); + + render( + + + + + + ); + + await waitFor(() => expect(history.location.pathname).toBe('/403')); + }); + + it('redirects a not-found response to the 404 page', async () => { + const history = createMemoryHistory({ + initialEntries: ['/inventory/domain/domain-1'] + }); + mockGetDomain.mockRejectedValueOnce( + new ApiError( + new Response(null, { status: 404, statusText: 'Not Found' }), + { detail: 'Domain not found.' } + ) + ); + + render( + + + + + + ); + + await waitFor(() => expect(history.location.pathname).toBe('/404')); + }); + + it('keeps authorized domain details visible for a successful response', async () => { + mockGetDomain.mockResolvedValueOnce({ + name: 'example.com', + ip: '192.0.2.1', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + country: null, + cloud_hosted: false, + organization: { name: 'Example Organization' }, + services: [], + vulnerabilities: [], + webpages: [] + }); + const history = createMemoryHistory({ + initialEntries: ['/inventory/domain/domain-1'] + }); + + render( + + + + + + ); + + expect( + await screen.findByRole('heading', { name: 'example.com' }) + ).toBeInTheDocument(); + expect(history.location.pathname).toBe('/inventory/domain/domain-1'); + }); +});