Skip to content

fix: close three batch-4 review follow-ups - #174

Open
thedancingdeveloper wants to merge 3 commits into
mainfrom
fix/b4-review-followups
Open

thedancingdeveloper wants to merge 3 commits into
mainfrom
fix/b4-review-followups

Conversation

@thedancingdeveloper

Copy link
Copy Markdown
Collaborator

Summary

  • Reject GET /api/history?days= windows above 36500 so chrono::Duration::days cannot panic on an overflowing millisecond span (days=100000000 now returns 400).
  • Keep the desktop single-instance lock for the process lifetime by moving InstanceLock out of StartupResult into EngineState before the startup result is dropped.
  • On a failed refresh, adopt the other tab's access token from ACCESS_KEY instead of storing that tab's refresh token as the access token.

nzb-web 0.4.24 exposes StartupResult.instance_lock (previously private). The crate version is not bumped in this PR.

Test plan

  • cargo test -p rustnzb (includes huge_days_query_is_rejected_instead_of_panicking)
  • cargo test -p nzb-web moved_instance_lock_still_excludes_a_second_instance
  • cargo clippy --workspace -- -D warnings
  • cargo fmt --all --check
  • npm test -- --watch=false in apps/rustnzb/frontend (154 passed, including the auth service and interceptor regressions)
  • Desktop cargo test was not run: libdbus-sys needs system libdbus-1-dev, which is not installed here.

start_engine moved AppState into the HTTP task and dropped StartupResult,
which unlocked the data-dir flock immediately. The lock is now public on
StartupResult and held in EngineState until the process exits.
After a refresh failed, discardFailedSession stored the other tab's
refresh token in the access-token signal. The retry then sent that
refresh token as a bearer. Keep the access token from ACCESS_KEY instead.
GET /api/history?days=100000000 panicked in Duration::days because the
span does not fit in an i64 of milliseconds. Cap the window at 36500
days and return 400 instead.

@thedancingdeveloper thedancingdeveloper left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security review: no blockers (head 4583c30)

I reviewed all three commits on current main (71fea38). All three batch-4 should-fix items are fixed correctly. CI: all 11 checks are green at this head: policy, rust, both Analyze jobs, desktop, frontend, e2e, container-smoke, CodeQL, dependency-review and runner-policy.

1. /api/history?days overflow (4583c30): fixed

  • h_history_list returns a 400 for days > MAX_HISTORY_DAYS (36,500, about 100 years) before computing Utc::now() - Duration::days(..).
  • In batch 4 I confirmed that 95,000,000 days still works and only values of about 100,000,000 and above panic, so the 36,500 cap is far inside chrono's range.
  • huge_days_query_is_rejected_instead_of_panicking drives /api/history?days=100000000 through the real handler and asserts a 400.
  • Nit: the check runs after the full history_list load. Moving it to the top of the handler would skip that work for a rejected request.

2. Desktop single-instance lock (f205479): fixed

  • StartupResult.instance_lock is now public. start_engine() moves it out before result is dropped, returns it, and start() stores it in Tauri-managed EngineState, which lives until the app exits. A second desktop or headless instance on the same data dir is now refused for the whole session.
  • Should-fix (small): the desktop build now warns field \lock` is never readandfield `instance_lock` is never read(seen in this PR'sdesktopCI log). They don't fail CI, but they invite someone to delete the "unused" field and reintroduce the bug. Rename them_lock/_instance_lock(the value is still held for the struct's lifetime), or add#[allow(dead_code)]with a comment saying the field is held for itsDrop`.
  • Nit: moved_instance_lock_still_excludes_a_second_instance only shows that moving an InstanceLock doesn't release it, which Rust guarantees. It doesn't exercise the desktop path, which can't easily be tested; the code change is what fixes it.

3. Refresh token in the access-token signal (0ba4e1d): fixed, plus a correction to my batch-4 description

  • discardFailedSession() now sets the signal from getAccessToken(), which is localStorage['access_token'], the access token the other tab stored, never the refresh token. It also marks the session verified.
  • That's safe: if that token is bad, the next 401 spends the stored refresh token. If that refresh fails too, stored === lastSpentRefresh holds and the tokens are cleared, so there's no loop.
  • Correction: in batch 4 I said this bug made the retry send Bearer <refresh token>. That was wrong. The interceptor builds every header from authService.getAccessToken() (auth.interceptor.ts:35/48/60), which reads localStorage, not the signal. The signal is only read in authenticated = computed(() => !!this.accessToken() && this.verified()) as a truthiness check. So the refresh token never left the browser in a header, and the old bug had no security impact. The fix is still the right cleanup.
  • Nit (regression tests): I applied only this PR's two spec changes to main's code and ran them.
    • The new interceptor test passes on the old code. It describes behaviour that was already correct.
    • The new service test fails on the old code only at authenticated(), because of the newly added verified.set(true).
    • Neither test checks that the signal holds the access token rather than the refresh token. If you want to pin that, assert on the signal's value, e.g. through a test-only accessor.

Notes

No dependency, CI or build-script changes. Not merged. This verdict covers only this head SHA.

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