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:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const rateMap = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
const WINDOW_MS = 15 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 10;
|
||||
|
||||
export function checkRateLimit(ip: string): { allowed: boolean; remaining: number } {
|
||||
const now = Date.now();
|
||||
const entry = rateMap.get(ip);
|
||||
|
||||
if (!entry || now > entry.resetAt) {
|
||||
rateMap.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return { allowed: true, remaining: MAX_ATTEMPTS - 1 };
|
||||
}
|
||||
|
||||
if (entry.count >= MAX_ATTEMPTS) {
|
||||
return { allowed: false, remaining: 0 };
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return { allowed: true, remaining: MAX_ATTEMPTS - entry.count };
|
||||
}
|
||||
|
||||
export function getClientIp(request: Request): string {
|
||||
return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||
|| request.headers.get("x-real-ip")
|
||||
|| "127.0.0.1";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const ALLOWED_ORIGINS = [
|
||||
"https://falahos.my",
|
||||
"https://www.falahos.my",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:4013",
|
||||
"http://localhost:4014",
|
||||
];
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const origin = request.headers.get("origin") || "";
|
||||
const isAllowed = !origin || ALLOWED_ORIGINS.includes(origin);
|
||||
|
||||
const response = NextResponse.next();
|
||||
|
||||
if (origin && isAllowed) {
|
||||
response.headers.set("Access-Control-Allow-Origin", origin);
|
||||
response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
response.headers.set("Access-Control-Max-Age", "86400");
|
||||
} else if (origin && !isAllowed) {
|
||||
response.headers.set("Access-Control-Allow-Origin", "https://falahos.my");
|
||||
}
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: "/api/:path*",
|
||||
};
|
||||
Reference in New Issue
Block a user