UNPKG

@cosmstack/blackshield

Version:

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

649 lines (638 loc) 21.2 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; 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 __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { DEFAULT_CONFIG: () => DEFAULT_CONFIG, SafeHTML: () => SafeHTML, SecureProvider: () => SecureProvider, createEnvValidator: () => createEnvValidator, createSafeHTML: () => createSafeHTML, envAudit: () => envAudit, getCsrfToken: () => getCsrfToken, sanitizeHTML: () => sanitizeHTML, scanEnvFiles: () => scanEnvFiles, useBlackshield: () => useBlackshield, useCsrfProtection: () => useCsrfProtection, useGuardedRoute: () => useGuardedRoute, useSanitizedHTML: () => useSanitizedHTML, useSecureUser: () => useSecureUser, validateEnvironmentVariables: () => validateEnvironmentVariables }); module.exports = __toCommonJS(index_exports); // src/core/context.tsx var import_react = require("react"); // src/config/defaults.ts var DEFAULT_CONFIG = { dev: process.env.NODE_ENV === "development", envValidation: { allowedPublicVars: ["NEXT_PUBLIC_APP_URL", "NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_VERCEL_URL"], schema: void 0 }, xssProtection: { autoSanitize: true, customRules: {} }, boundaryProtection: { validateServerProps: true, customValidators: {} }, csrfProtection: { enabled: false, tokenHeader: "x-csrf-token", tokenCookie: "csrf-token", tokenExpiry: 3600 } }; var DANGEROUS_ENV_PATTERNS = [ /^NEXT_PUBLIC_.*SECRET/i, /^NEXT_PUBLIC_.*KEY/i, /^NEXT_PUBLIC_.*TOKEN/i, /^NEXT_PUBLIC_.*PASSWORD/i, /^NEXT_PUBLIC_.*PRIVATE/i, /^NEXT_PUBLIC_.*API_SECRET/i, /^NEXT_PUBLIC_.*DATABASE/i ]; // src/core/env-validator.ts var import_zod = require("zod"); function validateEnvironmentVariables(config = {}) { const errors = []; const warnings = []; const exposedVars = []; const publicVars = Object.keys(process.env).filter((key) => key.startsWith("NEXT_PUBLIC_")); exposedVars.push(...publicVars); for (const varName of publicVars) { const isDangerous = DANGEROUS_ENV_PATTERNS.some((pattern) => pattern.test(varName)); if (isDangerous) { const isAllowed = config.allowedPublicVars?.includes(varName); if (!isAllowed) { errors.push( `Potentially sensitive environment variable exposed: ${varName}. Consider moving to server-side only or add to allowedPublicVars if intentional.` ); } } } if (config.allowedPublicVars) { for (const varName of publicVars) { if (!config.allowedPublicVars.includes(varName)) { warnings.push( `Environment variable ${varName} is not in allowedPublicVars list. Consider adding it explicitly or removing NEXT_PUBLIC_ prefix.` ); } } } if (config.schema) { try { const envObject = Object.fromEntries(publicVars.map((key) => [key, process.env[key]])); config.schema.parse(envObject); } catch (error) { if (error instanceof import_zod.ZodError) { errors.push(`Environment schema validation failed: ${error.message}`); } else { errors.push( `Environment schema validation failed: ${error instanceof Error ? error.message : "Unknown error"}` ); } } } return { isValid: errors.length === 0, errors, warnings, exposedVars }; } function createEnvValidator(allowedVars) { return () => validateEnvironmentVariables({ allowedPublicVars: allowedVars }); } // src/core/context.tsx var BlackshieldContext = (0, import_react.createContext)(null); function SecureProvider({ children, config = {}, initialUser = null, onAuthChange }) { const [user, setUser] = (0, import_react.useState)(initialUser); const [isLoading, setIsLoading] = (0, import_react.useState)(false); const [error, setError] = (0, import_react.useState)(); const mergedConfig = (0, import_react.useMemo)(() => ({ ...DEFAULT_CONFIG, ...config }), [config]); (0, import_react.useEffect)(() => { if (mergedConfig.dev) { const validation = validateEnvironmentVariables(mergedConfig.envValidation); if (!validation.isValid) { console.warn("[Blackshield] Environment validation issues:", validation.errors); } if (validation.warnings.length > 0) { console.warn("[Blackshield] Environment warnings:", validation.warnings); } } }, [mergedConfig]); const updateUser = (newUser) => { setUser(newUser); onAuthChange?.(newUser); }; const contextValue = { config: mergedConfig, auth: { user, isLoading, isAuthenticated: user !== null, error }, updateUser, setLoading: setIsLoading, setError }; return /* @__PURE__ */ React.createElement(BlackshieldContext.Provider, { value: contextValue }, children); } function useBlackshield() { const context = (0, import_react.useContext)(BlackshieldContext); if (!context) { throw new Error("useBlackshield must be used within a SecureProvider"); } return context; } // src/hooks/useSecureUser.ts var import_react2 = require("react"); function useSecureUser() { const { auth, updateUser, setLoading, setError } = useBlackshield(); const login = (0, import_react2.useCallback)( async (user) => { setLoading(true); setError(void 0); try { if (!user.id) { throw new Error("User ID is required"); } updateUser(user); } catch (error) { setError(error); } finally { setLoading(false); } }, [updateUser, setLoading, setError] ); const logout = (0, import_react2.useCallback)(async () => { setLoading(true); setError(void 0); try { updateUser(null); } catch (error) { setError(error); } finally { setLoading(false); } }, [updateUser, setLoading, setError]); const hasRole = (0, import_react2.useCallback)( (role) => { return auth.user?.roles?.includes(role) ?? false; }, [auth.user] ); const hasPermission = (0, import_react2.useCallback)( (permission) => { return auth.user?.permissions?.includes(permission) ?? false; }, [auth.user] ); const hasAnyRole = (0, import_react2.useCallback)( (roles) => { return roles.some((role) => hasRole(role)); }, [hasRole] ); const hasAllRoles = (0, import_react2.useCallback)( (roles) => { return roles.every((role) => hasRole(role)); }, [hasRole] ); return { user: auth.user, isLoading: auth.isLoading, isAuthenticated: auth.isAuthenticated, error: auth.error, login, logout, hasRole, hasPermission, hasAnyRole, hasAllRoles }; } // src/hooks/useGuardedRoute.ts var import_navigation = require("next/navigation"); var import_react3 = require("react"); function useGuardedRoute(config = {}) { const { user, isLoading, isAuthenticated } = useSecureUser(); const router = (0, import_navigation.useRouter)(); const { requiredRoles = [], requiredPermissions = [], redirectTo = "/login", customAuth } = config; (0, import_react3.useEffect)(() => { if (isLoading) return; if (!isAuthenticated) { if (process.env.NODE_ENV === "development") { console.warn("[Blackshield] Unauthenticated user accessing protected route"); } router.push(redirectTo); return; } if (customAuth && !customAuth(user)) { if (process.env.NODE_ENV === "development") { console.warn("[Blackshield] Custom authorization failed"); } router.push(redirectTo); return; } if (requiredRoles.length > 0) { const hasRequiredRole = requiredRoles.some((role) => user?.roles?.includes(role)); if (!hasRequiredRole) { if (process.env.NODE_ENV === "development") { console.warn("[Blackshield] User missing required roles:", requiredRoles); } router.push(redirectTo); return; } } if (requiredPermissions.length > 0) { const hasRequiredPermission = requiredPermissions.some( (permission) => user?.permissions?.includes(permission) ); if (!hasRequiredPermission) { if (process.env.NODE_ENV === "development") { console.warn("[Blackshield] User missing required permissions:", requiredPermissions); } router.push(redirectTo); return; } } }, [ isLoading, isAuthenticated, user, requiredRoles, requiredPermissions, customAuth, redirectTo, router ]); return { isAuthorized: isAuthenticated && (requiredRoles.length === 0 || requiredRoles.some((role) => user?.roles?.includes(role))) && (requiredPermissions.length === 0 || requiredPermissions.some((permission) => user?.permissions?.includes(permission))) && (!customAuth || customAuth(user)), isLoading, user }; } // src/hooks/useCsrfProtection.ts var import_react4 = require("react"); var DEFAULT_CONFIG2 = { tokenHeader: "x-csrf-token", tokenCookie: "csrf-token", tokenExpiry: 3600, autoInject: true, credentialsOnly: true }; function useCsrfProtection(options = {}) { const config = (0, import_react4.useMemo)(() => ({ ...DEFAULT_CONFIG2, ...options }), [options]); const [token, setToken] = (0, import_react4.useState)(null); const [isLoading, setIsLoading] = (0, import_react4.useState)(true); const getTokenFromCookie = (0, import_react4.useCallback)(() => { if (typeof document === "undefined") return null; const cookies = document.cookie.split(";"); const csrfCookie = cookies.find((cookie) => cookie.trim().startsWith(`${config.tokenCookie}=`)); if (csrfCookie) { return csrfCookie.split("=")[1]?.trim() || null; } return null; }, [config.tokenCookie]); const fetchToken = (0, import_react4.useCallback)(async () => { try { const response = await fetch("/api/csrf-token", { method: "GET", credentials: "include" }); if (response.ok) { const data = await response.json(); return data.token || null; } } catch (error) { console.warn("[Blackshield] Failed to fetch CSRF token:", error); } return null; }, []); const refreshToken = (0, import_react4.useCallback)(async () => { setIsLoading(true); let newToken = getTokenFromCookie(); if (!newToken) { newToken = await fetchToken(); } setToken(newToken); setIsLoading(false); }, [getTokenFromCookie, fetchToken]); const getCsrfToken2 = (0, import_react4.useCallback)(() => { return token || getTokenFromCookie(); }, [token, getTokenFromCookie]); const protectedFetch = (0, import_react4.useCallback)( async (input, init = {}) => { const currentToken = getCsrfToken2(); const shouldInject = config.autoInject && (!config.credentialsOnly || init.credentials === "include" || init.credentials === "same-origin"); const method = init.method?.toUpperCase() || "GET"; const isSafeMethod = ["GET", "HEAD", "OPTIONS"].includes(method); if (shouldInject && !isSafeMethod && currentToken) { const headers = new Headers(init.headers); headers.set(config.tokenHeader, currentToken); const updatedInit = { ...init, headers }; return fetch(input, updatedInit); } return fetch(input, init); }, [config, getCsrfToken2] ); (0, import_react4.useEffect)(() => { refreshToken(); }, [refreshToken]); (0, import_react4.useEffect)(() => { if (!config.autoInject) return; const interval = setInterval( () => { refreshToken(); }, 30 * 60 * 1e3 ); return () => clearInterval(interval); }, [config.autoInject, refreshToken]); return { token, isLoading, refreshToken, getCsrfToken: getCsrfToken2, protectedFetch }; } function getCsrfToken(tokenCookie = "csrf-token") { if (typeof document === "undefined") return null; const cookies = document.cookie.split(";"); const csrfCookie = cookies.find((cookie) => cookie.trim().startsWith(`${tokenCookie}=`)); if (csrfCookie) { return csrfCookie.split("=")[1]?.trim() || null; } return null; } // src/core/xss-protection.tsx var import_isomorphic_dompurify = __toESM(require("isomorphic-dompurify"), 1); function sanitizeHTML(input, options = {}) { const warnings = []; const removedTags = []; let sanitized = input; let wasModified = false; if (options.customRules) { for (const [ruleName, ruleFunction] of Object.entries(options.customRules)) { const before = sanitized; sanitized = ruleFunction(sanitized); if (before !== sanitized) { wasModified = true; warnings.push(`Applied custom rule: ${ruleName}`); } } } const config = {}; if (options.allowedTags) { config.ALLOWED_TAGS = options.allowedTags; } if (options.allowedAttributes) { config.ALLOWED_ATTR = options.allowedAttributes; } const originalContent = sanitized; const purifiedResult = import_isomorphic_dompurify.default.sanitize(sanitized, config); const purified = String(purifiedResult); if (purified !== originalContent) { wasModified = true; const originalTags = originalContent.match(/<(\w+)[^>]*>/g) || []; const purifiedTags = purified.match(/<(\w+)[^>]*>/g) || []; const originalTagNames = originalTags.map((tag) => tag.match(/<(\w+)/)?.[1]).filter(Boolean); const purifiedTagNames = purifiedTags.map((tag) => tag.match(/<(\w+)/)?.[1]).filter(Boolean); const removed = originalTagNames.filter((tag) => !purifiedTagNames.includes(tag)); removedTags.push(...removed); if (removed.length > 0) { warnings.push(`Removed potentially dangerous tags: ${removed.join(", ")}`); } if (originalContent.includes("<script") && !purified.includes("<script")) { warnings.push("Removed script tags"); } const eventHandlers = ["onclick", "onload", "onerror", "onmouseover"]; for (const handler of eventHandlers) { if (originalContent.includes(handler) && !purified.includes(handler)) { warnings.push(`Removed ${handler} event handler`); } } } return { sanitized: purified, wasModified, removedTags: [...new Set(removedTags)], // Remove duplicates warnings }; } function createSafeHTML(html) { const result = sanitizeHTML(html); if (process.env.NODE_ENV === "development" && result.wasModified) { console.warn("[Blackshield] HTML was sanitized:", { removedTags: result.removedTags, warnings: result.warnings }); } return { __html: result.sanitized }; } function SafeHTML({ html, tag = "div", className, allowedTags, allowedAttributes, ...props }) { const sanitizationOptions = { allowedTags, allowedAttributes }; const result = sanitizeHTML(html, sanitizationOptions); if (process.env.NODE_ENV === "development" && result.wasModified) { console.warn("[Blackshield] HTML was sanitized:", { removedTags: result.removedTags, warnings: result.warnings }); } const safeHTML = { __html: result.sanitized }; const Tag = tag; return /* @__PURE__ */ React.createElement(Tag, { className, dangerouslySetInnerHTML: safeHTML, ...props }); } function useSanitizedHTML(html, options) { return sanitizeHTML(html, options); } // src/core/env-audit.ts var import_node_fs = require("fs"); var import_node_path = __toESM(require("path"), 1); var import_glob = require("glob"); var DEFAULT_SENSITIVE_PATTERNS = [ /SECRET/i, /KEY/i, /TOKEN/i, /PASSWORD/i, /PRIVATE/i, /API_SECRET/i, /DATABASE/i, /DB_/i, /MONGO/i, /REDIS/i, /JWT/i, /AUTH/i, /STRIPE_SECRET/i, /PAYPAL/i, /WEBHOOK/i, /ENCRYPTION/i, /HASH/i, /SALT/i ]; async function envAudit(options = {}) { const { projectPath = process.cwd(), customPatterns = [], allowedPublicVars = [] } = options; const sensitivePatterns = [...DEFAULT_SENSITIVE_PATTERNS, ...customPatterns]; const sensitiveVars = []; const filesScanned = []; let totalVars = 0; try { const envFiles = await (0, import_glob.glob)("**/.env*", { cwd: projectPath, ignore: ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/build/**"], dot: true }); for (const envFile of envFiles) { const filePath = import_node_path.default.join(projectPath, envFile); filesScanned.push(envFile); try { const content = await import_node_fs.promises.readFile(filePath, "utf-8"); const lines = content.split("\n"); lines.forEach((line, index) => { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) return; const envMatch = trimmed.match(/^([A-Z_][A-Z0-9_]*)\s*=/); if (!envMatch) return; const varName = envMatch[1]; totalVars++; if (varName.startsWith("NEXT_PUBLIC_")) { if (allowedPublicVars.includes(varName)) return; const isSensitive = sensitivePatterns.some((pattern) => pattern.test(varName)); if (isSensitive) { sensitiveVars.push({ name: varName, file: envFile, line: index + 1, severity: "error", suggestion: `Remove NEXT_PUBLIC_ prefix or add "${varName}" to allowedPublicVars in config` }); } else { sensitiveVars.push({ name: varName, file: envFile, line: index + 1, severity: "warning", suggestion: `Consider if "${varName}" needs to be public. If not, remove NEXT_PUBLIC_ prefix` }); } } }); } catch (error) { console.warn(`Failed to read env file ${envFile}:`, error); } } } catch (error) { console.warn("Failed to scan for env files:", error); } return { isValid: sensitiveVars.filter((v) => v.severity === "error").length === 0, sensitiveVars, summary: { totalVars, sensitiveCount: sensitiveVars.length, filesScanned } }; } async function scanEnvFiles(projectPath) { const result = await envAudit({ projectPath }); console.log("\n\u{1F50D} Environment Variable Audit\n"); if (result.sensitiveVars.length === 0) { console.log("\u2705 No sensitive environment variables found exposed to client!"); console.log( `\u{1F4CA} Scanned ${result.summary.totalVars} variables in ${result.summary.filesScanned.length} files` ); return; } const errors = result.sensitiveVars.filter((v) => v.severity === "error"); const warnings = result.sensitiveVars.filter((v) => v.severity === "warning"); if (errors.length > 0) { console.log(`\u274C ${errors.length} sensitive variable(s) exposed to client:`); for (const issue of errors) { console.log(` ${issue.file}:${issue.line} - ${issue.name}`); console.log(` \u{1F4A1} ${issue.suggestion}`); } console.log(); } if (warnings.length > 0) { console.log(`\u26A0\uFE0F ${warnings.length} potentially unnecessary public variable(s):`); for (const issue of warnings) { console.log(` ${issue.file}:${issue.line} - ${issue.name}`); console.log(` \u{1F4A1} ${issue.suggestion}`); } console.log(); } console.log(`\u{1F4CA} Summary: ${errors.length} errors, ${warnings.length} warnings`); console.log(`\u{1F4C1} Files scanned: ${result.summary.filesScanned.join(", ")}`); } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { DEFAULT_CONFIG, SafeHTML, SecureProvider, createEnvValidator, createSafeHTML, envAudit, getCsrfToken, sanitizeHTML, scanEnvFiles, useBlackshield, useCsrfProtection, useGuardedRoute, useSanitizedHTML, useSecureUser, validateEnvironmentVariables });