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.
| Package | Affected versions | Patched versions |
|---|---|---|
StreamlitPyPI | < 1.54.0 | 1.54.0 |
Affected products
1Patches
123692ca70b2f[security] Prevent SSRF attacks via path traversal in component file handling (#13733)
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- github.com/streamlit/streamlit/commit/23692ca70b2f2ac720c72d1feb4f190c9d6eed76nvdPatchWEB
- github.com/advisories/GHSA-7p48-42j8-8846ghsaADVISORY
- github.com/streamlit/streamlit/security/advisories/GHSA-7p48-42j8-8846nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-33682ghsaADVISORY
- github.com/streamlit/streamlit/releases/tag/1.54.0nvdProductRelease NotesWEB
News mentions
0No linked articles in our index yet.