diff --git a/CLAUDE.md b/CLAUDE.md index ac914b23..eb0785b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,7 @@ Annual discount 17%. 30-day money-back. ONE pricing grid. No Federal tier until | Integration | Status | Action Required | |-------------|--------|-----------------| -| Supabase auth + DB | ✅ Wired | Migrations through 034 in repo. Applied to prod: 001–027, plus **028 (rate-limit buckets), 031 (auth lockouts) and 032 (auth audit trail) applied 2026-08-12** — shared rate limiting, lockout, CAPTCHA escalation and the auth audit trail are now live. **Unapplied: 029 + 030** (seed-anchor chain — separate subsystem), **033** (restrictive deny-all on the Better Auth tables) and **034** (marketing opt-out column — CAN-SPAM). `/api/health` now reports the rate-limit and lockout stores as degraded when a migration is missing, instead of reporting green. | +| Supabase auth + DB | ✅ Wired | Migrations through 036 are in the repo. Applied-to-production status must be verified in the release record. **Release prerequisites:** 028 (shared rate-limit buckets), 031 (auth lockouts), 032 (auth audit trail), 034 (marketing opt-out column before commercial outreach), **035 (hash-only, one-time password-reset codes before reset is enabled)**, and **036 (revoke public execution of privileged RPCs)**. `/api/health` reports missing control stores and reset-code configuration as degraded rather than green. | | Stripe checkout | ✅ Wired | Add a **$499 one-time** report SKU (Stage 1 primary product) | | Stripe webhook | ⚠️ Verify URL | Confirm `https://www.houndshield.com/api/stripe/webhook` | | STRIPE_WEBHOOK_SECRET | ❌ Verify | Confirm set in Vercel dashboard | @@ -334,7 +334,7 @@ compliance-firewall-agent/ lib/brain-ai/ — BM25 knowledge graph + query interface lib/gateway/ — Core AI interception engine lib/classifier/ — 53-pattern / 16-engine CUI/PII/IP/PHI detector - supabase/migrations/ — through 034 in repo (029, 030, 033, 034 not yet applied to prod) + supabase/migrations/ — through 036 in repo (verify production application before release; 035 enables code-only reset and 036 removes public privileged-RPC execution) proxy/ server.ts — HTTPS proxy (the actual product) diff --git a/compliance-firewall-agent/.env.example b/compliance-firewall-agent/.env.example index 96942206..e4946bea 100644 --- a/compliance-firewall-agent/.env.example +++ b/compliance-firewall-agent/.env.example @@ -21,6 +21,14 @@ OPENROUTER_API_KEY=REPLACE_WITH_YOUR_OPENROUTER_API_KEY # ─── Email (Resend) ─────────────────────────────────────── RESEND_API_KEY=REPLACE_WITH_YOUR_RESEND_API_KEY +# ─── Authentication security (required in production) ────── +# 32+ random bytes, base64/hex encoded; used to HMAC password-reset codes. +AUTH_RESET_CODE_PEPPER=REPLACE_WITH_A_32_BYTE_RANDOM_SECRET +# Cloudflare Turnstile: escalation fails closed when this secret is missing. +TURNSTILE_SECRET_KEY=REPLACE_WITH_YOUR_TURNSTILE_SECRET +# Public site key used only by the browser widget when CAPTCHA is requested. +NEXT_PUBLIC_TURNSTILE_SITE_KEY=REPLACE_WITH_YOUR_TURNSTILE_SITE_KEY + # ─── App ─────────────────────────────────────────────────── NEXT_PUBLIC_APP_URL=https://houndshield.com ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING diff --git a/compliance-firewall-agent/app/__tests__/direction-a-port.test.ts b/compliance-firewall-agent/app/__tests__/direction-a-port.test.ts index 71d9ff0d..d8759a8d 100644 --- a/compliance-firewall-agent/app/__tests__/direction-a-port.test.ts +++ b/compliance-firewall-agent/app/__tests__/direction-a-port.test.ts @@ -5,17 +5,18 @@ import { join } from 'node:path' /** * Content contract for the HERMES Direction-A exact-match port. * Reads page source directly so it stays fast and dependency-free, and locks in: - * 1. the demo's verbatim copy on each ported view, and - * 2. the legal/strategy guardrails that must NEVER regress to the demo's literal text. + * 1. the demo's visual-information architecture on each ported view, and + * 2. the legal/strategy guardrails that must NEVER regress to unsupported demo copy. * See docs/DIRECTION-A-PORT.md. */ const root = process.cwd() const read = (p: string) => readFileSync(join(root, p), 'utf8') -describe('Direction-A port — demo copy present', () => { - it('home: comparison, features and CTA use the demo headlines', () => { +describe('Direction-A port — information architecture present', () => { + it('home: comparison, features and CTA retain the ported hierarchy without unsupported claims', () => { const home = read('app/page.tsx') - expect(home).toContain("Cloud DLP scans your CUI in their cloud") + expect(home).toContain('Start with the boundary your assessor will ask about') + expect(home).not.toContain("Cloud DLP scans your CUI in their cloud") expect(home).toContain('Everything you need for CMMC Level 2') expect(home).toContain('Ready to protect your CUI?') // demo comparison cards diff --git a/compliance-firewall-agent/app/__tests__/page.test.tsx b/compliance-firewall-agent/app/__tests__/page.test.tsx index c0ce43de..44d3f2ef 100644 --- a/compliance-firewall-agent/app/__tests__/page.test.tsx +++ b/compliance-firewall-agent/app/__tests__/page.test.tsx @@ -47,15 +47,13 @@ describe('HomePage — HERMES demo parity', () => { }) // ── Hero ───────────────────────────────────────────────────────── - // Contract changed 2026-07-28: DoD suspended CMMC Phase 2 on 2026-07-13, - // removing the Nov 10 deadline the old CUI-first copy leaned on. The hero - // now leads with evidence ("prove what was pasted"), which sells to both a - // healthcare privacy officer and a contractor facing FCA/SPRS exposure. - it('H1 leads with the evidence promise, not the suspended CMMC deadline', () => { + // The hero leads with the control boundary rather than a claimed regulatory + // outcome or an unsupported assertion about every external AI provider. + it('H1 leads with the buyer-controlled boundary, not a compliance promise', () => { render() const h1 = screen.getByRole('heading', { level: 1 }) - expect(h1.textContent).toMatch(/Prove what your team pasted into/i) - expect(h1.textContent).toContain('ChatGPT') + expect(h1.textContent).toMatch(/Keep regulated data inside your control boundary/i) + expect(h1.textContent).not.toContain('ChatGPT') }) it('hero pill leads with HIPAA and NIST, not a CMMC certification date', () => { @@ -84,9 +82,9 @@ describe('HomePage — HERMES demo parity', () => { expect(screen.getByText('Live prompt scans')).toBeTruthy() }) - it('hero trust row makes no free-tier promise (single $499 offer)', () => { + it('hero trust row distinguishes hosted evaluation from the self-hosted path', () => { const { container } = render() - for (const t of ['One URL change', 'Runs on your hardware', 'Nothing transmitted', 'Audit-ready PDF']) { + for (const t of ['Hosted evaluation clearly labelled', 'Self-hosted path for sensitive workloads', 'Your deployment, your boundary', 'Evidence-oriented PDF']) { expect(container.textContent).toContain(t) } // The free tier was removed from /pricing; the hero must not re-promise it. @@ -102,7 +100,15 @@ describe('HomePage — HERMES demo parity', () => { expect(screen.getByText('NIST 800-171 controls')).toBeTruthy() }) - it('replaces the unverifiable "~80,000 contractors" stat with a sourced figure', () => { + /** + * Merge resolution (#302 <- main): this branch asserted a "2 / deployment + * paths" tile and the ABSENCE of 89%; main asserts the Netskope figure. The + * stat grid is a hard `repeat(4, 1fr)`, so only one tile fits and the source + * was resolved to main's. The deployment distinction is still asserted — by + * the Mode-B notice test below, which covers it more strictly than a stat + * tile ever did. + */ + it('replaces unverifiable market statistics with a sourced figure', () => { const { container } = render() expect(container.textContent).not.toContain('~80,000') expect(screen.getByText(REGULATED_SHARE_GENAI.value)).toBeTruthy() @@ -132,9 +138,9 @@ describe('HomePage — HERMES demo parity', () => { }) // ── Asymmetric advantage ───────────────────────────────────────── - it('renders the asymmetric-advantage headline (demo copy)', () => { + it('renders the evidence-first deployment-boundary headline', () => { render() - expect(screen.getByText(/Cloud DLP scans your CUI in their cloud/i)).toBeTruthy() + expect(screen.getByText(/Start with the boundary your assessor will ask about/i)).toBeTruthy() }) it('renders the demo 3-card comparison (Nightfall & Strac / Purview / HoundShield)', () => { @@ -200,13 +206,13 @@ describe('HomePage — HERMES demo parity', () => { expect(container.textContent).not.toMatch(/500\+\s*teams|2M\+\s*scans/i) }) - it('matches the demo section order: hero → stats → asymmetric → platform → CTA', () => { + it('keeps the conversion order: boundary hero → proof → comparison → platform → CTA', () => { const { container } = render() const text = container.textContent ?? '' const order = [ - 'Prove what your team pasted into', + 'Keep regulated data inside', 'Detection engines', - 'Cloud DLP scans your CUI in their cloud', + 'Start with the boundary your assessor will ask about', 'Everything you need for CMMC Level 2', 'Ready to protect your CUI?', ].map((s) => text.indexOf(s)) diff --git a/compliance-firewall-agent/app/api/auth/__tests__/enumeration-contract.test.ts b/compliance-firewall-agent/app/api/auth/__tests__/enumeration-contract.test.ts index 62c3a434..5e97abf1 100644 --- a/compliance-firewall-agent/app/api/auth/__tests__/enumeration-contract.test.ts +++ b/compliance-firewall-agent/app/api/auth/__tests__/enumeration-contract.test.ts @@ -93,9 +93,13 @@ describe('password reset does not distinguish a known address from an unknown on expect(src).toMatch(/const\s+ok\s*=\s*\(\)\s*=>\s*NextResponse\.json\(\{\s*ok:\s*true/); }); - it('keeps the email send off the response path', () => { + it('keeps code email delivery off the response path', () => { // after() bounds the slow half; the timing floor bounds the fast half. - expect(src).toMatch(/after\(\(\)\s*=>\s*sendPasswordResetEmail/); + expect(src).toMatch(/after\(\(\)\s*=>\s*sendPasswordResetCodeEmail/); + }); + + it('does not construct or dispatch a URL-borne recovery token', () => { + expect(src).not.toMatch(/generateLink|buildRecoveryConfirmUrl|token_hash|auth\/confirm/); }); it('never returns a 404 for an unknown account', () => { diff --git a/compliance-firewall-agent/app/api/auth/login/__tests__/route.test.ts b/compliance-firewall-agent/app/api/auth/login/__tests__/route.test.ts index ca7e5e60..663875c5 100644 --- a/compliance-firewall-agent/app/api/auth/login/__tests__/route.test.ts +++ b/compliance-firewall-agent/app/api/auth/login/__tests__/route.test.ts @@ -258,10 +258,11 @@ describe('lockout', () => { }); describe('rollback and configuration', () => { - it('answers 501 when AUTH_SERVER_ROUTES=off, without touching the provider', async () => { + it('answers a generic 503 when local server auth is disabled, without touching the provider', async () => { process.env.AUTH_SERVER_ROUTES = 'off'; const res = await POST(req(creds)); - expect(res.status).toBe(501); + expect(res.status).toBe(503); + expect((await res.json()).error).toBe('Authentication is unavailable in this development environment.'); expect(mockSignIn).not.toHaveBeenCalled(); expect(mockGuard).not.toHaveBeenCalled(); }); diff --git a/compliance-firewall-agent/app/api/auth/reset-password/__tests__/route.test.ts b/compliance-firewall-agent/app/api/auth/reset-password/__tests__/route.test.ts index 50f02ef0..448f62c2 100644 --- a/compliance-firewall-agent/app/api/auth/reset-password/__tests__/route.test.ts +++ b/compliance-firewall-agent/app/api/auth/reset-password/__tests__/route.test.ts @@ -1,30 +1,17 @@ -/** - * Tests for POST /api/auth/reset-password. - * - * The route must be ENUMERATION-SAFE: a well-formed email always returns 200, - * whether or not the account exists, and it must only actually send when a - * recovery link was minted. Malformed input returns 4xx (leaks nothing about - * account existence). generateLink failures/throws must never surface. - */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockGenerateLink, mockSend, mockConfigured, mockEnforce } = vi.hoisted(() => ({ - mockGenerateLink: vi.fn(), +const { + mockIssue, + mockSend, + mockEnforce, + mockVerifyCaptcha, +} = vi.hoisted(() => ({ + mockIssue: vi.fn(), mockSend: vi.fn().mockResolvedValue(undefined), - mockConfigured: vi.fn().mockReturnValue(true), mockEnforce: vi.fn().mockResolvedValue(null), + mockVerifyCaptcha: vi.fn().mockResolvedValue(true), })); -/** - * The limiter is stubbed here on purpose. - * - * Its real counters live in a module-level Map that survives between `it` - * blocks, so with the live limiter the 6th request in this file 429s and every - * later enumeration assertion fails for a reason that has nothing to do with - * enumeration. Stubbing it keeps each case independent; the wiring itself is - * asserted in the "rate limiting" describe below, and the limiter's own - * counting behaviour is covered by lib/__tests__/rate-limit-shared.test.ts. - */ vi.mock('@/lib/rate-limit-shared', () => ({ enforceRateLimit: (ns: string, id: string, opts: unknown) => mockEnforce(ns, id, opts), identifierFor: ({ ip }: { ip?: string | null }) => `i:${ip ?? 'anon'}`, @@ -32,23 +19,24 @@ vi.mock('@/lib/rate-limit-shared', () => ({ req.headers.get('x-forwarded-for') ?? '127.0.0.1', })); -vi.mock('@/lib/supabase/client', () => ({ - isSupabaseConfigured: () => mockConfigured(), - createServiceClient: () => ({ auth: { admin: { generateLink: mockGenerateLink } } }), +vi.mock('@/lib/auth/password-reset-codes', () => ({ + PASSWORD_RESET_CODE_TTL_MINUTES: 60, + issuePasswordResetCode: (email: string) => mockIssue(email), })); vi.mock('@/lib/auth/auth-emails', () => ({ - sendPasswordResetEmail: (to: string, url: string) => mockSend(to, url), + sendPasswordResetCodeEmail: (to: string, code: string) => mockSend(to, code), +})); + +vi.mock('@/lib/auth/captcha', () => ({ + verifyCaptcha: (...args: unknown[]) => mockVerifyCaptcha(...args), })); -// Keep the real NextResponse; make after() run its callback synchronously WITHOUT -// awaiting it, mirroring production (the send fires but never blocks the response). vi.mock('next/server', async (importOriginal) => { const actual = (await importOriginal()) as typeof import('next/server'); - return { ...actual, after: (cb: () => unknown) => { cb(); } }; + return { ...actual, after: (cb: () => unknown) => { void cb(); } }; }); -// The audit trail has its own suite (lib/auth/__tests__/audit-log.test.ts). vi.mock('@/lib/auth/audit-log', () => ({ recordAuthEvent: async () => {} })); import { POST } from '@/app/api/auth/reset-password/route'; @@ -62,129 +50,94 @@ function req(body: unknown, ip = '203.0.113.9'): Request { } beforeEach(() => { - mockGenerateLink.mockReset(); + mockIssue.mockReset(); + mockIssue.mockResolvedValue({ result: 'issued', code: 'A'.repeat(32) }); mockSend.mockClear(); - mockConfigured.mockReturnValue(true); mockEnforce.mockReset(); - mockEnforce.mockResolvedValue(null); // allowed unless a test says otherwise - delete process.env.NEXT_PUBLIC_SUPABASE_URL; - delete process.env.NEXT_PUBLIC_APP_URL; + mockEnforce.mockResolvedValue(null); + mockVerifyCaptcha.mockReset(); + mockVerifyCaptcha.mockResolvedValue(true); }); -describe('POST /api/auth/reset-password', () => { - it('sends a branded /auth/confirm link for an existing account (200)', async () => { - mockGenerateLink.mockResolvedValue({ data: { properties: { hashed_token: 'tok_abc' } }, error: null }); +describe('POST /api/auth/reset-password — code-only recovery', () => { + it('sends a one-time code, never a recovery URL, for a resolvable account', async () => { const res = await POST(req({ email: 'user@acme.com' })); expect(res.status).toBe(200); - expect(mockSend).toHaveBeenCalledTimes(1); - const [to, url] = mockSend.mock.calls[0]; - expect(to).toBe('user@acme.com'); - const u = new URL(url); - expect(u.pathname).toBe('/auth/confirm'); - expect(u.searchParams.get('token_hash')).toBe('tok_abc'); - expect(u.searchParams.get('type')).toBe('recovery'); - expect(u.searchParams.get('next')).toBe('/reset-password'); + expect(await res.json()).toEqual({ ok: true }); + expect(mockIssue).toHaveBeenCalledWith('user@acme.com'); + expect(mockSend).toHaveBeenCalledWith('user@acme.com', 'A'.repeat(32)); + for (const value of mockSend.mock.calls.flat()) { + expect(String(value)).not.toContain('?'); + expect(String(value)).not.toContain('token_hash'); + expect(String(value)).not.toContain('/auth/confirm'); + } }); - it('is enumeration-safe: non-existent account still returns 200 and sends nothing', async () => { - mockGenerateLink.mockResolvedValue({ data: null, error: { message: 'User not found' } }); + it('keeps an unknown address indistinguishable and sends no email', async () => { + mockIssue.mockResolvedValue({ result: 'unknown-or-unavailable', code: null }); const res = await POST(req({ email: 'ghost@acme.com' })); expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); expect(mockSend).not.toHaveBeenCalled(); }); - it('swallows a generateLink throw: still 200, no send, no crash', async () => { - mockGenerateLink.mockRejectedValue(new Error('supabase down')); + it('keeps an unavailable issuer indistinguishable and sends no email', async () => { + mockIssue.mockResolvedValue({ result: 'unknown-or-unavailable', code: null }); const res = await POST(req({ email: 'user@acme.com' })); expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); expect(mockSend).not.toHaveBeenCalled(); }); - it('normalizes the email before sending (trim + lowercase)', async () => { - mockGenerateLink.mockResolvedValue({ data: { properties: { hashed_token: 't' } }, error: null }); + it('normalizes email before issuance and delivery', async () => { await POST(req({ email: ' User@Acme.COM ' })); - expect(mockGenerateLink).toHaveBeenCalledWith({ type: 'recovery', email: 'user@acme.com' }); - expect(mockSend.mock.calls[0][0]).toBe('user@acme.com'); + expect(mockIssue).toHaveBeenCalledWith('user@acme.com'); + expect(mockSend).toHaveBeenCalledWith('user@acme.com', 'A'.repeat(32)); }); - it('rejects a malformed email with 400 and sends nothing', async () => { - const res = await POST(req({ email: 'nope' })); + it('rejects malformed body without issuing a code', async () => { + const res = await POST(req({ email: 'not-an-email' })); expect(res.status).toBe(400); - expect(mockGenerateLink).not.toHaveBeenCalled(); + expect(mockIssue).not.toHaveBeenCalled(); expect(mockSend).not.toHaveBeenCalled(); }); - it('rejects a malformed JSON body with 400', async () => { + it('rejects malformed JSON without issuing a code', async () => { const res = await POST(req('{not json')); expect(res.status).toBe(400); - expect(mockSend).not.toHaveBeenCalled(); - }); - - it('stays enumeration-safe when Supabase is not configured (200, no send)', async () => { - mockConfigured.mockReturnValue(false); - const res = await POST(req({ email: 'user@acme.com' })); - expect(res.status).toBe(200); - expect(mockGenerateLink).not.toHaveBeenCalled(); - expect(mockSend).not.toHaveBeenCalled(); - }); - - it('does not block the response on the email send (no enumeration timing oracle)', async () => { - mockGenerateLink.mockResolvedValue({ data: { properties: { hashed_token: 't' } }, error: null }); - // A send that never settles — if it were awaited on the response path, POST would hang. - mockSend.mockImplementationOnce(() => new Promise(() => {})); - const res = await POST(req({ email: 'user@acme.com' })); - expect(res.status).toBe(200); - expect(mockSend).toHaveBeenCalledTimes(1); // dispatched via after(), never awaited - }); - - it('uses NEXT_PUBLIC_APP_URL as the link base when set', async () => { - process.env.NEXT_PUBLIC_APP_URL = 'https://www.houndshield.com'; - mockGenerateLink.mockResolvedValue({ data: { properties: { hashed_token: 't2' } }, error: null }); - await POST(req({ email: 'user@acme.com' })); - expect(new URL(mockSend.mock.calls[0][1]).origin).toBe('https://www.houndshield.com'); + expect(mockIssue).not.toHaveBeenCalled(); }); }); -/** - * This endpoint is unauthenticated and makes HoundShield email a stranger, so - * an unbounded version is an inbox-flood vector plus a way to burn Supabase and - * Resend quota. The limiter that was supposed to cover it lived in middleware.ts, - * which does not execute on this deployment — hence the in-route limiter these - * tests pin. - */ -describe('POST /api/auth/reset-password — rate limiting', () => { - it('applies a per-IP bucket before parsing the body', async () => { +describe('POST /api/auth/reset-password — abuse controls', () => { + it('applies per-IP, hashed-email, and CAPTCHA-escalation counters', async () => { await POST(req({ email: 'user@acme.com' })); - const [namespace, identifier] = mockEnforce.mock.calls[0]; - expect(namespace).toBe('auth:reset-ip'); - expect(identifier).toContain('203.0.113.9'); + const namespaces = mockEnforce.mock.calls.map((call) => call[0]); + expect(namespaces).toContain('auth:reset-ip'); + expect(namespaces).toContain('auth:reset-email'); + expect(namespaces).toContain('auth:reset-captcha:ip'); + const emailCall = mockEnforce.mock.calls.find((call) => call[0] === 'auth:reset-email'); + expect(emailCall?.[1]).toMatch(/^e:[0-9a-f]{32}$/); + expect(emailCall?.[1]).not.toContain('acme'); }); - it('429s on the IP bucket without minting a link or sending mail', async () => { + it('does not issue or send when the IP bucket is exhausted', async () => { mockEnforce.mockResolvedValueOnce(new Response(null, { status: 429 })); const res = await POST(req({ email: 'user@acme.com' })); expect(res.status).toBe(429); - expect(mockGenerateLink).not.toHaveBeenCalled(); + expect(mockIssue).not.toHaveBeenCalled(); expect(mockSend).not.toHaveBeenCalled(); }); - it('applies a per-address bucket keyed on a hash, never the address itself', async () => { - mockGenerateLink.mockResolvedValue({ data: { properties: { hashed_token: 't' } }, error: null }); - await POST(req({ email: 'user@acme.com' })); - const [namespace, identifier] = mockEnforce.mock.calls[1]; - expect(namespace).toBe('auth:reset-email'); - expect(identifier).not.toContain('user@acme.com'); - expect(identifier).not.toContain('acme'); - expect(identifier).toMatch(/^e:[0-9a-f]{32}$/); - }); - - it('buckets a malformed address the same as a real one (no 429 oracle)', async () => { - // Both branches must reach the per-IP limiter; only a parseable address can - // reach the per-address one, and that bucket must not depend on whether the - // account exists. - mockGenerateLink.mockResolvedValue({ data: null, error: { message: 'User not found' } }); - await POST(req({ email: 'ghost@acme.com' })); - const emailCall = mockEnforce.mock.calls.find((c) => c[0] === 'auth:reset-email'); - expect(emailCall).toBeDefined(); + it('requires CAPTCHA after a burst without using account existence as input', async () => { + mockEnforce.mockImplementation((namespace: string) => + namespace === 'auth:reset-captcha:ip' ? Promise.resolve(new Response(null, { status: 429 })) : Promise.resolve(null), + ); + mockVerifyCaptcha.mockResolvedValue(false); + const res = await POST(req({ email: 'ghost@acme.com' })); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ captchaRequired: true }); + expect(mockIssue).not.toHaveBeenCalled(); + expect(mockSend).not.toHaveBeenCalled(); }); }); diff --git a/compliance-firewall-agent/app/api/auth/reset-password/complete/__tests__/route.test.ts b/compliance-firewall-agent/app/api/auth/reset-password/complete/__tests__/route.test.ts new file mode 100644 index 00000000..28934af2 --- /dev/null +++ b/compliance-firewall-agent/app/api/auth/reset-password/complete/__tests__/route.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { + mockGuard, + mockConsume, + mockUpdateUserById, + mockRegisterFailure, + mockClearFailures, +} = vi.hoisted(() => ({ + mockGuard: vi.fn(), + mockConsume: vi.fn(), + mockUpdateUserById: vi.fn(), + mockRegisterFailure: vi.fn(), + mockClearFailures: vi.fn(), +})); + +vi.mock('@/lib/auth/credential-guard', () => ({ + AUTH_LIMITS: { resetCompleteIp: {}, resetCompleteEmail: {} }, + guardCredentials: (...args: unknown[]) => mockGuard(...args), +})); +vi.mock('@/lib/auth/password-reset-codes', () => ({ + consumePasswordResetCode: (...args: unknown[]) => mockConsume(...args), +})); +vi.mock('@/lib/supabase/client', () => ({ + isSupabaseConfigured: () => true, + createServiceClient: () => ({ auth: { admin: { updateUserById: mockUpdateUserById } } }), +})); +vi.mock('@/lib/auth/lockout', () => ({ + registerFailure: (...args: unknown[]) => mockRegisterFailure(...args), + clearFailures: (...args: unknown[]) => mockClearFailures(...args), +})); +vi.mock('@/lib/auth/audit-log', () => ({ recordAuthEvent: async () => {} })); +vi.mock('@/lib/rate-limit-shared', () => ({ clientIp: () => '203.0.113.9' })); +vi.mock('@/lib/auth/timing', () => ({ settleAuthTiming: async () => {} })); +vi.mock('@/lib/auth/auth-error-message', () => ({ lockedOutMessage: () => 'Try again later.' })); +vi.mock('next/server', async (importOriginal) => { + const actual = (await importOriginal()) as typeof import('next/server'); + return { ...actual, after: (cb: () => unknown) => { void cb(); } }; +}); + +import { POST } from '@/app/api/auth/reset-password/complete/route'; + +const good = { + email: 'user@acme.com', + code: 'A'.repeat(32), + password: 'StrongPassword2026', +}; + +function req(body: unknown): Request { + return new Request('http://localhost/api/auth/reset-password/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + mockGuard.mockReset(); + mockGuard.mockResolvedValue({ blocked: null }); + mockConsume.mockReset(); + mockConsume.mockResolvedValue({ ok: true, userId: '11111111-1111-4111-8111-111111111111' }); + mockUpdateUserById.mockReset(); + mockUpdateUserById.mockResolvedValue({ error: null }); + mockRegisterFailure.mockReset(); + mockRegisterFailure.mockResolvedValue({ locked: false, minutesRemaining: 0 }); + mockClearFailures.mockReset(); +}); + +describe('POST /api/auth/reset-password/complete', () => { + it('requires the server-side password policy before consuming a code', async () => { + const res = await POST(req({ ...good, password: 'short' })); + expect(res.status).toBe(400); + expect(mockConsume).not.toHaveBeenCalled(); + expect(mockUpdateUserById).not.toHaveBeenCalled(); + }); + + it('consumes a valid code once and delegates password hashing to Supabase Auth', async () => { + const res = await POST(req(good)); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect(mockConsume).toHaveBeenCalledWith(good.email, good.code); + expect(mockUpdateUserById).toHaveBeenCalledWith('11111111-1111-4111-8111-111111111111', { + password: good.password, + }); + expect(mockClearFailures).toHaveBeenCalledWith(good.email); + }); + + it('returns a neutral invalid-code result and never calls the provider for an expired or used code', async () => { + mockConsume.mockResolvedValue({ ok: false, reason: 'invalid-or-expired' }); + const res = await POST(req(good)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'This reset request is invalid, expired, or already used.' }); + expect(mockUpdateUserById).not.toHaveBeenCalled(); + expect(mockRegisterFailure).toHaveBeenCalledWith(good.email); + }); + + it('locks after repeated invalid code attempts without disclosing a provider cause', async () => { + mockConsume.mockResolvedValue({ ok: false, reason: 'invalid-or-expired' }); + mockRegisterFailure.mockResolvedValue({ locked: true, minutesRemaining: 15 }); + const res = await POST(req(good)); + expect(res.status).toBe(429); + expect(await res.json()).toEqual({ error: 'Try again later.' }); + expect(mockUpdateUserById).not.toHaveBeenCalled(); + }); + + it('returns a fixed unavailable message when the privileged provider update fails', async () => { + mockUpdateUserById.mockResolvedValue({ error: { message: 'provider-internal-detail' } }); + const res = await POST(req(good)); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Password reset is unavailable right now. Please request a new code later.' }); + }); + + it('returns a ready blocked response before consuming the code', async () => { + mockGuard.mockResolvedValue({ blocked: new Response(null, { status: 429 }) }); + const res = await POST(req(good)); + expect(res.status).toBe(429); + expect(mockConsume).not.toHaveBeenCalled(); + }); +}); diff --git a/compliance-firewall-agent/app/api/auth/reset-password/complete/route.ts b/compliance-firewall-agent/app/api/auth/reset-password/complete/route.ts new file mode 100644 index 00000000..5fdb1153 --- /dev/null +++ b/compliance-firewall-agent/app/api/auth/reset-password/complete/route.ts @@ -0,0 +1,129 @@ +import { after, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { createServiceClient, isSupabaseConfigured } from '@/lib/supabase/client'; +import { guardCredentials, AUTH_LIMITS } from '@/lib/auth/credential-guard'; +import { registerFailure, clearFailures } from '@/lib/auth/lockout'; +import { settleAuthTiming } from '@/lib/auth/timing'; +import { consumePasswordResetCode } from '@/lib/auth/password-reset-codes'; +import { recordAuthEvent } from '@/lib/auth/audit-log'; +import { clientIp } from '@/lib/rate-limit-shared'; +import { lockedOutMessage } from '@/lib/auth/auth-error-message'; + +/** + * The code is 128 bits of random hex. Password policy is asserted here, not + * trusted to browser attributes. The password is passed directly to Supabase + * Auth, whose provider KDF stores it; HoundShield never hashes or persists it. + */ +const completeSchema = z.object({ + email: z.string().trim().toLowerCase().email().max(320), + code: z.string().trim().regex(/^[A-Fa-f0-9]{32}$/), + password: z + .string() + .min(12, 'Use at least 12 characters.') + .max(200) + .refine((value) => /[A-Za-z]/.test(value) && /\d/.test(value), { + message: 'Include at least one letter and one number.', + }), + captchaToken: z.string().max(4096).optional(), +}); + +const INVALID_CODE = 'This reset request is invalid, expired, or already used.'; +const UNAVAILABLE = 'Password reset is unavailable right now. Please request a new code later.'; + +export async function POST(request: Request) { + const startedAt = Date.now(); + const ip = clientIp(request); + + let body: z.infer; + try { + const parsed = completeSchema.safeParse(await request.json()); + if (!parsed.success) { + await settleAuthTiming(startedAt); + return NextResponse.json({ error: 'Invalid request.' }, { status: 400 }); + } + body = parsed.data; + } catch { + await settleAuthTiming(startedAt); + return NextResponse.json({ error: 'Invalid request.' }, { status: 400 }); + } + + const { blocked } = await guardCredentials({ + request, + email: body.email, + namespace: 'auth:reset-complete', + ipLimit: AUTH_LIMITS.resetCompleteIp, + emailLimit: AUTH_LIMITS.resetCompleteEmail, + captchaToken: body.captchaToken, + useLockout: true, + }); + if (blocked) { + await settleAuthTiming(startedAt); + return blocked; + } + + if (!isSupabaseConfigured()) { + await settleAuthTiming(startedAt); + return NextResponse.json({ error: UNAVAILABLE }, { status: 503 }); + } + + const consumed = await consumePasswordResetCode(body.email, body.code); + if (!consumed.ok) { + const lock = await registerFailure(body.email); + after(() => + recordAuthEvent({ + event: 'password_reset_completed', + email: body.email, + ip, + userAgent: request.headers.get('user-agent'), + detail: { outcome: consumed.reason }, + }), + ); + await settleAuthTiming(startedAt); + if (lock.locked) { + return NextResponse.json( + { error: lockedOutMessage(lock.minutesRemaining) }, + { status: 429, headers: { 'Retry-After': String(lock.minutesRemaining * 60) } }, + ); + } + return NextResponse.json({ error: INVALID_CODE }, { status: 400 }); + } + + try { + const supabase = createServiceClient(); + // The provider owns password hashing. This server route never stores the + // plaintext or a fast hash; `updateUserById` enters Supabase Auth's slow KDF. + const { error } = await supabase.auth.admin.updateUserById(consumed.userId, { + password: body.password, + }); + if (error) throw error; + + await clearFailures(body.email); + after(() => + recordAuthEvent({ + event: 'password_reset_completed', + email: body.email, + userId: consumed.userId, + ip, + userAgent: request.headers.get('user-agent'), + detail: { outcome: 'success', provider: 'supabase-admin' }, + }), + ); + await settleAuthTiming(startedAt); + return NextResponse.json({ ok: true }); + } catch (error: unknown) { + // The code has been consumed to preserve single-use semantics. Never expose + // provider detail, the account identity, password policy internals, or code. + console.error('[reset-password/complete] provider update failed:', error instanceof Error ? error.message : String(error)); + after(() => + recordAuthEvent({ + event: 'password_reset_completed', + email: body.email, + ip, + userAgent: request.headers.get('user-agent'), + detail: { outcome: 'provider_error' }, + }), + ); + await settleAuthTiming(startedAt); + return NextResponse.json({ error: UNAVAILABLE }, { status: 503 }); + } +} diff --git a/compliance-firewall-agent/app/api/auth/reset-password/route.ts b/compliance-firewall-agent/app/api/auth/reset-password/route.ts index 27bd7983..0328b28f 100644 --- a/compliance-firewall-agent/app/api/auth/reset-password/route.ts +++ b/compliance-firewall-agent/app/api/auth/reset-password/route.ts @@ -1,58 +1,68 @@ -import { NextResponse, after } from 'next/server'; -import { createServiceClient, isSupabaseConfigured } from '@/lib/supabase/client'; -import { sendPasswordResetEmail } from '@/lib/auth/auth-emails'; -import { recoveryRequestSchema, buildRecoveryConfirmUrl } from '@/lib/auth/recovery-link'; +import { after, NextResponse } from 'next/server'; +import { recoveryRequestSchema } from '@/lib/auth/recovery-link'; +import { sendPasswordResetCodeEmail } from '@/lib/auth/auth-emails'; +import { + issuePasswordResetCode, + PASSWORD_RESET_CODE_TTL_MINUTES, +} from '@/lib/auth/password-reset-codes'; import { enforceRateLimit, identifierFor, clientIp } from '@/lib/rate-limit-shared'; import { lockoutKey } from '@/lib/auth/lockout'; +import { captchaRequired, verifyCaptcha } from '@/lib/auth/captcha'; +import { AUTH_CAPTCHA_REQUIRED } from '@/lib/auth/auth-error-message'; import { settleAuthTiming } from '@/lib/auth/timing'; import { recordAuthEvent } from '@/lib/auth/audit-log'; /** - * POST /api/auth/reset-password — self-hosted password-reset send. + * POST /api/auth/reset-password * - * Mints the recovery link server-side (`admin.generateLink`) and sends a branded - * Resend email pointing at `/auth/confirm`. This removes every Supabase-dashboard - * dependency from the reset flow: no Redirect-URL allow-list (the link is - * same-origin), no custom email template, no custom SMTP (Resend is the sender). + * Password-reset delivery is intentionally code based. A raw recovery code is + * sent only in the email body; it is never created as a URL, query parameter, + * redirect target, browser-history entry, or log field. The server stores only + * a keyed hash, with a maximum 60-minute expiry and atomic single-use consume. * - * ENUMERATION-SAFE: always answers 200 for a well-formed email, whether or not an - * account exists — the client shows "check your email" either way. Only a - * malformed body returns 4xx (that leaks nothing about account existence). The - * Resend send runs in `after()` (off the response path) so an existing account - * does NOT return slower than a non-existent one — response latency can't be - * used as an account-existence oracle. - * - * …AND THAT WAS NOT SUFFICIENT ON ITS OWN. Moving the *email send* off the - * response path leaves `admin.generateLink` ON it, and that call is exactly the - * one whose cost depends on the answer: it mints and stores a recovery token - * for an address that resolves, and returns an error for one that does not. The - * gap is the same shape as the bcrypt gap that ../login guards against, so it - * gets the same treatment — every path below settles against the shared floor - * in lib/auth/timing.ts before returning. `after()` bounds the slow half; the - * floor bounds the fast half. Neither closes the oracle alone. - * - * RATE LIMITED HERE, IN THE ROUTE. A 5-per-minute bucket for this path already - * existed in middleware.ts — and was dead: the repo-root vercel.json uses the - * legacy `builds`/`routes` keys, which replace the routing table the middleware - * lives in, so none of it executes on this deployment (verified 2026-08-11: - * /auth/signup 404s, no X-RateLimit-* on any response). Until that config is - * fixed separately, an "unauthenticated endpoint that emails a stranger" was - * completely unbounded — an email-bomb vector aimed at a customer's inbox and a - * way to burn Supabase and Resend quota. A limiter in a file that never runs is - * worse than no limiter, because it reads as covered. + * Every well-formed email receives the same `200 { ok: true }` body after the + * same timing floor whether it names a real account, an unknown account, an + * exhausted token bucket, or an unavailable backend. That keeps this endpoint + * from becoming an account-existence oracle. */ const RESET_IP_LIMIT = { limit: 5, windowMs: 60_000 }; const RESET_EMAIL_LIMIT = { limit: 3, windowMs: 900_000 }; +const RESET_CAPTCHA_THRESHOLD = { limit: 2, windowMs: 60_000 }; const ok = () => NextResponse.json({ ok: true }); +async function challengeAfterBurst( + request: Request, + email: string, + captchaToken: string | undefined, +): Promise { + const ip = clientIp(request); + // A low, separate counter decides when a human challenge is required. The + // hard buckets below still set the absolute delivery ceiling. Both keys are + // calculated for any submitted email, never only for a real account. + const challenge = await enforceRateLimit( + 'auth:reset-captcha:ip', + identifierFor({ ip }), + RESET_CAPTCHA_THRESHOLD, + ); + if (!challenge) return null; + + const passed = await verifyCaptcha(captchaToken, ip); + if (passed) return null; + return NextResponse.json( + { error: AUTH_CAPTCHA_REQUIRED, captchaRequired: true }, + { status: 400 }, + ); +} + export async function POST(request: Request) { const startedAt = Date.now(); + const ip = clientIp(request); - // Per-IP first — cheapest check, and it needs no parsed body. + // Per-IP first: cheapest ceiling and independent of request-body parsing. const ipBlocked = await enforceRateLimit( 'auth:reset-ip', - identifierFor({ ip: clientIp(request) }), + identifierFor({ ip }), RESET_IP_LIMIT, ); if (ipBlocked) { @@ -61,6 +71,7 @@ export async function POST(request: Request) { } let email: string; + let captchaToken: string | undefined; try { const parsed = recoveryRequestSchema.safeParse(await request.json()); if (!parsed.success) { @@ -68,27 +79,12 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'A valid email is required.' }, { status: 400 }); } email = parsed.data.email; + captchaToken = parsed.data.captchaToken; } catch { await settleAuthTiming(startedAt); return NextResponse.json({ error: 'Invalid request.' }, { status: 400 }); } - // Recorded for the ATTEMPT, uniformly, before we know whether the address - // resolves — a password-reset request against a customer's address is exactly - // the event an incident review needs, and the one no log currently held. - after(() => - recordAuthEvent({ - event: 'password_reset_requested', - email, - ip: clientIp(request), - userAgent: request.headers.get('user-agent'), - }), - ); - - // Per-address, so a botnet cannot spread an inbox flood across many IPs. - // Keyed on the hash — this bucket must never hold an address. Applied to any - // well-formed email whether or not it has an account, or the 429 itself - // would become the enumeration oracle the rest of this route avoids. const emailBlocked = await enforceRateLimit( 'auth:reset-email', `e:${lockoutKey(email)}`, @@ -99,37 +95,31 @@ export async function POST(request: Request) { return emailBlocked; } - // No Supabase configured (dev/demo) → stay enumeration-safe, send nothing. - if (!isSupabaseConfigured()) { - // Server-side only (never in the response) — every failure here is silent by - // design, so log the outcome so a "nothing happening" report is diagnosable. - console.warn('[reset-password] Supabase not configured — no recovery email sent'); + const captchaBlocked = await challengeAfterBurst(request, email, captchaToken); + if (captchaBlocked) { await settleAuthTiming(startedAt); - return ok(); + return captchaBlocked; } - try { - const supabase = createServiceClient(); - const { data, error } = await supabase.auth.admin.generateLink({ type: 'recovery', email }); - // A non-existent email errors here — swallow it so response timing/shape - // never reveals whether the account exists. - if (!error && data?.properties?.hashed_token) { - const base = process.env.NEXT_PUBLIC_APP_URL?.trim() || new URL(request.url).origin; - const confirmUrl = buildRecoveryConfirmUrl(base, data.properties.hashed_token); - // Send after the response so both branches return in ~generateLink time. - after(() => sendPasswordResetEmail(email, confirmUrl)); - console.info('[reset-password] recovery link dispatched'); - } else { - // Unknown account or unexpected shape — no send (outcome logged, no PII). - console.info('[reset-password] no recovery link minted (unknown account or error)'); - } - } catch (err: unknown) { - // Never surface internals; log server-side only. - console.error('[reset-password] link generation failed:', err instanceof Error ? err.message : err); + // Record request attempts without raw email, code, password, token, or prompt + // data. This write is deliberately off the response path. + after(() => + recordAuthEvent({ + event: 'password_reset_requested', + email, + ip, + userAgent: request.headers.get('user-agent'), + detail: { recovery: 'code', ttlMinutes: PASSWORD_RESET_CODE_TTL_MINUTES }, + }), + ); + + // The issuer returns a code only for a real account. The caller observes the + // same response either way; delivery is scheduled off the response path. + const issued = await issuePasswordResetCode(email); + if (issued.result === 'issued' && issued.code) { + after(() => sendPasswordResetCodeEmail(email, issued.code!)); } - // Single exit for every outcome above — known account, unknown account, and - // thrown error all leave through the same floor. await settleAuthTiming(startedAt); return ok(); } diff --git a/compliance-firewall-agent/app/api/auth/signup/__tests__/route.test.ts b/compliance-firewall-agent/app/api/auth/signup/__tests__/route.test.ts index 1800f74d..e8f3cf38 100644 --- a/compliance-firewall-agent/app/api/auth/signup/__tests__/route.test.ts +++ b/compliance-firewall-agent/app/api/auth/signup/__tests__/route.test.ts @@ -240,10 +240,11 @@ describe('rate limiting and rollback', () => { await expect(res.json()).resolves.toEqual({ error: AUTH_RATE_LIMITED }); }); - it('answers 501 when AUTH_SERVER_ROUTES=off so the browser reverts', async () => { + it('answers a generic 503 when local server auth is disabled, without enabling browser fallback', async () => { process.env.AUTH_SERVER_ROUTES = 'off'; const res = await POST(req(creds)); - expect(res.status).toBe(501); + expect(res.status).toBe(503); + expect((await res.json()).error).toBe('Authentication is unavailable in this development environment.'); expect(mockSignUp).not.toHaveBeenCalled(); }); diff --git a/compliance-firewall-agent/app/auth/confirm/route.ts b/compliance-firewall-agent/app/auth/confirm/route.ts index 82e12f28..de35eb24 100644 --- a/compliance-firewall-agent/app/auth/confirm/route.ts +++ b/compliance-firewall-agent/app/auth/confirm/route.ts @@ -7,15 +7,12 @@ import { confirmRedirect, confirmFailureRedirect } from '@/lib/auth/confirm-redi /** * Email OTP confirmation handler (the SSR-canonical `token_hash` flow). * - * The branded Supabase email templates (see docs/auth-password-reset.md) link to - * {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery&next=/reset-password - * Because that link targets the Site URL directly, it is immune to the - * redirect-URL allow-list fallback that silently dumps recovery links on the - * homepage. We verify the OTP here (establishing a session cookie), then forward - * the user to set their new password. + * This route now confirms email-verification links only. Password recovery uses + * an application-owned one-time code entered in a POST body; accepting recovery + * `token_hash` values here would reintroduce a bearer credential in a URL. * - * This complements /auth/callback, which handles the PKCE `?code=` flow used by - * OAuth and the default (unbranded) email template. + * This complements /auth/callback, which handles OAuth and the default PKCE + * code-exchange flow. */ export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url); @@ -23,6 +20,13 @@ export async function GET(request: Request) { const type = searchParams.get('type') as EmailOtpType | null; const next = searchParams.get('next'); + // Recovery codes are intentionally never accepted from a URL. Existing legacy + // links are redirected to the code-entry page without verification, so their + // token remains unusable and is not copied to a subsequent request. + if (type === 'recovery') { + return noReferrer(NextResponse.redirect(new URL('/reset-password?error=REQUEST_CODE', origin))); + } + if (tokenHash && type) { try { const cookieStore = await cookies(); @@ -58,24 +62,7 @@ export async function GET(request: Request) { return noReferrer(NextResponse.redirect(new URL(confirmFailureRedirect(type), origin))); } -/** - * Partial mitigation for the one requirement this flow cannot fully meet: - * "reset tokens are never exposed in URLs". - * - * Supabase's SSR recovery design puts `token_hash` in the query string, and - * that is not ours to change — the link is minted by GoTrue's - * admin.generateLink and has to arrive as a GET. So the token lands in this - * request's URL, and from there in browser history and edge access logs. - * - * What we CAN stop is it travelling any further. `no-referrer` means the - * redirect target (/reset-password) and every asset it loads receive no - * Referer header, so the token-bearing URL is not handed to a third party or - * written into a downstream log. The token is a single-use hash with a short - * TTL, which bounds the rest. - * - * Reported as a partial in docs/SECURITY-AUDIT-2026-08-11.md rather than - * claimed as satisfied. - */ +/** Apply no-referrer to legacy-token rejection and verification redirects. */ function noReferrer(res: NextResponse): NextResponse { res.headers.set('Referrer-Policy', 'no-referrer'); return res; diff --git a/compliance-firewall-agent/app/forgot-password/page.tsx b/compliance-firewall-agent/app/forgot-password/page.tsx index 53ffb219..b2e78c47 100644 --- a/compliance-firewall-agent/app/forgot-password/page.tsx +++ b/compliance-firewall-agent/app/forgot-password/page.tsx @@ -3,54 +3,41 @@ import { useState } from "react"; import Link from "next/link"; import { Mail, ArrowLeft, CheckCircle } from "lucide-react"; -import { authClient, isBetterAuthClientEnabled } from "@/lib/auth/auth-client"; import { Logo } from "@/components/Logo"; import { TextLogo } from "@/components/TextLogo"; +import { TurnstileChallenge } from '@/components/auth/TurnstileChallenge'; export default function ForgotPasswordPage() { const [email, setEmail] = useState(""); const [loading, setLoading] = useState(false); const [sent, setSent] = useState(false); const [error, setError] = useState(""); + const [captchaRequired, setCaptchaRequired] = useState(false); + const [captchaToken, setCaptchaToken] = useState(""); const handleReset = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(""); - // Better Auth path: emails a single-use token linking to /reset-password. - // It intentionally reports success even for unknown emails (no account - // enumeration), so we always show the "check your email" state. - if (isBetterAuthClientEnabled()) { - try { - await authClient.requestPasswordReset({ - email, - redirectTo: `${window.location.origin}/reset-password`, - }); - setSent(true); - } catch { - setError("We couldn't reach the reset service. Please try again in a moment."); - } - setLoading(false); - return; - } - - // Supabase path: send via our own /api/auth/reset-password, which mints the - // recovery link server-side and emails a BRANDED link pointing at - // /auth/confirm (same-origin → immune to the Supabase Redirect-URL allow-list - // "lands on the homepage" fallback, and no Supabase email template / SMTP - // config required). The route is enumeration-safe (always 200 for a - // well-formed email), so we show "check your email" on any ok response. + // Server-owned code delivery is the only production path. It gives the + // caller the same neutral result whether an account exists and never places + // a reset bearer credential in an email URL. try { const res = await fetch("/api/auth/reset-password", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), + body: JSON.stringify({ email, captchaToken: captchaToken || undefined }), }); + const data = (await res.json().catch(() => ({}))) as { captchaRequired?: boolean }; if (res.ok) { setSent(true); + } else if (data.captchaRequired) { + setCaptchaRequired(true); + setCaptchaToken(""); + setError(""); } else { - setError("We couldn't send the reset link. Please check the email and try again."); + setError("We couldn't start password reset. Please try again in a moment."); } } catch { setError("We couldn't reach the reset service. Please try again in a moment."); @@ -82,10 +69,8 @@ export default function ForgotPasswordPage() {

