VYPR
Medium severity4.7NVD Advisory· Published Mar 26, 2026· Updated Apr 1, 2026

CVE-2026-33682

CVE-2026-33682

Description

Streamlit is a data oriented application development framework for python. Streamlit Open Source versions prior to 1.54.0 running on Windows hosts have an unauthenticated Server-Side Request Forgery (SSRF) vulnerability. The vulnerability arises from improper validation of attacker-supplied filesystem paths. In certain code paths, including within the ComponentRequestHandler, filesystem paths are resolved using os.path.realpath() or Path.resolve() before sufficient validation occurs. On Windows systems, supplying a malicious UNC path (e.g., \\attacker-controlled-host\share) can cause the Streamlit server to initiate outbound SMB connections over port 445. When Windows attempts to authenticate to the remote SMB server, NTLMv2 challenge-response credentials of the Windows user running the Streamlit process may be transmitted. This behavior may allow an attacker to perform NTLM relay attacks against other internal services and/or identify internally reachable SMB hosts via timing analysis. The vulnerability has been fixed in Streamlit Open Source version 1.54.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
StreamlitPyPI
< 1.54.01.54.0

Affected products

1

Patches

1
23692ca70b2f

[security] Prevent SSRF attacks via path traversal in component file handling (#13733)

https://github.com/streamlit/streamlitNico BellanteFeb 3, 2026via ghsa
18 files changed · +998 92
  • lib/streamlit/components/v2/component_path_utils.py+17 29 modified
    @@ -22,12 +22,12 @@
     
     from __future__ import annotations
     
    -import os
     from pathlib import Path
     from typing import Final
     
     from streamlit.errors import StreamlitComponentRegistryError
     from streamlit.logger import get_logger
    +from streamlit.path_security import is_unsafe_path_pattern
     
     _LOGGER: Final = get_logger(__name__)
     
    @@ -132,7 +132,12 @@ def resolve_glob_pattern(pattern: str, package_root: Path) -> Path:
     
         @staticmethod
         def _assert_relative_no_traversal(path: str, *, label: str) -> None:
    -        """Raise if ``path`` is absolute or contains ``..`` segments.
    +        """Raise if ``path`` is absolute, contains traversal, or has unsafe patterns.
    +
    +        This method uses the shared ``is_unsafe_path_pattern`` function to ensure
    +        consistent security checks across the codebase. The shared function also
    +        checks for additional patterns like null bytes, forward-slash UNC paths,
    +        and drive-relative paths (e.g., ``C:foo``).
     
             Parameters
             ----------
    @@ -141,34 +146,17 @@ def _assert_relative_no_traversal(path: str, *, label: str) -> None:
             label : str
                 Human-readable label used in error messages (e.g., "component paths").
             """
    -        # Absolute path checks (POSIX, Windows drive-letter, UNC)
    -        is_windows_drive_abs = (
    -            len(path) >= 3
    -            and path[0].isalpha()
    -            and path[1] == ":"
    -            and path[2] in {"/", "\\"}
    -        )
    -        is_unc_abs = path.startswith("\\\\")
    -
    -        # Consider rooted backslash paths "\\dir" as absolute on Windows-like inputs
    -        is_rooted_backslash = path.startswith("\\") and not is_unc_abs
    -
    -        if (
    -            os.path.isabs(path)
    -            or is_windows_drive_abs
    -            or is_unc_abs
    -            or is_rooted_backslash
    -        ):
    -            raise StreamlitComponentRegistryError(
    -                f"Absolute paths are not allowed in {label}: {path}"
    -            )
    -
    -        # Segment-based traversal detection to avoid false positives (e.g. "file..js")
    -        normalized = path.replace("\\", "/")
    -        segments = [seg for seg in normalized.split("/") if seg != ""]
    -        if any(seg == ".." for seg in segments):
    +        if is_unsafe_path_pattern(path):
    +            # Determine appropriate error message based on pattern.
    +            # Use segment-based check to avoid false positives like "file..js"
    +            normalized = path.replace("\\", "/")
    +            segments = [seg for seg in normalized.split("/") if seg]
    +            if ".." in segments:
    +                raise StreamlitComponentRegistryError(
    +                    f"Path traversal attempts are not allowed in {label}: {path}"
    +                )
                 raise StreamlitComponentRegistryError(
    -                f"Path traversal attempts are not allowed in {label}: {path}"
    +                f"Unsafe paths are not allowed in {label}: {path}"
                 )
     
         @staticmethod
    
  • lib/streamlit/path_security.py+98 0 added
    @@ -0,0 +1,98 @@
    +# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026)
    +#
    +# Licensed under the Apache License, Version 2.0 (the "License");
    +# you may not use this file except in compliance with the License.
    +# You may obtain a copy of the License at
    +#
    +#     http://www.apache.org/licenses/LICENSE-2.0
    +#
    +# Unless required by applicable law or agreed to in writing, software
    +# distributed under the License is distributed on an "AS IS" BASIS,
    +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +# See the License for the specific language governing permissions and
    +# limitations under the License.
    +
    +"""Shared path security utilities for preventing path traversal and SSRF attacks.
    +
    +This module provides a centralized implementation for path validation that is
    +used by multiple parts of the codebase. Having a single implementation ensures
    +consistent security checks and avoids divergent behavior between components.
    +
    +Security Context
    +----------------
    +These checks are designed to run BEFORE any filesystem operations (like
    +``os.path.realpath()``) to prevent Windows from triggering SMB connections
    +to attacker-controlled servers when resolving UNC paths. This prevents
    +SSRF attacks and NTLM hash disclosure.
    +"""
    +
    +from __future__ import annotations
    +
    +import os
    +import string
    +
    +
    +def is_unsafe_path_pattern(path: str) -> bool:
    +    r"""Return True if path contains UNC, absolute, drive, or traversal patterns.
    +
    +    This function checks for dangerous path patterns that could lead to:
    +    - SSRF attacks via Windows UNC path resolution
    +    - NTLM hash disclosure via SMB connections
    +    - Path traversal outside intended directories
    +    - Path truncation via null bytes
    +
    +    IMPORTANT: This check must run BEFORE any ``os.path.realpath()`` calls
    +    to prevent Windows from triggering SMB connections to attacker-controlled
    +    servers.
    +
    +    Parameters
    +    ----------
    +    path : str
    +        The path string to validate.
    +
    +    Returns
    +    -------
    +    bool
    +        True if the path contains unsafe patterns, False if it appears safe
    +        for further processing.
    +
    +    Examples
    +    --------
    +    >>> is_unsafe_path_pattern("subdir/file.js")
    +    False
    +    >>> is_unsafe_path_pattern("\\\\server\\share")
    +    True
    +    >>> is_unsafe_path_pattern("../../../etc/passwd")
    +    True
    +    >>> is_unsafe_path_pattern("C:\\Windows\\system32")
    +    True
    +    """
    +    # Null bytes can be used for path truncation attacks
    +    if "\x00" in path:
    +        return True
    +
    +    # UNC paths (Windows network shares, including \\?\ and \\.\ prefixes)
    +    if path.startswith(("\\\\", "//")):
    +        return True
    +
    +    # Windows drive paths (e.g. C:\, D:foo) - on Windows, os.path.realpath() on a
    +    # drive path can trigger SMB connections if the drive is mapped to a network share.
    +    # This enables SSRF attacks and NTLM hash disclosure. We reject all drive-qualified
    +    # paths including drive-relative paths like "C:foo" which resolve against the current
    +    # directory of that drive. Checked on all platforms for defense-in-depth and
    +    # testability (CI runs on Linux).
    +    if len(path) >= 2 and path[0] in string.ascii_letters and path[1] == ":":
    +        return True
    +
    +    # Rooted backslash or forward slash (absolute paths)
    +    if path.startswith(("\\", "/")):
    +        return True
    +
    +    # Also check os.path.isabs for platform-specific absolute path detection
    +    if os.path.isabs(path):
    +        return True
    +
    +    # Path traversal - check segments after normalizing separators
    +    normalized = path.replace("\\", "/")
    +    segments = [seg for seg in normalized.split("/") if seg]
    +    return ".." in segments
    
  • lib/streamlit/web/server/app_static_file_handler.py+9 0 modified
    @@ -21,6 +21,7 @@
     import tornado.web
     
     from streamlit.logger import get_logger
    +from streamlit.path_security import is_unsafe_path_pattern
     
     _LOGGER: Final = get_logger(__name__)
     
    @@ -53,6 +54,14 @@ class AppStaticFileHandler(tornado.web.StaticFileHandler):
         def initialize(self, path: str, default_filename: str | None = None) -> None:
             super().initialize(path, default_filename)
     
    +    @classmethod
    +    def get_absolute_path(cls, root: str, path: str) -> str:
    +        # SECURITY: Validate path pattern BEFORE any filesystem operations.
    +        # See is_unsafe_path_pattern() docstring for details.
    +        if is_unsafe_path_pattern(path):
    +            raise tornado.web.HTTPError(400, "Bad Request")
    +        return super().get_absolute_path(root, path)
    +
         def validate_absolute_path(self, root: str, absolute_path: str) -> str | None:
             full_path = os.path.abspath(absolute_path)
     
    
  • lib/streamlit/web/server/bidi_component_request_handler.py+4 4 modified
    @@ -77,8 +77,8 @@ def get(self, path: str) -> None:
             Notes
             -----
             This method writes directly to the response and sets appropriate HTTP
    -        status codes on error (``404`` for missing components/files, ``403`` for
    -        forbidden paths).
    +        status codes on error (``404`` for missing components/files, ``400`` for
    +        unsafe paths).
             """
             parts = path.split("/")
             component_name = parts[0]
    @@ -105,8 +105,8 @@ def get(self, path: str) -> None:
                 return
             abspath = build_safe_abspath(component_path, filename)
             if abspath is None:
    -            self.write("forbidden")
    -            self.set_status(403)
    +            self.write("Bad Request")
    +            self.set_status(400)
                 return
     
             # If the resolved path is a directory, return 404 not found.
    
  • lib/streamlit/web/server/component_file_utils.py+14 6 modified
    @@ -27,29 +27,37 @@
     import os
     from typing import Final
     
    +from streamlit.path_security import is_unsafe_path_pattern
    +
     _OCTET_STREAM: Final[str] = "application/octet-stream"
     
     
     def build_safe_abspath(component_root: str, relative_url_path: str) -> str | None:
    -    """Build an absolute path inside ``component_root`` if safe.
    +    r"""Build an absolute path inside ``component_root`` if safe.
     
    -    The function joins ``relative_url_path`` with ``component_root`` and
    -    normalizes and resolves symlinks. If the resulting path escapes the
    -    component root, ``None`` is returned to indicate a forbidden traversal.
    +    The function first validates that ``relative_url_path`` does not contain
    +    dangerous patterns using :func:`~streamlit.path_security.is_unsafe_path_pattern`,
    +    then joins it with ``component_root`` and resolves symlinks.
    +    Returns ``None`` if the path is rejected by security checks or escapes the root.
     
         Parameters
         ----------
         component_root : str
             Absolute path to the component's root directory.
         relative_url_path : str
             Relative URL path from the component root to the requested file.
    +        Must be a simple relative path without dangerous patterns.
     
         Returns
         -------
         str or None
    -        The resolved absolute path if it stays within ``component_root``;
    -        otherwise ``None`` when the path would traverse outside the root.
    +        The resolved absolute path if it passes all validation and stays
    +        within ``component_root``; otherwise ``None``.
         """
    +    # See is_unsafe_path_pattern() for security details.
    +    if is_unsafe_path_pattern(relative_url_path):
    +        return None
    +
         root_real = os.path.realpath(component_root)
         candidate = os.path.normpath(os.path.join(root_real, relative_url_path))
         candidate_real = os.path.realpath(candidate)
    
  • lib/streamlit/web/server/component_request_handler.py+2 2 modified
    @@ -48,8 +48,8 @@ def get(self, path: str) -> None:
             filename = "/".join(parts[1:])
             abspath = build_safe_abspath(component_root, filename)
             if abspath is None:
    -            self.write("forbidden")
    -            self.set_status(403)
    +            self.write("Bad Request")
    +            self.set_status(400)
                 return
             try:
                 with open(abspath, "rb") as file:
    
  • lib/streamlit/web/server/starlette/starlette_app.py+7 1 modified
    @@ -140,7 +140,7 @@ def create_streamlit_middleware() -> list[Middleware]:
         """Create the Streamlit-internal middleware stack.
     
         This function creates the middleware required for Streamlit's core functionality
    -    including session management and GZip compression.
    +    including path security, session management, and GZip compression.
     
         Returns
         -------
    @@ -153,9 +153,15 @@ def create_streamlit_middleware() -> list[Middleware]:
         from streamlit.web.server.starlette.starlette_gzip_middleware import (
             MediaAwareGZipMiddleware,
         )
    +    from streamlit.web.server.starlette.starlette_path_security_middleware import (
    +        PathSecurityMiddleware,
    +    )
     
         middleware: list[Middleware] = []
     
    +    # FIRST: Path security middleware to block dangerous paths before any other processing.
    +    middleware.append(Middleware(PathSecurityMiddleware))
    +
         # Add session middleware
         middleware.append(
             Middleware(
    
  • lib/streamlit/web/server/starlette/starlette_path_security_middleware.py+97 0 added
    @@ -0,0 +1,97 @@
    +# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026)
    +#
    +# Licensed under the Apache License, Version 2.0 (the "License");
    +# you may not use this file except in compliance with the License.
    +# You may obtain a copy of the License at
    +#
    +#     http://www.apache.org/licenses/LICENSE-2.0
    +#
    +# Unless required by applicable law or agreed to in writing, software
    +# distributed under the License is distributed on an "AS IS" BASIS,
    +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +# See the License for the specific language governing permissions and
    +# limitations under the License.
    +
    +"""Path security middleware for blocking unsafe path patterns.
    +
    +This middleware implements the "Swiss Cheese" defense model - it provides
    +an additional layer of protection that catches dangerous path patterns even
    +if individual route handlers forget to validate paths. This is especially
    +important for preventing SSRF attacks via Windows UNC paths.
    +
    +Defense Layers
    +--------------
    +Layer 1 (this middleware): Catch-all for any route, including future routes
    +Layer 2 (route handlers): Defense-in-depth via build_safe_abspath() and
    +                          explicit is_unsafe_path_pattern() checks
    +
    +Each layer has potential "holes" (ways it could fail):
    +- Middleware: Could be accidentally removed, misconfigured, or bypassed
    +- Route handlers: Developer could forget to add checks to new routes
    +
    +By keeping both layers, an attack only succeeds if BOTH fail simultaneously.
    +
    +See Also
    +--------
    +streamlit.path_security : Core path validation functions used by this middleware
    +"""
    +
    +from __future__ import annotations
    +
    +from typing import TYPE_CHECKING
    +
    +from starlette.responses import Response
    +
    +from streamlit.path_security import is_unsafe_path_pattern
    +
    +if TYPE_CHECKING:
    +    from starlette.types import ASGIApp, Receive, Scope, Send
    +
    +
    +class PathSecurityMiddleware:
    +    """ASGI middleware that blocks requests with unsafe path patterns.
    +
    +    Implements Swiss Cheese defense - catches dangerous patterns even if
    +    route handlers forget to validate paths. This prevents SSRF attacks
    +    via Windows UNC paths and other path traversal vulnerabilities.
    +
    +    Parameters
    +    ----------
    +    app
    +        The ASGI application to wrap.
    +    """
    +
    +    def __init__(self, app: ASGIApp) -> None:
    +        self.app = app
    +
    +    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
    +        """Process incoming requests and block unsafe paths.
    +
    +        Only validates HTTP requests; WebSocket and lifespan scopes are
    +        passed through without validation since they don't serve file content.
    +        """
    +        # Only validate HTTP requests (skip WebSocket, lifespan)
    +        if scope["type"] != "http":
    +            await self.app(scope, receive, send)
    +            return
    +
    +        path = scope.get("path", "")
    +
    +        # SECURITY: Check for double-slash patterns BEFORE stripping slashes.
    +        # UNC paths like "//server/share" would be normalized to "server/share"
    +        # by lstrip("/"), making them look safe. We must reject these early.
    +        if path.startswith(("//", "\\\\")):
    +            response = Response(content="Bad Request", status_code=400)
    +            await response(scope, receive, send)
    +            return
    +
    +        # Strip leading slash to get the relative path for validation
    +        relative_path = path.lstrip("/")
    +
    +        # Check if the path contains unsafe patterns
    +        if relative_path and is_unsafe_path_pattern(relative_path):
    +            response = Response(content="Bad Request", status_code=400)
    +            await response(scope, receive, send)
    +            return
    +
    +        await self.app(scope, receive, send)
    
  • lib/streamlit/web/server/starlette/starlette_routes.py+6 3 modified
    @@ -701,7 +701,8 @@ async def _component_endpoint(request: Request) -> Response:
             # Use build_safe_abspath to properly resolve symlinks and prevent traversal
             abspath = build_safe_abspath(component_root, filename)
             if abspath is None:
    -            raise HTTPException(status_code=403, detail="Forbidden")
    +            # Return 400 for malicious paths (consistent with middleware behavior)
    +            raise HTTPException(status_code=400, detail="Bad Request")
     
             try:
                 async with await anyio.open_file(abspath, "rb") as file:
    @@ -772,7 +773,8 @@ async def _text_response(body: str, status_code: int) -> PlainTextResponse:
     
             abspath = build_safe_abspath(component_root, filename)
             if abspath is None:
    -            return await _text_response("forbidden", 403)
    +            # Return 400 for unsafe paths (matches Tornado behavior for opacity)
    +            return await _text_response("Bad Request", 400)
     
             if await AsyncPath(abspath).is_dir():
                 return await _text_response("not found", 404)
    @@ -838,7 +840,8 @@ async def _app_static_endpoint(request: Request) -> Response:
             relative_path = request.path_params.get("path", "")
             safe_path = build_safe_abspath(app_static_root, relative_path)
             if safe_path is None:
    -            raise HTTPException(status_code=404, detail="File not found")
    +            # Return 400 for malicious paths (consistent with middleware behavior)
    +            raise HTTPException(status_code=400, detail="Bad Request")
     
             async_path = AsyncPath(safe_path)
             if not await async_path.exists() or await async_path.is_dir():
    
  • lib/streamlit/web/server/starlette/starlette_static_routes.py+14 4 modified
    @@ -24,6 +24,7 @@
     from typing import TYPE_CHECKING, Any, Final
     
     from streamlit import file_util
    +from streamlit.path_security import is_unsafe_path_pattern
     from streamlit.url_util import make_url_path
     from streamlit.web.server.routes import (
         NO_CACHE_PATTERN,
    @@ -51,7 +52,7 @@ def create_streamlit_static_handler(
         - Long-term caching of hashed assets
         - No-cache for HTML/manifest files
         - Trailing slash redirect (301)
    -    - Double-slash protection (403 for protocol-relative URL security)
    +    - Double-slash protection (400 for protocol-relative URL security)
         """
         from starlette.exceptions import HTTPException
         from starlette.responses import FileResponse, RedirectResponse, Response
    @@ -74,10 +75,19 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
                 # Security check: Block paths starting with double slash (protocol-relative
                 # URL protection). A path like //example.com could be misinterpreted as a
                 # protocol-relative URL if redirected, which is a security risk.
    -            # This matches Tornado's behavior where such paths would escape the static
    -            # directory and trigger a 403 Forbidden.
                 if path.startswith("//"):
    -                response = Response(content="Forbidden", status_code=403)
    +                response = Response(content="Bad Request", status_code=400)
    +                await response(scope, receive, send)
    +                return
    +
    +            # Security check: Block UNC paths, absolute paths, drive-qualified paths,
    +            # and path traversal patterns BEFORE any filesystem operations.
    +            # See is_unsafe_path_pattern() docstring for details.
    +            # Strip the leading slash since paths come in as "/filename" but we check
    +            # the relative portion.
    +            relative_path = path.lstrip("/")
    +            if relative_path and is_unsafe_path_pattern(relative_path):
    +                response = Response(content="Bad Request", status_code=400)
                     await response(scope, receive, send)
                     return
     
    
  • lib/tests/streamlit/components/v2/test_component_registry.py+1 1 modified
    @@ -788,7 +788,7 @@ def test_resolve_glob_pattern_direct() -> None:
             # Test absolute path protection
             with pytest.raises(StreamlitComponentRegistryError) as exc_info:
                 ComponentPathUtils.resolve_glob_pattern("/absolute/path.js", package_root)
    -        assert "Absolute paths are not allowed" in str(exc_info.value)
    +        assert "Unsafe paths are not allowed" in str(exc_info.value)
     
     
     @pytest.fixture
    
  • lib/tests/streamlit/path_security_test.py+90 0 added
    @@ -0,0 +1,90 @@
    +# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026)
    +#
    +# Licensed under the Apache License, Version 2.0 (the "License");
    +# you may not use this file except in compliance with the License.
    +# You may obtain a copy of the License at
    +#
    +#     http://www.apache.org/licenses/LICENSE-2.0
    +#
    +# Unless required by applicable law or agreed to in writing, software
    +# distributed under the License is distributed on an "AS IS" BASIS,
    +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +# See the License for the specific language governing permissions and
    +# limitations under the License.
    +
    +"""Tests for the shared path security utilities."""
    +
    +from __future__ import annotations
    +
    +import pytest
    +
    +from streamlit.path_security import is_unsafe_path_pattern
    +
    +
    +class TestIsUnsafePathPattern:
    +    """Tests for the is_unsafe_path_pattern function."""
    +
    +    @pytest.mark.parametrize(
    +        ("path", "expected_unsafe"),
    +        [
    +            # Safe paths
    +            pytest.param("inside.txt", False, id="simple_filename_safe"),
    +            pytest.param("subdir/file.js", False, id="subdir_forward_slash_safe"),
    +            pytest.param("subdir\\file.js", False, id="subdir_backslash_safe"),
    +            pytest.param("file..name.js", False, id="double_dots_in_filename_safe"),
    +            pytest.param("..file.js", False, id="dots_prefix_filename_safe"),
    +            pytest.param("file..", False, id="dots_suffix_filename_safe"),
    +            pytest.param("", False, id="empty_string_safe"),
    +            pytest.param(".", False, id="current_dir_safe"),
    +            # UNC paths
    +            pytest.param("\\\\server\\share", True, id="unc_backslash_unsafe"),
    +            pytest.param("//server/share", True, id="unc_forward_unsafe"),
    +            pytest.param("\\\\?\\C:\\Windows", True, id="extended_length_path"),
    +            pytest.param("\\\\.\\device", True, id="device_namespace"),
    +            # Absolute paths
    +            pytest.param("/etc/passwd", True, id="absolute_posix_unsafe"),
    +            pytest.param("\\rooted", True, id="rooted_backslash_unsafe"),
    +            # Path traversal
    +            pytest.param("../secret", True, id="traversal_parent_unsafe"),
    +            pytest.param("dir/../secret", True, id="traversal_in_middle_unsafe"),
    +            pytest.param("a/b/../c/../../../d", True, id="traversal_complex_unsafe"),
    +            # Windows drive paths
    +            pytest.param("C:\\file.txt", True, id="windows_drive_backslash"),
    +            pytest.param("C:/file.txt", True, id="windows_drive_forward"),
    +            pytest.param("D:\\path\\to\\file", True, id="windows_drive_d"),
    +            pytest.param("c:/users/file.txt", True, id="windows_drive_lowercase"),
    +            pytest.param("Z:foo", True, id="windows_drive_relative"),
    +            pytest.param("C:Windows", True, id="windows_drive_relative_no_slash"),
    +            # Null bytes
    +            pytest.param("\x00", True, id="null_only"),
    +            pytest.param("file\x00.txt", True, id="null_in_middle"),
    +            pytest.param("\x00../secret", True, id="null_before_traversal"),
    +        ],
    +    )
    +    def test_pattern_detection(self, path: str, expected_unsafe: bool) -> None:
    +        """Validates is_unsafe_path_pattern correctly identifies dangerous patterns.
    +
    +        This is the shared core function used by both component_file_utils and
    +        component_path_utils to ensure consistent security checks.
    +        """
    +        assert is_unsafe_path_pattern(path) == expected_unsafe
    +
    +    def test_traversal_with_mixed_separators(self) -> None:
    +        """Path traversal using mixed separators should be detected."""
    +        mixed_traversal_paths = [
    +            "sub\\..\\..\\secret",
    +            "sub/../..\\secret",
    +            "sub\\../secret",
    +        ]
    +        for path in mixed_traversal_paths:
    +            assert is_unsafe_path_pattern(path), f"Expected {path!r} to be unsafe"
    +
    +    def test_safe_nested_paths(self) -> None:
    +        """Nested subdirectory paths should be safe."""
    +        safe_paths = [
    +            "sub/nested/deep.txt",
    +            "a/b/c/d/e/f.js",
    +            "components/my-component/index.js",
    +        ]
    +        for path in safe_paths:
    +            assert not is_unsafe_path_pattern(path), f"Expected {path!r} to be safe"
    
  • lib/tests/streamlit/web/server/app_static_file_handler_test.py+68 8 modified
    @@ -202,30 +202,48 @@ def test_staticfiles_404(self):
                 )
     
         def test_staticfiles_403(self):
    -        """files outside static directory and symlinks pointing to
    -        files outside static directory and directories should return 403.
    +        """Directories and symlinks pointing outside should return 403.
    +
    +        This tests Tornado's built-in directory/symlink handling which correctly
    +        returns 403 for these cases.
             """
             responses = [
                 # Access to directory with trailing slash
                 self.fetch("/app/static/"),
    +            # Access to symlink outside static directory
    +            self.fetch(f"/app/static/{self._symlink_outside_directory}"),
    +        ]
    +        for r in responses:
    +            assert r.code == 403
    +            assert (
    +                r.body == b"<html><title>403: Forbidden</title>"
    +                b"<body>403: Forbidden</body></html>"
    +            )
    +
    +    def test_staticfiles_400_for_path_security(self):
    +        """Path traversal and absolute paths should return 400.
    +
    +        These are caught by our path security check which runs before Tornado's
    +        built-in handling.
    +        """
    +        responses = [
                 # Access to directory inside static folder without trailing slash
    +            # Note: _tmp_dir_inside_static_folder.name is an absolute path like /tmp/...
                 self.fetch(f"/app/static/{self._tmp_dir_inside_static_folder.name}"),
                 # Access to directory inside static folder with trailing slash
                 self.fetch(f"/app/static/{self._tmp_dir_inside_static_folder.name}/"),
    -            # Access to file outside static directory
    +            # Access to file outside static directory (path traversal)
                 self.fetch("/app/static/../test_file_outside_directory.py"),
                 # Access to file outside static directory with same prefix
                 self.fetch(
                     f"/app/static/{self._tmpdir.name}_foo/test_file_outside_directory.py"
                 ),
    -            # Access to symlink outside static directory
    -            self.fetch(f"/app/static/{self._symlink_outside_directory}"),
             ]
             for r in responses:
    -            assert r.code == 403
    +            assert r.code == 400
                 assert (
    -                r.body == b"<html><title>403: Forbidden</title>"
    -                b"<body>403: Forbidden</body></html>"
    +                r.body == b"<html><title>400: Bad Request</title>"
    +                b"<body>400: Bad Request</body></html>"
                 )
     
         def test_mimetype_is_overridden_by_server(self):
    @@ -241,3 +259,45 @@ def test_mimetype_is_overridden_by_server(self):
     
             r = self.fetch(f"/app/static/{self._temp_filenames['webp']}")
             assert r.headers["Content-Type"] == "image/webp"
    +
    +    @parameterized.expand(
    +        [
    +            # UNC paths (Windows network shares)
    +            ("unc_backslash", "\\\\server\\share\\file.txt"),
    +            ("unc_forward", "//server/share/file.txt"),
    +            # Windows drive paths
    +            ("drive_absolute", "C:\\Windows\\file.txt"),
    +            ("drive_forward", "C:/Windows/file.txt"),
    +            ("drive_relative", "D:file.txt"),
    +            # Absolute paths
    +            ("absolute_forward", "/etc/passwd"),
    +            ("absolute_backslash", "\\etc\\passwd"),
    +            # Path traversal
    +            ("traversal_simple", "../secret.txt"),
    +            ("traversal_complex", "foo/../../../etc/passwd"),
    +            # Windows special prefixes
    +            ("win_extended", "\\\\?\\C:\\file.txt"),
    +            ("win_device", "\\\\.\\device\\file.txt"),
    +        ],
    +    )
    +    def test_unsafe_path_patterns_rejected(self, name: str, unsafe_path: str) -> None:
    +        """Unsafe path patterns should be rejected with 400 before filesystem access."""
    +        response = self.fetch(f"/app/static/{unsafe_path}")
    +        assert response.code == 400, f"Expected 400 for {name}: {unsafe_path}"
    +
    +    def test_null_byte_path_rejected(self) -> None:
    +        """Null byte in path should be rejected with 400."""
    +        response = self.fetch("/app/static/file.txt\x00.jpg")
    +        # Tornado rejects null bytes at the HTTP layer with 400
    +        assert response.code == 400, f"Expected 400, got {response.code}"
    +
    +    def test_safe_paths_not_rejected_by_security_check(self) -> None:
    +        """Safe paths should not be rejected by the security check."""
    +        # This file exists, so it should return 200
    +        response = self.fetch(f"/app/static/{self._filename}")
    +        assert response.code == 200
    +
    +        # Files with dots in the name should be allowed
    +        response = self.fetch("/app/static/file..name.txt")
    +        # 404 because file doesn't exist, not 400 (security rejection)
    +        assert response.code == 404
    
  • lib/tests/streamlit/web/server/bidi_component_request_handler_test.py+7 7 modified
    @@ -109,7 +109,7 @@ def test_disallow_path_traversal(self) -> None:
             response = self.fetch(
                 "/_stcore/bidi-components/pkg.test_component/../../../etc/passwd"
             )
    -        assert response.code == 403
    +        assert response.code == 400
     
         def test_file_not_found_in_component_dir(self) -> None:
             response = self.fetch(
    @@ -211,19 +211,19 @@ def test_serves_within_asset_dir(self) -> None:
             assert resp.body.decode() == "console.log('served from asset_dir');"
     
         def test_forbids_traversal_outside_asset_dir(self) -> None:
    -        """Traversal outside asset_dir is forbidden and returns 403."""
    +        """Traversal outside asset_dir is forbidden and returns 400."""
             resp = self.fetch("/_stcore/bidi-components/pkg.slider/../../etc/passwd")
    -        assert resp.code == 403
    +        assert resp.code == 400
     
         def test_absolute_path_injection_forbidden(self) -> None:
    -        """Absolute path injection via double-slash should be forbidden (403)."""
    +        """Absolute path injection via double-slash should be forbidden (400)."""
             # When the requested filename begins with a slash due to a double-slash in the URL,
             # joining with the component root would otherwise discard the root. We must reject it.
             resp = self.fetch("/_stcore/bidi-components/pkg.slider//etc/passwd")
    -        assert resp.code == 403
    +        assert resp.code == 400
     
         def test_symlink_escape_outside_asset_dir_forbidden(self) -> None:
    -        """Symlink in asset_dir pointing outside should be forbidden (403)."""
    +        """Symlink in asset_dir pointing outside should be forbidden (400)."""
             # Create a real file outside of the component asset_dir
             outside_file = Path(self.temp_dir.name) / "outside.js"
             outside_file.write_text("console.log('outside');")
    @@ -238,4 +238,4 @@ def test_symlink_escape_outside_asset_dir_forbidden(self) -> None:
     
             # Attempt to fetch the symlinked file via the handler
             resp = self.fetch("/_stcore/bidi-components/pkg.slider/link_out.js")
    -        assert resp.code == 403
    +        assert resp.code == 400
    
  • lib/tests/streamlit/web/server/component_file_utils_test.py+214 7 modified
    @@ -16,7 +16,9 @@
     
     import os
     from pathlib import Path
    +from typing import TYPE_CHECKING
     from unittest import mock
    +from urllib.parse import unquote
     
     import pytest
     
    @@ -25,6 +27,9 @@
         guess_content_type,
     )
     
    +if TYPE_CHECKING:
    +    from collections.abc import Callable
    +
     
     @pytest.fixture
     def root(tmp_path: Path) -> Path:
    @@ -116,11 +121,6 @@ def test_symlink_within_root_allowed(root: Path) -> None:
             pytest.param(
                 ".", lambda root: os.path.realpath(str(root)), id="dot_means_root"
             ),
    -        pytest.param(
    -            os.path.join("sub", "..", "inside.txt"),
    -            lambda root: os.path.realpath(str(root / "inside.txt")),
    -            id="normalized_parent_segments",
    -        ),
             pytest.param(
                 os.path.join("does", "not", "exist.txt"),
                 lambda root: os.path.realpath(str(root / "does" / "not" / "exist.txt")),
    @@ -129,8 +129,8 @@ def test_symlink_within_root_allowed(root: Path) -> None:
         ],
     )
     def test_normalization_and_nonexistent_paths(
    -    root: Path, candidate: str, expect
    -) -> None:  # type: ignore[no-untyped-def]
    +    root: Path, candidate: str, expect: Callable[[Path], str]
    +) -> None:
         """Normalizes candidates and allows non-existent paths that remain inside root.
     
         The helper under test does not enforce existence; it only enforces that the
    @@ -141,6 +141,17 @@ def test_normalization_and_nonexistent_paths(
         assert abspath == expect(root)
     
     
    +def test_normalized_parent_segments_rejected(root: Path) -> None:
    +    """Paths containing '..' are now rejected early for SSRF protection.
    +
    +    This is a security-motivated behavior change: paths like 'sub/../inside.txt'
    +    are rejected at validation time even if they would resolve safely. This prevents
    +    potential SSRF attacks where path traversal could be combined with other techniques.
    +    """
    +    traversal_path = os.path.join("sub", "..", "inside.txt")
    +    assert build_safe_abspath(str(root), traversal_path) is None
    +
    +
     def test_component_root_is_symlink(tmp_path: Path) -> None:
         """Supports a component root that itself is a symlink to a real directory."""
         real_root = tmp_path / "real_root"
    @@ -204,3 +215,199 @@ def test_guess_content_type_unknown_extension() -> None:
             guess_content_type("file.somethingreallyrandomext")
             == "application/octet-stream"
         )
    +
    +
    +# ---------------------------------------------------------------------------
    +# UNC path and SSRF prevention tests
    +# ---------------------------------------------------------------------------
    +
    +
    +@pytest.mark.parametrize(
    +    "unsafe_path",
    +    [
    +        pytest.param("\\\\server\\share\\file.js", id="unc_backslash"),
    +        pytest.param("//server/share/file.js", id="unc_forward_slash"),
    +        pytest.param("\\rooted\\path", id="rooted_backslash"),
    +        pytest.param("/etc/passwd", id="rooted_forward_slash"),
    +        pytest.param("../../../etc/passwd", id="traversal_relative"),
    +        pytest.param("foo/../../../etc/passwd", id="traversal_in_middle"),
    +    ],
    +)
    +def test_rejects_unsafe_paths_before_realpath(root: Path, unsafe_path: str) -> None:
    +    """Unsafe paths must be rejected before os.path.realpath() is called.
    +
    +    This prevents Windows UNC paths from triggering SMB connections (SSRF).
    +    """
    +    abspath = build_safe_abspath(str(root), unsafe_path)
    +    assert abspath is None
    +
    +
    +@pytest.mark.parametrize(
    +    "unsafe_path",
    +    [
    +        pytest.param("\\\\server\\share\\file.js", id="unc_backslash"),
    +        pytest.param("//server/share/file.js", id="unc_forward_slash"),
    +        pytest.param("../../../etc/passwd", id="traversal"),
    +    ],
    +)
    +def test_realpath_not_called_for_unsafe_paths(root: Path, unsafe_path: str) -> None:
    +    """Verify os.path.realpath is NOT called when an unsafe path is provided.
    +
    +    This is a regression test for the SSRF fix. The security invariant is that
    +    realpath() must never be called on untrusted input, because on Windows it
    +    can trigger SMB connections to attacker-controlled servers.
    +
    +    If someone refactors and moves the is_unsafe_path_pattern() check after the
    +    realpath() call, this test will catch it.
    +    """
    +    with mock.patch(
    +        "streamlit.web.server.component_file_utils.os.path.realpath"
    +    ) as mock_realpath:
    +        result = build_safe_abspath(str(root), unsafe_path)
    +
    +        # Should return None (rejected)
    +        assert result is None
    +
    +        # realpath should NOT have been called at all
    +        mock_realpath.assert_not_called()
    +
    +
    +@pytest.mark.parametrize(
    +    "unsafe_path",
    +    [
    +        pytest.param("C:\\Windows\\system32\\file.dll", id="windows_drive_backslash"),
    +        pytest.param("C:/Windows/system32/file.dll", id="windows_drive_forward"),
    +        pytest.param("Z:mapped_drive_file", id="windows_drive_relative"),
    +    ],
    +)
    +def test_rejects_windows_drive_paths(root: Path, unsafe_path: str) -> None:
    +    """Windows drive paths are rejected to prevent mapped drive access.
    +
    +    This includes drive-relative paths like 'Z:foo' which on Windows resolve
    +    against the current directory of that drive. Checked on all platforms
    +    for defense-in-depth and testability (CI runs on Linux).
    +    """
    +    abspath = build_safe_abspath(str(root), unsafe_path)
    +    assert abspath is None
    +
    +
    +def test_safe_path_still_resolves_correctly(root: Path) -> None:
    +    """Ensures that safe paths still work after the security check is added.
    +
    +    This is an anti-regression test to verify the fix doesn't break normal usage.
    +    """
    +    abspath = build_safe_abspath(str(root), "inside.txt")
    +    assert abspath is not None
    +    assert Path(abspath).read_text(encoding="utf-8") == "ok"
    +
    +
    +def test_safe_nested_path_resolves(root: Path) -> None:
    +    """Nested subdirectory paths should still resolve correctly."""
    +    subdir = root / "sub" / "nested"
    +    subdir.mkdir(parents=True, exist_ok=True)
    +    (subdir / "deep.txt").write_text("deep content")
    +
    +    abspath = build_safe_abspath(str(root), "sub/nested/deep.txt")
    +    assert abspath is not None
    +    assert Path(abspath).read_text(encoding="utf-8") == "deep content"
    +
    +
    +@pytest.mark.parametrize(
    +    "decoded_path",
    +    [
    +        pytest.param("../../etc/passwd", id="url_decoded_traversal"),
    +        pytest.param("\\\\server\\share", id="url_decoded_unc_backslash"),
    +        pytest.param("//server/share", id="url_decoded_unc_forward"),
    +    ],
    +)
    +def test_url_decoded_paths_are_rejected(root: Path, decoded_path: str) -> None:
    +    """Verify that URL-decoded malicious paths are correctly rejected.
    +
    +    Tornado and Starlette automatically URL-decode path parameters before passing
    +    them to handlers. This test documents that our validation works correctly on
    +    the decoded paths (e.g., %2e%2e becomes .., %5c%5c becomes \\\\).
    +
    +    Note: Double URL encoding (e.g., %252e%252e) is not a concern because web
    +    frameworks only decode once. So %252e%252e becomes %2e%2e (literal string),
    +    not "..", which our check would not flag as traversal. This is safe because
    +    the filesystem would look for a literal "%2e%2e" directory.
    +    """
    +    # These are example encoded forms that would decode to the test paths
    +    encoded_examples = {
    +        "../../etc/passwd": "%2e%2e/%2e%2e/etc/passwd",
    +        "\\\\server\\share": "%5c%5cserver%5cshare",
    +        "//server/share": "%2f%2fserver%2fshare",
    +    }
    +
    +    # Verify the encoding/decoding relationship for documentation
    +    if decoded_path in encoded_examples:
    +        assert unquote(encoded_examples[decoded_path]) == decoded_path
    +
    +    # The actual test: decoded paths should be rejected
    +    abspath = build_safe_abspath(str(root), decoded_path)
    +    assert abspath is None
    +
    +
    +@pytest.mark.parametrize(
    +    "path_with_null",
    +    [
    +        pytest.param("\x00", id="null_only"),
    +        pytest.param("file\x00.txt", id="null_in_middle"),
    +        pytest.param("\x00../secret", id="null_before_traversal"),
    +        pytest.param("safe.txt\x00", id="null_at_end"),
    +    ],
    +)
    +def test_rejects_null_bytes(root: Path, path_with_null: str) -> None:
    +    """Paths containing null bytes should be rejected.
    +
    +    Null byte injection can be used to truncate paths in some contexts.
    +    While Python 3 generally handles this safely, we reject them as
    +    defense in depth.
    +    """
    +    assert build_safe_abspath(str(root), path_with_null) is None
    +
    +
    +@pytest.mark.parametrize(
    +    "windows_special_path",
    +    [
    +        pytest.param("\\\\?\\C:\\Windows", id="extended_length_path"),
    +        pytest.param("\\\\.\\device", id="device_namespace"),
    +        pytest.param("\\\\?\\UNC\\server\\share", id="extended_unc"),
    +    ],
    +)
    +def test_rejects_windows_special_path_prefixes(
    +    root: Path, windows_special_path: str
    +) -> None:
    +    """Windows extended-length and device namespace paths should be rejected.
    +
    +    These paths start with \\\\ so they're caught by the UNC check, but this
    +    test documents that coverage explicitly.
    +    """
    +    assert build_safe_abspath(str(root), windows_special_path) is None
    +
    +
    +def test_mixed_separators_not_rejected_early(root: Path) -> None:
    +    """Paths with mixed separators should not be rejected by the early validation.
    +
    +    On Windows, backslashes are path separators. On Unix, they're valid filename
    +    characters. Safe relative paths with backslashes should not be rejected.
    +    """
    +    # On Unix, this would look for a directory literally named "sub\nested"
    +    # On Windows, this would be equivalent to "sub/nested/file.js"
    +    # Either way, build_safe_abspath should not reject it as unsafe
    +    abspath = build_safe_abspath(str(root), "sub\\nested/file.js")
    +    # The path passes validation (not None) - it just might not exist
    +    assert abspath is not None
    +
    +
    +def test_traversal_with_mixed_separators_rejected(root: Path) -> None:
    +    """Path traversal using mixed separators should be rejected."""
    +    # These all contain '..' and should be rejected regardless of separator style
    +    mixed_traversal_paths = [
    +        "sub\\..\\..\\secret",
    +        "sub/../..\\secret",
    +        "sub\\../secret",
    +    ]
    +    for path in mixed_traversal_paths:
    +        abspath = build_safe_abspath(str(root), path)
    +        assert abspath is None, f"Expected {path!r} to be rejected"
    
  • lib/tests/streamlit/web/server/component_request_handler_test.py+8 8 modified
    @@ -133,8 +133,8 @@ def test_outside_component_root_request(self):
                 "tests.streamlit.web.server.component_request_handler_test.test//etc/hosts"
             )
     
    -        assert response.code == 403
    -        assert response.body == b"forbidden"
    +        assert response.code == 400
    +        assert response.body == b"Bad Request"
     
         def test_outside_component_dir_with_same_prefix_request(self):
             """Tests to ensure a path based on the same prefix but a different
    @@ -148,8 +148,8 @@ def test_outside_component_dir_with_same_prefix_request(self):
                 f"tests.streamlit.web.server.component_request_handler_test.test/{PATH}_really"
             )
     
    -        assert response.code == 403
    -        assert response.body == b"forbidden"
    +        assert response.code == 400
    +        assert response.body == b"Bad Request"
     
         def test_relative_outside_component_root_request(self):
             """Tests to ensure a path relative to the component root directory
    @@ -163,8 +163,8 @@ def test_relative_outside_component_root_request(self):
                 "tests.streamlit.web.server.component_request_handler_test.test/../foo"
             )
     
    -        assert response.code == 403
    -        assert response.body == b"forbidden"
    +        assert response.code == 400
    +        assert response.body == b"Bad Request"
     
         def test_invalid_component_request(self):
             """Test request failure when invalid component name is provided."""
    @@ -241,8 +241,8 @@ def test_symlink_escape_outside_component_root_forbidden(self) -> None:
                 )
                 response = self._request_component(f"{fq_comp}/link_out.js")
     
    -            assert response.code == 403
    -            assert response.body == b"forbidden"
    +            assert response.code == 400
    +            assert response.body == b"Bad Request"
     
         def test_support_binary_files_request(self):
             """Test support for binary files reads."""
    
  • lib/tests/streamlit/web/server/starlette/starlette_path_security_middleware_test.py+330 0 added
    @@ -0,0 +1,330 @@
    +# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026)
    +#
    +# Licensed under the Apache License, Version 2.0 (the "License");
    +# you may not use this file except in compliance with the License.
    +# You may obtain a copy of the License at
    +#
    +#     http://www.apache.org/licenses/LICENSE-2.0
    +#
    +# Unless required by applicable law or agreed to in writing, software
    +# distributed under the License is distributed on an "AS IS" BASIS,
    +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +# See the License for the specific language governing permissions and
    +# limitations under the License.
    +
    +"""Unit tests for starlette_path_security_middleware module."""
    +
    +from __future__ import annotations
    +
    +from typing import TYPE_CHECKING
    +
    +import pytest
    +from starlette.applications import Starlette
    +from starlette.responses import PlainTextResponse
    +from starlette.routing import Route
    +from starlette.testclient import TestClient
    +
    +from streamlit.web.server.starlette.starlette_path_security_middleware import (
    +    PathSecurityMiddleware,
    +)
    +
    +if TYPE_CHECKING:
    +    from starlette.websockets import WebSocket
    +
    +
    +def _create_test_app() -> Starlette:
    +    """Create a test Starlette app with the PathSecurityMiddleware."""
    +
    +    async def echo_path(request):
    +        return PlainTextResponse(f"Path: {request.url.path}")
    +
    +    app = Starlette(
    +        routes=[
    +            Route("/{path:path}", echo_path),
    +        ]
    +    )
    +    app.add_middleware(PathSecurityMiddleware)
    +    return app
    +
    +
    +def _create_websocket_app() -> Starlette:
    +    """Create a test app with a WebSocket endpoint."""
    +    from starlette.routing import WebSocketRoute
    +
    +    async def websocket_endpoint(websocket: WebSocket):
    +        await websocket.accept()
    +        await websocket.send_text("connected")
    +        await websocket.close()
    +
    +    app = Starlette(
    +        routes=[
    +            WebSocketRoute("/ws", websocket_endpoint),
    +        ]
    +    )
    +    app.add_middleware(PathSecurityMiddleware)
    +    return app
    +
    +
    +class TestPathSecurityMiddleware:
    +    """Tests for PathSecurityMiddleware."""
    +
    +    @pytest.mark.parametrize(
    +        ("path", "expected_path"),
    +        [
    +            ("/../../../etc/passwd", "/etc/passwd"),
    +            ("///attacker.com/share", "/attacker.com/share"),
    +        ],
    +        ids=[
    +            "forward-slash-traversal-normalized",
    +            "multiple-forward-slashes-normalized",
    +        ],
    +    )
    +    def test_starlette_normalizes_paths(self, path: str, expected_path: str) -> None:
    +        """Test that Starlette normalizes certain path patterns before middleware.
    +
    +        These patterns are handled securely by the framework's path normalization,
    +        so they reach the middleware as safe paths.
    +        """
    +        app = _create_test_app()
    +        client = TestClient(app)
    +
    +        response = client.get(path)
    +
    +        assert response.status_code == 200
    +        assert f"Path: {expected_path}" in response.text
    +
    +    @pytest.mark.parametrize(
    +        "unsafe_path",
    +        [
    +            "/..\\..\\etc\\passwd",
    +            "/C:/Windows/system32",
    +            "/D:/secrets",
    +            "/%5c%5cattacker%5cshare",  # \\attacker\share (URL-decoded by Starlette)
    +            "/file%00.txt",
    +        ],
    +        ids=[
    +            "path-traversal-backslash",
    +            "windows-drive-c",
    +            "windows-drive-d",
    +            "unc-backslash",
    +            "null-byte",
    +        ],
    +    )
    +    def test_blocks_unsafe_paths(self, unsafe_path: str) -> None:
    +        """Test that unsafe path patterns are blocked with 400.
    +
    +        Note: Forward-slash path traversal (/../..) and multiple forward slashes
    +        (///) are normalized by Starlette before reaching the middleware, which
    +        is secure framework behavior. This test covers patterns that are NOT
    +        normalized by the framework.
    +        """
    +        app = _create_test_app()
    +        client = TestClient(app)
    +
    +        response = client.get(unsafe_path)
    +
    +        assert response.status_code == 400
    +        assert response.text == "Bad Request"
    +
    +    @pytest.mark.parametrize(
    +        "safe_path",
    +        [
    +            "/",
    +            "/index.html",
    +            "/static/app.js",
    +            "/component/my_component/index.html",
    +            "/deeply/nested/path/to/file.css",
    +            "/file-with-dots.min.js",
    +            "/path.with.dots/file.txt",
    +            "/file..js",
    +            "/files/...hidden",
    +        ],
    +        ids=[
    +            "root",
    +            "simple-file",
    +            "static-dir",
    +            "component-path",
    +            "deeply-nested",
    +            "dots-in-filename",
    +            "dots-in-dirname",
    +            "double-dots-in-filename",
    +            "triple-dots-in-filename",
    +        ],
    +    )
    +    def test_allows_safe_paths(self, safe_path: str) -> None:
    +        """Test that safe path patterns are allowed."""
    +        app = _create_test_app()
    +        client = TestClient(app)
    +
    +        response = client.get(safe_path)
    +
    +        assert response.status_code == 200
    +        assert f"Path: {safe_path}" in response.text
    +
    +    def test_websocket_connections_pass_through(self) -> None:
    +        """Test that WebSocket connections are not blocked by path validation."""
    +        app = _create_websocket_app()
    +        client = TestClient(app)
    +
    +        with client.websocket_connect("/ws") as websocket:
    +            data = websocket.receive_text()
    +            assert data == "connected"
    +
    +
    +class TestDoubleSlashBypass:
    +    """Tests for the double-slash UNC path bypass vulnerability.
    +
    +    This tests a specific attack vector where `//server/share` (a UNC path on Windows)
    +    could bypass the middleware's path validation because lstrip("/") normalizes
    +    away the leading slashes before the check, but the original path remains in scope.
    +
    +    Note: We test with raw ASGI scope rather than TestClient because TestClient
    +    interprets `//host/path` as a URL with authority component (host), not as a
    +    path starting with `//`. Raw ASGI scope tests the actual attack scenario.
    +    """
    +
    +    @pytest.mark.parametrize(
    +        "unc_path",
    +        [
    +            "//attacker.com/share",
    +            "//192.168.1.1/admin",
    +            "//localhost/c$/Windows",
    +        ],
    +        ids=[
    +            "unc-domain",
    +            "unc-ip-address",
    +            "unc-localhost-admin-share",
    +        ],
    +    )
    +    @pytest.mark.anyio
    +    async def test_double_slash_unc_paths_are_blocked(self, unc_path: str) -> None:
    +        """Test that double-slash UNC paths are blocked by the middleware.
    +
    +        The middleware must detect and block paths like `//server/share` which
    +        are UNC paths on Windows. These should NOT pass through even though
    +        `attacker.com/share` (after lstrip) looks like a safe relative path.
    +
    +        We use raw ASGI scope to simulate an attacker sending a malicious request
    +        directly, bypassing URL parsing that would interpret // as authority.
    +        """
    +        # Build the app with middleware
    +        app = _create_test_app()
    +
    +        # Construct a raw ASGI scope with the malicious path
    +        scope = {
    +            "type": "http",
    +            "method": "GET",
    +            "path": unc_path,
    +            "query_string": b"",
    +            "headers": [],
    +            "server": ("localhost", 8000),
    +            "asgi": {"version": "3.0"},
    +        }
    +
    +        response_status: int | None = None
    +        response_body = b""
    +
    +        async def receive():
    +            return {"type": "http.request", "body": b""}
    +
    +        async def send(message):
    +            nonlocal response_status, response_body
    +            if message["type"] == "http.response.start":
    +                response_status = message["status"]
    +            elif message["type"] == "http.response.body":
    +                response_body += message.get("body", b"")
    +
    +        await app(scope, receive, send)
    +
    +        # These MUST be blocked - if they return 200, we have a security bypass
    +        assert response_status == 400, (
    +            f"UNC path {unc_path!r} was not blocked! "
    +            "Double-slash paths should be rejected for SSRF protection."
    +        )
    +        assert response_body == b"Bad Request"
    +
    +
    +class TestMiddlewarePosition:
    +    """Tests to verify the middleware is positioned correctly in the stack."""
    +
    +    def test_middleware_is_first_in_streamlit_stack(self) -> None:
    +        """Test that PathSecurityMiddleware is the first middleware added."""
    +        from starlette.middleware import Middleware
    +
    +        from streamlit.web.server.starlette.starlette_app import (
    +            create_streamlit_middleware,
    +        )
    +
    +        middleware_list = create_streamlit_middleware()
    +
    +        # PathSecurityMiddleware should be first
    +        assert len(middleware_list) >= 1
    +        first_middleware = middleware_list[0]
    +        assert isinstance(first_middleware, Middleware)
    +        assert first_middleware.cls is PathSecurityMiddleware
    +
    +    def test_middleware_runs_before_other_processing(self) -> None:
    +        """Test that unsafe paths are blocked before reaching session middleware."""
    +        from starlette.middleware import Middleware
    +        from starlette.middleware.sessions import SessionMiddleware
    +
    +        # Create app with both middlewares (path security first, then session)
    +        async def echo_path(request):
    +            # If we get here, path security didn't block us
    +            return PlainTextResponse(f"Path: {request.url.path}")
    +
    +        app = Starlette(
    +            routes=[Route("/{path:path}", echo_path)],
    +            middleware=[
    +                Middleware(PathSecurityMiddleware),
    +                Middleware(SessionMiddleware, secret_key="test-secret"),
    +            ],
    +        )
    +        client = TestClient(app)
    +
    +        # Safe path should work
    +        response = client.get("/safe/path")
    +        assert response.status_code == 200
    +
    +        # Unsafe path (backslash traversal - not normalized by Starlette)
    +        # should be blocked before session processing
    +        response = client.get("/..\\..\\etc\\passwd")
    +        assert response.status_code == 400
    +
    +    def test_middleware_protects_routes_without_explicit_validation(self) -> None:
    +        """Test that middleware blocks unsafe paths even when handler doesn't validate.
    +
    +        This verifies the Swiss Cheese defense model: the middleware acts as a
    +        catch-all safety net for routes that forget to call is_unsafe_path_pattern().
    +        """
    +        # Track whether the handler was called
    +        handler_called = False
    +
    +        async def naive_handler(request):
    +            """A deliberately vulnerable handler that does NOT validate the path.
    +
    +            In production, this would be a security vulnerability without middleware.
    +            """
    +            nonlocal handler_called
    +            handler_called = True
    +            path = request.path_params.get("path", "")
    +            return PlainTextResponse(f"Received: {path}")
    +
    +        app = Starlette(
    +            routes=[Route("/vulnerable/{path:path}", naive_handler)],
    +        )
    +        app.add_middleware(PathSecurityMiddleware)
    +        client = TestClient(app)
    +
    +        # Safe path should reach the handler
    +        handler_called = False
    +        response = client.get("/vulnerable/safe/file.txt")
    +        assert response.status_code == 200
    +        assert handler_called is True
    +
    +        # Unsafe path should be blocked by middleware BEFORE reaching handler
    +        handler_called = False
    +        response = client.get("/vulnerable/..\\..\\etc\\passwd")
    +        assert response.status_code == 400
    +        assert response.text == "Bad Request"
    +        assert handler_called is False  # Key assertion: handler was never called
    
  • lib/tests/streamlit/web/server/starlette/starlette_static_routes_test.py+12 12 modified
    @@ -268,8 +268,8 @@ class TestDoubleSlashProtection:
         """
     
         @pytest.mark.anyio
    -    async def test_double_slash_returns_403(self, tmp_path: Path) -> None:
    -        """Test that paths starting with // return 403 Forbidden.
    +    async def test_double_slash_returns_400(self, tmp_path: Path) -> None:
    +        """Test that paths starting with // return 400 Bad Request.
     
             Double-slash paths like //example.com could be misinterpreted as
             protocol-relative URLs if redirected, which is a security risk.
    @@ -309,12 +309,12 @@ async def send(message: dict[str, object]) -> None:
     
             await static_files(scope, receive, send)
     
    -        assert response_status == 403
    -        assert response_body == b"Forbidden"
    +        assert response_status == 400
    +        assert response_body == b"Bad Request"
     
         @pytest.mark.anyio
    -    async def test_double_slash_with_path_returns_403(self, tmp_path: Path) -> None:
    -        """Test that paths like //evil.com/path return 403."""
    +    async def test_double_slash_with_path_returns_400(self, tmp_path: Path) -> None:
    +        """Test that paths like //evil.com/path return 400."""
             static_dir = tmp_path / "static"
             static_dir.mkdir()
             (static_dir / "index.html").write_text("<html>Home</html>")
    @@ -347,12 +347,12 @@ async def send(message: dict[str, object]) -> None:
     
             await static_files(scope, receive, send)
     
    -        assert response_status == 403
    -        assert response_body == b"Forbidden"
    +        assert response_status == 400
    +        assert response_body == b"Bad Request"
     
         @pytest.mark.anyio
    -    async def test_double_slash_at_root_returns_403(self, tmp_path: Path) -> None:
    -        """Test that just // returns 403."""
    +    async def test_double_slash_at_root_returns_400(self, tmp_path: Path) -> None:
    +        """Test that just // returns 400."""
             static_dir = tmp_path / "static"
             static_dir.mkdir()
             (static_dir / "index.html").write_text("<html>Home</html>")
    @@ -385,8 +385,8 @@ async def send(message: dict[str, object]) -> None:
     
             await static_files(scope, receive, send)
     
    -        assert response_status == 403
    -        assert response_body == b"Forbidden"
    +        assert response_status == 400
    +        assert response_body == b"Bad Request"
     
         def test_single_slash_path_works(self, static_app: TestClient) -> None:
             """Test that normal single-slash paths still work correctly."""
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.