security: fix critical webhook direct upgrade path, seed auth, CORS Vary header

- Webhook polar/route: block direct ?userId=&tier= upgrades in production (NODE_ENV guard)
- Seed route: require ADMIN_SECRET via x-admin-secret header
- Feedback route: require FEEDBACK_ADMIN_TOKEN env var in production
- CORS middleware: add Vary: Origin header for caching correctness
- MOCK_PAYMENTS: add NODE_ENV production guard
- Wallet top-up: add production guard for mock mode when POLAR_ACCESS_TOKEN unset
This commit is contained in:
2026-06-28 01:15:45 +08:00
parent 0c3521ba4c
commit de48918acf
10 changed files with 541 additions and 15 deletions
+11 -1
View File
@@ -66,7 +66,17 @@ export async function POST(request: NextRequest) {
export async function GET(request: NextRequest) {
// Admin-only endpoint — return paginated feedback
const authHeader = request.headers.get("authorization") || "";
const adminToken = process.env.FEEDBACK_ADMIN_TOKEN || "flh-feedback-admin";
const adminToken = process.env.FEEDBACK_ADMIN_TOKEN;
// Require token to be set in production
if (!adminToken) {
if (process.env.NODE_ENV === "production") {
console.error("FEEDBACK_ADMIN_TOKEN not configured");
return NextResponse.json({ error: "Server configuration error" }, { status: 500 });
}
// Dev fallback only
return NextResponse.json({ error: "Unauthorized — configure FEEDBACK_ADMIN_TOKEN" }, { status: 401 });
}
// Simple token auth
if (authHeader !== `Bearer ${adminToken}`) {
+11 -2
View File
@@ -1,9 +1,18 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
export async function POST() {
export async function POST(req: NextRequest) {
try {
// Require ADMIN_SECRET to prevent unauthorized seeding
const adminSecret = process.env.ADMIN_SECRET;
const providedSecret = req.headers.get("x-admin-secret") || req.headers.get("authorization")?.replace("Bearer ", "");
if (adminSecret && providedSecret !== adminSecret) {
return NextResponse.json(
{ error: "Unauthorized — valid admin secret required" },
{ status: 401 }
);
}
// ── 1. Seed Demo Users ──────────────────────────────────────────
const users = [
{
+2 -1
View File
@@ -61,7 +61,8 @@ 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 === "true";
const useMock = process.env.MOCK_PAYMENTS === "true"
&& process.env.NODE_ENV !== "production";
if (useMock) {
const trialEndsAt = new Date();
@@ -30,8 +30,15 @@ export async function POST(req: NextRequest) {
const pricing = TOP_UP_AMOUNTS[amount as keyof typeof TOP_UP_AMOUNTS];
const totalFlh = amount + (pricing.bonus || 0);
// Mock mode: use when POLAR_ACCESS_TOKEN is not configured
// Mock mode: use when POLAR_ACCESS_TOKEN is not configured (dev only)
if (!process.env.POLAR_ACCESS_TOKEN) {
// Production guard: never allow mock top-ups in production
if (process.env.NODE_ENV === "production") {
return NextResponse.json(
{ error: "Payment service not configured" },
{ status: 500 }
);
}
await prisma.user.update({
where: { id: jwtPayload.userId },
data: { flhBalance: { increment: totalFlh } },
+9 -1
View File
@@ -11,12 +11,20 @@ import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
try {
// Check for direct fallback query params (dev/testing)
// Check for direct fallback query params (dev/testing only)
const url = new URL(req.url);
const directUserId = url.searchParams.get("userId");
const directTier = url.searchParams.get("tier");
if (directUserId && directTier) {
// Production guard: never allow direct upgrades in production
if (process.env.NODE_ENV === "production") {
console.error("Direct upgrade blocked — not allowed in production");
return NextResponse.json(
{ error: "Direct upgrades not allowed in production" },
{ status: 403 }
);
}
return handleDirectUpgrade(directUserId, directTier);
}
+2
View File
@@ -20,8 +20,10 @@ export function middleware(request: NextRequest) {
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");
response.headers.set("Vary", "Origin");
} else if (origin && !isAllowed) {
response.headers.set("Access-Control-Allow-Origin", "https://falahos.my");
response.headers.set("Vary", "Origin");
}
if (request.method === "OPTIONS") {