diff --git a/api/package.json b/api/package.json index de294d2d..5475a96b 100644 --- a/api/package.json +++ b/api/package.json @@ -16,7 +16,7 @@ "db:cleanup": "node dist/cron/dbCleanup.js", "funnel-report": "node dist/scripts/funnelReport.js", "npm:audit": "npm audit --json > /tmp/audit.json && echo \"Audit complete\"", -"test": "TS_NODE_TRANSPILE_ONLY=1 node --loader ts-node/esm tests/wallet-provisioning.test.js && node --loader ts-node/esm tests/ssrf.test.js && node tests/integration.test.js && node tests/pages.test.js && node tests/x402-v1-passthrough.test.mjs && node tests/model-cost.test.mjs && node tests/session-pricing.test.mjs && node tests/prompt-moderation.test.mjs && node tests/critical-regressions.test.mjs && node tests/unsubscribe.test.mjs && node tests/reactivation-render.test.mjs && node tests/outreach-active-devs.test.mjs && node tests/credit-alert-dedup.test.mjs && node tests/verify-activation.test.mjs && node tests/signup-firstcall.test.mjs && node tests/oauth-signup-cta.test.mjs && node tests/x402-sell-copy.test.mjs && node tests/verify-resend.test.mjs && node tests/intent-funnel.test.mjs && node tests/credit-email-buylinks.test.mjs", +"test": "TS_NODE_TRANSPILE_ONLY=1 node --loader ts-node/esm tests/wallet-provisioning.test.js && node --loader ts-node/esm tests/ssrf.test.js && node tests/integration.test.js && node tests/pages.test.js && node tests/x402-v1-passthrough.test.mjs && node tests/model-cost.test.mjs && node tests/session-pricing.test.mjs && node tests/prompt-moderation.test.mjs && node tests/critical-regressions.test.mjs && node tests/auth-forgot-password-hardening.test.mjs && node tests/unsubscribe.test.mjs && node tests/reactivation-render.test.mjs && node tests/outreach-active-devs.test.mjs && node tests/credit-alert-dedup.test.mjs && node tests/verify-activation.test.mjs && node tests/signup-firstcall.test.mjs && node tests/oauth-signup-cta.test.mjs && node tests/x402-sell-copy.test.mjs && node tests/verify-resend.test.mjs && node tests/intent-funnel.test.mjs && node tests/credit-email-buylinks.test.mjs", "test:integration": "node tests/integration.test.js", "test:verify-activation": "node tests/verify-activation.test.mjs", "test:verify-resend": "node tests/verify-resend.test.mjs", diff --git a/api/src/lib/verification.ts b/api/src/lib/verification.ts index c4ce260d..fd2b8646 100644 --- a/api/src/lib/verification.ts +++ b/api/src/lib/verification.ts @@ -85,6 +85,11 @@ export function normalizeEmailIdentity(email: string): string { return `${local}@${domain}`; } +export function signupIdentityTombstone(email: string): string { + const normalized = normalizeEmailIdentity(email); + return `deleted-sha256:${crypto.createHash("sha256").update(normalized).digest("hex")}`; +} + /** * Atomically claim the free-grant slot for a normalized email identity. * INSERT … ON CONFLICT DO NOTHING against the UNIQUE "SignupIdentity" table — @@ -98,6 +103,7 @@ export function normalizeEmailIdentity(email: string): string { */ export async function claimSignupIdentity(email: string): Promise { const normalized = normalizeEmailIdentity(email); + const tombstone = signupIdentityTombstone(email); // Prisma's @default(cuid()) does NOT apply to raw SQL, and the migration // defines "id" as TEXT NOT NULL with no DB default — so the id MUST be // supplied explicitly here. created_at has DEFAULT CURRENT_TIMESTAMP in @@ -106,11 +112,15 @@ export async function claimSignupIdentity(email: string): Promise { try { const inserted = await prisma.$executeRaw` INSERT INTO "SignupIdentity" ("id", "normalized_email") - VALUES (${id}, ${normalized}) + SELECT ${id}, ${normalized} + WHERE NOT EXISTS ( + SELECT 1 FROM "SignupIdentity" + WHERE "normalized_email" = ${tombstone} + ) ON CONFLICT ("normalized_email") DO NOTHING`; // Deterministic, non-throwing outcomes: // 1 row inserted → new claim → grant credits (true) - // 0 rows (ON CONFLICT) → identity already claimed → gate credits (false) + // 0 rows (ON CONFLICT or deletion tombstone) → already claimed → gate credits (false) return inserted > 0; } catch (e) { // Fail CLOSED: on an unexpected DB error we cannot prove this identity has @@ -122,6 +132,27 @@ export async function claimSignupIdentity(email: string): Promise { } } +export async function tombstoneSignupIdentity( + email: string, + db: Pick = prisma, +): Promise { + const normalized = normalizeEmailIdentity(email); + const tombstone = signupIdentityTombstone(email); + const id = crypto.randomUUID(); + + // Preserve the anti-farming guard without retaining the plaintext normalized + // email after GDPR deletion. Future claims check this tombstone before grant. + const inserted = await db.$executeRaw` + INSERT INTO "SignupIdentity" ("id", "normalized_email") + VALUES (${id}, ${tombstone}) + ON CONFLICT ("normalized_email") DO NOTHING`; + const deletedPlaintext = await db.$executeRaw` + DELETE FROM "SignupIdentity" + WHERE "normalized_email" = ${normalized}`; + + return Number(inserted) + Number(deletedPlaintext); +} + // Per-IP signup counter (in-process, daily window). Conservative blast-radius // limiter; resets on deploy/restart which is acceptable for abuse throttling. const ipSignupCounts = new Map(); diff --git a/api/src/routes/agent.ts b/api/src/routes/agent.ts index 3fafb1d1..3670bd8e 100644 --- a/api/src/routes/agent.ts +++ b/api/src/routes/agent.ts @@ -9,7 +9,7 @@ import { stripe } from "../lib/stripe.js"; import Stripe from "stripe"; import crypto from "crypto"; import bcrypt from "bcryptjs"; -import { SIGNUP_FREE_CREDITS, isDisposableEmail, issueEmailVerification, verifyEmailToken, peekEmailVerifyToken, enforceSignupLimits, recordSignupIp, normalizeEmailIdentity, allowVerificationResend, reissueEmailVerification } from "../lib/verification.js"; +import { SIGNUP_FREE_CREDITS, isDisposableEmail, issueEmailVerification, verifyEmailToken, peekEmailVerifyToken, enforceSignupLimits, recordSignupIp, tombstoneSignupIdentity, allowVerificationResend, reissueEmailVerification } from "../lib/verification.js"; import { VERIFY_TOKEN_RE, renderVerifyConfirmPage, renderVerifyActivationPage, renderVerifyErrorPage, renderVerifyResendSentPage } from "../assets/verifyEmailHtml.js"; import { REFERRAL_REWARD } from "../lib/referralReward.js"; @@ -539,9 +539,10 @@ router.delete("/", requireAuth, requireApiKeyAuth, async (req: AuthedRequest, re const reqs = await tx.apiRequest.deleteMany({ where: { agentId: agent.id } }); const toks = await tx.oAuthToken.deleteMany({ where: { agentId: agent.id } }); const codes = await tx.oAuthAuthCode.deleteMany({ where: { agentId: agent.id } }); - // Erase the email suppression record so no plaintext email remains (GDPR erasure - // wins over the bounded free-signup-again risk). - const ident = email ? await tx.signupIdentity.deleteMany({ where: { normalizedEmail: normalizeEmailIdentity(email) } }) : { count: 0 }; + // Replace the plaintext grant guard with a one-way tombstone. This keeps + // GDPR erasure from retaining normalized email while preserving the + // "one free grant per identity" invariant after account deletion. + const signupIdentity = email ? await tombstoneSignupIdentity(email, tx) : 0; // Anonymize the Agent row in place — keeps Purchase/X402Payment FK integrity for // financial retention while removing every PII field. await tx.agent.update({ @@ -557,7 +558,7 @@ router.delete("/", requireAuth, requireApiKeyAuth, async (req: AuthedRequest, re emailVerified: false, isPublic: false, }, }); - const counts = { apiRequests: reqs.count, oauthTokens: toks.count, oauthCodes: codes.count, signupIdentity: ident.count, stripeSubscriptions: canceledSubscriptions }; + const counts = { apiRequests: reqs.count, oauthTokens: toks.count, oauthCodes: codes.count, signupIdentity, stripeSubscriptions: canceledSubscriptions }; // Durable deletion audit trail (GDPR Art.5(2) accountability). Written // INSIDE the transaction so a deletion can never commit without its audit diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index bf59d12f..b7eb63af 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -223,11 +223,12 @@ export default router; router.post("/forgot-password", forgotLimiter, async (req: Request, res: Response): Promise => { const { email } = req.body ?? {}; - if (!email) { + if (typeof email !== "string" || !email.trim()) { res.status(400).json({ ok: false, error: "email_required" }); return; } - const agent = await prisma.agent.findUnique({ where: { email: email.toLowerCase().trim() } }); + const normalizedEmail = email.toLowerCase().trim(); + const agent = await prisma.agent.findUnique({ where: { email: normalizedEmail } }); if (agent) { const token = crypto.randomBytes(32).toString("hex"); const expiry = new Date(Date.now() + 60 * 60 * 1000); diff --git a/api/tests/auth-forgot-password-hardening.test.mjs b/api/tests/auth-forgot-password-hardening.test.mjs new file mode 100644 index 00000000..b6bb40d6 --- /dev/null +++ b/api/tests/auth-forgot-password-hardening.test.mjs @@ -0,0 +1,76 @@ +/** + * Security regression — forgot-password malformed email handling. + * + * Public auth endpoints must reject non-string emails before normalization. + * In Express 4, an exception thrown inside an async route can escape route + * error handling and take down the Node process. + * + * Run: cd api && npm run build && node tests/auth-forgot-password-hardening.test.mjs + */ +import assert from "assert"; + +process.env.DATABASE_URL ??= "postgresql://stub:stub@127.0.0.1:5432/stub"; +process.env.JWT_SECRET ??= "test-secret-do-not-use-in-prod"; + +const { prisma } = await import("../dist/lib/prisma.js"); +const { default: authRouter } = await import("../dist/routes/auth.js"); +const express = (await import("express")).default; + +let failures = 0; +async function test(name, fn) { + try { await fn(); console.log(` ✓ ${name}`); } + catch (e) { failures++; console.error(` ✗ ${name}: ${e.message}`); } +} + +const app = express(); +app.use(express.json()); +app.use("/auth", authRouter); +const server = app.listen(0); +const BASE = `http://127.0.0.1:${server.address().port}`; + +let lookups = []; +prisma.agent.findUnique = async (args) => { + lookups.push(args); + return null; +}; + +async function postForgot(email) { + const res = await fetch(`${BASE}/auth/forgot-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + const body = await res.json(); + return { res, body }; +} + +console.log("Forgot-password hardening:"); + +await test("rejects object email with 400 and no DB lookup", async () => { + lookups = []; + const { res, body } = await postForgot({ toString: "boom@example.com" }); + assert.strictEqual(res.status, 400); + assert.strictEqual(body.error, "email_required"); + assert.deepStrictEqual(lookups, []); +}); + +await test("rejects blank email with 400 and no DB lookup", async () => { + lookups = []; + const { res, body } = await postForgot(" "); + assert.strictEqual(res.status, 400); + assert.strictEqual(body.error, "email_required"); + assert.deepStrictEqual(lookups, []); +}); + +await test("normalizes string email and keeps neutral success response", async () => { + lookups = []; + const { res, body } = await postForgot(" USER@Example.COM "); + assert.strictEqual(res.status, 200); + assert.strictEqual(body.ok, true); + assert.deepStrictEqual(lookups, [{ where: { email: "user@example.com" } }]); +}); + +server.close(); + +console.log(`\n${3 - failures} passed, ${failures} failed`); +process.exit(failures > 0 ? 1 : 0); diff --git a/api/tests/critical-regressions.test.mjs b/api/tests/critical-regressions.test.mjs index 5d4b4609..f8489db4 100644 --- a/api/tests/critical-regressions.test.mjs +++ b/api/tests/critical-regressions.test.mjs @@ -43,12 +43,24 @@ function responseCredits(toolName) { return new Set(matches); } -test("account deletion erases SignupIdentity using the shared normalized email identity", () => { - assert.ok(agentSrc.includes("normalizeEmailIdentity"), "agent route imports normalizeEmailIdentity"); +test("account deletion preserves free-grant suppression with a non-plaintext tombstone", () => { + const verificationSrc = fs.readFileSync(path.join(srcRoot, "lib", "verification.ts"), "utf8"); + assert.ok(agentSrc.includes("tombstoneSignupIdentity"), "deletion route imports the SignupIdentity tombstone helper"); + assert.ok(!agentSrc.includes("signupIdentity.deleteMany"), "deletion must not remove the free-grant guard outright"); assert.match( agentSrc, - /signupIdentity\.deleteMany\(\{\s*where:\s*\{\s*normalizedEmail:\s*normalizeEmailIdentity\(email\)\s*\}\s*\}\)/, - "DELETE /v1/agent must delete the same normalized identity signup stored", + /tombstoneSignupIdentity\(email,\s*tx\)/, + "DELETE /v1/agent must replace the plaintext grant guard inside the deletion transaction", + ); + assert.match( + verificationSrc, + /deleted-sha256:\$\{crypto\.createHash\("sha256"\)\.update\(normalized\)\.digest\("hex"\)\}/, + "SignupIdentity tombstones must not retain the plaintext normalized email", + ); + assert.match( + verificationSrc, + /WHERE NOT EXISTS \(\s*SELECT 1 FROM "SignupIdentity"\s*WHERE "normalized_email" = \$\{tombstone\}\s*\)/, + "free-credit claims must check deletion tombstones before inserting a new grant row", ); });