stemtrace 0.3.3__tar.gz → 0.3.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {stemtrace-0.3.3 → stemtrace-0.3.5}/.bumpversion.toml +1 -1
- {stemtrace-0.3.3 → stemtrace-0.3.5}/CHANGELOG.md +11 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/PKG-INFO +3 -3
- {stemtrace-0.3.3 → stemtrace-0.3.5}/README.md +1 -1
- {stemtrace-0.3.3 → stemtrace-0.3.5}/pyproject.toml +1 -1
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/__init__.py +1 -1
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/api/websocket.py +6 -2
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/fastapi/login_routes.py +21 -4
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/package.json +1 -1
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/static.py +53 -3
- stemtrace-0.3.5/tests/integration/test_static_ui.py +925 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_form_login.py +104 -6
- {stemtrace-0.3.3 → stemtrace-0.3.5}/uv.lock +11 -11
- stemtrace-0.3.3/tests/integration/test_static_ui.py +0 -451
- {stemtrace-0.3.3 → stemtrace-0.3.5}/.dockerignore +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/.github/dependabot.yml +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/.github/workflows/ci.yml +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/.github/workflows/e2e.yml +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/.github/workflows/release.yml +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/.gitignore +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/.pre-commit-config.yaml +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/.python-version +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/CONTRIBUTING.md +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/Dockerfile +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/Dockerfile.e2e +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/LICENSE +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/Makefile +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/build_ui.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/docker-compose.e2e.rabbitmq.yml +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/docker-compose.e2e.yml +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/docker-compose.rabbitmq.yml +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/docker-compose.yml +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/docs/screenshots/task_details.png +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/docs/screenshots/unregistered.png +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/docs/screenshots/workflow.png +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/examples/celery_app.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/examples/fastapi_integration.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/examples/with_auth.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/examples/with_login.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/progress.md +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/scripts/wait_for_http.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/core/__init__.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/core/events.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/core/exceptions.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/core/graph.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/core/ports.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/__init__.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/bootsteps.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/config.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/scrubbing.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/signals.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/transports/__init__.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/transports/memory.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/transports/rabbitmq.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/transports/redis.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/py.typed +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/__init__.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/__main__.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/api/__init__.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/api/routes.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/api/schemas.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/consumer.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/fastapi/__init__.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/fastapi/auth.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/fastapi/extension.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/fastapi/form_auth.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/fastapi/router.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/store.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/__init__.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/biome.json +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/index.html +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/package-lock.json +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/playwright.config.ts +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/postcss.config.js +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/api/client.ts +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/api/queries.ts +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/Filters.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/TaskList.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/Tooltip.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/index.css +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/main.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/routes/__root.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/routes/graphs.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/routes/index.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/routes/registry.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/routes/workers.tsx +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/utils/format.ts +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/vite-env.d.ts +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tests/fixtures/mock-api.ts +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tests/fixtures/mock-data.ts +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tests/graphs.spec.ts +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tests/registry.spec.ts +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tests/task-detail.spec.ts +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tests/tasks.spec.ts +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tests/workers.spec.ts +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tsconfig.json +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/vite.config.ts +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/templates/login.html +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/websocket.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/__init__.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/conftest.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/e2e/__init__.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/e2e/conftest.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/e2e/tasks.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/e2e/test_api_e2e.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/e2e/test_workers_e2e.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/integration/__init__.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/integration/test_embedded_static.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/integration/test_fastapi_integration.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/integration/test_rabbitmq_transport.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/integration/test_redis_transport.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/integration/test_workers_refresh.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/__init__.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_auth.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_bootsteps.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_config.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_consumer.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_events.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_examples.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_exceptions.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_form_auth.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_graph.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_init.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_routes.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_scrubbing.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_signals.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_store.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_transports.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_ui_static_prefix.py +0 -0
- {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_websocket.py +0 -0
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.5] - 2026-10-09
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- UI: fixed a path traversal in the static file handler. Crafted URLs could read any file the server process can access, not just the bundled frontend. When the UI is exposed without authentication (no form login or `auth_dependency`, which is the default for `stemtrace server` and the Docker image), no credentials were needed. All users should upgrade. If you can't upgrade right away, put the UI behind authentication or a reverse proxy that blocks it from untrusted networks.
|
|
14
|
+
|
|
15
|
+
## [0.3.4] - 2026-06-25
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- WebSocket: deny unauthenticated connections during the handshake instead of accepting and then closing them. The accept-then-close path half-opened the connection, so when an unauthenticated client (e.g. a port scanner) aborted mid-handshake, uvicorn's legacy `websockets` implementation ran `close()` on a protocol whose `transfer_data_task` was never set, raising `AttributeError: 'WebSocketProtocol' object has no attribute 'transfer_data_task'`. Closing before `accept()` rejects the handshake (HTTP 403) without opening the protocol object.
|
|
19
|
+
- Form login: build the login form action and redirects as relative paths instead of absolute URLs, and derive the session cookie's `Secure` flag from `X-Forwarded-Proto`. Behind a TLS-terminating proxy that doesn't forward the scheme to the ASGI scope (e.g. uvicorn without `--forwarded-allow-ips`), absolute URLs were downgraded to `http://`, so the browser posted the login form over http, the proxy answered `301 -> https`, the POST body was dropped, and login silently failed (with a mixed-content warning). stemtrace now works behind such a proxy without requiring forwarded-headers configuration.
|
|
20
|
+
|
|
10
21
|
## [0.3.3] - 2026-03-20
|
|
11
22
|
|
|
12
23
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: stemtrace
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: A lightweight Celery task flow visualizer
|
|
5
5
|
Project-URL: Homepage, https://github.com/iansokolskyi/stemtrace
|
|
6
6
|
Project-URL: Documentation, https://github.com/iansokolskyi/stemtrace#readme
|
|
@@ -56,7 +56,7 @@ Description-Content-Type: text/markdown
|
|
|
56
56
|
|
|
57
57
|
**Zero-infrastructure Celery task flow visualizer**
|
|
58
58
|
|
|
59
|
-
[](https://pypi.org/project/stemtrace)
|
|
60
60
|
[](https://pypi.org/project/stemtrace/)
|
|
61
61
|
[](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml)
|
|
62
62
|
[](https://codecov.io/gh/iansokolskyi/stemtrace)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Zero-infrastructure Celery task flow visualizer**
|
|
4
4
|
|
|
5
|
-
[](https://pypi.org/project/stemtrace)
|
|
6
6
|
[](https://pypi.org/project/stemtrace/)
|
|
7
7
|
[](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml)
|
|
8
8
|
[](https://codecov.io/gh/iansokolskyi/stemtrace)
|
|
@@ -33,8 +33,12 @@ def create_websocket_router(
|
|
|
33
33
|
secret=form_auth_config.secret,
|
|
34
34
|
expected_username=form_auth_config.username,
|
|
35
35
|
):
|
|
36
|
-
#
|
|
37
|
-
|
|
36
|
+
# Deny the handshake without completing it. Accepting first and
|
|
37
|
+
# then closing half-opens the connection; if the unauthenticated
|
|
38
|
+
# client aborts during that dance, uvicorn's legacy websockets
|
|
39
|
+
# impl runs close() on a protocol whose transfer_data_task was
|
|
40
|
+
# never set and raises AttributeError. Closing before accept lets
|
|
41
|
+
# Starlette reject the handshake (HTTP 403) without opening it.
|
|
38
42
|
await websocket.close(code=1008)
|
|
39
43
|
return
|
|
40
44
|
|
|
@@ -40,6 +40,20 @@ def _safe_next(next_path: str | None, *, default: str) -> str:
|
|
|
40
40
|
return next_path
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _request_is_https(request: Request) -> bool:
|
|
44
|
+
"""Best-effort HTTPS detection that survives a TLS-terminating proxy.
|
|
45
|
+
|
|
46
|
+
Falls back to the ``X-Forwarded-Proto`` header when the ASGI scheme is
|
|
47
|
+
``http`` because proxy headers weren't applied to the scope (e.g. uvicorn
|
|
48
|
+
without ``--forwarded-allow-ips``). The first value of a comma-separated
|
|
49
|
+
list is the original client scheme.
|
|
50
|
+
"""
|
|
51
|
+
if request.url.scheme == "https":
|
|
52
|
+
return True
|
|
53
|
+
forwarded = request.headers.get("x-forwarded-proto", "")
|
|
54
|
+
return forwarded.split(",")[0].strip().lower() == "https"
|
|
55
|
+
|
|
56
|
+
|
|
43
57
|
def _login_page_html(*, action_path: str, error: str | None, next_path: str) -> str:
|
|
44
58
|
"""Render the login page HTML from a template."""
|
|
45
59
|
escaped_title = html.escape(_DEFAULT_TITLE, quote=True)
|
|
@@ -92,7 +106,10 @@ def create_login_router(
|
|
|
92
106
|
next_path = _safe_next(
|
|
93
107
|
request.query_params.get("next"), default=default_next_path
|
|
94
108
|
)
|
|
95
|
-
|
|
109
|
+
# Relative path (not an absolute URL): the browser submits using the
|
|
110
|
+
# page's own scheme/host, so a TLS-terminating proxy that doesn't
|
|
111
|
+
# forward the scheme can't downgrade the form action to http.
|
|
112
|
+
action_path = request.url_for("stemtrace_login_submit").path
|
|
96
113
|
rendered_html = _login_page_html(
|
|
97
114
|
action_path=action_path, error=error, next_path=next_path
|
|
98
115
|
)
|
|
@@ -125,7 +142,7 @@ def create_login_router(
|
|
|
125
142
|
qs = {"error": "Invalid username or password"}
|
|
126
143
|
if next_param:
|
|
127
144
|
qs["next"] = next_param
|
|
128
|
-
login_url =
|
|
145
|
+
login_url = request.url_for("stemtrace_login_page").path
|
|
129
146
|
return RedirectResponse(
|
|
130
147
|
url=login_url + "?" + urllib.parse.urlencode(qs),
|
|
131
148
|
status_code=303,
|
|
@@ -141,7 +158,7 @@ def create_login_router(
|
|
|
141
158
|
value=config.create_session_cookie_value(),
|
|
142
159
|
httponly=True,
|
|
143
160
|
samesite="lax",
|
|
144
|
-
secure=request
|
|
161
|
+
secure=_request_is_https(request),
|
|
145
162
|
path=config.cookie_path,
|
|
146
163
|
)
|
|
147
164
|
return response
|
|
@@ -149,7 +166,7 @@ def create_login_router(
|
|
|
149
166
|
@router.post("/logout", include_in_schema=False, name="stemtrace_logout")
|
|
150
167
|
async def logout(request: Request) -> RedirectResponse:
|
|
151
168
|
"""Clear the session cookie and redirect to login."""
|
|
152
|
-
login_url =
|
|
169
|
+
login_url = request.url_for("stemtrace_login_page").path
|
|
153
170
|
response = RedirectResponse(url=login_url, status_code=303)
|
|
154
171
|
response.delete_cookie(key=config.cookie_name, path=config.cookie_path)
|
|
155
172
|
return response
|
|
@@ -4,7 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
+
import os
|
|
7
8
|
import re
|
|
9
|
+
import stat
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
|
|
10
12
|
from fastapi import APIRouter, Request
|
|
@@ -160,6 +162,46 @@ def _rewrite_html_for_prefix(
|
|
|
160
162
|
return html.replace("<head>", script)
|
|
161
163
|
|
|
162
164
|
|
|
165
|
+
def _contained_frontend_path(path: str) -> str | None:
|
|
166
|
+
"""Confine a request path to the frontend directory, lexically.
|
|
167
|
+
|
|
168
|
+
The request path is user-controlled. It is joined onto the resolved
|
|
169
|
+
frontend directory and normalized as a string (dot-segments collapsed,
|
|
170
|
+
no symlink resolution), then accepted only if the result is still under
|
|
171
|
+
that directory. The filesystem is never touched for the candidate before
|
|
172
|
+
this check passes. Symlinks inside the frontend directory are trusted and
|
|
173
|
+
followed when the file is served (they come from the installed package,
|
|
174
|
+
e.g. symlink-mode installs), since only the request path is untrusted.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
path: Path parameter taken from the request URL.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
The normalized absolute path if it lies under the frontend directory
|
|
181
|
+
(it may not exist), or None if it escapes the directory.
|
|
182
|
+
"""
|
|
183
|
+
base = os.path.realpath(_FRONTEND_DIR)
|
|
184
|
+
candidate = os.path.normpath(Path(base) / path)
|
|
185
|
+
# Plain string prefix check: an absolute request path, a drive change or
|
|
186
|
+
# a UNC path (Windows) all produce a candidate outside this prefix.
|
|
187
|
+
base_prefix = base if base.endswith(os.sep) else base + os.sep
|
|
188
|
+
if candidate != base and not candidate.startswith(base_prefix):
|
|
189
|
+
return None
|
|
190
|
+
return candidate
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _is_regular_file(path: str) -> bool:
|
|
194
|
+
"""Return True if path is an existing regular file (symlinks followed).
|
|
195
|
+
|
|
196
|
+
Any error while inspecting the path (missing file, name too long,
|
|
197
|
+
embedded NUL byte, permission denied) is treated as "not a file".
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
return stat.S_ISREG(Path(path).stat().st_mode)
|
|
201
|
+
except (OSError, ValueError):
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
163
205
|
def get_static_router(*, show_logout: bool = False) -> APIRouter | None:
|
|
164
206
|
"""Create router for UI static files. Returns None if dist/ missing."""
|
|
165
207
|
return get_static_router_with_base(None, show_logout=show_logout)
|
|
@@ -220,9 +262,17 @@ def get_static_router_with_base(
|
|
|
220
262
|
|
|
221
263
|
@router.get("/{path:path}", response_model=None)
|
|
222
264
|
async def serve_spa(path: str, request: Request) -> FileResponse | HTMLResponse:
|
|
223
|
-
"""Serve static files or fall back to index.html for SPA routing.
|
|
224
|
-
|
|
225
|
-
|
|
265
|
+
"""Serve static files or fall back to index.html for SPA routing.
|
|
266
|
+
|
|
267
|
+
Only regular files under the frontend directory are served. Paths
|
|
268
|
+
that normalize to a location outside of it get a 404; any other path
|
|
269
|
+
that is not a servable file (missing, a directory, or not inspectable)
|
|
270
|
+
falls back to index.html so client-side routes keep working.
|
|
271
|
+
"""
|
|
272
|
+
file_path = _contained_frontend_path(path)
|
|
273
|
+
if file_path is None:
|
|
274
|
+
return HTMLResponse("<h1>Not found</h1>", status_code=404)
|
|
275
|
+
if _is_regular_file(file_path):
|
|
226
276
|
return FileResponse(file_path)
|
|
227
277
|
|
|
228
278
|
index_path = _FRONTEND_DIR / "index.html"
|