Skip to content

Security review fixes: XSS, cookie leak, scheduler stall, optional login#60

Merged
bin101 merged 7 commits into
mainfrom
fix/security-and-robustness-review
Jul 24, 2026
Merged

Security review fixes: XSS, cookie leak, scheduler stall, optional login#60
bin101 merged 7 commits into
mainfrom
fix/security-and-robustness-review

Conversation

@bin101

@bin101 bin101 commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Summary

Follow-up from a full project review (3 parallel Sonnet explorations: backend architecture,
security/Strava integration, tests/frontend/hygiene). Fixes a chained XSS→cookie-theft path,
a scheduler bug that could permanently stall the auto-kudos job, and adds an opt-in login
screen — plus assorted robustness, CI, and Docker hardening. All changes are backward
compatible: nothing here changes default behavior for existing installs beyond the security
fixes themselves (login is off unless KUDOSY_AUTH_PASSWORD is set).

Security

  • XSS fix (feed.js): feed cards were built via innerHTML with unescaped Strava
    activity/athlete names — a crafted activity title from any followed athlete could execute
    script in the page. Added an escapeHtml() helper and applied it everywhere a Strava-sourced
    string is interpolated; also fixed a latent instance in dom.js's toast().
  • Cookie leak fix: GET /api/config no longer returns the raw _strava4_session cookie
    (only hasCookie + a masked preview). PUT /api/config now merges + validates instead of
    writing raw untyped data, and keeps the existing cookie when the request omits/empties it.
  • CSP + security headers: strict Content-Security-Policy (nonce-based for the one inline
    importmap script), X-Content-Type-Options, X-Frame-Options, Referrer-Policy on every
    response. Auto-generated FastAPI docs disabled (would break under this CSP anyway).
  • Path-param validation: athlete_id/activity_id must be numeric before being
    interpolated into Strava URLs via str.format.
  • Optional login screen (new auth.py): inactive unless KUDOSY_AUTH_PASSWORD is set.
    Signed session cookie (HMAC-SHA256, HttpOnly, timing-safe verify), simple login-lockout,
    and a frontend overlay that blocks the app until authenticated. See README "Zugriffsschutz".

Robustness

  • Scheduler stall fix: reschedule-after-run now happens in finally (previously an
    exception in job_fn skipped it entirely), and an overlapping tick reschedules instead of
    silently dropping the chain — both could previously stall the scheduler forever.
  • A run that stops early (rate limit / consecutive failures) is now reported as
    success=False with distinct notification wording, instead of looking identical to a
    normal completed run.
  • Batched kudoed-activity persistence (one read+write per run, not per kudo).
  • Retries on transient network errors for idempotent Strava GETs; GET /api/feed converts a
    network failure into a clean 502 instead of an unhandled 500; early-warning log when a
    Strava stat label goes fully unrecognized (feed-format drift signal).

CI / Docker

  • SHA-pinned every third-party GitHub Action (supply-chain hardening).
  • Added .github/scripts/check_module_coverage.py: actually enforces the ≥90% pure-module
    coverage target CLAUDE.md already documented but CI never checked.
  • Dockerfile healthcheck: curl → stdlib urllib (drops the curl apt dependency).
  • docker-compose.yaml: published port bound to 127.0.0.1 by default, added
    security_opt/cap_drop hardening.

Public-repo housekeeping

Verified the repo/releases/Docker image are correctly public post-switch (no action needed —
documented in commit history for the record); .gitignore tightened for local Claude Code
overrides.

