UNPKG

@cosmstack/blackshield

Version:

A developer-first security toolkit for React/Next.js applications

381 lines (377 loc) 12.5 kB
// src/server/validation.ts import { z } from "zod"; function validateServerInput(schema, input) { try { const data = schema.parse(input); return { data, errors: {}, isValid: true }; } catch (error) { if (error instanceof z.ZodError) { const errors = {}; for (const issue of error.issues) { const path = issue.path.join("."); if (!errors[path]) { errors[path] = []; } errors[path].push(issue.message); } return { data: input, errors, isValid: false }; } return { data: input, errors: { _root: ["Validation failed"] }, isValid: false }; } } function createServerValidator(schema) { return (input) => validateServerInput(schema, input); } var commonSchemas = { email: z.string().email(), password: z.string().min(8), id: z.string().uuid(), slug: z.string().regex(/^[a-z0-9-]+$/), url: z.string().url(), safeString: z.string().max(1e3).regex(/^[^<>]*$/) // No HTML tags }; // src/server/cookies.ts import { SignJWT, jwtVerify } from "jose"; import { cookies } from "next/headers"; var secret = new TextEncoder().encode( process.env.BLACKSHIELD_SECRET || "fallback-secret-change-in-production" ); async function createSignedCookie(name, value, options = {}) { const { maxAge = 60 * 60 * 24 * 7, // 7 days httpOnly = true, secure = process.env.NODE_ENV === "production", sameSite = "lax", path = "/" } = options; try { const token = await new SignJWT({ data: value }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(Math.floor(Date.now() / 1e3) + maxAge).sign(secret); const cookieStore = await cookies(); cookieStore.set(name, token, { maxAge, httpOnly, secure, sameSite, path }); } catch (error) { if (process.env.NODE_ENV === "development") { console.error("[Blackshield] Failed to create signed cookie:", error); } throw new Error("Failed to create secure cookie"); } } async function readSecureCookie(name) { try { const cookieStore = await cookies(); const token = cookieStore.get(name)?.value; if (!token) { return null; } const { payload } = await jwtVerify(token, secret); return payload.data; } catch (error) { if (process.env.NODE_ENV === "development") { console.error("[Blackshield] Failed to read secure cookie:", error); } return null; } } async function deleteSecureCookie(name) { const cookieStore = await cookies(); cookieStore.delete(name); } // src/server/csrf.ts import { SignJWT as SignJWT2, jwtVerify as jwtVerify2 } from "jose"; import { cookies as cookies2 } from "next/headers"; var DEFAULT_CONFIG = { tokenHeader: "x-csrf-token", tokenCookie: "csrf-token", tokenExpiry: 3600 // 1 hour }; function getJwtSecret() { const secret2 = process.env.JWT_SECRET || process.env.NEXTAUTH_SECRET; if (!secret2) { throw new Error( "JWT_SECRET or NEXTAUTH_SECRET environment variable is required for CSRF protection" ); } return new TextEncoder().encode(secret2); } async function generateCsrfToken(config = {}) { const { tokenExpiry } = { ...DEFAULT_CONFIG, ...config }; const secret2 = getJwtSecret(); const expiresAt = Date.now() + tokenExpiry * 1e3; const token = await new SignJWT2({ type: "csrf", iat: Math.floor(Date.now() / 1e3), exp: Math.floor(expiresAt / 1e3) }).setProtectedHeader({ alg: "HS256" }).sign(secret2); return { token, expiresAt }; } async function verifyCsrfToken(token) { try { const secret2 = getJwtSecret(); const { payload } = await jwtVerify2(token, secret2); return payload.type === "csrf" && typeof payload.exp === "number" && payload.exp > Math.floor(Date.now() / 1e3); } catch { return false; } } async function setCsrfTokenCookie(config = {}) { const { tokenCookie } = { ...DEFAULT_CONFIG, ...config }; const { token } = await generateCsrfToken(config); const cookieStore = await cookies2(); cookieStore.set(tokenCookie, token, { httpOnly: false, // Allow client-side access secure: process.env.NODE_ENV === "production", sameSite: "strict", maxAge: config.tokenExpiry || DEFAULT_CONFIG.tokenExpiry }); return token; } function getCsrfTokenFromRequest(req, config = {}) { const { tokenHeader, tokenCookie } = { ...DEFAULT_CONFIG, ...config }; const headerToken = req.headers.get(tokenHeader); if (headerToken) return headerToken; const cookieToken = req.cookies.get(tokenCookie)?.value; if (cookieToken) return cookieToken; if (req.method === "POST" && req.headers.get("content-type")?.includes("application/x-www-form-urlencoded")) { return null; } return null; } async function validateCsrfToken(req, config = {}) { if (["GET", "HEAD", "OPTIONS"].includes(req.method)) { return true; } const token = getCsrfTokenFromRequest(req, config); if (!token) return false; return await verifyCsrfToken(token); } function csrfMiddleware(config = {}) { return async (req) => { const isValid = await validateCsrfToken(req, config); if (!isValid) { return Response.json({ error: "Invalid or missing CSRF token" }, { status: 403 }); } return null; }; } // src/server/middleware.ts import { NextResponse } from "next/server"; var rateLimitStore = /* @__PURE__ */ new Map(); function checkRateLimit(key, max, windowSeconds) { const now = Date.now(); const windowMs = windowSeconds * 1e3; const entry = rateLimitStore.get(key); if (entry && now > entry.resetTime) { rateLimitStore.delete(key); } const currentEntry = rateLimitStore.get(key); if (!currentEntry) { rateLimitStore.set(key, { count: 1, resetTime: now + windowMs }); return true; } if (currentEntry.count >= max) { return false; } currentEntry.count++; return true; } function cleanupExpiredEntries() { const now = Date.now(); for (const [key, entry] of rateLimitStore.entries()) { if (now > entry.resetTime) { rateLimitStore.delete(key); } } } var lastCleanup = 0; var CLEANUP_INTERVAL = 5 * 60 * 1e3; function maybeCleanup() { const now = Date.now(); if (now - lastCleanup > CLEANUP_INTERVAL) { cleanupExpiredEntries(); lastCleanup = now; } } function protect(handler, options = {}) { return async (req, params) => { try { if (options.rateLimit) { maybeCleanup(); const { max, windowSeconds, keyGenerator } = options.rateLimit; const key = keyGenerator ? keyGenerator(req) : req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "anonymous"; if (!checkRateLimit(key, max, windowSeconds)) { return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 }); } } if (options.csrf) { const csrfConfig = typeof options.csrf === "boolean" ? {} : options.csrf; const isValidCsrf = await validateCsrfToken(req, csrfConfig); if (!isValidCsrf) { return NextResponse.json({ error: "Invalid or missing CSRF token" }, { status: 403 }); } } let user = null; if (options.requireAuth || options.roles || options.permissions || options.customAuth) { user = await readSecureCookie("session"); if (options.requireAuth && !user) { return NextResponse.json({ error: "Authentication required" }, { status: 401 }); } } if (options.roles && options.roles.length > 0) { if (!user || !user.roles) { return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 }); } const hasRequiredRole = options.roles.some((role) => user.roles?.includes(role)); if (!hasRequiredRole) { return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 }); } } if (options.permissions && options.permissions.length > 0) { if (!user || !user.permissions) { return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 }); } const hasRequiredPermission = options.permissions.some( (permission) => user.permissions?.includes(permission) ); if (!hasRequiredPermission) { return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 }); } } if (options.customAuth) { const isAuthorized = await options.customAuth(user, req); if (!isAuthorized) { return NextResponse.json({ error: "Access denied" }, { status: 403 }); } } let validatedInput; if (options.schemaValidation) { let input; if (req.method === "GET") { const url = new URL(req.url); input = Object.fromEntries(url.searchParams.entries()); } else { try { const contentType = req.headers.get("content-type") || ""; if (contentType.includes("application/json")) { input = await req.json(); } else if (contentType.includes("application/x-www-form-urlencoded")) { const formData = await req.formData(); input = Object.fromEntries(formData.entries()); } else { input = await req.text(); } } catch (error) { return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } } const validation = validateServerInput(options.schemaValidation, input); if (!validation.isValid) { return NextResponse.json( { error: "Validation failed", details: validation.errors }, { status: 400 } ); } validatedInput = validation.data; } const context = { user, validatedInput }; return await handler(req, context, params); } catch (error) { console.error("[Blackshield] Protection middleware error:", error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } }; } function protectServerAction(action, options = {}) { return async (...args) => { try { let user = null; if (options.requireAuth || options.roles || options.permissions || options.customAuth) { user = await readSecureCookie("session"); if (options.requireAuth && !user) { return { error: "Authentication required", status: 401 }; } } if (options.roles && options.roles.length > 0) { if (!user || !user.roles) { return { error: "Insufficient permissions", status: 403 }; } const hasRequiredRole = options.roles.some((role) => user.roles?.includes(role)); if (!hasRequiredRole) { return { error: "Insufficient permissions", status: 403 }; } } if (options.permissions && options.permissions.length > 0) { if (!user || !user.permissions) { return { error: "Insufficient permissions", status: 403 }; } const hasRequiredPermission = options.permissions.some( (permission) => user.permissions?.includes(permission) ); if (!hasRequiredPermission) { return { error: "Insufficient permissions", status: 403 }; } } if (options.customAuth) { const mockReq = {}; const isAuthorized = await options.customAuth(user, mockReq); if (!isAuthorized) { return { error: "Access denied", status: 403 }; } } if (options.schemaValidation && args.length > 0) { const validation = validateServerInput(options.schemaValidation, args[0]); if (!validation.isValid) { return { error: "Validation failed", status: 400, details: validation.errors // biome-ignore lint/suspicious/noExplicitAny: Complex return type requires any for compatibility }; } args[0] = validation.data; } return await action(...args); } catch (error) { console.error("[Blackshield] Server action protection error:", error); return { error: "Internal server error", status: 500 }; } }; } export { commonSchemas, createServerValidator, createSignedCookie, csrfMiddleware, deleteSecureCookie, generateCsrfToken, protect, protectServerAction, readSecureCookie, setCsrfTokenCookie, validateCsrfToken, validateServerInput, verifyCsrfToken };