security: critical fixes from audit
CI / test (push) Successful in 1m58s

- 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
+1
View File
@@ -39,3 +39,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.gstack/
+1 -1
View File
@@ -11,7 +11,7 @@ services:
- NODE_ENV=production
- HOSTNAME=0.0.0.0
- NEXT_PUBLIC_APP_URL=https://falahos.my/mobile-staging
- JWT_SECRET=${JWT_SECRET:-flh-staging-jwt-secret}
- JWT_SECRET=${JWT_SECRET}
- DATABASE_URL=file:/app/data/staging.db
- LLM_API_KEY=${LLM_API_KEY:-}
- LLM_BASE_URL=${LLM_BASE_URL:-}
+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: {
+27
View File
@@ -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";
}
+39
View File
@@ -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*",
};