Files
linkder/SECURITY-FINDINGS.md
T
serfowiandClaude Opus 5 c617bc9687 M1 security: close the password backdoor, apply re-review, pin E.164
Acts on an adversarial review of the M1 auth and authorization code.
Five findings fixed; the rest recorded in SECURITY-FINDINGS.md as the
M1 exit criteria rather than left in a tool transcript.

- auth: serve /phone-number/request-password-reset, /phone-number/
  reset-password and /sign-in/phone-number as 404. better-auth's
  phoneNumber() registers all three unconditionally -- they are NOT
  gated on emailAndPassword.enabled:false. Left live they form a
  silent second credential path: request-password-reset stores an OTP
  and sends no SMS (sendPasswordResetOTP was never configured, so the
  owner is never told), reset-password mints a bcrypt credential row,
  and sign-in/phone-number then accepts it forever with no OTP. The
  OTP gate still applies, so this is not remote unauthenticated
  takeover -- it converts one momentary OTP compromise into permanent
  access the victim cannot see or rotate.

- auth: drop bearer(). It accepts the plaintext sessions.token column
  as an Authorization credential, making any single leaked row a
  replayable login. The mobile client it was added for is hypothetical.

- auth: pin E.164 via phoneNumberValidator, and add toE164/isE164 to
  @linkder/shared. phone is UNIQUE and bans are per-account, so
  "+34600111222" and "0034600111222" being separately storable meant
  one handset could hold two accounts and a ban was escapable by
  retyping. 15 tests.

- auth: NEXT_PUBLIC_APP_URL now throws in production instead of
  falling back to localhost, which was silently dropping Secure and
  the __Secure- prefix from the production session cookie.

- pro.upsertProfile: actually apply requiresReReview. It was computed,
  returned to the client and never acted on, so a verified plumber
  could become a verified electrician in another city by ignoring a
  response flag. Now demotes to pending in the same transaction and
  audits it. Trade changes count as material (they did not before) --
  the licence is per-trade. Needed a verified -> pending edge in
  VERIFICATION_GRAPH, which did not exist.

Removed two untracked scratch repro files. The impersonation repro
depended on bearer() for transport and no longer applies as written;
the underlying finding (resolveSession drops impersonatedBy, so admin
actions are audited as the victim) is open and documented.

typecheck, lint, build clean; 127 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 01:19:32 -04:00

4.8 KiB

Open security findings (M1 auth review)

An adversarial review of the M1 auth, session and authorization code produced 28 confirmed findings (~20 after deduplication). The ones fixed are listed first so nobody re-reports them; the rest are open and are the M1 exit criteria.

Fixed

# Issue Fix
1 pro.upsertProfile computed requiresReReview and never applied it — a verified plumber could silently become a verified electrician 40 km away Demotes a verified profile to pending in the same transaction, writes an audit row, and now counts a trade change as material (it did not before). Needed a new verified -> pending edge in VERIFICATION_GRAPH
2 phoneNumber() registers /phone-number/request-password-reset, /phone-number/reset-password and /sign-in/phone-number unconditionally — not gated on emailAndPassword.enabled: false. Together they mint a password credential with no SMS sent to the owner, then accept it forever with no OTP All three served as 404 via disabledPaths (checked in onRequest, before rate limiting)
3 bearer() accepted the raw plaintext sessions.token column as an Authorization: Bearer credential — one leaked DB row is a replayable login Plugin removed. The mobile client it was for does not exist yet
4 Phone numbers stored exactly as typed, so +34600111222 and 0034600111222 are two "unique" accounts — defeating the UNIQUE constraint, bans, and duplicate detection phoneNumberValidator pins E.164 on send-otp and sign-in; toE164/isE164 added to @linkder/shared
5 NEXT_PUBLIC_APP_URL fell back to http://localhost:3000, which drops Secure and the __Secure- prefix from the production session cookie Throws at boot in production

Open — must close before M1 ships

Authorization / attribution

  • resolveSession discards session.impersonatedBy, so every admin action taken while impersonating is written to audit_log as the victim. The Session type has no field for it, so no procedure can tell. Admin impersonation is enabled (admin() plugin) and is currently invisible to the application. Fix: add impersonatedBy to Session, populate it in resolveSession, and record it on all three audit_log insert sites.
  • user.setRole is a check-then-write across two connections; a concurrent job.create permanently strands the job it was meant to protect.
  • deck.swipe checks job.status outside its transaction — a concurrent job.cancel loses the race and a pending request lands on a cancelled job.
  • deck.undo's "already reached the pro" guard is check-then-write: it deletes the tombstone for a request that really was sent, and reports success.

Storage / uploads

  • Government ID and insurance scans go to the public bucket; isPrivateKind is never called from production code.
  • Presigned PUT does not pin Content-Type, so a caller can store HTML under an image key in a public bucket.
  • pro.addMedia, pro.addCredential and job.create accept any URL the caller sends, with no binding to an object that caller actually uploaded — which bypasses the presign design entirely.

Abuse / spend

  • No per-number cap, destination allowlist, or global ceiling on /phone-number/send-otp. SMS spend is unmetered.
  • better-auth rate limiting is per-process in-memory and collapses to one global bucket behind a proxy chain — 10 req/min then locks out the whole platform. Needs the Redis-backed rateLimit.customStorage, not secondary-storage (which throws unless all five SecondaryStorage methods exist).

Lower severity

  • user.setEmail lets any authenticated user claim an arbitrary unverified address, locking the real owner out of Google sign-up and leaking which addresses are registered.
  • Open redirect: the next query param on the sign-in form goes straight to router.push.
  • audit_log.ip trusts the leftmost X-Forwarded-For entry verbatim — attacker-controlled.
  • findPossibleDuplicates has no call site; the guarantee it was written to provide never executes.
  • DeckClient renders a frozen initialCards prop and invalidates a query nothing subscribes to, capping the deck at 20 cards per page load.
  • A right swipe after a left swipe creates a live request while the stored swipe row still records a rejection.

Note on the OTP-in-plaintext decision

apps/web/src/lib/auth.ts argues the plaintext OTP is acceptable partly because "anyone who can read that table can already read session.token". With bearer() removed that argument is weaker than when it was written — a session token now requires an httpOnly browser cookie to use, an OTP does not. The decision still stands (300s window, 3 attempts, rate limited) but revisit it if OTP lifetime or attempt budget ever increases.