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:
@@ -39,3 +39,4 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
.gstack/
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- HOSTNAME=0.0.0.0
|
- HOSTNAME=0.0.0.0
|
||||||
- NEXT_PUBLIC_APP_URL=https://falahos.my/mobile-staging
|
- 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
|
- DATABASE_URL=file:/app/data/staging.db
|
||||||
- LLM_API_KEY=${LLM_API_KEY:-}
|
- LLM_API_KEY=${LLM_API_KEY:-}
|
||||||
- LLM_BASE_URL=${LLM_BASE_URL:-}
|
- LLM_BASE_URL=${LLM_BASE_URL:-}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { UMMAHID_URL } from "@/lib/auth";
|
import { UMMAHID_URL } from "@/lib/auth";
|
||||||
|
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
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();
|
const { email, password } = await req.json();
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
@@ -40,7 +50,7 @@ export async function POST(req: NextRequest) {
|
|||||||
name: ummahUser?.name || email?.split("@")[0] || "User",
|
name: ummahUser?.name || email?.split("@")[0] || "User",
|
||||||
provider: "ummahid",
|
provider: "ummahid",
|
||||||
providerId: ummahUser.id,
|
providerId: ummahUser.id,
|
||||||
walletBalance: 5000,
|
flhBalance: 5000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { UMMAHID_URL } from "@/lib/auth";
|
import { UMMAHID_URL } from "@/lib/auth";
|
||||||
|
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
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();
|
const { email, name, password } = await req.json();
|
||||||
|
|
||||||
if (!email || !name || !password) {
|
if (!email || !name || !password) {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// In production, this would create a Polar.sh checkout session.
|
// In production, this would create a Polar.sh checkout session.
|
||||||
// For development, we mock the flow by marking the user as premium/pro.
|
// 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) {
|
if (useMock) {
|
||||||
const trialEndsAt = new Date();
|
const trialEndsAt = new Date();
|
||||||
|
|||||||
@@ -1,42 +1,61 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
// Polar.sh webhook for FLH purchase checkouts
|
function timingSafeEqual(a: string, b: string): boolean {
|
||||||
// Receives checkout.completed events and credits the buyer's FLH balance
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Log the incoming webhook for debugging
|
const rawBody = await request.text();
|
||||||
const body = await request.json();
|
let body: Record<string, unknown>;
|
||||||
const eventType = body.type || "";
|
try {
|
||||||
console.log("[polar-webhook] Received:", JSON.stringify({ type: eventType, id: body.data?.id }));
|
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.")) {
|
if (!eventType.startsWith("checkout.")) {
|
||||||
return NextResponse.json({ received: true, ignored: true }, { status: 200 });
|
return NextResponse.json({ received: true, ignored: true }, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify webhook secret if configured
|
|
||||||
const webhookSecret = process.env.POLAR_WEBHOOK_SECRET;
|
const webhookSecret = process.env.POLAR_WEBHOOK_SECRET;
|
||||||
if (webhookSecret) {
|
if (webhookSecret) {
|
||||||
const signature = request.headers.get("polar-signature") || "";
|
const signature = request.headers.get("polar-signature") || "";
|
||||||
// In production, verify the signature using crypto.timingSafeEqual
|
const encoder = new TextEncoder();
|
||||||
// For now, use the secret as a simple check
|
const key = await crypto.subtle.importKey(
|
||||||
if (signature !== webhookSecret) {
|
"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");
|
console.warn("[polar-webhook] Invalid signature");
|
||||||
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
|
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkout = body.data || {};
|
const checkout = (body.data as Record<string, unknown>) || {};
|
||||||
const metadata = checkout.metadata || {};
|
const metadata = (checkout.metadata as Record<string, unknown>) || {};
|
||||||
|
|
||||||
// Only process FLH purchase events
|
|
||||||
if (metadata.type !== "flh_purchase") {
|
if (metadata.type !== "flh_purchase") {
|
||||||
return NextResponse.json({ received: true, ignored: true }, { status: 200 });
|
return NextResponse.json({ received: true, ignored: true }, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only process completed/paid checkouts
|
|
||||||
if (checkout.status !== "succeeded" && checkout.status !== "paid") {
|
if (checkout.status !== "succeeded" && checkout.status !== "paid") {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ received: true, status: checkout.status },
|
{ received: true, status: checkout.status },
|
||||||
@@ -44,8 +63,8 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = metadata.user_id;
|
const userId = metadata.user_id as string;
|
||||||
const flhAmount = parseInt(metadata.flh_amount, 10);
|
const flhAmount = parseInt(metadata.flh_amount as string, 10);
|
||||||
|
|
||||||
if (!userId || !flhAmount || flhAmount < 1) {
|
if (!userId || !flhAmount || flhAmount < 1) {
|
||||||
console.error("[polar-webhook] Invalid metadata:", { userId, flhAmount });
|
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 as string) || (body.id as string);
|
||||||
const checkoutId = checkout.id || body.id;
|
|
||||||
if (checkoutId) {
|
if (checkoutId) {
|
||||||
const existing = await prisma.xpTransaction.findFirst({
|
const existing = await prisma.xpTransaction.findFirst({
|
||||||
where: {
|
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 } });
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.error(`[polar-webhook] User not found: ${userId}`);
|
console.error(`[polar-webhook] User not found: ${userId}`);
|
||||||
@@ -82,7 +99,6 @@ export async function POST(request: NextRequest) {
|
|||||||
data: { flhBalance: { increment: flhAmount } },
|
data: { flhBalance: { increment: flhAmount } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Record the transaction for idempotency and audit
|
|
||||||
if (checkoutId) {
|
if (checkoutId) {
|
||||||
await prisma.xpTransaction.create({
|
await prisma.xpTransaction.create({
|
||||||
data: {
|
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