Cloud link/login security review and fixes - #271
Merged
Conversation
…he lock Security review of the merged cloud-linking and cloud-login work. Fixes here are the device- and backend-side ones; the cloud-side changes ship in the frameos-cloud repo. Login handoff (HIGH): the state token was stored server-side only, so it proved *a* handoff was in flight, not who started it. Anyone able to call the open /api/cloud/login/start could hand the resulting callback URL to a victim's browser (logging them into the attacker's account), and any leaked code could be replayed from the attacker's own browser to take over the owner's session. The state is now also set as an HttpOnly, SameSite=Lax, path-scoped cookie and must match at the callback — checked before the state is consumed, so a forged callback cannot burn the real one. Both the Python backend and the Nim frame implement it. Frame callback owner check: minting an admin session was skipped when the frame did not know its owner account, which is exactly what happens when the grants call failed during connect. A network hiccup while linking therefore silently weakened the check. Now it refuses and re-syncs the owner instead. Device DoS (the frame's HTTP server runs 1-4 worker threads): - /api/cloud/poll held the global cloud lock across two outbound cloud requests, so a slow provider could freeze every cloud route for over a minute. The sync now runs after the lock is released and its result is merged afterwards. - login_states grew without bound from the open /login/start, making every later cloud request parse and rewrite a growing state file under that lock (O(n^2)). Capped at 16, oldest evicted. - The open /login/callback rewrote the state file on every bogus request; it now only writes when pruning actually changed something. Lockout: a cloud-created user has no local password, and the change- password route required the current one — so if the link later broke they could never get in again. Setting a first password no longer requires it (they are already authenticated), and User.check_password no longer raises on a NULL hash. Empty passwords still never authenticate. Tests: state-cookie binding (missing and mismatched), and passwordless recovery. 310 backend tests, all Nim server tests, and the frame build pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tracks the findings from the link/login review that are not fixed yet, each with the attack that motivates it, so they do not get lost: unbounded DNS in the frame's outbound calls, no rate limiting on device, pre-auth 50 MB body buffering (the one path that becomes a restart loop), unauthenticated setup-mode linking, unrevocable local sessions, and the device-flow phishing surface on the cloud side. Also records the answers to the questions that prompted the review: an offline server never loses its link, and no broken-link path lets a passwordless user in. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stored link token was encrypted with a key derived from SECRET_KEY, so rotating SECRET_KEY made it undecryptable — and nothing said so. The sync worker treated "cannot decrypt" the same as "no link", so the install kept reporting a healthy connected link while every cloud request silently stopped happening. Recovery required knowing to reconnect. Two ways out, both opt-in and defaulting to today's behaviour: - CLOUD_SECRET_KEY encrypts cloud secrets with its own key, so SECRET_KEY can be rotated freely from then on. - PREVIOUS_SECRET_KEYS lists old keys, tried on decrypt only. Stored secrets are re-encrypted with the current key as they are read (the sync worker does this on its next pass), so the old key can be dropped after one cycle. When no configured key works, the worker now logs a red error naming the likely cause and sets poll_error = "secret_key_changed" on the link, instead of looping silently forever. Rotation procedure documented in docs/cloud-link.md. Tests cover all three paths: rotation without migration keys is reported, an old key in PREVIOUS_SECRET_KEYS recovers and migrates the token, and CLOUD_SECRET_KEY survives a SECRET_KEY change. 771 backend tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Security review of the merged cloud work (#263 linking, #264 login), plus fixes for what it found. Four parallel audits covered device-flow linking, the login handoff, offline/grant-sync behaviour, and device-side DoS. Every finding — fixed and still open — is recorded in
docs/cloud-security-review.md.Companion cloud-service fixes: https://github.com/FrameOS/frameos-cloud/pull/1.
Fixed here
The login handoff was not bound to the browser (HIGH)
The
statetoken lived only server-side (Redis on the backend,login_stateson the frame), so it proved a handoff was in flight, never who started it:/api/cloud/login/startcould complete a handoff for their own cloud account, not follow the final redirect, and feed the callback URL to the owner's browser — silently issuing them a 7-day session for the attacker's account./start, replaycode+state, and the backend redeems it with its link token and issues a session as the code's owner.The state is now also an
HttpOnly,SameSite=Lax, path-scoped cookie that must match at the callback — checked before the state is consumed, so a forged callback cannot burn the real one. Implemented on both the Python backend and the Nim frame. (RFC 6749 §10.12.)The frame skipped its owner check when it didn't know the owner
Which is exactly the case when the grants call failed during connect — so a network hiccup while linking silently downgraded the check to "trust the provider's word" before minting an admin session. Now it refuses and re-syncs the owner.
Device DoS
The frame's HTTP server runs 1–4 worker threads, so a blocking handler is an outage:
/api/cloud/pollheld the global cloud lock across two outbound cloud requests (70 s+ against a slow provider), freezing every cloud route. Sync moved outside the lock, results merged after.login_statesgrew without bound from the open/login/start, so every later cloud request re-parsed and rewrote a growing state file under that lock — O(n²). Capped at 16, oldest evicted./login/callbackrewrote the state file on every bogus request (SD-card wear + lock contention). Now writes only when pruning actually changed something.SECRET_KEYcould not be rotated without killing the cloud linkThe stored link token is encrypted with a key derived from
SECRET_KEY, so rotating it made the token permanently undecryptable — and the sync worker treated "cannot decrypt" the same as "no link", leaving the UI reporting a healthy connected link while every cloud request silently stopped. Two opt-in escape hatches, defaults unchanged:CLOUD_SECRET_KEYSECRET_KEYis freely rotatable from then on.PREVIOUS_SECRET_KEYSSelf-healing: a secret that decrypts with an old key is re-encrypted with the current one on the next sync pass, so
PREVIOUS_SECRET_KEYScan be dropped after one cycle. When nothing works, the worker logs a red error naming the cause and setspoll_error = "secret_key_changed"instead of looping silently. Procedure documented indocs/cloud-link.md.Passwordless lockout
Cloud signup leaves
passwordNULL and the change-password route demanded the current one — so those users could never set a local password, and a later broken link (revoked, provider gone,FRAMEOS_CLOUD_URL=disabled) locked them out permanently with no recovery short of editing the database. Setting a first password no longer requires the current one (they are already authenticated), andcheck_passwordno longer raises on a NULL hash. Empty passwords still never authenticate.Verified good — no change needed
not user.passwordis checked before the hash comparison, so a NULL hash cannot be matched by an empty password.linked_clientshas no expiry,last_seen_atis never read by any query, cleanup never touches links, and neither side auto-disconnects or requires rotation. Only an explicit 401 resets it — and that deliberately re-enables local password login.Tests
New regression coverage for the state-cookie binding (missing and mismatched cookie — neither may consume the real state), passwordless recovery, and all three key-rotation paths. 771 backend tests, all Nim server tests, and the frame build pass.
Still open
Tracked in
docs/cloud-security-review.mdwith attack scenarios. The notable ones: unbounded DNS in the frame's outbound calls (one request per worker thread wedges the server for minutes), no rate limiting on the device at all, pre-auth 50 MB body buffering (the one path that does become an OOM restart loop), unauthenticated setup-mode linking, and unrevocable local sessions.🤖 Generated with Claude Code