Check your email

- We sent a password reset link to{" "} - {email}. -
- Click the link in the email to reset your password. + If an account uses this address, a one-time reset code will arrive shortly. + Enter that code with your new password on the reset page.

Didn't get it? Check your spam folder, or{" "} @@ -105,7 +90,7 @@ export default function ForgotPasswordPage() { <>

Reset your password

- Enter your email and we'll send you a reset link. + Enter your email and we'll send a one-time reset code. The code is never placed in a link.

{error && ( @@ -132,12 +117,19 @@ export default function ForgotPasswordPage() { + {captchaRequired && ( + setCaptchaToken(token)} + onExpired={() => setCaptchaToken("")} + /> + )} + diff --git a/compliance-firewall-agent/app/page.tsx b/compliance-firewall-agent/app/page.tsx index 62281e0f..6334f692 100644 --- a/compliance-firewall-agent/app/page.tsx +++ b/compliance-firewall-agent/app/page.tsx @@ -31,12 +31,24 @@ export const metadata: Metadata = { const STATS = [ { n: String(ENGINE_COUNT), l: 'Detection engines', s: `${PATTERN_COUNT} patterns · CUI · PHI · PII` }, + // Resolved in the #302 <- main merge. Both sides rewrote this same tile: + // this branch put "2 / deployment paths" here, main put the Netskope figure. + // The grid is a hard `repeat(4, 1fr)` (app/hermes.css:263), so a fifth tile + // orphans on its own row — it is genuinely one or the other. + // + // Main's tile wins because keeping it loses nothing: renders + // IMMEDIATELY below this row and already says "CUI-safe = Mode B (Docker on + // your infrastructure); the hosted trial runs on Vercel and is not + // FedRAMP-authorized" — the same two paths, with the honesty the NEVER-DO + // list requires. The Netskope figure has no such second home, and it is the + // market proof for Rachel, the fastest-closing buyer. + // // Scope matters: this is the GENERATIVE-AI slice, not all healthcare // violations (that figure is 81%). Both live in lib/market/netskope.ts with // their denominators attached, so the tile cannot drift off its source. { n: REGULATED_SHARE_GENAI.value, l: 'of healthcare genAI', s: `violations involve regulated data — vs ${CROSS_INDUSTRY_GENAI.value} across all industries` }, { n: '110', l: 'NIST 800-171 controls', s: 'Mapped & SPRS-scored' }, - { n: '<10ms', l: 'Scan latency', s: 'p99 0.49ms measured, fully local' }, + { n: '<10ms', l: 'Local scan target', s: 'Measured locally; workload dependent' }, ] const PLATFORM_CARDS = [ @@ -68,26 +80,24 @@ export default function HomePage() { visitor would act on it. */}

- Prove what your team pasted into ChatGPT, Claude and Gemini. + Keep regulated data inside your control boundary.

- Staff paste patient records and contract data into AI tools every day. HoundShield - scans every prompt on your own hardware before it leaves your network, then - hands you a signed PDF mapped to HIPAA and NIST 800-171 — evidence you can give an - auditor. Your prompts are never transmitted to us, because there is no us in the - data path. + Evaluate AI prompt controls without pretending every workload is the same. HoundShield + scans compatible traffic inside your environment, helps you document the control + boundary, and produces an evidence-oriented assessment mapped to HIPAA and NIST 800-171.

- Scan a prompt now — free, in your browser + Explore the control boundary Get the $499 report
- One URL change - Runs on your hardware - Nothing transmitted - Audit-ready PDF + Hosted evaluation clearly labelled + Self-hosted path for sensitive workloads + Your deployment, your boundary + Evidence-oriented PDF
@@ -117,11 +127,11 @@ export default function HomePage() {
The asymmetric advantage
-

Cloud DLP scans your CUI in their cloud. That's the spill.

+

Start with the boundary your assessor will ask about.

- Every cloud-based AI DLP tool has to receive your data to inspect it. For a DoD - contractor, that transmission is itself a DFARS 7012 CUI exposure. HoundShield is - the only one that never sees your data. + Cloud controls, Microsoft governance and local proxy enforcement solve different problems. + HoundShield is designed for teams that need a self-hosted control path for AI traffic outside + their existing productivity suite. Validate the deployment model against your contract and SSP.

@@ -129,24 +139,24 @@ export default function HomePage() {

Nightfall & Strac

- Cloud DLP. To scan a prompt they must transmit your CUI to their servers — the - exact exposure CMMC L2 forbids. Architecturally disqualified for the DIB. + Broad cloud DLP can be a strong fit for SaaS data protection. Teams handling controlled + data should document its data path and decide whether a cloud inspection model fits their boundary.

Microsoft Purview

- M365-only. No API proxy. Your team's ChatGPT, Claude, Cursor and Copilot - traffic outside Microsoft's walls goes completely unmonitored. + Strong Microsoft 365 governance. It complements—not replaces—a deliberate control path for + third-party AI services and developer tools outside the Microsoft productivity surface.

HoundShield

- Local-only. Detection runs on your hardware. Nothing reaches our servers — - ever. The moat cloud vendors can't match without a full rebuild. + A self-hosted enforcement option for compatible AI traffic. Detection runs in your environment; + use the deployment guide to validate scope, integrations and data residency before rollout.

diff --git a/compliance-firewall-agent/app/reset-password/page.tsx b/compliance-firewall-agent/app/reset-password/page.tsx index 4433f1fd..6ab3f5fe 100644 --- a/compliance-firewall-agent/app/reset-password/page.tsx +++ b/compliance-firewall-agent/app/reset-password/page.tsx @@ -1,69 +1,58 @@ -"use client"; +'use client'; -import { Suspense, useState, useEffect } from "react"; -import Link from "next/link"; -import { useSearchParams } from "next/navigation"; -import { Lock, Eye, EyeOff, ArrowLeft, CheckCircle, AlertCircle, Loader2 } from "lucide-react"; -import { authClient, isBetterAuthClientEnabled } from "@/lib/auth/auth-client"; -import { createClient } from "@/lib/supabase/browser"; -import { resetView } from "@/lib/auth/reset-password-state"; -import { Logo } from "@/components/Logo"; -import { TextLogo } from "@/components/TextLogo"; +import { useState } from 'react'; +import Link from 'next/link'; +import { Lock, Eye, EyeOff, ArrowLeft, CheckCircle, AlertCircle, KeyRound } from 'lucide-react'; +import { Logo } from '@/components/Logo'; +import { TextLogo } from '@/components/TextLogo'; +import { TurnstileChallenge } from '@/components/auth/TurnstileChallenge'; /** - * Reset password (Better Auth). The emailed link lands here as - * /reset-password?token=… (or ?error=INVALID_TOKEN when expired/used). We take - * the new password and call authClient.resetPassword({ newPassword, token }). - * useSearchParams must sit behind Suspense in the App Router. + * Password reset is deliberately code entry, not a recovery session or an email + * URL. The raw code is delivered only in the email and is posted once to the + * server-side completion route together with the new password. */ -function ResetPasswordInner() { - const params = useSearchParams(); - const token = params.get("token") ?? ""; - const tokenError = params.get("error"); // e.g. INVALID_TOKEN - const betterAuth = isBetterAuthClientEnabled(); - - const [password, setPassword] = useState(""); +export default function ResetPasswordPage() { + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); + const [error, setError] = useState(''); const [done, setDone] = useState(false); - // Supabase mode: null while we confirm the recovery session /auth/callback set. - const [sessionReady, setSessionReady] = useState(betterAuth ? true : null); - - useEffect(() => { - if (betterAuth) return; - // The recovery link came through /auth/callback, which exchanged the code - // into a session before forwarding here. Confirm that session exists so we - // show the form (not "expired") and can updateUser against it. - const supabase = createClient(); - supabase.auth - .getSession() - .then(({ data }) => setSessionReady(!!data.session)) - .catch(() => setSessionReady(false)); - }, [betterAuth]); - - const view = resetView({ betterAuth, token, tokenError, sessionReady }); + const [captchaRequired, setCaptchaRequired] = useState(false); + const [captchaToken, setCaptchaToken] = useState(''); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - if (password.length < 8) { - setError("Password must be at least 8 characters."); + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(''); + if (password.length < 12 || !/[A-Za-z]/.test(password) || !/\d/.test(password)) { + setError('Use at least 12 characters, including a letter and a number.'); return; } + setLoading(true); try { - const resetError = betterAuth - ? (await authClient.resetPassword({ newPassword: password, token })).error - : (await createClient().auth.updateUser({ password })).error; - if (resetError) { - setError(resetError.message || "This reset link is invalid or has expired."); - setLoading(false); + const response = await fetch('/api/auth/reset-password/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, code: code.trim().toUpperCase(), password, captchaToken: captchaToken || undefined }), + }); + const data = (await response.json().catch(() => ({}))) as { ok?: boolean; error?: string; captchaRequired?: boolean }; + if (data.captchaRequired) { + setCaptchaRequired(true); + setCaptchaToken(''); + setError(''); + return; + } + if (!response.ok || !data.ok) { + setError(data.error || 'This reset request is invalid, expired, or already used.'); return; } setDone(true); } catch { setError("We couldn't reach the reset service. Please try again in a moment."); + } finally { setLoading(false); } }; @@ -85,50 +74,23 @@ function ResetPasswordInner() {

Your password has been reset. You can now sign in with your new password.

- + Go to login - ) : view === "loading" ? ( -
- -

Verifying your reset link…

-
- ) : view === "invalid" ? ( -
-
- -
-

Link expired

-

- This password reset link is invalid or has already been used. Request a fresh one. -

- - Request a new link - -
) : ( <> - + - Back to login + Request a new code

Set a new password

- Choose a strong password — at least 8 characters. + Enter the one-time code from your email. It expires after one hour and is never sent in a link.

{error && ( -
+
{error}
@@ -136,37 +98,34 @@ function ResetPasswordInner() {
- + + setEmail(event.target.value)} placeholder="you@company.com" required className="w-full px-3 py-3 rounded-xl bg-white border border-[var(--hs-border)] text-[var(--hs-ink)] text-sm placeholder:text-[var(--hs-ink-tertiary)] focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-300 transition-all" /> +
+
+ +
+ + setCode(event.target.value.replace(/\s/g, '').toUpperCase())} placeholder="32-character code" autoComplete="one-time-code" inputMode="text" pattern="[A-Fa-f0-9]{32}" minLength={32} maxLength={32} required className="w-full pl-10 pr-3 py-3 rounded-xl bg-white border border-[var(--hs-border)] text-[var(--hs-ink)] font-mono text-sm tracking-wide placeholder:font-sans placeholder:tracking-normal placeholder:text-[var(--hs-ink-tertiary)] focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-300 transition-all" /> +
+
+
+
- setPassword(e.target.value)} - placeholder="At least 8 characters" - required - minLength={8} - className="w-full pl-10 pr-12 py-3 rounded-xl bg-white border border-[var(--hs-border)] text-[var(--hs-ink)] text-sm placeholder:text-[var(--hs-ink-tertiary)] focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-300 transition-all" - /> -
- -
@@ -175,11 +134,3 @@ function ResetPasswordInner() {
); } - -export default function ResetPasswordPage() { - return ( - - - - ); -} diff --git a/compliance-firewall-agent/components/auth/TurnstileChallenge.tsx b/compliance-firewall-agent/components/auth/TurnstileChallenge.tsx new file mode 100644 index 00000000..f861b4bf --- /dev/null +++ b/compliance-firewall-agent/components/auth/TurnstileChallenge.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useEffect, useId, useRef, useState } from 'react'; + +type TurnstileApi = { + render: (element: HTMLElement, options: Record) => string; + remove?: (widgetId: string) => void; +}; + +declare global { + interface Window { + turnstile?: TurnstileApi; + } +} + +interface TurnstileChallengeProps { + onToken: (token: string) => void; + onExpired?: () => void; +} + +const SCRIPT_ID = 'cloudflare-turnstile-script'; + +/** + * Loads Turnstile only after a route asks for a challenge. No CAPTCHA script is + * loaded on ordinary sign-in/reset traffic. The verification token is still + * meaningless until the server exchanges it using TURNSTILE_SECRET_KEY. + */ +export function TurnstileChallenge({ onToken, onExpired }: TurnstileChallengeProps) { + const containerId = useId().replace(/:/g, ''); + const widgetId = useRef(null); + const [ready, setReady] = useState(false); + const [unavailable, setUnavailable] = useState(false); + const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY?.trim(); + + useEffect(() => { + if (!siteKey) { + setUnavailable(true); + return; + } + + const render = () => { + const element = document.getElementById(containerId); + if (!element || !window.turnstile || widgetId.current) return; + widgetId.current = window.turnstile.render(element, { + sitekey: siteKey, + theme: 'light', + callback: (token: string) => onToken(token), + 'expired-callback': () => onExpired?.(), + 'error-callback': () => setUnavailable(true), + }); + setReady(true); + }; + + const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null; + if (window.turnstile) { + render(); + } else if (existing) { + existing.addEventListener('load', render, { once: true }); + existing.addEventListener('error', () => setUnavailable(true), { once: true }); + } else { + const script = document.createElement('script'); + script.id = SCRIPT_ID; + script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + script.async = true; + script.defer = true; + script.onload = render; + script.onerror = () => setUnavailable(true); + document.head.appendChild(script); + } + + return () => { + if (widgetId.current && window.turnstile?.remove) window.turnstile.remove(widgetId.current); + widgetId.current = null; + }; + }, [containerId, onExpired, onToken, siteKey]); + + if (unavailable) { + return ( +

+ Security verification is unavailable. Please try again later. +

+ ); + } + + return ( +
+

Complete the security check to continue.

+
+ {!ready &&

Loading security check…

} +
+ ); +} diff --git a/compliance-firewall-agent/components/dashboard/OperatorDashboard.tsx b/compliance-firewall-agent/components/dashboard/OperatorDashboard.tsx index 21040fcf..a9d2fc2b 100644 --- a/compliance-firewall-agent/components/dashboard/OperatorDashboard.tsx +++ b/compliance-firewall-agent/components/dashboard/OperatorDashboard.tsx @@ -38,6 +38,7 @@ import { LCC_CSS } from './lccStyles' import { OperatorOverview } from './OperatorOverview' import { ProvenancePanel } from './ProvenancePanel' import { BrainQuickAsk, FirstRunChecklist } from './operator/OperatorSlots' +import { OperationalReadiness } from './operator/OperationalReadiness' import { getThemeById, consoleThemeVars } from '@/lib/dashboard/design-themes' import { useDashboardPrefs, SIGNED_IN_STRIPPED_HIDDEN } from '@/lib/dashboard/use-dashboard-prefs' import type { ProvenanceId } from './dataProvenance' @@ -122,6 +123,7 @@ export function OperatorDashboard({ name, connected = false }: { onTab={go} brainSlot={ router.push(`${BRAIN_ROUTE}?q=${encodeURIComponent(q)}`)} />} checklistSlot={} + readinessSlot={ go('settings')} />} /> {/* live: this dashboard only ever renders behind the auth gate, so every diff --git a/compliance-firewall-agent/components/dashboard/OperatorOverview.tsx b/compliance-firewall-agent/components/dashboard/OperatorOverview.tsx index 35a5b65c..deb6bfab 100644 --- a/compliance-firewall-agent/components/dashboard/OperatorOverview.tsx +++ b/compliance-firewall-agent/components/dashboard/OperatorOverview.tsx @@ -61,7 +61,7 @@ const WINDOWS: { value: TelemetryWindow; label: string }[] = [ { value: 30, label: 'Last 30 days' }, ] -export function OperatorOverview({ prefs, editing, onSource, onTab, brainSlot, checklistSlot, name }: { +export function OperatorOverview({ prefs, editing, onSource, onTab, brainSlot, checklistSlot, readinessSlot, name }: { prefs: DashboardPrefs editing: boolean onSource: (id: ProvenanceId) => void @@ -82,6 +82,8 @@ export function OperatorOverview({ prefs, editing, onSource, onTab, brainSlot, c brainSlot: React.ReactNode /** Likewise the first-run checklist, which drives activation to the PDF. */ checklistSlot: React.ReactNode + /** Authenticated-shell-only control status. Never render tenant health in a public preview. */ + readinessSlot?: React.ReactNode }) { const t = useOperatorTelemetry() const [filter, setFilter] = useState<'all' | EventOutcome>('all') @@ -167,6 +169,13 @@ export function OperatorOverview({ prefs, editing, onSource, onTab, brainSlot, c )}
+ {/* Configuration truth comes before risk charts when this is the live, + authenticated shell. Public previews intentionally omit the slot. */} + {readinessSlot && ( +
+ {readinessSlot} +
+ )}
diff --git a/compliance-firewall-agent/components/dashboard/operator/OperationalReadiness.tsx b/compliance-firewall-agent/components/dashboard/operator/OperationalReadiness.tsx new file mode 100644 index 00000000..72499b03 --- /dev/null +++ b/compliance-firewall-agent/components/dashboard/operator/OperationalReadiness.tsx @@ -0,0 +1,104 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { AlertTriangle, ArrowRight, RefreshCw, ShieldCheck } from 'lucide-react' + +type HealthPayload = { + services?: Record + degraded?: string[] +} + +type ReadinessItem = { + key: string + label: string + healthy: string[] + help: string +} + +const READINESS_ITEMS: ReadinessItem[] = [ + { key: 'rate_limit_store', label: 'Shared rate limits', healthy: ['shared'], help: 'Apply the shared limiter migration before relying on a single global ceiling.' }, + { key: 'auth_lockout_store', label: 'Account lockout', healthy: ['enforcing'], help: 'Apply the lockout migration so repeated failures are not forgotten between instances.' }, + { key: 'captcha', label: 'CAPTCHA escalation', healthy: ['enforcing'], help: 'Set the Turnstile secret and site key before handling challenged authentication attempts.' }, + { key: 'reset_code_pepper', label: 'Recovery-code protection', healthy: ['set'], help: 'Set the dedicated recovery-code pepper before enabling password reset in production.' }, + { key: 'quarantine_encryption', label: 'Quarantine encryption', healthy: ['enabled'], help: 'Set a valid 64-hex encryption key so sensitive quarantine writes remain available.' }, +] + +/** + * A post-login operator panel backed only by `/api/health`. It reports control + * state, never values: no secret, provider token, email, prompt, or audit data + * enters the browser. Unknown data stays unknown rather than being rendered as a + * reassuring green success state. + */ +export function OperationalReadiness({ onOpenSettings }: { onOpenSettings: () => void }) { + const [payload, setPayload] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + const [checkedAt, setCheckedAt] = useState(null) + + const refresh = useCallback(async () => { + setLoading(true) + setError(false) + try { + const response = await fetch('/api/health', { cache: 'no-store' }) + if (!response.ok) throw new Error('health request failed') + const next = (await response.json()) as HealthPayload + setPayload(next) + setCheckedAt(Date.now()) + } catch { + setPayload(null) + setError(true) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { void refresh() }, [refresh]) + + const services = payload?.services ?? {} + const readyCount = READINESS_ITEMS.filter((item) => item.healthy.includes(services[item.key] ?? '')).length + const degradedCount = payload?.degraded?.length ?? 0 + + return ( +
+
+
+

Operational readiness

+

+ {loading ? 'Checking live control state…' : error ? 'Control state could not be loaded' : `${readyCount}/${READINESS_ITEMS.length} core controls ready · ${degradedCount} system conditions need attention`} +

+
+ +
+ +
+ {error ? ( +
Health data is unavailable. Do not assume controls are active; refresh or check deployment settings.
+ ) : READINESS_ITEMS.map((item) => { + const status = services[item.key] + const ready = item.healthy.includes(status ?? '') + return ( +
+
+ {item.label} + + {loading ? 'Checking…' : ready ? 'Ready' : status ? `${status.replace(/_/g, ' ')}` : 'Unknown'} + + {!loading && !ready && {item.help}} +
+ + {ready ? 'Ready' : 'Review'} + +
+ ) + })} +
+ +
+ {checkedAt ? `Checked ${new Date(checkedAt).toLocaleTimeString()}` : 'No result yet'} + +
+
+ ) +} diff --git a/compliance-firewall-agent/docs/MARKET-OUTREACH-STRATEGY.md b/compliance-firewall-agent/docs/MARKET-OUTREACH-STRATEGY.md new file mode 100644 index 00000000..a2b69581 --- /dev/null +++ b/compliance-firewall-agent/docs/MARKET-OUTREACH-STRATEGY.md @@ -0,0 +1,67 @@ +# Market and Outreach Strategy + +**Research date:** 17 August 2026 +**Purpose:** Define an evidence-led launch motion for HoundShield without overstating product scope or regulatory conclusions. + +## Market signal + +The AI data-protection market is converging on three buyer expectations: visibility across AI activity, enforcement at the point of data movement, and evidence for an existing governance programme. Nightfall markets broad coverage across AI agents, MCP servers, endpoints, SaaS, email, and browsers, with endpoint/browser agents, integrations, detection, lineage, remediation, and customer proof.[1] Microsoft Purview positions AI risk management as an extension of data classification, DLP, audit, retention, eDiscovery, endpoint controls, and DSPM across Microsoft and supported enterprise/third-party AI contexts.[2] + +For defence contractors handling CUI, the architecture and system boundary are the decision point. The Cloud Security Alliance’s 2026 CMMC Level 2 guide explains that AI platforms processing, storing, or transmitting CUI must be evaluated under the applicable cloud-use requirements; it recommends approved boundaries, technical safeguards, documented SSP scope, access controls, auditability, and ongoing monitoring.[3] HoundShield should therefore not promise universal CMMC compliance. It should offer a **self-hosted control path and an evidence-oriented assessment** that helps the buyer document how compatible AI traffic is governed within their chosen boundary. + +## Positioning decision + +> **HoundShield is not “Nightfall for smaller companies” and not “a replacement for Microsoft Purview.” It is a self-hosted AI prompt-control and evidence workflow for regulated teams that need to prove what crosses their approved boundary.** + +| Buyer situation | Correct HoundShield message | Do not claim | +|---|---|---| +| Defence contractor preparing for CMMC Level 2 | “Map approved AI use, put compatible traffic through a self-hosted control path, and assemble evidence for your SSP and assessment.” | “This makes you CMMC compliant” or “all CUI is safe in every AI tool.” | +| Microsoft 365-heavy organisation | “Use HoundShield alongside Purview when you need an explicit control path for AI services and developer tools outside the M365 productivity surface.” | “Purview cannot protect third-party AI” or “replace Purview.” | +| SaaS/security team comparing broad DLP | “If browser/endpoint/SaaS-wide coverage is the priority, evaluate a broad DLP platform. If the priority is a contained AI prompt boundary and evidence workflow, evaluate HoundShield.” | “HoundShield has equivalent enterprise endpoint/browser coverage.” | +| HIPAA-sensitive team | “Use the self-hosted path to assess compatible prompt controls and document your data flow; validate every workload with your privacy/security programme.” | “HIPAA-certified” or “the hosted demo is appropriate for PHI.” | + +## ICP and outreach sequence + +The recommended first ICP is a **20–200 person defence-industrial-base contractor** that is actively preparing an SSP/POA&M or CMMC assessment, has visible employee adoption of external AI tools, and lacks a clean answer to “where does prompt data go?” This buyer has a near-term proof requirement, a bounded evaluation path, and a reason to value local deployment over broad enterprise DLP features. + +| Step | Asset or action | Success condition | +|---|---|---| +| 1. Segment | Build a consented/legitimate-business-interest list by role: CISO/IT director, CMMC programme owner, security consultant, or MSP serving DIB clients. | Every record has source, role, company, region, and suppression status. | +| 2. Educate | Send a short, plain-language note: “Can you show where AI prompts leave your approved boundary?” Link to a sourced boundary checklist, not a product deck. | Positive reply, checklist download, or self-selected assessment interest. | +| 3. Diagnose | Offer a 20-minute architecture review or a fixed-scope $499 assessment. Ask about AI services, data types, authorised boundary, SSP owner, and evidence gap. | Prospect chooses a bounded next step. | +| 4. Prove | Run a self-hosted, non-CUI test with a customer-controlled sample; show policy, event, and evidence output. | Buyer validates fit and identifies deployment owner. | +| 5. Convert | Deliver the evidence-oriented report with scope caveats, findings, remediation order, and a 30-day implementation plan. | Paid report or implementation engagement. | + +## Email readiness gate + +Do not send outbound email until the release checklist confirms sender authentication, recipient suppression, unsubscribe processing, physical address, reply handling, and measurement. Google requires sender authentication and specifies stronger SPF/DKIM/DMARC and unsubscribe requirements for bulk senders; it also recommends gradual volume increases and low complaint rates.[4] Yahoo has parallel authentication, alignment, unsubscribe, and complaint-rate requirements.[5] The US FTC states CAN-SPAM applies to commercial email, including B2B, and requires truthful routing/subjects, a physical address, a clear opt-out mechanism, and honoring opt-outs within ten business days.[6] + +## First email: approved style, not a send instruction + +**Subject:** Where do AI prompts leave your approved boundary? + +Hi {{FirstName}}, + +Teams preparing for CMMC can usually point to their AI policy. Fewer can show the architecture, enforcement point, and evidence trail for prompts sent to external AI tools. + +We built a short boundary checklist for teams that need to document approved AI use without routing sensitive prompt content through another inspection cloud. If that question is active for {{Company}}, I can send it over or walk through it in 20 minutes. + +{{SenderName}} +{{Company}} +{{PostalAddress}} +Unsubscribe: {{OneClickUnsubscribeUrl}} + +This is a copy template only. It must not be sent until the legal, deliverability, suppression, and security gates above are verified. + +## Evidence requirements for website comparison pages + +Every comparison row must include a source URL and review date. Use scope-qualified wording such as “HoundShield’s self-hosted path is designed for compatible AI prompt traffic” rather than absolutes such as “only HoundShield” or “competitor X cannot.” Update or remove claims when the source changes. + +## References + +[1]: https://www.nightfall.ai/ "Nightfall — AI Data Security & DLP" +[2]: https://learn.microsoft.com/en-us/purview/ai-microsoft-purview "Microsoft Purview — AI data security and compliance" +[3]: https://cloudsecurityalliance.org/blog/2026/01/23/securing-ai-in-cmmc-level-2-environments-a-strategic-guide-for-cisos-and-cloud-security-engineers "Cloud Security Alliance — Securing AI in CMMC Level 2 Environments" +[4]: https://support.google.com/mail/answer/81126?hl=en "Google — Email sender guidelines" +[5]: https://senders.yahooinc.com/best-practices/ "Yahoo — Sender requirements and recommendations" +[6]: https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business "FTC — CAN-SPAM compliance guide" diff --git a/compliance-firewall-agent/docs/PRE-DEPLOYMENT-REVIEW.md b/compliance-firewall-agent/docs/PRE-DEPLOYMENT-REVIEW.md new file mode 100644 index 00000000..af47ff6c --- /dev/null +++ b/compliance-firewall-agent/docs/PRE-DEPLOYMENT-REVIEW.md @@ -0,0 +1,120 @@ +# HoundShield Pre-Deployment Review + +**Branch:** `security/p0-p1-auth-and-launch-readiness` +**Review state:** Ready for owner review; **not yet committed, pull-requested, deployed, or production-migrated**. +**Temporary preview:** https://3000-iixp7w1waypz179jj7bqg-e9d106da.sg1.manus.computer/ + +## Design read + +> **Reading this as a regulated B2B SaaS landing page and authenticated operator workspace for compliance owners, with a trust-first, evidence-led language and restrained steel-and-cream visual system.** + +The work preserves the existing Direction-A visual hierarchy. It removes claims that overstate competitor limitations or product/regulatory outcomes, without turning the site into a generic redesign. + +## What changed on the public website + +| Area | Previous risk | Change now visible in preview | Buyer benefit | +|---|---|---|---| +| Hero | Led with named AI vendors and broad assertions about all traffic. | Leads with **“Keep regulated data inside your control boundary.”** | Frames the first buyer question correctly: architecture and authorised boundary. | +| Hero proof points | Contained absolute statements that could be read as universal. | Distinguishes **hosted evaluation** from a **self-hosted path for sensitive workloads** and calls the PDF evidence-oriented. | Sets truthful expectations before a buyer enters a demo. | +| Market statistic | Displayed an externally attributed percentage without inline context. | Replaces it with two tangible deployment paths: hosted evaluation and self-hosted control. | Avoids a fragile, unqualified proof point. | +| Comparison section | Claimed competitors were architecturally disqualified or entirely unable to protect external AI usage. | Recasts Nightfall, Purview, and HoundShield as different deployment/control models with explicit scope. | Improves factual durability and credibility with security buyers. | +| Conversion path | Existing CTA topology remains intact. | Primary CTA is **Explore the control boundary**; secondary CTA remains the $499 report. | Preserves immediate evaluation and paid-assessment paths. | + +The revised homepage rendered successfully in the temporary preview. The above-the-fold composition remains intact: a one-line value proposition, short explanatory copy, two CTAs, a four-point trust row, and the labelled product demo below it. + +## What changed after login + +The authenticated dashboard adds a new **Operational readiness** panel immediately below the toolbar. It is intentionally present only in `OperatorDashboard`, not in the public/demo overview, to avoid showing tenant-control state in a marketing surface. + +| Panel behaviour | Implementation | +|---|---| +| Uses real state | Fetches `/api/health` with `no-store`; no seeded or inferred control status is shown. | +| Shows control readiness | Reports shared rate limits, account lockout, CAPTCHA escalation, reset-code protection, and quarantine encryption. | +| Fails honestly | Shows `Unknown` or an explicit unavailable state if health data cannot be retrieved; it never paints an unverified control green. | +| Protects secrets | Health data includes only control state—not keys, emails, prompts, audit content, or provider credentials. | +| Provides a next action | A direct **Open settings** action leads the operator to the existing configuration route. | + +## Security remediation delivered + +| Priority | Delivered change | +|---|---| +| P0: reset token exposure | Replaces query-string recovery links with 128-bit random, email-delivered reset codes. Raw codes are never persisted, logged, or URL-borne. | +| P0: reset replay and expiry | Adds migration `035_password_reset_codes.sql`: keyed HMAC digest at rest, 60-minute maximum expiry, one active code per user, and atomic single-use redemption. | +| P0: password update boundary | Adds `POST /api/auth/reset-password/complete`; password policy, code redemption, CAPTCHA/rate controls, auditing, and Supabase’s provider-managed slow password KDF remain server-side. | +| P0: enumeration | Retains neutral responses and timing settlement for well-formed reset requests; known and unknown addresses do not receive different JSON outcomes. | +| P0: abuse control | Adds reset-completion ceilings and accessible Turnstile handling. A required CAPTCHA challenge fails closed if server configuration is missing. | +| P1: deployment safety | Makes TypeScript errors release-blocking in `next build`; removes the production authentication-route rollback that allowed a browser-direct fallback. | +| P1: operational visibility | `/api/health` now exposes reset-code-secret and corrected CAPTCHA readiness state, without exposing values. | +| P1: documentation | Adds password-reset architecture, production checklist, and sourced market/outreach strategy documents. | + +## Validation evidence + +| Gate | Result | +|---|---| +| Focused authentication suite | **30 files, 369 tests passed** after reset-flow implementation. | +| P0 regression suite | **4 files, 59 tests passed.** | +| Health-status suite | **19 tests passed.** | +| Website/documentation regressions | **27 tests passed.** | +| Dashboard shell regression | **59 tests passed.** | +| Full suite | **205 files, 2,904 tests passed.** | +| TypeScript | **Passed** with `ignoreBuildErrors: false`. | +| Production build | **Passed**; all 233 static pages generated and route manifest completed. | +| Diff hygiene | `git diff --check` passed. | +| Dependency production audit | Earlier audit returned zero high-severity production findings. | + +## Non-blocking engineering warnings retained for follow-up + +| Warning | Impact | Disposition | +|---|---|---| +| Next.js middleware convention is deprecated in favour of `proxy`. | Build passes today; future framework migration needed. | Do not bundle a high-risk edge-routing migration into this security release. Track as a dedicated P1 follow-up. | +| Better Auth/Jose emits Edge-runtime compatibility warnings in the build. | Build passes; existing dependency/runtime compatibility signal. | Keep as release evidence and validate edge paths after provider consolidation. | +| Lint reports 35 pre-existing warning-only issues, primarily React effect-state patterns. | No lint errors; does not block build. | Address as a separate cleanup PR to avoid widening the authentication release. | + +## External release prerequisites + +A code merge alone is not a safe customer launch. Before deployment, the owner must confirm that production has the required secrets and migrations, using the new `docs/PRODUCTION-RELEASE-CHECKLIST.md`. + +| Must be confirmed before production release | Why | +|---|---| +| Apply migration 035. | Required for reset-code issuance and atomic consumption. | +| Set `AUTH_RESET_CODE_PEPPER`. | Required to protect code digests; reset issuance fails safely without it. | +| Set both Turnstile keys. | Required when anti-abuse escalation challenges a user. | +| Verify Confirm Email and leaked-password protection in Supabase. | Ensures new accounts remain unverified until email ownership is confirmed and provider password checks are active. | +| Verify sender authentication, suppression, unsubscribe, postal address, and migration 034. | Required before commercial outreach begins. | +| Perform a disposable-account signup → verification → login → reset-code → sign-in smoke test. | Validates the integration without exposing a customer account or secret. | + +## Decision requested + +Please review the temporary homepage preview and this document. If you approve the website/dashboard copy, security implementation, and explicit production prerequisites, reply **“Go: create the PR; do not deploy until configuration is verified”** or **“Go: create the PR and deploy after the checklist is verified.”** + +The latter still requires configuration verification before production action; no PR or deployment will be performed without your explicit release confirmation. + + +## Production preflight update — 17 August 2026 + +Read-only checks against the connected production services found the following release state. + +| System | Verified state | Release implication | +|---|---|---| +| Supabase project `HoundShield` (`qifynzuyrdxmxlumpsrq`) | Active and healthy. | Production database is reachable for the approved migration step. | +| Production migration history | Includes 028, 031, and 032; does **not** include 034, 035, or 036. | Apply 035 before enabling code-only reset; apply 036 to remove public execution of privileged functions; apply 034 before commercial email outreach. | +| Supabase security advisor | Reports public execution warnings for `auth_audit_events_immutable`, `consume_rate_limit`, and `sweep_rate_limit_buckets`. | Migration 036 is included in this release to revoke anon/authenticated access while retaining service-role execution. Re-run advisor after migration. | +| Supabase security advisor | Reports `auth_leaked_password_protection` disabled. | **Manual Supabase Auth dashboard release gate:** enable leaked-password protection before production auth launch. This cannot be safely toggled through the repository migration. | +| Supabase RLS advisory | RLS-enabled/no-policy information notices exist for protected audit, lockout, rate-limit, and Better Auth tables. | Expected defensive posture: no PostgREST policy means anon/authenticated clients are denied; service-role/server access is intentional. | +| Vercel project | Project `compliance-firewall-agent`, Next.js, owns `www.houndshield.com` and `houndshield.com`. | The permanent site is correctly linked to the intended project. | +| Vercel deployment history | Latest production deployment is `READY`; the latest branch preview is also `READY`. | The historical build outage is resolved. A new production deployment must wait for the approved commit plus database/configuration gates above. | + +> **Current release decision:** the website and code are ready to make permanent through a pull request, but production deployment remains blocked until migrations 035 and 036 are applied, leaked-password protection is enabled, and the required production secrets/checklist entries are verified. + +### Current production evidence sources + +[1]: https://supabase.com/docs/guides/database/database-linter?lint=0028_anon_security_definer_function_executable "Supabase — Public SECURITY DEFINER function advisory" +[2]: https://supabase.com/docs/guides/auth/password-security#password-strength-and-leaked-password-protection "Supabase — Leaked password protection" +[3]: https://vercel.com/thecelestialmismatch-9194s-projects/compliance-firewall-agent "Vercel — HoundShield project" + +| Vercel runtime errors, last 7 days | `/api/cron/email-drip` recorded two `CRON_SECRET not set` errors, most recently 16 August 2026. | Keep drip automation disabled; set `CRON_SECRET` only when the sender, unsubscribe, suppression, and commercial-email checks are complete. Do not treat this as a reason to enable outreach before launch gates are met. | + + +### Final local validation note + +The full suite completed on the exact release worktree with **206 files / 2,908 tests passed**, and `npx tsc --noEmit` completed successfully. The application source last changed before the successful production build recorded in this review; afterward, the only additions were migration 036, its static SQL contract test, and release documentation. A final repeat `next build` was interrupted by the sandbox with `SIGTERM` while the environment was under high memory pressure, before compilation completed; it did not report a TypeScript, application, or bundler error. The deployment platform must still run its own build on the proposed commit before a production promotion. diff --git a/compliance-firewall-agent/docs/PRODUCTION-RELEASE-CHECKLIST.md b/compliance-firewall-agent/docs/PRODUCTION-RELEASE-CHECKLIST.md new file mode 100644 index 00000000..2d64d7f5 --- /dev/null +++ b/compliance-firewall-agent/docs/PRODUCTION-RELEASE-CHECKLIST.md @@ -0,0 +1,40 @@ +# Production Release Checklist + +This checklist is a **deployment gate**, not a retrospective. A release is not approved until every applicable item is evidenced in the PR/release record. + +## Security and authentication + +| Gate | Evidence required | +|---|---| +| Password-reset migration | `035_password_reset_codes.sql` is applied and `password_reset_codes`, `issue_password_reset_code`, and `consume_password_reset_code` exist. Verify RLS and service-role-only function access. | +| Privileged database RPCs | `036_revoke_public_security_definer.sql` is applied. Re-run Supabase security advisors and confirm `auth_audit_events_immutable`, `consume_rate_limit`, and `sweep_rate_limit_buckets` are not executable by `anon` or `authenticated`; service-role calls still work. | +| Recovery-code secret | `AUTH_RESET_CODE_PEPPER` is set to a separate, random 32-byte-or-more secret. Do not reuse it as a browser value or record it in tickets. | +| CAPTCHA | `TURNSTILE_SECRET_KEY` and matching `NEXT_PUBLIC_TURNSTILE_SITE_KEY` are set. Complete a real Turnstile challenge in a non-customer test account. | +| Email ownership | Supabase **Confirm email** is enabled. A newly created account cannot reach a guarded route before verification. | +| Password quality | Supabase leaked-password protection is enabled. Test a known-breached password against a disposable account without retaining the password in logs. | +| Session controls | Confirm production `Secure` cookies, JWT expiry, refresh-token rotation, and session revocation policy in Supabase Authentication settings. | +| Audit trail | Migrations 031 and 032 are applied; audit rows are append-only, have RLS, and contain no raw email, IP, password, code, or reset token. | +| Shared abuse controls | Migration 028 is applied and `/api/health` reports `rate_limit_store: shared`, `auth_lockout_store: enforcing`, `captcha: enforcing`, and `reset_code_pepper: set`. | + +## Sensitive-data handling + +| Gate | Evidence required | +|---|---| +| Quarantine encryption | `ENCRYPTION_KEY` is exactly 64 hexadecimal characters; `/api/health` reports `quarantine_encryption: enabled`. | +| Secrets | `SUPABASE_SERVICE_ROLE_KEY`, `RESEND_API_KEY`, `STRIPE_WEBHOOK_SECRET`, `OPENROUTER_API_KEY`, and `CRON_SECRET` are present only as production environment secrets. | +| Dependency gate | `npm audit --omit=dev --audit-level=high` returns zero findings; patch updates are reviewed separately. | +| Build gate | `npm run lint`, `npx tsc --noEmit`, the focused auth suite, full test suite, and `npm run build` all pass on the commit to deploy. | + +## Revenue, website, and outreach + +| Gate | Evidence required | +|---|---| +| Checkout | Test Stripe webhook event records a non-customer test order, sends the receipt, and does not log buyer email. | +| Website claims | Hosted-vs-self-hosted/CUI scope is accurate; comparison claims are sourced and date-stamped; illustrative dashboards are labelled. | +| Marketing legality | `MARKETING_POSTAL_ADDRESS` is configured, migration 034 is applied, global suppression/unsubscribe works, and no campaign is queued until this is verified. | +| Sender authentication | SPF, DKIM, aligned DMARC, reply handling, unsubscribe, bounce/complaint suppression, and transactional/marketing stream separation are verified for the sending domain. | +| Deployment topology | Vercel Root Directory is `compliance-firewall-agent`; the legacy root `vercel.json` change is coordinated with this setting; canonical domain redirect smoke tests pass. | + +## Final smoke test + +Use a non-customer, disposable test identity. Exercise signup → email verification → login → intentional failed-login threshold → CAPTCHA → unlock/cooldown → password-reset code request → code-only reset completion → login with the new password → logout. Record only pass/fail and timestamps; never store the password, code, or raw email in the release artifact. diff --git a/compliance-firewall-agent/docs/auth-password-reset.md b/compliance-firewall-agent/docs/auth-password-reset.md index 387b3974..53245784 100644 --- a/compliance-firewall-agent/docs/auth-password-reset.md +++ b/compliance-firewall-agent/docs/auth-password-reset.md @@ -1,217 +1,50 @@ -# Auth: password reset, GitHub OAuth, and the "name before login" question +# Password Reset Architecture -HoundShield runs **two** auth clients behind one UI. Which is active is decided -at runtime by `isBetterAuthClientEnabled()`: +## Purpose -- **Supabase** (the live provider today) — email/password + OAuth via Supabase Auth. -- **Better Auth** (self-hosted) — used when its env is configured. +HoundShield now uses an **application-owned, code-entry password-reset flow** for the Supabase authentication path. A recovery bearer credential is never placed in an email URL, query parameter, redirect target, browser history entry, analytics event, or application log. -## Password reset — the bug and the fix +> The user receives a one-time code in the email body, then submits the code, their email address, and a new password to a protected server endpoint. -**Symptom (founder report):** "When I click the reset-password link it goes -straight to the homepage — I never get to set a new password." +## Security invariants -**Root cause:** the Supabase reset email pointed at -`/auth/callback?redirect=/console`. `/auth/callback` exchanges the recovery code -into a **session** and forwards to `redirect` — so the user was silently logged -in and dropped on the console, skipping the password step entirely. Worse, the -`/reset-password` page was written **Better-Auth-only**: it required a `?token=` -that Supabase reset links never carry, so even reaching it showed "Link expired." +| Property | Implementation | +|---|---| +| Cryptographic randomness | `lib/auth/password-reset-codes.ts` creates a 128-bit code with `crypto.randomBytes(16)`. | +| No raw secret at rest | `AUTH_RESET_CODE_PEPPER` domain-separates an HMAC-SHA-256 digest; only that digest is stored. | +| One-hour maximum lifetime | Migration `035_password_reset_codes.sql` constrains expiration to at most 60 minutes. | +| Single use | `consume_password_reset_code` uses one conditional `UPDATE … RETURNING`; only one concurrent request can redeem a code. | +| Replacement behaviour | Issuing a new code marks prior unused codes for the same user as used. | +| No URL secret | The reset route does not call `generateLink`, build `/auth/confirm` URLs, or emit `token_hash`. Legacy recovery URLs are rejected by `/auth/confirm`. | +| Server-side password update | `POST /api/auth/reset-password/complete` validates input, consumes the code, and calls Supabase Auth’s privileged password-update API. Application code never persists a plaintext password or a fast password hash. | +| Enumeration resistance | A well-formed reset request returns the same `200 { ok: true }` response after timing settlement whether the address is known, unknown, throttled, or unavailable. Delivery occurs only for a resolvable account. | +| Abuse protection | Request and completion endpoints have IP/account rate limits, CAPTCHA escalation, and lockout/cooldown handling. CAPTCHA verification fails closed after escalation. | +| Auditability | Request and completion attempts create privacy-safe append-only audit events. Events contain hashes and coarse metadata only—never email, code, password, reset link, or token. | -**Fix (this PR):** +## Flow -1. `app/forgot-password/page.tsx` (Supabase path) now sends the recovery link to - `/auth/callback?redirect=/reset-password` — the user lands on the set-password - page, not the console. -2. `/auth/callback` still exchanges the recovery code into a session, then - forwards to `/reset-password`. That recovery session is what authorizes the - password change. -3. `app/reset-password/page.tsx` now supports **both** providers: - - **Better Auth:** `?token=` → `authClient.resetPassword({ newPassword, token })`. - - **Supabase:** confirms the recovery session, then - `supabase.auth.updateUser({ password })`. - - A `Verifying your reset link…` state shows while the Supabase session - resolves, so a valid link never flashes "expired." +1. The user submits an email to `/forgot-password`. +2. `POST /api/auth/reset-password` normalizes and rate-limits the request, applies CAPTCHA escalation when required, records a privacy-safe audit event, and returns the neutral result after timing settlement. +3. For a known profile, `issue_password_reset_code` resolves the user ID inside a service-role-only Postgres function, invalidates prior unused codes, and stores an HMAC digest with a maximum 60-minute expiry. +4. The code is dispatched through Resend after the response. It appears only in the email body. +5. The user enters email, code, and a policy-compliant password at `/reset-password`. +6. `POST /api/auth/reset-password/complete` repeats abuse controls, atomically consumes the code, invokes Supabase Auth to apply its password KDF, clears the failed-attempt state, and records completion. Invalid, expired, and used codes receive the same neutral failure. -The branch decision is a pure, unit-tested function: -`lib/auth/reset-password-state.ts` (`resetView`) — guarded by -`lib/auth/__tests__/reset-password-state.test.ts`. +## Required production configuration -## Symptom recurred 2026-07-22 — and why code alone can't close it +| Requirement | Why it is required | +|---|---| +| `AUTH_RESET_CODE_PEPPER` | A separate 32-byte-or-more random server secret for recovery-code HMACs. Do not log it or expose it to the browser. | +| `TURNSTILE_SECRET_KEY` and `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Required to complete escalated CAPTCHA. Missing server configuration fails closed after escalation. | +| `RESEND_API_KEY` and verified transactional sender domain | Required to deliver the reset code. | +| `SUPABASE_SERVICE_ROLE_KEY` | Required only on the server for the code RPC and privileged password update. | +| Migration `035_password_reset_codes.sql` | Creates the hash-only store and atomic issue/consume functions. | +| Supabase Confirm Email + leaked-password protection | New accounts remain inactive until ownership is verified and breached passwords are rejected by the provider. | -Founder report: *"nothing happening — the reset link is sent by Supabase (not -HoundShield) and it takes me to the home page."* Two distinct problems, **both -rooted in the Supabase dashboard, not the app code** (verified: #224 / `baa7787` -is on `origin/main` and deployed, and every link in the code chain is correct): +## Operational rules -1. **"…takes me to the home page."** The default email template's link routes - through GoTrue, which redirects to the `redirect_to` (our - `/auth/callback?redirect=/reset-password`). If that URL is **not in the - Redirect-URL allowlist, GoTrue falls back to the Site URL** (the homepage). - Landing on `/` — not `/login?error=auth_failed` — is the tell: an allowlist - miss, not a code failure. (The dead OAuth buttons share the same `/auth/callback` - and confirm the allowlist is incomplete.) -2. **"…sent by Supabase, not HoundShield."** Auth emails come from the Supabase - project's **email templates + SMTP sender**, not the app's Resend integration. - Unbranded sender + Supabase default template = a config gap no app code touches. +Do not add raw recovery codes to support tickets, logs, analytics, email subjects, URL parameters, redirect URLs, exception payloads, or audit details. Do not restore the old `admin.generateLink({ type: 'recovery' })` route without a formal security review. A password-reset completion provider error consumes the code rather than allowing replay; support should instruct the user to request a new code. -### The permanent fix — the allowlist-immune branded flow (this PR) +## Validation required before release -Rather than depend on the founder getting the allowlist wildcard exactly right, -this PR adds the **SSR-canonical `token_hash` route** so the recovery link can -target the Site URL directly (which is always trusted — it *cannot* fall back to -the homepage): - -- New route `app/auth/confirm/route.ts` reads `?token_hash&type&next`, calls - `supabase.auth.verifyOtp(...)` to establish the recovery session server-side, - then redirects (recovery → `/reset-password`). Redirect decisions are the pure, - unit-tested `lib/auth/confirm-redirect.ts` (`confirmRedirect` / - `confirmFailureRedirect`), guarded by `__tests__/confirm-redirect.test.ts`. -- `/auth/callback` (PKCE `?code=`) is unchanged — it still serves OAuth and the - default email template. -- `/reset-password` is unchanged — it consumes the session and calls - `updateUser({ password })`. - -### Founder dashboard steps (one-time — REQUIRED; the code above does nothing until these land) - -**A. Redirect-URL allowlist + Site URL** — Supabase → Authentication → URL -Configuration. Set **Site URL** to `https://www.houndshield.com`. Add, using -`/**` wildcards (the links carry query strings a bare path can miss): - -- `https://houndshield.com/**` -- `https://www.houndshield.com/**` -- `http://localhost:3000/**` (local dev) - -This alone fixes the "homepage" symptom for the current default-template flow and -unblocks Google/GitHub OAuth (same callback). - -**B. Branded recovery email template** — Supabase → Authentication → Email -Templates → **Reset Password**. Replace the body with the HoundShield-branded -template below. It points at `/auth/confirm` (the new route), so it is immune to -the allowlist fallback in step A: - -```html -

Reset your HoundShield password

-

- We received a request to reset the password for your HoundShield account. - Click below to choose a new one. This link expires in 1 hour and can be used once. -

-

- - Set a new password - -

-

- Didn't request this? You can safely ignore this email — your password won't change.
- — HoundShield · AI Compliance Firewall -

-``` - -**C. Branded sender (fixes "sent by Supabase")** — Supabase → Project Settings → -Authentication → **SMTP Settings** → enable custom SMTP with Resend -(`smtp.resend.com`, port 465, user `resend`, password = a Resend API key), sender -`no-reply@houndshield.com`. Requires the houndshield.com domain verified in -Resend (it already is for the $499 sale alerts). Without this, mail still ships -from Supabase's shared sender and is rate-limited to a few per hour. - -> **Verification is founder-gated.** This flow cannot be verified end-to-end from -> the worktree: it runs against the **live** Supabase project, and we deliberately -> do not trigger real recovery emails against production (it would email a real -> account). Unit tests + `tsc` + build cover the code; the live click-through is -> the founder's step after A–C are applied. Test path: request a reset → -> confirm the email is HoundShield-branded → click → land on `/reset-password` -> (never `/`) → set password → sign in. - -### The zero-dashboard-dependency upgrade — reset works with NO founder config - -Steps A–C above make the **default Supabase-template** flow correct, but they -still require the founder to touch the dashboard. This iteration removes that -dependency entirely: the app now mints the recovery link **and** sends the email -itself, so a working reset needs zero Supabase-dashboard changes. - -- **New route `app/api/auth/reset-password/route.ts`** — calls - `supabase.auth.admin.generateLink({ type: 'recovery', email })`. `generateLink` - returns `data.properties.hashed_token` **without sending any Supabase email**. - The route builds `/auth/confirm?token_hash=…&type=recovery&next=/reset-password` - and sends it through the app's **Resend** integration - (`sendPasswordResetEmail` — the same branded shell used for $499 sale alerts). -- **New helper `lib/auth/recovery-link.ts`** — `recoveryRequestSchema` (trim + - lowercase + `.email()`) and the pure `buildRecoveryConfirmUrl(base, tokenHash)`. - Guarded by `lib/auth/__tests__/recovery-link.test.ts`. -- **`app/forgot-password/page.tsx` (Supabase path)** now `POST`s to - `/api/auth/reset-password` instead of the client `resetPasswordForEmail`, so the - send goes through our route, not Supabase's mailer. - -**Why this needs none of A–C for reset:** - -- **No Redirect-URL allowlist (A):** the link targets `/auth/confirm`, a - same-origin **app route** that runs `verifyOtp` directly. It never passes - through GoTrue's `redirect_to` machinery, so there is nothing to allowlist and - nothing that can fall back to the homepage. -- **No template (B) / no SMTP (C):** the email is authored by the app and sent - from Resend (`noreply@houndshield.com`), so it is HoundShield-branded by - construction and never uses Supabase's template or shared sender. - -**Security properties:** - -- **Enumeration-safe** — always answers `200 { ok: true }` for a well-formed - email; a non-existent account errors inside `generateLink` and is swallowed - (no send). Only a malformed body returns `400`. -- **No timing oracle** — the Resend send runs in `after()` (off the response - path), so an existing account does not return slower than a non-existent one. - Regression-guarded in `route.test.ts` (a never-settling send must not delay the - `200`). -- **Anti email-bomb** — middleware rate-limits the route to - `PASSWORD_RESET_RATE_LIMIT_MAX = 5` requests/min per IP. - -**Requirements (Vercel prod env):** `SUPABASE_SERVICE_ROLE_KEY` (for -`generateLink`) and `RESEND_API_KEY` (the sender) must be set — both already are -for existing features, but confirm them: **every** misconfiguration here fails -*silently* (still `200 { ok: true }`, no email), i.e. it reproduces the exact -"nothing happening" symptom. `NEXT_PUBLIC_APP_URL` should also be set to the -canonical host so the link origin never falls back to a preview URL (absent it, -the route uses the request origin). If Supabase is unconfigured the route stays -enumeration-safe and sends nothing. The route logs its outcome server-side -(`recovery link dispatched` / `no recovery link minted` / `Supabase not -configured`) so a "nothing happening" report is a one-look diagnosis in the -Vercel function logs. - -**Steps A–C are now optional for password reset.** Step **A (allowlist)** is still -required for **OAuth** (Google/GitHub use `/auth/callback`). B and C only matter -if you ever fall back to Supabase's default recovery template. - -## GitHub OAuth — code is correct; enable it in the dashboards - -The button wiring is standard and correct -(`supabase.auth.signInWithOAuth({ provider: 'github', options: { redirectTo: -'/auth/callback?redirect=…' } })`). "GitHub sign-in isn't working" is a -**provider-configuration** gap, not a code bug. To turn it on (one-time, founder): - -1. **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** - - Homepage URL: `https://www.houndshield.com` - - Authorization callback URL: `https://.supabase.co/auth/v1/callback` - (the Supabase project ref, e.g. `qifynzuyrdxmxlumpsrq`). -2. Copy the **Client ID** and generate a **Client secret**. -3. **Supabase → Authentication → Providers → GitHub** → enable, paste Client ID + - secret, save. -4. **Supabase → Authentication → URL Configuration** → confirm - `https://www.houndshield.com/auth/callback` is in the redirect allowlist - (already there for Google OAuth). - -Until step 3 is done, GitHub sign-in returns to `/login?error=auth_failed` by -design (the callback's failure path). - -## "Brain AI shows my name before I log in" - -`/api/me` is strictly session-derived (`supabase.auth.getUser()` on the -server-verified cookie); it returns `{ authenticated: false }` for guests, and -the Brain AI greeting is **not** cached client-side. So a name appears **only -when a valid session cookie is present** — i.e., you are still signed in from a -previous session (sessions persist by design; that is not "before login"). - -**To confirm it's working correctly:** open an incognito window and open Brain -AI — it must greet generically ("Hi! I'm Brain AI…"), with no name. If a name -still appears in incognito, that is a real bug worth a screenshot. Otherwise, to -make the app "forget" you faster, use Sign out, or we can add a shorter session -TTL / "sign out everywhere" control. +The release suite must prove neutral known/unknown response shape and timing, absence of token-bearing URL construction, hash-only storage, one-hour expiry, single-use concurrency, provider password-update delegation, CAPTCHA escalation, lockout, and audit-event redaction. Production verification must additionally confirm migration application, required secrets, transactional-email delivery, and a complete test reset using a non-customer test account. diff --git a/compliance-firewall-agent/lib/auth/__tests__/captcha.test.ts b/compliance-firewall-agent/lib/auth/__tests__/captcha.test.ts index d110a910..0abe2997 100644 --- a/compliance-firewall-agent/lib/auth/__tests__/captcha.test.ts +++ b/compliance-firewall-agent/lib/auth/__tests__/captcha.test.ts @@ -2,8 +2,8 @@ * Cloudflare Turnstile verification. * * Three properties, each of which has a wrong answer that ships silently: - * - unconfigured is OPEN, so a missing key never locks customers out and CI - * passes before the founder adds the secret; + * - unconfigured is CLOSED once a challenge is necessary, so a missing key + * becomes visible instead of silently removing a protection; * - configured-and-failing is CLOSED, so a Cloudflare blip is not a bypass; * - the challenge ESCALATES, so a customer who signs in correctly never sees * one. @@ -75,28 +75,22 @@ describe('isCaptchaConfigured', () => { }); describe('captchaRequired', () => { - it('never demands a challenge while unconfigured — no dead-end for customers', () => { - for (const n of [0, 1, 3, 50]) expect(captchaRequired(n)).toBe(false); - }); - - it('demands one at and above the threshold once configured', () => { - configure('0xAAAA'); + it('demands one at and above the threshold even when configuration is missing', () => { expect(captchaRequired(CAPTCHA_AFTER_FAILURES)).toBe(true); expect(captchaRequired(CAPTCHA_AFTER_FAILURES + 10)).toBe(true); }); it('does not demand one below the threshold', () => { - configure('0xAAAA'); expect(captchaRequired(CAPTCHA_AFTER_FAILURES - 1)).toBe(false); expect(captchaRequired(0)).toBe(false); }); }); describe('verifyCaptcha', () => { - it('is a safe no-op when unconfigured — the code ships before the key does', async () => { + it('fails closed when unconfigured — missing production configuration is not a bypass', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); - expect(await verifyCaptcha(undefined)).toBe(true); - expect(await verifyCaptcha('anything')).toBe(true); + expect(await verifyCaptcha(undefined)).toBe(false); + expect(await verifyCaptcha('anything')).toBe(false); expect(fetchSpy).not.toHaveBeenCalled(); }); diff --git a/compliance-firewall-agent/lib/auth/__tests__/credential-guard.test.ts b/compliance-firewall-agent/lib/auth/__tests__/credential-guard.test.ts index 2b7742db..8529c955 100644 --- a/compliance-firewall-agent/lib/auth/__tests__/credential-guard.test.ts +++ b/compliance-firewall-agent/lib/auth/__tests__/credential-guard.test.ts @@ -258,19 +258,27 @@ describe('guardCredentials', () => { }); }); -describe('isServerAuthEnabled — the no-rebuild rollback', () => { +describe('isServerAuthEnabled — production server boundary', () => { it('is on by default, so the protections ship enabled', () => { delete process.env.AUTH_SERVER_ROUTES; expect(isServerAuthEnabled()).toBe(true); }); - it('is off only for the exact opt-out value', () => { + it('permits an opt-out only outside production for local compatibility testing', () => { process.env.AUTH_SERVER_ROUTES = 'off'; expect(isServerAuthEnabled()).toBe(false); process.env.AUTH_SERVER_ROUTES = ' OFF '; expect(isServerAuthEnabled()).toBe(false); }); + it('cannot disable the hardened route boundary in production', () => { + const previousNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + process.env.AUTH_SERVER_ROUTES = 'off'; + expect(isServerAuthEnabled()).toBe(true); + process.env.NODE_ENV = previousNodeEnv; + }); + it('is not disabled by an unrelated value — no accidental silent rollback', () => { for (const v of ['on', 'true', '1', 'disabled', 'false', '']) { process.env.AUTH_SERVER_ROUTES = v; @@ -286,9 +294,9 @@ describe('isServerAuthEnabled — the no-rebuild rollback', () => { }); describe('serverAuthDisabled', () => { - it('answers 501, the status the browser fallback keys on', async () => { + it('answers a generic 503 and does not invite a browser-direct fallback', async () => { const res = serverAuthDisabled(); - expect(res.status).toBe(501); - expect((await res.json()).error).toMatch(/AUTH_SERVER_ROUTES/); + expect(res.status).toBe(503); + expect((await res.json()).error).toBe('Authentication is unavailable in this development environment.'); }); }); diff --git a/compliance-firewall-agent/lib/auth/__tests__/security-definer-rpc-privileges.test.ts b/compliance-firewall-agent/lib/auth/__tests__/security-definer-rpc-privileges.test.ts new file mode 100644 index 00000000..ed37a032 --- /dev/null +++ b/compliance-firewall-agent/lib/auth/__tests__/security-definer-rpc-privileges.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from 'fs' +import path from 'path' + +/** + * Migration 036 least-privilege contract. + * + * These functions require SECURITY DEFINER to bypass RLS for trusted server-side + * work, but that does not make them safe public RPCs. They must be callable only + * by the service-role server client (or, for the trigger helper, by PostgreSQL + * itself). The Supabase production advisor found each was exposed to anon and + * authenticated roles before this migration existed. + */ +const SQL = readFileSync( + path.resolve(__dirname, '../../../supabase/migrations/036_revoke_public_security_definer.sql'), + 'utf8', +) + +const CODE = SQL.replace(/--.*$/gm, '') + +describe('migration 036 — security-definer functions are least privilege', () => { + it('revokes audit trigger-helper execution from all external PostgREST roles', () => { + expect(CODE).toMatch(/revoke\s+all\s+on\s+function\s+public\.auth_audit_events_immutable\(\)\s+from\s+public,\s*anon,\s*authenticated/i) + }) + + it('revokes shared rate-limit RPC execution from all external PostgREST roles', () => { + expect(CODE).toMatch(/revoke\s+all\s+on\s+function\s+public\.consume_rate_limit\(text,\s*integer,\s*integer\)\s+from\s+public,\s*anon,\s*authenticated/i) + expect(CODE).toMatch(/revoke\s+all\s+on\s+function\s+public\.sweep_rate_limit_buckets\(\)\s+from\s+public,\s*anon,\s*authenticated/i) + }) + + it('restores only service-role execution for trusted server-side rate limiting', () => { + expect(CODE).toMatch(/grant\s+execute\s+on\s+function\s+public\.consume_rate_limit\(text,\s*integer,\s*integer\)\s+to\s+service_role/i) + expect(CODE).toMatch(/grant\s+execute\s+on\s+function\s+public\.sweep_rate_limit_buckets\(\)\s+to\s+service_role/i) + }) + + it('never grants the privileged functions to anon or authenticated roles', () => { + expect(CODE).not.toMatch(/grant\s+execute\s+on\s+function[^;]+to\s+(anon|authenticated)/i) + }) +}) diff --git a/compliance-firewall-agent/lib/auth/auth-emails.ts b/compliance-firewall-agent/lib/auth/auth-emails.ts index ba912c63..70cb0954 100644 --- a/compliance-firewall-agent/lib/auth/auth-emails.ts +++ b/compliance-firewall-agent/lib/auth/auth-emails.ts @@ -57,6 +57,31 @@ export function buildPasswordResetEmail(url: string): EmailContent { }; } +/** + * Supabase application-owned recovery flow. Unlike a magic link, the raw code + * is delivered only inside this email and is never placed in a URL or loggable + * request target. The value is hexadecimal by construction before this helper + * is called, so it is safe to interpolate into this minimal email template. + */ +export function buildPasswordResetCodeEmail(code: string): EmailContent { + const html = ` +
+ + + + +
HoundShield

Reset your password

+

Enter this one-time code on the HoundShield password-reset page. It expires in 1 hour and can be used once.

+
${code}
+

HoundShield will never ask you to send this code back by email, chat, or phone. If you did not request a reset, ignore this email.

+
HoundShield — local-only AI compliance firewall.
`; + return { + subject: "Your HoundShield password-reset code", + html, + text: `Your HoundShield password-reset code: ${code}\n\nEnter it on the password-reset page. It expires in 1 hour and can be used once.\n\nIf you did not request this, ignore this email.`, + }; +} + /** * Sign-in code email (email 2FA). Code-style layout — a large monospace code * instead of a button/link, since the user types it into the login screen. @@ -121,6 +146,10 @@ export async function sendPasswordResetEmail(to: string, url: string): Promise { + await send(to, buildPasswordResetCodeEmail(code)); +} + export async function sendVerificationEmail(to: string, url: string): Promise { await send(to, buildVerificationEmail(url)); } diff --git a/compliance-firewall-agent/lib/auth/captcha.ts b/compliance-firewall-agent/lib/auth/captcha.ts index 4a7958aa..cb22206c 100644 --- a/compliance-firewall-agent/lib/auth/captcha.ts +++ b/compliance-firewall-agent/lib/auth/captcha.ts @@ -13,13 +13,11 @@ * scripted attacker simply does not run it. The token must be exchanged with * Cloudflare from the route, which is only possible because the route exists. * - * UNCONFIGURED = OPEN, deliberately. With no TURNSTILE_SECRET_KEY set this - * no-ops and reports success, so the code ships and passes CI before the - * founder adds the key in Vercel, and a missing key can never lock customers - * out of a working product. The trade-off is explicit: until the key is set, - * requirement 2's CAPTCHA fallback is inactive, and `isCaptchaConfigured()` - * exists so the audit report can state that plainly rather than implying - * coverage that is not there. + * UNCONFIGURED = BLOCKED once a challenge is required. A missing production + * secret must not silently turn an abuse-control policy into a bypass. Normal + * traffic remains unaffected until the escalation threshold, while a release + * readiness check and health signal make missing configuration immediately + * visible before a customer ever reaches the challenged branch. */ const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; @@ -27,14 +25,14 @@ const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; /** Consecutive failures on a bucket before a challenge is required. */ export const CAPTCHA_AFTER_FAILURES = 3; -/** Verification is enforced only when a secret is present. */ +/** True when the release configuration can verify a Turnstile challenge. */ export function isCaptchaConfigured(): boolean { return (process.env.TURNSTILE_SECRET_KEY ?? '').trim().length > 0; } /** Pure: does this attempt need a challenge? */ export function captchaRequired(recentFailures: number): boolean { - return isCaptchaConfigured() && recentFailures >= CAPTCHA_AFTER_FAILURES; + return recentFailures >= CAPTCHA_AFTER_FAILURES; } /** @@ -48,7 +46,7 @@ export function captchaRequired(recentFailures: number): boolean { */ export async function verifyCaptcha(token: string | undefined, ip?: string): Promise { const secret = (process.env.TURNSTILE_SECRET_KEY ?? '').trim(); - if (!secret) return true; // Not configured — see module note. + if (!secret) return false; // Never silently bypass an escalated challenge. if (!token) return false; try { diff --git a/compliance-firewall-agent/lib/auth/credential-guard.ts b/compliance-firewall-agent/lib/auth/credential-guard.ts index 6d3d3dc3..7b409534 100644 --- a/compliance-firewall-agent/lib/auth/credential-guard.ts +++ b/compliance-firewall-agent/lib/auth/credential-guard.ts @@ -58,6 +58,8 @@ export const AUTH_LIMITS = { signupEmail: { limit: 5, windowMs: 900_000 }, otpIp: { limit: 10, windowMs: 60_000 }, otpEmail: { limit: 5, windowMs: 900_000 }, + resetCompleteIp: { limit: 10, windowMs: 60_000 }, + resetCompleteEmail: { limit: 5, windowMs: 900_000 }, } as const satisfies Record; export interface GuardInput { @@ -158,13 +160,16 @@ export async function guardCredentials(input: GuardInput): Promise * no redeploy. */ export function isServerAuthEnabled(): boolean { + // Production must never silently fall back to browser-direct provider calls: + // that bypasses the timing, abuse-control, audit, and verification boundary. + if (process.env.NODE_ENV === 'production') return true; return (process.env.AUTH_SERVER_ROUTES ?? '').trim().toLowerCase() !== 'off'; } -/** The 501 that tells the browser to use its legacy direct-to-Supabase path. */ +/** Development-only compatibility response; production always keeps the server boundary enabled. */ export function serverAuthDisabled(): NextResponse { return NextResponse.json( - { error: 'Server auth routes are disabled (AUTH_SERVER_ROUTES=off).' }, - { status: 501 }, + { error: 'Authentication is unavailable in this development environment.' }, + { status: 503 }, ); } diff --git a/compliance-firewall-agent/lib/auth/password-reset-codes.ts b/compliance-firewall-agent/lib/auth/password-reset-codes.ts new file mode 100644 index 00000000..7626e99d --- /dev/null +++ b/compliance-firewall-agent/lib/auth/password-reset-codes.ts @@ -0,0 +1,99 @@ +import { createHmac, randomBytes } from 'crypto'; +import { createServiceClient, isSupabaseConfigured } from '@/lib/supabase/client'; +import { lockoutKey } from '@/lib/auth/lockout'; + +/** + * Application-owned password-reset codes. + * + * A code is deliberately sent in the email body, not embedded in a URL. The + * raw value never enters a redirect, page URL, browser history, request log, or + * database row. The database receives only an HMAC digest and atomically marks + * it used before the privileged password update proceeds. + */ +export const PASSWORD_RESET_CODE_TTL_MINUTES = 60; + +function resetCodePepper(): string | null { + // A deployment must set the dedicated secret. The service role key fallback + // keeps an emergency migration from silently creating predictable hashes, but + // `AUTH_RESET_CODE_PEPPER` remains the required release configuration. + return ( + process.env.AUTH_RESET_CODE_PEPPER?.trim() || + process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || + null + ); +} + +/** 128 bits of cryptographically secure entropy, readable enough to paste. */ +export function generatePasswordResetCode(): string { + return randomBytes(16).toString('hex').toUpperCase(); +} + +/** Domain-separated keyed digest; the raw code and email are never persisted. */ +export function hashPasswordResetCode(email: string, code: string): string { + const pepper = resetCodePepper(); + if (!pepper) throw new Error('AUTH_RESET_CODE_PEPPER is not configured'); + const normalizedEmail = email.trim().toLowerCase(); + const normalizedCode = code.trim().toUpperCase(); + return createHmac('sha256', pepper) + .update(`houndshield:password-reset:v1:${normalizedEmail}:${normalizedCode}`) + .digest('hex'); +} + +export type ResetCodeIssueResult = 'issued' | 'unknown-or-unavailable'; + +/** + * Issue a code only when the profile exists. Callers must always return a + * neutral response regardless of this outcome; the result controls email only. + */ +export async function issuePasswordResetCode(email: string): Promise<{ + result: ResetCodeIssueResult; + code: string | null; +}> { + if (!isSupabaseConfigured() || !resetCodePepper()) { + return { result: 'unknown-or-unavailable', code: null }; + } + + const code = generatePasswordResetCode(); + try { + const supabase = createServiceClient(); + const { data, error } = await supabase.rpc('issue_password_reset_code', { + p_email: email, + p_email_hash: lockoutKey(email), + p_code_hash: hashPasswordResetCode(email, code), + p_ttl_minutes: PASSWORD_RESET_CODE_TTL_MINUTES, + }); + if (error || data !== true) return { result: 'unknown-or-unavailable', code: null }; + return { result: 'issued', code }; + } catch { + return { result: 'unknown-or-unavailable', code: null }; + } +} + +export type ResetCodeConsumeResult = + | { ok: true; userId: string } + | { ok: false; reason: 'invalid-or-expired' | 'unavailable' }; + +/** Redeem exactly one still-valid code. The database operation is atomic. */ +export async function consumePasswordResetCode( + email: string, + code: string, +): Promise { + if (!isSupabaseConfigured() || !resetCodePepper()) { + return { ok: false, reason: 'unavailable' }; + } + + try { + const supabase = createServiceClient(); + const { data, error } = await supabase.rpc('consume_password_reset_code', { + p_email_hash: lockoutKey(email), + p_code_hash: hashPasswordResetCode(email, code), + }); + const row = Array.isArray(data) ? data[0] : null; + if (error || !row || typeof row.user_id !== 'string') { + return { ok: false, reason: 'invalid-or-expired' }; + } + return { ok: true, userId: row.user_id }; + } catch { + return { ok: false, reason: 'unavailable' }; + } +} diff --git a/compliance-firewall-agent/lib/auth/recovery-link.ts b/compliance-firewall-agent/lib/auth/recovery-link.ts index 164ce475..1f0cd76d 100644 --- a/compliance-firewall-agent/lib/auth/recovery-link.ts +++ b/compliance-firewall-agent/lib/auth/recovery-link.ts @@ -17,6 +17,7 @@ import { z } from 'zod'; * address canonical; 320 is the RFC 5321 max. */ export const recoveryRequestSchema = z.object({ email: z.string().trim().toLowerCase().email().max(320), + captchaToken: z.string().max(4096).optional(), }); /** diff --git a/compliance-firewall-agent/lib/health/__tests__/service-status.test.ts b/compliance-firewall-agent/lib/health/__tests__/service-status.test.ts index 74854a9b..20372206 100644 --- a/compliance-firewall-agent/lib/health/__tests__/service-status.test.ts +++ b/compliance-firewall-agent/lib/health/__tests__/service-status.test.ts @@ -184,10 +184,9 @@ describe("buildHealthReport — controls are measured, not declared", () => { const { services, degraded } = await build(); expect(services.captcha).toBe("not_configured"); expect(degraded).toContain("captcha"); - // The hint has to state the fail-open behaviour, not merely "not set" — - // an absent key means verifyCaptcha() answers TRUE, which is the opposite - // of "captcha is off". - expect(services.captcha_hint).toMatch(/returns true for every token/i); + // The hint must state that escalation is blocked rather than silently + // bypassed, so the operator knows this is a release configuration failure. + expect(services.captcha_hint).toMatch(/fail closed/i); }); it("reports captcha as enforcing when the key is present", async () => { diff --git a/compliance-firewall-agent/lib/health/service-status.ts b/compliance-firewall-agent/lib/health/service-status.ts index 2f12c856..4365f593 100644 --- a/compliance-firewall-agent/lib/health/service-status.ts +++ b/compliance-firewall-agent/lib/health/service-status.ts @@ -23,12 +23,11 @@ import { marketingBlockReason } from "@/lib/legal/marketing-email"; * 2. Audit finding #20c — three security controls FAIL OPEN and say nothing: * • lib/rate-limit-shared.ts:149 no bucket table -> per-instance counting * • lib/auth/lockout.ts:117,146 no lockout table -> no account lockout - * • lib/auth/captcha.ts:52 no Turnstile key -> verifyCaptcha() TRUE - * Each degradation is individually defensible (availability of a paid - * endpoint outranks perfect accounting during an outage) but together they - * mean three controls can be entirely absent while every health check stays - * green. That is precisely what happened. A control that fails open must be - * loud, or it is not a control. + * • lib/auth/captcha.ts no Turnstile key -> challenge fails CLOSED + * The first two controls can degrade under datastore failure; CAPTCHA and + * recovery-code configuration are release-critical and now fail closed once + * reached. Every such condition is nevertheless reported here: an operator + * needs a precise remediation signal, not a generic red health light. * * WHY `degraded` IS COMPUTED HERE AND NOT BY THE READER. `app/status/page.tsx` * used to decide what "operational" meant with its own local @@ -164,14 +163,16 @@ async function marketingOptOutStore(): Promise { } } -/** - * Turnstile. `verifyCaptcha()` returns TRUE for every token when the secret is - * absent, so an unset key is not "captcha off", it is "captcha answers yes". - */ +/** Turnstile is a required escalation control; a missing secret fails closed. */ function captcha(): string { return (process.env.TURNSTILE_SECRET_KEY ?? "").trim() ? "enforcing" : "not_configured"; } +/** Recovery-code HMAC material is presence-only: values never reach this endpoint. */ +function resetCodePepper(): string { + return (process.env.AUTH_RESET_CODE_PEPPER ?? "").trim() ? "set" : "not_configured"; +} + /** * Quarantine encryption. Unlike the three above this one fails CLOSED — * `lib/quarantine/encryption.ts:11-17` throws without a 64-hex key, so a @@ -254,6 +255,7 @@ export async function buildHealthReport(): Promise { marketingOptOutStore(), ]); const captchaState = captcha(); + const resetPepper = resetCodePepper(); const marketingBlocked = marketingBlockReason(); const encryptionState = quarantineEncryption(); @@ -266,6 +268,13 @@ export async function buildHealthReport(): Promise { ...(webhook.hint ? { payments_webhook_hint: webhook.hint } : {}), reset_service_role: reset.service_role, reset_resend: reset.resend, + reset_code_pepper: resetPepper, + ...(resetPepper !== "set" + ? { + reset_code_pepper_hint: + "AUTH_RESET_CODE_PEPPER is not set. Password-reset code issuance is disabled rather than storing an unkeyed recovery secret. Add a 32-byte-or-more random value in Vercel before enabling production reset.", + } + : {}), reset_app_url: reset.app_url, ...(reset.app_url_hint ? { reset_app_url_hint: reset.app_url_hint } : {}), reset_sender_domain: reset.sender_domain, @@ -302,7 +311,7 @@ export async function buildHealthReport(): Promise { ...(captchaState !== "enforcing" ? { captcha_hint: - "TURNSTILE_SECRET_KEY is not set. verifyCaptcha() returns true for every token, so the CAPTCHA escalation step after repeated failures is a no-op.", + "TURNSTILE_SECRET_KEY is not set. Escalated authentication challenges fail closed; set the secret and matching NEXT_PUBLIC_TURNSTILE_SITE_KEY before release.", } : {}), // Onboarding email. NOT a control failing open — it fails CLOSED by design diff --git a/compliance-firewall-agent/next.config.js b/compliance-firewall-agent/next.config.js index e42a9e30..3fb46dca 100644 --- a/compliance-firewall-agent/next.config.js +++ b/compliance-firewall-agent/next.config.js @@ -30,10 +30,11 @@ const nextConfig = { }, // Next 16 removed `next build` linting and the `eslint` config key — lint runs - // as its own CI step (`npm run lint` → eslint.config.mjs). TS errors are still - // ignored at build so a type slip can't block a deploy (CI's tsc step gates that). + // as its own CI step (`npm run lint` → eslint.config.mjs). TypeScript remains + // release-blocking here as well: a CI configuration mistake must not let a + // type error pass through `next build` into production. typescript: { - ignoreBuildErrors: true, + ignoreBuildErrors: false, }, // Kill the "N" dev indicator diff --git a/compliance-firewall-agent/supabase/migrations/035_password_reset_codes.sql b/compliance-firewall-agent/supabase/migrations/035_password_reset_codes.sql new file mode 100644 index 00000000..5558cfc8 --- /dev/null +++ b/compliance-firewall-agent/supabase/migrations/035_password_reset_codes.sql @@ -0,0 +1,130 @@ +-- Migration 035: Application-owned password-reset codes +-- +-- Reset links put a bearer artifact in request URLs, browser history, edge logs, +-- and mail-security scanners. This flow sends the raw code only in the email +-- body. The database stores only an HMAC/SHA-256 digest and atomically consumes +-- it before the application changes a password. +-- +-- Security properties: +-- * raw codes are never stored +-- * a code expires within 60 minutes +-- * one successful consumer wins; races fail closed +-- * issuing a new code invalidates the prior unused code for that user +-- * no anonymous/authenticated role can read or execute these operations + +create table if not exists public.password_reset_codes ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + email_hash text not null, + code_hash text not null unique, + expires_at timestamptz not null, + used_at timestamptz, + created_at timestamptz not null default now(), + constraint password_reset_codes_expiry_bounds + check (expires_at <= created_at + interval '60 minutes') +); + +create index if not exists password_reset_codes_user_active_idx + on public.password_reset_codes (user_id, expires_at) + where used_at is null; + +create index if not exists password_reset_codes_expiry_idx + on public.password_reset_codes (expires_at); + +alter table public.password_reset_codes enable row level security; + +-- Issue one code. Service role first resolves profiles.email to auth user id, +-- then this function invalidates any previous unused code for that user before +-- inserting the replacement. The input hashes are already opaque identifiers. +create or replace function public.issue_password_reset_code( + p_email text, + p_email_hash text, + p_code_hash text, + p_ttl_minutes integer default 60 +) +returns boolean +language plpgsql +security definer +set search_path = public +as $$ +declare + v_user_id uuid; +begin + if p_ttl_minutes < 1 or p_ttl_minutes > 60 then + raise exception 'issue_password_reset_code: p_ttl_minutes must be between 1 and 60'; + end if; + + if length(p_email_hash) < 32 or length(p_code_hash) < 32 then + raise exception 'issue_password_reset_code: invalid hash input'; + end if; + + -- `profiles_email_unique` makes the normalised lookup unambiguous. The raw + -- address is only a function parameter and is never written to this table. + select id into v_user_id + from public.profiles + where lower(email) = lower(p_email) + limit 1; + + if v_user_id is null then + return false; + end if; + + update public.password_reset_codes + set used_at = now() + where user_id = v_user_id + and used_at is null; + + insert into public.password_reset_codes (user_id, email_hash, code_hash, expires_at) + values (v_user_id, p_email_hash, p_code_hash, now() + make_interval(mins => p_ttl_minutes)); + + return true; +end; +$$; + +-- Atomically consume one valid code. An UPDATE predicate—not read then write— +-- means concurrent requests cannot redeem the same code twice. +create or replace function public.consume_password_reset_code( + p_email_hash text, + p_code_hash text +) +returns table (user_id uuid) +language plpgsql +security definer +set search_path = public +as $$ +begin + return query + update public.password_reset_codes + set used_at = now() + where email_hash = p_email_hash + and code_hash = p_code_hash + and used_at is null + and expires_at > now() + returning password_reset_codes.user_id; +end; +$$; + +-- Housekeeping only; no correctness property relies on the sweep. +create or replace function public.sweep_password_reset_codes() +returns integer +language plpgsql +security definer +set search_path = public +as $$ +declare + v_deleted integer; +begin + delete from public.password_reset_codes + where expires_at < now() - interval '24 hours'; + get diagnostics v_deleted = row_count; + return v_deleted; +end; +$$; + +revoke all on table public.password_reset_codes from public, anon, authenticated; +revoke execute on function public.issue_password_reset_code(text, text, text, integer) from public, anon, authenticated; +revoke execute on function public.consume_password_reset_code(text, text) from public, anon, authenticated; +revoke execute on function public.sweep_password_reset_codes() from public, anon, authenticated; +grant execute on function public.issue_password_reset_code(text, text, text, integer) to service_role; +grant execute on function public.consume_password_reset_code(text, text) to service_role; +grant execute on function public.sweep_password_reset_codes() to service_role; diff --git a/compliance-firewall-agent/supabase/migrations/036_revoke_public_security_definer.sql b/compliance-firewall-agent/supabase/migrations/036_revoke_public_security_definer.sql new file mode 100644 index 00000000..0822a28e --- /dev/null +++ b/compliance-firewall-agent/supabase/migrations/036_revoke_public_security_definer.sql @@ -0,0 +1,27 @@ +-- Migration 036: Least-privilege RPC execution for security-definer functions +-- +-- Supabase's database advisor confirmed that these functions are currently +-- executable through PostgREST by anon and authenticated roles. They are only +-- invoked by server-side code using the service-role client (or a trusted +-- maintenance job), so public execution is unnecessary attack surface. +-- +-- This migration is deliberately additive and idempotent. It does not change the +-- function bodies, RLS posture, or rate-limit/audit semantics; it only narrows +-- who may invoke privileged functions. + +-- Authentication audit trigger helper: called by a database trigger only, never +-- by a browser or API caller. EXECUTE is revoked from every external role. +revoke all on function public.auth_audit_events_immutable() from public, anon, authenticated; + +-- Shared rate-limit RPCs: application server code calls these through the +-- service-role key. Explicit service_role grants keep shared enforcement working +-- while preventing an attacker from spending or sweeping buckets via PostgREST. +revoke all on function public.consume_rate_limit(text, integer, integer) from public, anon, authenticated; +revoke all on function public.sweep_rate_limit_buckets() from public, anon, authenticated; +grant execute on function public.consume_rate_limit(text, integer, integer) to service_role; +grant execute on function public.sweep_rate_limit_buckets() to service_role; + +-- RLS-without-policy advisories for auth_audit_events, auth_lockouts, +-- rate_limit_buckets and Better Auth tables are intentional: RLS is enabled and +-- no client policy exists, so anon/authenticated PostgREST access is denied. +-- Server-side service-role code bypasses RLS for protected maintenance paths.