security: critical fixes from audit

- Rotate 5 exposed production secrets (JWT, ENCRYPTION, ADMIN, POSTGRES, REDIS)
- Fix MOCK_PAYMENTS opt-in (now defaults to disabled)
- HMAC-SHA256 webhook verification with timing-safe comparison
- Purge dangling git blobs (git gc --prune=now)
- Rate limiting on auth routes (10/15min per IP)
- CORS middleware restricted to falahos.my
- Fix walletBalance → flhBalance (Prisma schema mismatch)
This commit is contained in:
2026-06-27 17:54:34 +08:00
parent bfd3509add
commit 0c3521ba4c
8 changed files with 127 additions and 24 deletions
+11 -1
View File
@@ -1,9 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { UMMAHID_URL } from "@/lib/auth";
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
export async function POST(req: NextRequest) {
try {
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) {
return NextResponse.json(
{ error: "Too many requests. Try again later." },
{ status: 429, headers: { "Retry-After": "900", "X-RateLimit-Remaining": "0" } }
);
}
const { email, password } = await req.json();
if (!email || !password) {
@@ -40,7 +50,7 @@ export async function POST(req: NextRequest) {
name: ummahUser?.name || email?.split("@")[0] || "User",
provider: "ummahid",
providerId: ummahUser.id,
walletBalance: 5000,
flhBalance: 5000,
},
});
}
+10
View File
@@ -1,9 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { UMMAHID_URL } from "@/lib/auth";
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
export async function POST(req: NextRequest) {
try {
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) {
return NextResponse.json(
{ error: "Too many requests. Try again later." },
{ status: 429, headers: { "Retry-After": "900", "X-RateLimit-Remaining": "0" } }
);
}
const { email, name, password } = await req.json();
if (!email || !name || !password) {
+1 -1
View File
@@ -61,7 +61,7 @@ export async function POST(req: NextRequest) {
// In production, this would create a Polar.sh checkout session.
// For development, we mock the flow by marking the user as premium/pro.
const useMock = process.env.MOCK_PAYMENTS !== "false";
const useMock = process.env.MOCK_PAYMENTS === "true";
if (useMock) {
const trialEndsAt = new Date();
+37 -21
View File
@@ -1,42 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// Polar.sh webhook for FLH purchase checkouts
// Receives checkout.completed events and credits the buyer's FLH balance
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
export async function POST(request: NextRequest) {
try {
// Log the incoming webhook for debugging
const body = await request.json();
const eventType = body.type || "";
console.log("[polar-webhook] Received:", JSON.stringify({ type: eventType, id: body.data?.id }));
const rawBody = await request.text();
let body: Record<string, unknown>;
try {
body = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const eventType = (body.type as string) || "";
console.log("[polar-webhook] Received:", JSON.stringify({ type: eventType, id: (body.data as Record<string, unknown> | undefined)?.id }));
// Only process checkout events
if (!eventType.startsWith("checkout.")) {
return NextResponse.json({ received: true, ignored: true }, { status: 200 });
}
// Verify webhook secret if configured
const webhookSecret = process.env.POLAR_WEBHOOK_SECRET;
if (webhookSecret) {
const signature = request.headers.get("polar-signature") || "";
// In production, verify the signature using crypto.timingSafeEqual
// For now, use the secret as a simple check
if (signature !== webhookSecret) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(webhookSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const expectedSignatureBytes = await crypto.subtle.sign("HMAC", key, encoder.encode(rawBody));
const expectedSignature = Array.from(new Uint8Array(expectedSignatureBytes))
.map(b => b.toString(16).padStart(2, "0"))
.join("");
if (!timingSafeEqual(signature, expectedSignature)) {
console.warn("[polar-webhook] Invalid signature");
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
}
const checkout = body.data || {};
const metadata = checkout.metadata || {};
const checkout = (body.data as Record<string, unknown>) || {};
const metadata = (checkout.metadata as Record<string, unknown>) || {};
// Only process FLH purchase events
if (metadata.type !== "flh_purchase") {
return NextResponse.json({ received: true, ignored: true }, { status: 200 });
}
// Only process completed/paid checkouts
if (checkout.status !== "succeeded" && checkout.status !== "paid") {
return NextResponse.json(
{ received: true, status: checkout.status },
@@ -44,8 +63,8 @@ export async function POST(request: NextRequest) {
);
}
const userId = metadata.user_id;
const flhAmount = parseInt(metadata.flh_amount, 10);
const userId = metadata.user_id as string;
const flhAmount = parseInt(metadata.flh_amount as string, 10);
if (!userId || !flhAmount || flhAmount < 1) {
console.error("[polar-webhook] Invalid metadata:", { userId, flhAmount });
@@ -55,8 +74,7 @@ export async function POST(request: NextRequest) {
);
}
// Check if this checkout was already processed (idempotency)
const checkoutId = checkout.id || body.id;
const checkoutId = (checkout.id as string) || (body.id as string);
if (checkoutId) {
const existing = await prisma.xpTransaction.findFirst({
where: {
@@ -70,7 +88,6 @@ export async function POST(request: NextRequest) {
}
}
// Credit the user's FLH balance
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
console.error(`[polar-webhook] User not found: ${userId}`);
@@ -82,7 +99,6 @@ export async function POST(request: NextRequest) {
data: { flhBalance: { increment: flhAmount } },
});
// Record the transaction for idempotency and audit
if (checkoutId) {
await prisma.xpTransaction.create({
data: {