Test plan

  • ruff check / ruff format --check / mypy src / pytest all green (569 tests,
    92% overall coverage, all 4 "pure module" targets ≥90%)
  • Full auth flow (login → session cookie → protected access → logout) verified live
    against a running server via curl
  • Docker image built and smoke-tested locally (healthcheck command verified directly
    against a running container)
  • Default (no-auth) flow smoke-tested live: /, /api/status, /api/config (cookie
    redaction), /api/auth-status, security headers all confirmed correct
  • Visual/interactive browser verification of the login overlay's rendering — blocked:
    no isolated browser-automation tool was available in this environment (per CLAUDE.md,
    the user's live browser must never be driven for this)

🤖 Generated with Claude Code

https://claude.ai/code/session_01FXccJ9FUpxNxAqHDmhZ6Bh

bin101 added 7 commits July 24, 2026 11:09
renderFeed() built each feed card via innerHTML with unescaped activity/athlete names and stat labels straight from the Strava feed. Add an escapeHtml() helper and apply it to every Strava-sourced value in feed.js; also stop toast() from interpolating its message into innerHTML.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01FXccJ9FUpxNxAqHDmhZ6Bh
… gate, harden Docker deploy defaults

- SHA-pin every third-party GitHub Action (checkout, setup-python, upload-artifact, release-please-action, docker/*) for supply-chain safety, keeping a version comment for readability.

- Add .github/scripts/check_module_coverage.py: enforces the >=90% pure-module coverage target CLAUDE.md already documented but CI never actually checked (coverage.py has no built-in per-file threshold).

- Dockerfile: replace the curl-based healthcheck with stdlib urllib (drops the curl apt dependency); clarify why KUDOSY_HOST must stay 0.0.0.0 inside the container.

- docker-compose.yaml: bind the published port to 127.0.0.1 by default (no auth in front of the API otherwise — see the login-screen commit), add security_opt/cap_drop hardening.

- .dockerignore: exclude *.har (browser network captures can contain session cookies).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01FXccJ9FUpxNxAqHDmhZ6Bh
Security:

- GET /api/config no longer returns the raw Strava session cookie (only hasCookie + a masked preview); PUT /api/config now merges+validates instead of writing raw untyped data, and keeps the existing cookie when the request omits/empties it.

- Add a strict Content-Security-Policy (nonce-based for the one inline importmap script) plus X-Content-Type-Options/X-Frame-Options/Referrer-Policy on every response; disable the auto-generated FastAPI docs (would break under this CSP anyway).

- Validate athlete_id/activity_id path params as numeric before they're interpolated into Strava URLs; harden cursor-pagination parsing against non-numeric drift instead of raising a bare 500.

Robustness:

- Fix the scheduler: reschedule-after-run now happens in finally (previously an exception in job_fn skipped it entirely), and an overlapping tick reschedules instead of silently dropping the chain — both could previously stall the scheduler forever.

- A run that stops early (rate limit / consecutive failures) is now reported as success=False with its own notification wording, instead of looking identical to a normal completed run.

- Batch kudoed-activity persistence into one read+write per run instead of one per kudo sent.

- Retry idempotent Strava GETs on transient network errors; GET /api/feed converts a network failure into a clean 502 instead of an unhandled 500; log a warning when a Strava stat item's label goes fully unrecognized (early signal for feed-format drift).

Feature: optional login screen (auth.py) — inactive unless KUDOSY_AUTH_PASSWORD is set, so existing unauthenticated deployments are unaffected. Signed session cookie (HMAC-SHA256, HttpOnly, timing-safe verify), simple login-attempt lockout, and a frontend overlay that blocks the app until authenticated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01FXccJ9FUpxNxAqHDmhZ6Bh
applyStaticTranslations() ran after ensureAuthenticated(), which blocks on the login overlay when KUDOSY_AUTH_PASSWORD is set. The overlay's own labels stayed in the raw fallback text baked into index.html regardless of detected browser/stored language, while errors surfaced via t() (e.g. a wrong-password message) correctly used the real language — producing a visibly mismatched-language UI during login. Found via live browser verification (agent-browser) of the login overlay; moving the translation call before the auth gate fixes it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01FXccJ9FUpxNxAqHDmhZ6Bh
…rman), fill missing STRAVA_UNREACHABLE translation

The new Access Control section/comments used a German heading ("## Zugriffsschutz") that was then quoted verbatim in docker-compose.yaml, CLAUDE.md, models.py and settings.py — inconsistent with this project's convention of English docs/comments (the app's own UI strings are the separately-maintained multi-language i18n.js catalog, which is unaffected). Renamed to "Access Control" everywhere it's referenced.

Also added the error.STRAVA_UNREACHABLE translation (all 5 languages) that was missing since that error code was introduced — without it, the German backend fallback message would have leaked through untranslated to non-German UI languages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01FXccJ9FUpxNxAqHDmhZ6Bh
t(key) silently falls back to German when a key is missing for the current language, so a gap is invisible at runtime — exactly what happened here: the entire Statistics tab (tab.stats + 14 stats.* keys) was never translated for fr/es/it, a pre-existing gap this test caught immediately. Fixed those 15 missing translations so the new test starts from a clean baseline.

Exports MESSAGES from i18n.js (additive, no behavior change) so the test can load the real ES module via Node rather than hand-parsing the file with regex — this project has no JS test runner (pytest is the only test tool per CLAUDE.md), so the test shells out to node and skips cleanly if it's unavailable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01FXccJ9FUpxNxAqHDmhZ6Bh
Names the specific CLI (agent-browser — isolated Chrome via CDP, no Playwright/Puppeteer dep) instead of just gesturing at 'a headless/dedicated tool', with a quick command-flow example and the immutable-cache gotcha (?v=... assets need a fresh session, not just reload, to pick up an edited static file) discovered while verifying the login-overlay in this branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01FXccJ9FUpxNxAqHDmhZ6Bh
@bin101
bin101 merged commit 076d6a4 into main Jul 24, 2026
1 check passed
@bin101
bin101 deleted the fix/security-and-robustness-review branch July 24, 2026 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant