UNPKG

@cosmstack/blackshield

Version:

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

598 lines (589 loc) 18.5 kB
// src/core/context.tsx import { createContext, useContext, useEffect, useMemo, useState } from "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 import { ZodError } from "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 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 = createContext(null); function SecureProvider({ children, config = {}, initialUser = null, onAuthChange }) { const [user, setUser] = useState(initialUser); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); const mergedConfig = useMemo(() => ({ ...DEFAULT_CONFIG, ...config }), [config]); 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 = useContext(BlackshieldContext); if (!context) { throw new Error("useBlackshield must be used within a SecureProvider"); } return context; } // src/hooks/useSecureUser.ts import { useCallback } from "react"; function useSecureUser() { const { auth, updateUser, setLoading, setError } = useBlackshield(); const login = 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 = useCallback(async () => { setLoading(true); setError(void 0); try { updateUser(null); } catch (error) { setError(error); } finally { setLoading(false); } }, [updateUser, setLoading, setError]); const hasRole = useCallback( (role) => { return auth.user?.roles?.includes(role) ?? false; }, [auth.user] ); const hasPermission = useCallback( (permission) => { return auth.user?.permissions?.includes(permission) ?? false; }, [auth.user] ); const hasAnyRole = useCallback( (roles) => { return roles.some((role) => hasRole(role)); }, [hasRole] ); const hasAllRoles = 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 import { useRouter } from "next/navigation"; import { useEffect as useEffect2 } from "react"; function useGuardedRoute(config = {}) { const { user, isLoading, isAuthenticated } = useSecureUser(); const router = useRouter(); const { requiredRoles = [], requiredPermissions = [], redirectTo = "/login", customAuth } = config; useEffect2(() => { 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 import { useCallback as useCallback2, useEffect as useEffect3, useMemo as useMemo2, useState as useState2 } from "react"; var DEFAULT_CONFIG2 = { tokenHeader: "x-csrf-token", tokenCookie: "csrf-token", tokenExpiry: 3600, autoInject: true, credentialsOnly: true }; function useCsrfProtection(options = {}) { const config = useMemo2(() => ({ ...DEFAULT_CONFIG2, ...options }), [options]); const [token, setToken] = useState2(null); const [isLoading, setIsLoading] = useState2(true); const getTokenFromCookie = useCallback2(() => { 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 = useCallback2(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 = useCallback2(async () => { setIsLoading(true); let newToken = getTokenFromCookie(); if (!newToken) { newToken = await fetchToken(); } setToken(newToken); setIsLoading(false); }, [getTokenFromCookie, fetchToken]); const getCsrfToken2 = useCallback2(() => { return token || getTokenFromCookie(); }, [token, getTokenFromCookie]); const protectedFetch = useCallback2( 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] ); useEffect3(() => { refreshToken(); }, [refreshToken]); useEffect3(() => { 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 import DOMPurify from "isomorphic-dompurify"; 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 = DOMPurify.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 import { promises as fs } from "fs"; import path from "path"; import { glob } from "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 glob("**/.env*", { cwd: projectPath, ignore: ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/build/**"], dot: true }); for (const envFile of envFiles) { const filePath = path.join(projectPath, envFile); filesScanned.push(envFile); try { const content = await fs.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(", ")}`); } export { DEFAULT_CONFIG, SafeHTML, SecureProvider, createEnvValidator, createSafeHTML, envAudit, getCsrfToken, sanitizeHTML, scanEnvFiles, useBlackshield, useCsrfProtection, useGuardedRoute, useSanitizedHTML, useSecureUser, validateEnvironmentVariables };