Skip to content

Cloud link/login security review and fixes - #271

Merged
mariusandra merged 4 commits into
mainfrom
cloud-security-hardening
Aug 1, 2026
Merged

Cloud link/login security review and fixes#271
mariusandra merged 4 commits into
mainfrom
cloud-security-hardening

Conversation

@mariusandra

@mariusandra mariusandra commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

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 state token lived only server-side (Redis on the backend, login_states on the frame), so it proved a handoff was in flight, never who started it:

  • Anyone able to call the open /api/cloud/login/start could 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.
  • Any leaked code (proxy logs, history) became directly exchangeable: mint a state via the open /start, replay code+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/poll held 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_states grew 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.
  • The open /login/callback rewrote the state file on every bogus request (SD-card wear + lock contention). Now writes only when pruning actually changed something.

SECRET_KEY could not be rotated without killing the cloud link

The 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:

Variable Meaning
CLOUD_SECRET_KEY Encrypt cloud secrets with their own key, so SECRET_KEY is freely rotatable from then on.
PREVIOUS_SECRET_KEYS Comma-separated old keys, tried on decrypt only — recovery for an install that already rotated.

Self-healing: a secret that decrypts with an old key is re-encrypted with the current one on the next sync pass, so PREVIOUS_SECRET_KEYS can be dropped after one cycle. When nothing works, the worker logs a red error naming the cause and sets poll_error = "secret_key_changed" instead of looping silently. Procedure documented in docs/cloud-link.md.

Passwordless lockout

Cloud signup leaves password NULL 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), and check_password no longer raises on a NULL hash. Empty passwords still never authenticate.

Verified good — no change needed

  • No fall-open anywhere. Every broken-link path fails closed: cloud down, 500, timeout, revoked, expired, disabled. not user.password is checked before the hash comparison, so a NULL hash cannot be matched by an empty password.
  • An offline server never loses its link. linked_clients has no expiry, last_seen_at is 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.
  • Identity mapping is keyed on issuer+subject, never email; a random cloud account cannot gain admin on someone's install.
  • Worker-pool exhaustion does not starve the systemd watchdog — the runner thread heartbeats independently, so these are HTTP-only outages, not restart loops.

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.md with 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

mariusandra and others added 4 commits August 2, 2026 00:00
…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>
@mariusandra
mariusandra merged commit 30b26f4 into main Aug 1, 2026
35 checks passed
@mariusandra
mariusandra deleted the cloud-security-hardening branch August 1, 2026 22:37
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