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.
Files changed (140) hide show
  1. {stemtrace-0.3.3 → stemtrace-0.3.5}/.bumpversion.toml +1 -1
  2. {stemtrace-0.3.3 → stemtrace-0.3.5}/CHANGELOG.md +11 -0
  3. {stemtrace-0.3.3 → stemtrace-0.3.5}/PKG-INFO +3 -3
  4. {stemtrace-0.3.3 → stemtrace-0.3.5}/README.md +1 -1
  5. {stemtrace-0.3.3 → stemtrace-0.3.5}/pyproject.toml +1 -1
  6. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/__init__.py +1 -1
  7. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/api/websocket.py +6 -2
  8. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/fastapi/login_routes.py +21 -4
  9. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/package.json +1 -1
  10. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/static.py +53 -3
  11. stemtrace-0.3.5/tests/integration/test_static_ui.py +925 -0
  12. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_form_login.py +104 -6
  13. {stemtrace-0.3.3 → stemtrace-0.3.5}/uv.lock +11 -11
  14. stemtrace-0.3.3/tests/integration/test_static_ui.py +0 -451
  15. {stemtrace-0.3.3 → stemtrace-0.3.5}/.dockerignore +0 -0
  16. {stemtrace-0.3.3 → stemtrace-0.3.5}/.github/dependabot.yml +0 -0
  17. {stemtrace-0.3.3 → stemtrace-0.3.5}/.github/workflows/ci.yml +0 -0
  18. {stemtrace-0.3.3 → stemtrace-0.3.5}/.github/workflows/e2e.yml +0 -0
  19. {stemtrace-0.3.3 → stemtrace-0.3.5}/.github/workflows/release.yml +0 -0
  20. {stemtrace-0.3.3 → stemtrace-0.3.5}/.gitignore +0 -0
  21. {stemtrace-0.3.3 → stemtrace-0.3.5}/.pre-commit-config.yaml +0 -0
  22. {stemtrace-0.3.3 → stemtrace-0.3.5}/.python-version +0 -0
  23. {stemtrace-0.3.3 → stemtrace-0.3.5}/CONTRIBUTING.md +0 -0
  24. {stemtrace-0.3.3 → stemtrace-0.3.5}/Dockerfile +0 -0
  25. {stemtrace-0.3.3 → stemtrace-0.3.5}/Dockerfile.e2e +0 -0
  26. {stemtrace-0.3.3 → stemtrace-0.3.5}/LICENSE +0 -0
  27. {stemtrace-0.3.3 → stemtrace-0.3.5}/Makefile +0 -0
  28. {stemtrace-0.3.3 → stemtrace-0.3.5}/build_ui.py +0 -0
  29. {stemtrace-0.3.3 → stemtrace-0.3.5}/docker-compose.e2e.rabbitmq.yml +0 -0
  30. {stemtrace-0.3.3 → stemtrace-0.3.5}/docker-compose.e2e.yml +0 -0
  31. {stemtrace-0.3.3 → stemtrace-0.3.5}/docker-compose.rabbitmq.yml +0 -0
  32. {stemtrace-0.3.3 → stemtrace-0.3.5}/docker-compose.yml +0 -0
  33. {stemtrace-0.3.3 → stemtrace-0.3.5}/docs/screenshots/task_details.png +0 -0
  34. {stemtrace-0.3.3 → stemtrace-0.3.5}/docs/screenshots/unregistered.png +0 -0
  35. {stemtrace-0.3.3 → stemtrace-0.3.5}/docs/screenshots/workflow.png +0 -0
  36. {stemtrace-0.3.3 → stemtrace-0.3.5}/examples/celery_app.py +0 -0
  37. {stemtrace-0.3.3 → stemtrace-0.3.5}/examples/fastapi_integration.py +0 -0
  38. {stemtrace-0.3.3 → stemtrace-0.3.5}/examples/with_auth.py +0 -0
  39. {stemtrace-0.3.3 → stemtrace-0.3.5}/examples/with_login.py +0 -0
  40. {stemtrace-0.3.3 → stemtrace-0.3.5}/progress.md +0 -0
  41. {stemtrace-0.3.3 → stemtrace-0.3.5}/scripts/wait_for_http.py +0 -0
  42. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/core/__init__.py +0 -0
  43. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/core/events.py +0 -0
  44. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/core/exceptions.py +0 -0
  45. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/core/graph.py +0 -0
  46. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/core/ports.py +0 -0
  47. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/__init__.py +0 -0
  48. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/bootsteps.py +0 -0
  49. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/config.py +0 -0
  50. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/scrubbing.py +0 -0
  51. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/signals.py +0 -0
  52. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/transports/__init__.py +0 -0
  53. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/transports/memory.py +0 -0
  54. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/transports/rabbitmq.py +0 -0
  55. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/library/transports/redis.py +0 -0
  56. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/py.typed +0 -0
  57. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/__init__.py +0 -0
  58. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/__main__.py +0 -0
  59. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/api/__init__.py +0 -0
  60. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/api/routes.py +0 -0
  61. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/api/schemas.py +0 -0
  62. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/consumer.py +0 -0
  63. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/fastapi/__init__.py +0 -0
  64. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/fastapi/auth.py +0 -0
  65. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/fastapi/extension.py +0 -0
  66. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/fastapi/form_auth.py +0 -0
  67. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/fastapi/router.py +0 -0
  68. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/store.py +0 -0
  69. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/__init__.py +0 -0
  70. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/biome.json +0 -0
  71. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/index.html +0 -0
  72. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/package-lock.json +0 -0
  73. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/playwright.config.ts +0 -0
  74. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/postcss.config.js +0 -0
  75. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/api/client.ts +0 -0
  76. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/api/queries.ts +0 -0
  77. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +0 -0
  78. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/Filters.tsx +0 -0
  79. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +0 -0
  80. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/TaskList.tsx +0 -0
  81. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +0 -0
  82. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +0 -0
  83. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +0 -0
  84. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/components/Tooltip.tsx +0 -0
  85. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +0 -0
  86. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +0 -0
  87. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/index.css +0 -0
  88. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/main.tsx +0 -0
  89. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/routes/__root.tsx +0 -0
  90. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +0 -0
  91. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/routes/graphs.tsx +0 -0
  92. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/routes/index.tsx +0 -0
  93. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/routes/registry.tsx +0 -0
  94. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +0 -0
  95. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/routes/workers.tsx +0 -0
  96. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/utils/format.ts +0 -0
  97. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/src/vite-env.d.ts +0 -0
  98. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tests/fixtures/mock-api.ts +0 -0
  99. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tests/fixtures/mock-data.ts +0 -0
  100. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tests/graphs.spec.ts +0 -0
  101. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tests/registry.spec.ts +0 -0
  102. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tests/task-detail.spec.ts +0 -0
  103. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tests/tasks.spec.ts +0 -0
  104. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tests/workers.spec.ts +0 -0
  105. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tsconfig.json +0 -0
  106. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +0 -0
  107. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/frontend/vite.config.ts +0 -0
  108. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/ui/templates/login.html +0 -0
  109. {stemtrace-0.3.3 → stemtrace-0.3.5}/src/stemtrace/server/websocket.py +0 -0
  110. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/__init__.py +0 -0
  111. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/conftest.py +0 -0
  112. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/e2e/__init__.py +0 -0
  113. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/e2e/conftest.py +0 -0
  114. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/e2e/tasks.py +0 -0
  115. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/e2e/test_api_e2e.py +0 -0
  116. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/e2e/test_workers_e2e.py +0 -0
  117. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/integration/__init__.py +0 -0
  118. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/integration/test_embedded_static.py +0 -0
  119. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/integration/test_fastapi_integration.py +0 -0
  120. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/integration/test_rabbitmq_transport.py +0 -0
  121. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/integration/test_redis_transport.py +0 -0
  122. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/integration/test_workers_refresh.py +0 -0
  123. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/__init__.py +0 -0
  124. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_auth.py +0 -0
  125. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_bootsteps.py +0 -0
  126. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_config.py +0 -0
  127. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_consumer.py +0 -0
  128. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_events.py +0 -0
  129. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_examples.py +0 -0
  130. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_exceptions.py +0 -0
  131. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_form_auth.py +0 -0
  132. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_graph.py +0 -0
  133. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_init.py +0 -0
  134. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_routes.py +0 -0
  135. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_scrubbing.py +0 -0
  136. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_signals.py +0 -0
  137. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_store.py +0 -0
  138. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_transports.py +0 -0
  139. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_ui_static_prefix.py +0 -0
  140. {stemtrace-0.3.3 → stemtrace-0.3.5}/tests/unit/test_websocket.py +0 -0
