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>
This commit is contained in:
serfowi
2026-08-21 01:19:32 -04:00
co-authored by Claude Opus 5
parent cebeda7f4c
commit c617bc9687
26 changed files with 2012 additions and 52 deletions
+97
View File
@@ -0,0 +1,97 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* DESIGN.md §6.1. Always a pill, always the display family.
*
* `buttonClasses` is exported separately because Next's <Link> cannot be wrapped
* without a Slot primitive, and adding Radix for one component is not worth it.
* Anchors take `className={buttonClasses({ variant, size })}`.
*/
export const buttonClasses = cva(
[
'inline-flex items-center justify-center gap-2 rounded-pill font-display font-semibold',
'whitespace-nowrap select-none',
'transition-[color,background-color,border-color,opacity] duration-[120ms] ease-standard',
'disabled:pointer-events-none disabled:opacity-45',
],
{
variants: {
variant: {
primary: 'bg-brand-500 text-white hover:bg-brand-600',
dark: 'bg-ink-950 text-white hover:bg-[#1a2145]',
outline: 'border-[1.5px] border-ink-950 text-strong hover:bg-ink-50 dark:border-hairline',
ghost: 'text-accent hover:bg-accent-soft',
danger: 'bg-stop-500 text-white hover:bg-stop-600',
},
size: {
sm: 'h-9 px-4 text-body-sm',
md: 'h-11 px-6 text-body',
lg: 'h-14 px-8 text-body-lg',
},
block: { true: 'w-full', false: '' },
},
defaultVariants: { variant: 'primary', size: 'md', block: false },
},
);
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonClasses> & {
/** Shows a leading spinner and disables the control. The label never changes. */
busy?: boolean;
};
export function Button({
className,
variant,
size,
block,
busy = false,
disabled,
children,
...props
}: ButtonProps) {
return (
<button
{...props}
disabled={disabled || busy}
aria-busy={busy || undefined}
className={cn(buttonClasses({ variant, size, block }), className)}
>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{children}
</button>
);
}
/** Circular icon-only button. §6.1 — requires an accessible label. */
export function IconButton({
label,
tone = 'neutral',
className,
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
label: string;
tone?: 'neutral' | 'go' | 'stop';
}) {
return (
<button
{...props}
aria-label={label}
title={label}
className={cn(
'flex h-16 w-16 items-center justify-center rounded-pill border-2 bg-raised shadow-md',
'transition-[transform,border-color,color] duration-[120ms] ease-standard',
'hover:scale-105 active:scale-95 motion-reduce:hover:scale-100 motion-reduce:active:scale-100',
tone === 'go' && 'border-go-600 text-go-600',
tone === 'stop' && 'border-stop-500 text-stop-500',
tone === 'neutral' && 'border-hairline text-strong',
className,
)}
>
{children}
</button>
);
}