@cosmstack/blackshield
Version:
A developer-first security toolkit for React/Next.js applications
420 lines (414 loc) • 14.5 kB
JavaScript
;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/server/index.ts
var index_exports = {};
__export(index_exports, {
commonSchemas: () => commonSchemas,
createServerValidator: () => createServerValidator,
createSignedCookie: () => createSignedCookie,
csrfMiddleware: () => csrfMiddleware,
deleteSecureCookie: () => deleteSecureCookie,
generateCsrfToken: () => generateCsrfToken,
protect: () => protect,
protectServerAction: () => protectServerAction,
readSecureCookie: () => readSecureCookie,
setCsrfTokenCookie: () => setCsrfTokenCookie,
validateCsrfToken: () => validateCsrfToken,
validateServerInput: () => validateServerInput,
verifyCsrfToken: () => verifyCsrfToken
});
module.exports = __toCommonJS(index_exports);
// src/server/validation.ts
var import_zod = require("zod");
function validateServerInput(schema, input) {
try {
const data = schema.parse(input);
return {
data,
errors: {},
isValid: true
};
} catch (error) {
if (error instanceof import_zod.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: import_zod.z.string().email(),
password: import_zod.z.string().min(8),
id: import_zod.z.string().uuid(),
slug: import_zod.z.string().regex(/^[a-z0-9-]+$/),
url: import_zod.z.string().url(),
safeString: import_zod.z.string().max(1e3).regex(/^[^<>]*$/)
// No HTML tags
};
// src/server/cookies.ts
var import_jose = require("jose");
var import_headers = require("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 import_jose.SignJWT({ data: value }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(Math.floor(Date.now() / 1e3) + maxAge).sign(secret);
const cookieStore = await (0, import_headers.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 (0, import_headers.cookies)();
const token = cookieStore.get(name)?.value;
if (!token) {
return null;
}
const { payload } = await (0, import_jose.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 (0, import_headers.cookies)();
cookieStore.delete(name);
}
// src/server/csrf.ts
var import_jose2 = require("jose");
var import_headers2 = require("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 import_jose2.SignJWT({
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 (0, import_jose2.jwtVerify)(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 (0, import_headers2.cookies)();
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
var import_server = require("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 import_server.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 import_server.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 import_server.NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
}
if (options.roles && options.roles.length > 0) {
if (!user || !user.roles) {
return import_server.NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
}
const hasRequiredRole = options.roles.some((role) => user.roles?.includes(role));
if (!hasRequiredRole) {
return import_server.NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
}
}
if (options.permissions && options.permissions.length > 0) {
if (!user || !user.permissions) {
return import_server.NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
}
const hasRequiredPermission = options.permissions.some(
(permission) => user.permissions?.includes(permission)
);
if (!hasRequiredPermission) {
return import_server.NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
}
}
if (options.customAuth) {
const isAuthorized = await options.customAuth(user, req);
if (!isAuthorized) {
return import_server.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 import_server.NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
}
const validation = validateServerInput(options.schemaValidation, input);
if (!validation.isValid) {
return import_server.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 import_server.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 };
}
};
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
commonSchemas,
createServerValidator,
createSignedCookie,
csrfMiddleware,
deleteSecureCookie,
generateCsrfToken,
protect,
protectServerAction,
readSecureCookie,
setCsrfTokenCookie,
validateCsrfToken,
validateServerInput,
verifyCsrfToken
});