@@ -1,5 +1,5 @@
1
1
  [tool.bumpversion]
2
- current_version = "0.3.3"
2
+ current_version = "0.3.5"
3
3
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
4
4
  serialize = ["{major}.{minor}.{patch}"]
5
5
  commit = true
@@ -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.4
1
+ Metadata-Version: 2.5
2
2
  Name: stemtrace
3
- Version: 0.3.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
- [![PyPI version](https://img.shields.io/badge/pypi-v0.3.3-darklime)](https://pypi.org/project/stemtrace)
59
+ [![PyPI version](https://img.shields.io/badge/pypi-v0.3.5-darklime)](https://pypi.org/project/stemtrace)
60
60
  [![Python](https://img.shields.io/pypi/pyversions/stemtrace.svg)](https://pypi.org/project/stemtrace/)
61
61
  [![CI](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml/badge.svg)](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml)
62
62
  [![codecov](https://codecov.io/gh/iansokolskyi/stemtrace/graph/badge.svg)](https://codecov.io/gh/iansokolskyi/stemtrace)
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Zero-infrastructure Celery task flow visualizer**
4
4
 
5
- [![PyPI version](https://img.shields.io/badge/pypi-v0.3.3-darklime)](https://pypi.org/project/stemtrace)
5
+ [![PyPI version](https://img.shields.io/badge/pypi-v0.3.5-darklime)](https://pypi.org/project/stemtrace)
6
6
  [![Python](https://img.shields.io/pypi/pyversions/stemtrace.svg)](https://pypi.org/project/stemtrace/)
7
7
  [![CI](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml/badge.svg)](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml)
8
8
  [![codecov](https://codecov.io/gh/iansokolskyi/stemtrace/graph/badge.svg)](https://codecov.io/gh/iansokolskyi/stemtrace)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stemtrace"
3
- version = "0.3.3"
3
+ version = "0.3.5"
4
4
  description = "A lightweight Celery task flow visualizer"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -43,7 +43,7 @@ if TYPE_CHECKING:
43
43
  from celery import Celery
44
44
  from fastapi import FastAPI
45
45
 
46
- __version__ = "0.3.3"
46
+ __version__ = "0.3.5"
47
47
  __all__ = [
48
48
  "ConfigurationError",
49
49
  "StemtraceConfig",
@@ -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
- # Close without registering the connection.
37
- await websocket.accept()
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
- action_path = str(request.url_for("stemtrace_login_submit"))
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 = str(request.url_for("stemtrace_login_page"))
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.url.scheme == "https",
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 = str(request.url_for("stemtrace_login_page"))
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stemtrace-ui",
3
3
  "private": true,
4
- "version": "0.3.3",
4
+ "version": "0.3.5",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
@@ -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
- file_path = _FRONTEND_DIR / path
225
- if file_path.exists() and file_path.is_file():
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"