Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 33 additions & 2 deletions api/src/lib/verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -98,6 +103,7 @@ export function normalizeEmailIdentity(email: string): string {
*/
export async function claimSignupIdentity(email: string): Promise<boolean> {
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
Expand All @@ -106,11 +112,15 @@ export async function claimSignupIdentity(email: string): Promise<boolean> {
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
Expand All @@ -122,6 +132,27 @@ export async function claimSignupIdentity(email: string): Promise<boolean> {
}
}

export async function tombstoneSignupIdentity(
email: string,
db: Pick<typeof prisma, "$executeRaw"> = prisma,
): Promise<number> {
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<string, { day: string; count: number }>();
Expand Down
11 changes: 6 additions & 5 deletions api/src/routes/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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({
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions api/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,12 @@ export default router;

router.post("/forgot-password", forgotLimiter, async (req: Request, res: Response): Promise<void> => {
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);
Expand Down
76 changes: 76 additions & 0 deletions api/tests/auth-forgot-password-hardening.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
20 changes: 16 additions & 4 deletions api/tests/critical-regressions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});

Expand Down
Loading