CVE-2023-36827: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: Fides is an open-source privacy engineering platform for managing the fulfillment of data privacy requests in a runtime environment, and the enforcement of privacy regulations in code. A path traversal (directory traversal) vulnerability affects fides versions lower than version `2.15.1`, allowing remote attackers to access arbitrary files on the fides webserver container's filesystem. The vulnerability is patched in fides `2.15.1`.

If the Fides webserver API is not directly accessible to attackers and is instead deployed behind a reverse proxy as recommended in Ethyca's security best practice documentation, and the reverse proxy is an AWS application load balancer, the vulnerability can't be exploited by these attackers. An AWS application load balancer will reject this attack with a 400 error. Additionally, any secrets supplied to the container using environment variables rather than a `fides.toml` configuration file are not affected by this vulnerability.

CWE description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
CVSS v3 base score: 7.5
Published: 2023-07-05T22:15Z
Repository: https://github.com/ethyca/fides
Language: Python
Files changed: src/fides/api/common_exceptions.py, src/fides/api/main.py, tests/ops/util/test_api_router.py
Commit date: 2023-07-05 15:53:48 +0100
Commit message: Merge pull request from GHSA-r25m-cr6v-p9hq

* fix for vuln

* Apply suggestions from code review

Use correct spelling of malicious.

Co-authored-by: Adam Sachs <adam@ethyca.com>

* Update src/fides/api/main.py

Verbiage

Co-authored-by: Thomas <thomas.lapiana+github@pm.me>

---------

Co-authored-by: Adam Sachs <adam@ethyca.com>
Co-authored-by: Thomas <thomas.lapiana+github@pm.me>
Co-authored-by: Dave Quinlan <83430497+daveqnet@users.noreply.github.com>
Diff: diff --git a/src/fides/api/common_exceptions.py b/src/fides/api/common_exceptions.py
index c12d5294..45e7937f 100644
--- a/src/fides/api/common_exceptions.py
+++ b/src/fides/api/common_exceptions.py
@@ -221,10 +221,14 @@ class NoSuchConnectionTypeSecretSchemaError(Exception):
 
 class SSHTunnelConfigNotFoundException(Exception):
     """Exception for when Fides is configured to use an SSH tunnel without config provided."""
 
 
+class MalisciousUrlException(Exception):
+    """Fides has detected a potentially maliscious URL."""
+
+
 class AuthenticationError(HTTPException):
     """To be raised when attempting to fetch an access token using
     invalid credentials.
     """
 
diff --git a/src/fides/api/main.py b/src/fides/api/main.py
index 809d843c..83e5dec2 100644
--- a/src/fides/api/main.py
+++ b/src/fides/api/main.py
@@ -1,27 +1,30 @@
 """
 Contains the code that sets up the API.
 """
+import os
 import sys
 from datetime import datetime, timezone
 from logging import WARNING
 from typing import Callable, Optional
 
 from fastapi import HTTPException, Request, Response, status
 from fastapi.responses import FileResponse
 from fideslog.sdk.python.event import AnalyticsEvent
 from loguru import logger
 from starlette.background import BackgroundTask
+from urllib.parse import unquote
 from uvicorn import Config, Server
 
 import fides
 from fides.api.app_setup import (
     check_redis,
     create_fides_app,
     log_startup,
     run_database_startup,
 )
