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