@cosmstack/blackshield
Version:
A developer-first security toolkit for React/Next.js applications
381 lines (377 loc) • 12.5 kB
JavaScript
// 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
};