Skip to content

fix(security): block cross-site state changes (ROADMAP 2.2) - #20

Merged
SeanathanVT merged 2 commits into
developmentfrom
feature/route-security
Oct 7, 2026
Merged

SeanathanVT merged 2 commits into
developmentfrom
feature/route-security

Conversation

@SeanathanVT

@SeanathanVT SeanathanVT commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

Closes ROADMAP 2.2 (Route Security) with a CSRF guard instead of the originally planned token. The app is LAN-only and devices on the LAN are trusted by design, so a token would add a sign-in step for every phone and break scripts, guarding against a threat that doesn't apply here. The realistic threat is a website open in a browser on the network driving the treadmill, and that's what this blocks.

  • _block_cross_site_posts() (before_request): rejects any non-GET request whose Origin isn't the app's own origin (scheme, host, port), or whose Sec-Fetch-Site is cross-site / same-site. Requests without either header (curl, run.py's /shutdown) and all GETs pass.
  • /reconnect is now POST-only, so every state-changing route goes through the guard. The connecting screen's Connect / Try Again links are now form buttons.
  • Docs and comments harmonized with the code:
    • shutdown comments reference /stats_stream, not /stats polling
    • README config table and Settings-page claims corrected, LAN trust model noted
    • ROADMAP 2.2 and 3.4 updated
    • CHANGELOG [Unreleased] entries added

No impact on 3.6 (QR for LAN access): a scanned phone's page posts back to its own origin, which passes the guard.

Known limit: a targeted DNS-rebinding attack gets through (documented in ROADMAP 2.2 with the fix and its trade-off).

Test plan

  • python -m pytest: 357 passed; new tests/test_csrf.py covers each blocked/allowed header combination on Start, Shutdown, Clear History, Settings, and Reconnect, and asserts no side effects
  • ruff check . clean
  • Live Waitress server: same-origin POSTs via 127.0.0.1 and LAN IP → 200; no-Origin → 200; foreign / https / cross-site / LAN-to-loopback origins → 403
  • Manual (macOS): Start, Pause, speed, End work; Connect / Try Again on the connecting screen; phone on LAN unchanged

🤖 Generated with Claude Code

SeanathanVT and others added 2 commits October 6, 2026 16:31
Reject any non-GET request whose Origin isn't the app's own origin, or
whose Sec-Fetch-Site is cross-site or same-site, so a website open in a
browser on the network can't start the belt, change speed, clear
history, or shut the app down.

No token or sign-in: the app is LAN-only and LAN devices are trusted by
design. Same-origin forms and fetches, requests without either header
(curl, run.py's /shutdown), and all GETs are unaffected.

Co-Authored-By: Claude <[email protected]>
…2.2)

/reconnect changed state on GET, so the cross-site guard skipped it and
any web page could trigger a Bluetooth reconnect. Make it POST-only and
turn the connecting screen's Connect / Try Again links into form
buttons, so every state-changing route goes through the guard.

Also harmonize docs and comments with the code:
- shutdown comments: /stats_stream frames instead of /stats polling
- remove "previously / before this change" narration from comments
- README: startup connects in up to 3 attempts, database_path isn't on
  the Settings page, add apple_health_export_enabled row, note the LAN
  trust model on the host row
- ROADMAP 2.2: files and known limits; 3.4: point at
  _build_stats_payload()
- CHANGELOG: /reconnect POST-only, reconnect in the blocked actions

Co-Authored-By: Claude <[email protected]>
@SeanathanVT
SeanathanVT merged commit 2ea09b7 into development Oct 7, 2026
2 checks passed
@SeanathanVT
SeanathanVT deleted the feature/route-security branch October 7, 2026 14:10
@SeanathanVT SeanathanVT mentioned this pull request Oct 8, 2026
SeanathanVT added a commit that referenced this pull request Oct 8, 2026
* Add unreleased section to CHANGELOG.

* Add full unit test suite, CI for GitHub Actions and GitLab, and two crash fixes (2.5, 2.7) (#19)

* feat(tests): add full unit test suite and CI (ROADMAP 2.5, 2.7)

Cover routes, BLE sequences (fake controller), status-packet math,
persistence, and run.py; 300 tests, ~98% line coverage, green on
Python 3.10/3.12/3.13. CI runs ruff and pytest with coverage on
GitHub Actions and GitLab CI.

fix: /pause and speed routes no longer return 500 when the BLE loop
is gone; a non-UTF-8 session_state.json no longer crashes startup.

Install signal and atexit handlers only when startup is enabled, move
run.py's launcher into main() for testability, and pin ruff with its
lint fixes applied.

Co-Authored-By: Claude <[email protected]>

* docs(roadmap): add 3.18 transition hint flashing on every button press

Co-Authored-By: Claude <[email protected]>

* test: cover migration edge cases, shutdown paths, and SSE failures

Add tests for non-list and bad-end_time legacy history, a failed
.migrated rename, graceful shutdown with no session/controller/client,
End while paused, connect with no BLE client, discard with nothing
pending, /shutdown loop-stop edge cases, SSE double-remove on close,
and a failed broadcaster tick. Each test fails if its target branch is
broken. storage.py reaches 100%; line+branch coverage 97.3% -> 98.4%.

Update ROADMAP 2.5's "Not covered" list to match the measured gaps.

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>

* fix(security): block cross-site state changes (ROADMAP 2.2) (#20)

* fix(security): block cross-site POST requests (ROADMAP 2.2)

Reject any non-GET request whose Origin isn't the app's own origin, or
whose Sec-Fetch-Site is cross-site or same-site, so a website open in a
browser on the network can't start the belt, change speed, clear
history, or shut the app down.

No token or sign-in: the app is LAN-only and LAN devices are trusted by
design. Same-origin forms and fetches, requests without either header
(curl, run.py's /shutdown), and all GETs are unaffected.

Co-Authored-By: Claude <[email protected]>

* fix(security): make /reconnect POST-only and harmonize docs (ROADMAP 2.2)

/reconnect changed state on GET, so the cross-site guard skipped it and
any web page could trigger a Bluetooth reconnect. Make it POST-only and
turn the connecting screen's Connect / Try Again links into form
buttons, so every state-changing route goes through the guard.

Also harmonize docs and comments with the code:
- shutdown comments: /stats_stream frames instead of /stats polling
- remove "previously / before this change" narration from comments
- README: startup connects in up to 3 attempts, database_path isn't on
  the Settings page, add apple_health_export_enabled row, note the LAN
  trust model on the host row
- ROADMAP 2.2: files and known limits; 3.4: point at
  _build_stats_payload()
- CHANGELOG: /reconnect POST-only, reconnect in the blocked actions

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>

* docs(roadmap): add 3.19 phone layout

Co-Authored-By: Claude <[email protected]>

* Phone layout, Apple Health from iPhone (status, steps), and a data/ folder (#21)

* feat(ui): phone layout and screen wake lock (ROADMAP 3.19, 3.12)

Add viewport meta so phones get a real layout instead of a scaled-down
desktop page. Under 480px: full-height hero, bottom-docked presets and
Pause/End, 44px+ targets, header controls behind a menu button, history
as cards. Add a web app manifest for full-screen home-screen launch,
pressed states for buttons (Bootstrap's default went transparent), and
a native screen wake lock on the active screen.

Co-Authored-By: Claude <[email protected]>

* feat(health): on-device Log/Install buttons and auto-confirm on iOS

A phone can't scan its own screen, so on iPhone/iPad the per-session
and setup QR codes become buttons. The log link is now an x-callback-url
whose x-success opens /health_logged/<id>, marking the session logged
and clearing the prompt on every open page via SSE. Dismiss is stored
separately (health_logged: 0 pending, 1 logged, 2 dismissed). Loopback
hosts are swapped for the LAN IP so the phone can reach the callback.

Co-Authored-By: Claude <[email protected]>

* feat(health): per-session Apple Health status, retroactive log, delete

Recent Sessions shows a filled/outline heart per session when Apple
Health export is on. An Edit toggle reveals per-row Log (QR on desktop,
direct Shortcut link on iOS) and Delete (POST /delete_session/<id>,
completed sessions only). SSE health_status_changed now carries
{id, status} so other pages update the row, banner, and open QR modal.

Co-Authored-By: Claude <[email protected]>

* chore: move runtime files into data/, tool configs into pyproject.toml

Settings, the session database, crash-recovery state, and JSON-migration
backups now live in data/ (backups in data/backups/) instead of next to
the code. Files left in the app folder by older versions are moved on
first start, never overwriting an existing file. config.py reads the
legacy root config.json until it moves, and migrate_json() takes a
backup_dir.

pytest, ruff, and coverage settings merge into pyproject.toml, replacing
pytest.ini, ruff.toml, and .coveragerc; requirements-dev.txt adds
coverage[toml] so coverage reads it on Python 3.10.

Co-Authored-By: Claude <[email protected]>

* fix: review follow-ups for Apple Health status and data/ relocation

- Close the Log QR modal only on a "logged" status change; a lingering
  "dismissed" change closed a later Log for that same session.
- Dismiss sends the banner's session id instead of marking whichever
  session is newest at click time.
- /health_logged and dismiss only act on completed sessions; unknown,
  in-progress, or failed writes return 404 without broadcasting.
- Move the database with its -wal/-shm as one group so a stale file in
  data/ can't separate them.

Co-Authored-By: Claude <[email protected]>

* feat(ui): heart icon tooltips, right-aligned history numbers

Bootstrap tooltips on the Recent Sessions heart icons, shown on hover,
tap, and keyboard focus, and updated live when a session is logged
elsewhere. Duration through Avg Speed are now right-aligned like Avg
Speed already was, so spare width spreads evenly instead of collecting
before the last column.

Co-Authored-By: Claude <[email protected]>

* style(ui): monochrome heart for logged sessions

The logged heart uses the theme's text color instead of Apple Health
pink, which clashed with themed palettes, fell just under 3:1 contrast
on some light rows, and read as the app's danger red. Theme accents
were too faint on most dark-mode rows. Fill vs outline carries the
state.

Co-Authored-By: Claude <[email protected]>

* feat(health): send step count to the Apple Health Shortcut

The Shortcut logs it as a Steps sample alongside the workout.
Points the install link and the exported backup at the updated
Shortcut.

Co-Authored-By: Claude <[email protected]>

* fix(health): don't let Dismiss undo a logged session

A page that hasn't seen the log yet (or the Settings off/on
path) could downgrade a logged session to dismissed, bringing
back its Log button and inviting a duplicate Health workout.

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>

* docs(roadmap): add 5.4 native HealthKit companion app

Records the Shortcuts limits found while adding steps (no indoor
flag, no End Date, no attaching samples) and that HealthKit works
on a free Apple ID with 7-day reinstalls.

Co-Authored-By: Claude <[email protected]>

* Fix transition hint flashing on every button press (ROADMAP 3.18); add AGENTS.md (#22)

* fix(ui): show transition hint only during belt sequences (ROADMAP 3.18)

Submitting any form called setTransitioning() with the page's hint id,
so presets and steppers flashed "Getting the belt moving…" and shifted
the layout, and Start/Pause/Resume/End flashed the wrong text.
disableButtonsOnSubmit() now only disables and dims the buttons, and
the hint follows the SSE belt_transitioning flag alone.

Also stop SSE ticks from re-enabling the buttons while a submit is in
flight: a page-level submitting flag keeps them disabled until the page
navigates away, and is cleared on a back/forward-cache restore.

Co-Authored-By: Claude <[email protected]>

* docs: mark ROADMAP 3.18 complete and add CHANGELOG entries

Co-Authored-By: Claude <[email protected]>

* docs: add AGENTS.md (CLAUDE.md symlink) and Shortcut rebuild note

AGENTS.md holds repo rules for coding agents: keep docs in sync with
code, git workflow (maintainer commits, PRs target development),
desktop-first design target, and supported platforms. CLAUDE.md links
to it so Claude Code reads the same file.

ROADMAP: note that Log Health Sample's Steps Value row only appears
after granting Shortcuts write access to Steps.

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>

* docs(changelog): release 1.9.0

Map every completed ROADMAP item to its CHANGELOG version.

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
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