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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user