Files
linkder/packages/shared/src/phone.ts
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

56 lines
2.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Phone identity.
*
* Phone is the primary identity on this platform, which makes its *string form*
* load-bearing: `users.phone` carries a UNIQUE constraint, bans are enforced per
* account, and duplicate detection matches on it. If "+34600111222" and
* "0034 600 111 222" can both be stored, then one handset holds two distinct
* "unique" accounts — which defeats the constraint, lets a banned user return,
* and hides the duplicate from `findPossibleDuplicates`.
*
* So there is exactly one accepted stored form: E.164, no spaces, no separators.
* Normalise on the way in, reject anything that cannot be normalised.
*/
/** E.164: a leading +, a nonzero leading digit, and 815 digits total. */
const E164 = /^\+[1-9]\d{7,14}$/;
export function isE164(value: string): boolean {
return E164.test(value);
}
/**
* Coerce common user input into E.164, or return null if it cannot be done
* unambiguously.
*
* Handles the shapes people actually type: spaces, hyphens, parentheses and dots
* as separators, and a `00` international prefix instead of `+`. It deliberately
* does NOT guess a country code for a bare national number — "600111222" is
* meaningless without knowing the country, and silently assuming one would
* attach a real person's account to the wrong number.
*/
export function toE164(input: string | null | undefined): string | null {
if (!input) return null;
// Strip everything a human might use as a separator.
let s = input.trim().replace(/[\s().-]/g, '');
if (s.length === 0) return null;
// "0034..." is the same as "+34..."
if (s.startsWith('00')) s = `+${s.slice(2)}`;
// A bare national number is ambiguous — refuse rather than guess a country.
if (!s.startsWith('+')) return null;
if (!/^\+\d+$/.test(s)) return null;
return isE164(s) ? s : null;
}
/**
* Last four digits, for display ("••• ••• 222"). Never render a full number
* belonging to someone other than the viewer.
*/
export function phoneLast4(e164: string): string {
return e164.slice(-4);
}