+from fides.api.common_exceptions import MalisciousUrlException
 from fides.api.middleware import handle_audit_log_resource
 from fides.api.schemas.analytics import Event, ExtraData
 
 # pylint: disable=wildcard-import, unused-wildcard-import
 from fides.api.service.privacy_request.email_batch_service import (
@@ -149,17 +152,34 @@ def read_index() -> Response:
     """
 
     return get_admin_index_as_response()
 
 
+def sanitise_url_path(path: str) -> str:
+    """Returns a URL path that does not contain any ../ or //"""
+    path = unquote(path)
+    path = os.path.normpath(path)
+    for token in path.split("/"):
+        if ".." in token:
+            logger.warning(f"Potentially dangerous use of URL hierarchy in path: {path}")
+            raise MalisciousUrlException()
+    return path
+
+
 @app.get("/{catchall:path}", response_class=Response, tags=["Default"])
 def read_other_paths(request: Request) -> Response:
     """
     Return related frontend files. Adapted from https://github.com/tiangolo/fastapi/issues/130
     """
     # check first if requested file exists (for frontend assets)
     path = request.path_params["catchall"]
+    logger.debug(f"Catch all path detected: {path}")
+    try:
+        path = sanitise_url_path(path)
+    except MalisciousUrlException:
+        # if a maliscious URL is detected, route the user to the index
+        return get_admin_index_as_response()
 
     # search for matching route in package (i.e. /dataset)
     ui_file = match_route(get_ui_file_map(), path)
 
     # if not, check if the requested file is an asset (i.e. /_next/static/...)
diff --git a/tests/ops/util/test_api_router.py b/tests/ops/util/test_api_router.py
index d468b947..e1872fb1 100644
--- a/tests/ops/util/test_api_router.py
+++ b/tests/ops/util/test_api_router.py
@@ -43,5 +43,26 @@ class TestApiRouter:
 
         resp_4 = api_client.get(
             f"{V1_URL_PREFIX}/route/does/not/exist/", headers=auth_header
         )
         assert resp_4.status_code == HTTP_404_NOT_FOUND
+
+    def test_malicious_url(
+        self,
+        api_client: TestClient,
+        url,
+    ) -> None:
+        malicious_paths = [
+            "../../../../../../../../../etc/passwd",
+            "..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2fetc/passwd",
+            "%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd",
+            "%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc/passwd",
+            "..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f/etc/passwd",
+            ".../...//.../...//.../...//.../...//.../...//.../...//.../...//.../...//.../...//etc/passwd",
+            "...%2f...%2f%2f...%2f...%2f%2f...%2f...%2f%2f...%2f...%2f%2f...%2f...%2f%2f...%2f...%2f%2f...%2f...%2f%2f...%2f...%2f%2f...%2f...%2f%2fetc/passwd",
+            "%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//etc/passwd",
+            "%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2fetc/passwd",
+        ]
+        for path in malicious_paths:
+            resp = api_client.get(f"{url}/{path}")
+            assert resp.status_code == 200
+            assert resp.text == "<h1>Privacy is a Human Right!</h1>"

Fixed code: class MalisciousUrlException(Exception):
    """Fides has detected a potentially maliscious URL."""


import os
from urllib.parse import unquote
from fides.api.common_exceptions import MalisciousUrlException
def sanitise_url_path(path: str) -> str:
    """Returns a URL path that does not contain any ../ or //"""
    path = unquote(path)
    path = os.path.normpath(path)
    for token in path.split("/"):
        if ".." in token:
            logger.warning(f"Potentially dangerous use of URL hierarchy in path: {path}")
            raise MalisciousUrlException()
    return path


    logger.debug(f"Catch all path detected: {path}")
    try:
        path = sanitise_url_path(path)
    except MalisciousUrlException:
        # if a maliscious URL is detected, route the user to the index
        return get_admin_index_as_response()

    def test_malicious_url(
        self,
        api_client: TestClient,
        url,
    ) -> None:
        malicious_paths = [
            "../../../../../../../../../etc/passwd",
            "..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2fetc/passwd",
            "%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd",
            "%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc/passwd",
            "..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f..%c0%2f/etc/passwd",
            ".../...//.../...//.../...//.../...//.../...//.../...//.../...//.../...//.../...//etc/passwd",
            "...%2f...%2f%2f...%2f...%2f%2f...%2f...%2f%2f...%2f...%2f%2f...%2f...%2f%2f...%2f...%2f%2f...%2f...%2f%2f...%2f...%2f%2f...%2f...%2f%2fetc/passwd",
            "%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//%2e%2e%2e/%2e%2e%2e//etc/passwd",
            "%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2f%2e%2e%2e%2f%2e%2e%2e%2f%2fetc/passwd",
        ]
        for path in malicious_paths:
            resp = api_client.get(f"{url}/{path}")
            assert resp.status_code == 200
            assert resp.text == "<h1>Privacy is a Human Right!</h1>"