screentinker/server/routes
ScreenTinker d26aaebef6 SSO: fix an account takeover, a remote crash, and login CSRF found in review
Five reviewers went at the two SSO commits. Three of them independently
demonstrated a full account takeover, and it was the same defect each time.

TAKEOVER. An org admin supplies the issuer and client_id, so they control that
identity provider completely and can mint an id_token asserting ANY email with
email_verified:true — including a platform_admin's. Every cryptographic check
passed honestly, because the attacker IS the issuer. upsertFederatedUser then
re-pointed the existing account at whichever provider spoke last, because the
only guard was `password_hash IS NULL` — and every SSO-created account has a
null password. Sessions were issued as the victim, and the victim's own login
then failed forever with subject_mismatch.

The rule came from the old Google handler, where it was safe: only the operator
could add a provider. Making providers customer-configurable turned it into a
takeover primitive and the assumption was not re-examined. Now an org provider
may only assert emails inside the domains it registered, and may never adopt an
account another provider established.

REMOTE CRASH, unauthenticated. The state comparison guarded on UTF-16 character
length while Buffer.from produces UTF-8 bytes, so a state of 43 characters
containing one multi-byte character reached timingSafeEqual with mismatched
buffers and threw — inside an async handler, which Express does not catch, which
server.js turns into process.exit. One request per restart killed any instance
with SSO enabled. Compared as bytes now, and /api/auth/oidc gained a rate limit.

LOGIN CSRF. The callback returned the session token in the URL fragment, so a
crafted link installed an ATTACKER'S token and silently signed the victim into
their account. The token now goes in a one-shot httpOnly cookie exchanged at
POST /sso/claim, which a link cannot forge.

FRONTEND, dead on arrival twice over. login.js used `await` in a non-async
function — a SyntaxError that takes the WHOLE app down, since app.js imports it
statically and there is no bundler. And `esc` was never imported, so the org-SSO
button could never render; the ReferenceError was swallowed by the catch written
for network failures. Both slipped through because `node --check` parses these
files as CommonJS and exits 0 on a broken module. The correct check is
`node --input-type=module --check`, and all four frontend files now pass it.

PUBLIC EMAIL DOMAINS cannot be claimed. A tenant had claimed gmail.com in
review, after which every Gmail user typing their address was offered "sign in
with your organization" pointing at that tenant's infrastructure — phishing from
this product's own login page. server/lib/public-email-domains.js.

MICROSOFT multi-tenant is refused rather than silently broken. `common` metadata
advertises the literal template {tenantid}, so the issuer never matches and
every login already failed; and loosening that check is nOAuth. A tenant GUID is
now required, with a loud warning at boot.

SSRF: https only, loopback/RFC1918/link-local refused, redirects not followed,
and the test endpoint no longer echoes upstream status for a caller-supplied
jwks_uri (it was a readable internal port scanner).

Also: an omitted email_verified was accepted (the comment already said it should
not be); the domain-uniqueness check raced an 8s network call before its insert
and is now inside the transaction; same-org duplicate domains were allowed and
made routing depend on table-scan order; routing is now ordered; a client secret
that cannot be decrypted fails closed instead of silently downgrading to a public
client; SSO audit rows were writing the org id into the deviceId column; and
/sso/start was capped at 10/min per IP, which would 429 the 11th employee behind
a corporate NAT.

Adds per-provider editing in the org admin UI (replace-only secrets — never
returned, blank means keep, explicit clear) and a Test button that checks
discovery, endpoints and signing keys while stating plainly that it cannot
verify the client ID, the secret, or the redirect URI registration.

⚠️ STILL MISSING: domain-ownership verification. A claimed domain means "nobody
else had claimed it", not "they own it". DNS TXT proof is the remaining control.

1582 tests pass. New regression tests cover the takeover confinement, ordering,
fail-closed secrets, the Microsoft refusal and the public-domain blocklist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 18:12:07 -05:00
..
activity.js Phase 2.1: tenancy middleware, permission helpers, JWT workspace context, frontend + backend role-rename compat 2026-05-11 20:02:00 -05:00
admin.js Show every plan on the admin tab, with who is on each 2026-07-29 19:36:10 -05:00
agency.js Default a video playlist item to the clip's own length (#237) 2026-08-06 09:36:53 -05:00
ai.js feat(ai): separate optional image API key (#41) 2026-06-09 13:47:47 -05:00
assignments.js Default a video playlist item to the clip's own length (#237) 2026-08-06 09:36:53 -05:00
auth.js SSO: fix an account takeover, a remote crash, and login CSRF found in review 2026-08-10 18:12:07 -05:00
billing.js feat(#146): billing:read scoped token — dual-path auth for the Usage Report (Option C) 2026-07-01 21:16:21 -05:00
contact.js fix(landing): replace broken Custom pricing card with enterprise contact form 2026-05-14 13:52:24 -05:00
content.js QA: close four ways a control or an asset lied about itself 2026-08-06 16:12:29 -05:00
device-groups.js Default a video playlist item to the clip's own length (#237) 2026-08-06 09:36:53 -05:00
devices.js BrightSign: report IPv6, the attached display and the active video mode 2026-08-10 15:30:40 -05:00
folders.js Phase 2.2c: content_folders gets workspace_id (schema + backfill); folders.js scoped; content.js folder-move strict same-workspace 2026-05-11 21:04:03 -05:00
kiosk.js Validate kiosk style values as CSS rather than as HTML 2026-07-27 22:28:47 -05:00
layouts.js Save a layout by diffing its zones, not by deleting and re-inserting them 2026-07-30 20:51:28 -05:00
media.js feat: transition engine — GL wipes across web, Tizen & Android (+ image↔video) (#204) 2026-07-20 16:45:32 -05:00
org-sso.js SSO: fix an account takeover, a remote crash, and login CSRF found in review 2026-08-10 18:12:07 -05:00
pip.js Hold overlay pushes to the same write check as every other fleet action 2026-07-30 20:46:03 -05:00
player-debug.js Add player debug overlay and server-side error telemetry sink 2026-05-15 15:20:42 -05:00
playlists.js Default a video playlist item to the clip's own length (#237) 2026-08-06 09:36:53 -05:00
provisioning.js fix(api): consolidate device pairing to /pair, remove vestigial bare endpoint (#90) 2026-06-12 20:13:16 -05:00
reports.js Phase 2.2g: reports.js scoped to workspace_id; fixes pre-existing /export and /uptime cross-tenant leaks 2026-05-11 21:36:54 -05:00
schedules.js Default a video playlist item to the clip's own length (#237) 2026-08-06 09:36:53 -05:00
status.js #240: stop the morning wave buying itself a blocking checkpoint 2026-08-06 20:22:21 -05:00
stripe.js feat(stripe): enable promotion codes on checkout sessions (#227) 2026-07-23 23:44:18 -05:00
subscription.js Initial open source release 2026-04-08 12:14:53 -05:00
teams.js feat(teams): temporarily disable Teams API while feature is redesigned 2026-05-12 13:30:55 -05:00
tokens.js feat(agency): per-token upload folder — auto-created, subtree-confined (#158) (#171) 2026-07-12 21:23:25 -05:00
video-walls.js Describe a portrait video wall as portrait, and stop a wall hiding its screens 2026-08-06 09:46:31 -05:00
white-label.js fix(security): patch quick-win findings from the codebase review 2026-06-08 19:02:19 -05:00
widgets.js Stop shrinking hand-written text widgets into illegibility 2026-07-30 20:28:28 -05:00
workspaces.js fix(workspaces): use APP_URL env var for invite-accept URL generation 2026-05-17 15:26:07 -05:00