security: P1/P2 hardening — rate limiting, CORS, Redis auth, network isolation
- Add Redis-based sliding window rate limiting on login, register, forgot-password, reset-password - Fix user enumeration: register returns generic 200 for both new and existing emails - Add Redis authentication (requirepass) and password in .env - Docker network isolation: postgres/redis on internal-only network - Whitelist Stripe redirect origins (prevent open redirect) - Add 10MB request size limit on trace ingestion - Limit API keys to 10 per user - Add CORS headers via middleware (whitelist agentlens.vectry.tech + localhost) - Reduce JWT max age from 30 days to 7 days
This commit is contained in:
@@ -3,6 +3,7 @@ import { randomBytes, createHash } from "crypto";
|
||||
import { z } from "zod";
|
||||
import nodemailer from "nodemailer";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||
|
||||
const forgotPasswordSchema = z.object({
|
||||
email: z.email("Invalid email address"),
|
||||
@@ -24,6 +25,15 @@ function hashToken(token: string): string {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const rl = await checkRateLimit(`forgot:${ip}`, AUTH_RATE_LIMITS.forgotPassword);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Please try again later." },
|
||||
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
|
||||
);
|
||||
}
|
||||
|
||||
const body: unknown = await request.json();
|
||||
const parsed = forgotPasswordSchema.safeParse(body);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import crypto from "crypto";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendEmail } from "@/lib/email";
|
||||
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.email("Invalid email address"),
|
||||
@@ -13,6 +14,15 @@ const registerSchema = z.object({
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const rl = await checkRateLimit(`register:${ip}`, AUTH_RATE_LIMITS.register);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many registration attempts. Please try again later." },
|
||||
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
|
||||
);
|
||||
}
|
||||
|
||||
const body: unknown = await request.json();
|
||||
const parsed = registerSchema.safeParse(body);
|
||||
|
||||
@@ -32,8 +42,8 @@ export async function POST(request: Request) {
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "An account with this email already exists" },
|
||||
{ status: 409 }
|
||||
{ message: "If this email is available, a confirmation email will be sent." },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +69,6 @@ export async function POST(request: Request) {
|
||||
},
|
||||
});
|
||||
|
||||
// Send verification email (non-blocking — don't fail registration on email errors)
|
||||
try {
|
||||
const rawToken = crypto.randomBytes(32).toString("hex");
|
||||
const tokenHash = crypto
|
||||
@@ -71,7 +80,7 @@ export async function POST(request: Request) {
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: tokenHash,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -98,7 +107,10 @@ export async function POST(request: Request) {
|
||||
console.error("[register] Failed to send verification email:", emailError);
|
||||
}
|
||||
|
||||
return NextResponse.json(user, { status: 201 });
|
||||
return NextResponse.json(
|
||||
{ message: "If this email is available, a confirmation email will be sent." },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createHash } from "crypto";
|
||||
import { hash } from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
token: z.string().min(1, "Token is required"),
|
||||
@@ -15,6 +16,15 @@ function hashToken(token: string): string {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const rl = await checkRateLimit(`reset:${ip}`, AUTH_RATE_LIMITS.resetPassword);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many attempts. Please try again later." },
|
||||
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
|
||||
);
|
||||
}
|
||||
|
||||
const body: unknown = await request.json();
|
||||
const parsed = resetPasswordSchema.safeParse(body);
|
||||
|
||||
|
||||
@@ -37,6 +37,17 @@ export async function POST(request: Request) {
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const MAX_KEYS_PER_USER = 10;
|
||||
const keyCount = await prisma.apiKey.count({
|
||||
where: { userId: session.user.id, revoked: false },
|
||||
});
|
||||
if (keyCount >= MAX_KEYS_PER_USER) {
|
||||
return NextResponse.json(
|
||||
{ error: `Maximum of ${MAX_KEYS_PER_USER} API keys allowed. Revoke an existing key first.` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const name =
|
||||
typeof body.name === "string" && body.name.trim()
|
||||
|
||||
@@ -72,8 +72,14 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const origin =
|
||||
request.headers.get("origin") ?? "https://agentlens.vectry.tech";
|
||||
const ALLOWED_ORIGINS = [
|
||||
"https://agentlens.vectry.tech",
|
||||
"http://localhost:3000",
|
||||
];
|
||||
const requestOrigin = request.headers.get("origin");
|
||||
const origin = ALLOWED_ORIGINS.includes(requestOrigin ?? "")
|
||||
? requestOrigin!
|
||||
: "https://agentlens.vectry.tech";
|
||||
|
||||
const checkoutSession = await getStripe().checkout.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
|
||||
@@ -22,8 +22,14 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const origin =
|
||||
request.headers.get("origin") ?? "https://agentlens.vectry.tech";
|
||||
const ALLOWED_ORIGINS = [
|
||||
"https://agentlens.vectry.tech",
|
||||
"http://localhost:3000",
|
||||
];
|
||||
const requestOrigin = request.headers.get("origin");
|
||||
const origin = ALLOWED_ORIGINS.includes(requestOrigin ?? "")
|
||||
? requestOrigin!
|
||||
: "https://agentlens.vectry.tech";
|
||||
|
||||
const portalSession = await getStripe().billingPortal.sessions.create({
|
||||
customer: subscription.stripeCustomerId,
|
||||
|
||||
@@ -92,6 +92,12 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Missing or invalid Authorization header" }, { status: 401 });
|
||||
}
|
||||
|
||||
const contentLength = parseInt(request.headers.get("content-length") ?? "0", 10);
|
||||
const MAX_BODY_SIZE = 10 * 1024 * 1024;
|
||||
if (contentLength > MAX_BODY_SIZE) {
|
||||
return NextResponse.json({ error: "Request body too large (max 10MB)" }, { status: 413 });
|
||||
}
|
||||
|
||||
const rawApiKey = authHeader.slice(7);
|
||||
if (!rawApiKey) {
|
||||
return NextResponse.json({ error: "Missing API key in Authorization header" }, { status: 401 });
|
||||
|
||||
Reference in New Issue
Block a user