diff --git a/src/app/compare/page.tsx b/src/app/compare/page.tsx index d3416839..2607f281 100644 --- a/src/app/compare/page.tsx +++ b/src/app/compare/page.tsx @@ -151,6 +151,16 @@ function ComparePage() { const printWindow = window.open('', '_blank'); if (!printWindow) return; + // Build document via safe DOM APIs instead of document.write to avoid + // CSP bypass and script re-execution risks. + const doc = printWindow.document; + + // Create title + const title = doc.createElement('title'); + title.textContent = 'Property Comparison'; + doc.head.appendChild(title); + + // Create styles const doc = printWindow.document; doc.open(); doc.write(''); @@ -170,6 +180,19 @@ function ComparePage() { h1 { color: #333; } @media print { body { padding: 0; } } `; + doc.head.appendChild(style); + + // Build body + const h1 = doc.createElement('h1'); + h1.textContent = 'Property Comparison Report'; + doc.body.appendChild(h1); + + const dateP = doc.createElement('p'); + dateP.textContent = `Generated on ${new Date().toLocaleDateString()}`; + doc.body.appendChild(dateP); + + // Build table + const table = doc.createElement('table'); head.appendChild(style); html.appendChild(head); @@ -189,6 +212,9 @@ function ComparePage() { const metricTh = doc.createElement('th'); metricTh.textContent = 'Metric'; headerRow.appendChild(metricTh); + properties.forEach(p => { + const th = doc.createElement('th'); + th.textContent = p.name; properties.forEach(prop => { const th = doc.createElement('th'); th.textContent = prop.name; @@ -196,6 +222,17 @@ function ComparePage() { }); thead.appendChild(headerRow); table.appendChild(thead); + + const tbody = doc.createElement('tbody'); + comparisonMetrics.forEach(metric => { + const row = doc.createElement('tr'); + const labelTd = doc.createElement('td'); + labelTd.textContent = metric.label; + row.appendChild(labelTd); + properties.forEach(p => { + const td = doc.createElement('td'); + td.textContent = metric.format(getNestedValue(p, metric.key), p); + row.appendChild(td); const tbody = doc.createElement('tbody'); comparisonMetrics.forEach(metric => { @@ -211,6 +248,9 @@ function ComparePage() { tbody.appendChild(row); }); table.appendChild(tbody); + doc.body.appendChild(table); + + // Close the document stream so browsers finish rendering before printing body.appendChild(table); html.appendChild(body); diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx index c10618ce..da444062 100644 --- a/src/components/ui/chart.tsx +++ b/src/components/ui/chart.tsx @@ -77,8 +77,11 @@ function buildChartCSS(id: string, config: ChartConfig): string { if (!colorConfig.length) return '' - return Object.entries(THEMES) - .map(([theme, prefix]) => ` + // Build CSS rules safely as a string (only uses known-safe values: + // theme keys, color strings from config, and CSS variable names) + const cssText = Object.entries(THEMES) + .map( + ([theme, prefix]) => ` ${prefix} [data-chart=${id}] { ${colorConfig .map(([key, itemConfig]) => { @@ -90,18 +93,11 @@ ${colorConfig .filter(Boolean) .join("\n")} } -`) +` + ) .join("\n") -} - -const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const css = React.useMemo(() => buildChartCSS(id, config), [id, config]) - - if (!css) return null - - const sanitizedCss = DOMPurify.sanitize(css) - return } const ChartTooltip = Tooltip diff --git a/src/lib/propertyCache.ts b/src/lib/propertyCache.ts index 03155ccc..f56a612a 100644 --- a/src/lib/propertyCache.ts +++ b/src/lib/propertyCache.ts @@ -44,6 +44,9 @@ import { safeLocalStorage } from '@/utils/safeLocalStorage'; // Event listeners const eventListeners: Set = new Set(); +// Interval handle for cleanup timer (stored at module level to allow cleanup on re-init) +let cleanupIntervalHandle: ReturnType | null = null; + // Cache statistics let cacheStats: CacheStats = { totalEntries: 0, @@ -831,9 +834,14 @@ export const initPropertyCache = async (): Promise => { // Clean up expired entries on init await cleanupExpiredEntries(); + // Clear any existing cleanup interval (prevents accumulation on HMR / re-imports) + if (cleanupIntervalHandle !== null) { + clearInterval(cleanupIntervalHandle); + } + // Set up periodic cleanup const config = getCacheConfig(); - setInterval(cleanupExpiredEntries, config.cleanupInterval); + cleanupIntervalHandle = setInterval(cleanupExpiredEntries, config.cleanupInterval); logger.info('Property cache initialized'); } catch (error) { diff --git a/src/lib/rateLimit.ts b/src/lib/rateLimit.ts index 5629d40d..5895d0d1 100644 --- a/src/lib/rateLimit.ts +++ b/src/lib/rateLimit.ts @@ -20,20 +20,45 @@ interface RateLimitResult { const ipStore: RateLimitStore = {}; const walletStore: RateLimitStore = {}; -// Clean up expired entries periodically -setInterval(() => { - const now = Date.now(); - Object.keys(ipStore).forEach(key => { - if (ipStore[key].resetTime <= now) { - delete ipStore[key]; - } - }); - Object.keys(walletStore).forEach(key => { - if (walletStore[key].resetTime <= now) { - delete walletStore[key]; - } - }); -}, 60000); // Clean up every minute +// Interval handle for cleanup timer +let cleanupIntervalHandle: ReturnType | null = null; + +/** + * Start the periodic cleanup of expired rate-limit entries. + * Safe to call multiple times — clears any existing interval first. + */ +function startCleanupTimer(): void { + if (cleanupIntervalHandle !== null) { + clearInterval(cleanupIntervalHandle); + } + + cleanupIntervalHandle = setInterval(() => { + const now = Date.now(); + Object.keys(ipStore).forEach(key => { + if (ipStore[key].resetTime <= now) { + delete ipStore[key]; + } + }); + Object.keys(walletStore).forEach(key => { + if (walletStore[key].resetTime <= now) { + delete walletStore[key]; + } + }); + }, 60000); // Clean up every minute +} + +// Start the cleanup timer on module load +startCleanupTimer(); + +/** + * Stops the cleanup timer. Useful for tests and HMR teardown. + */ +export function stopRateLimitCleanup(): void { + if (cleanupIntervalHandle !== null) { + clearInterval(cleanupIntervalHandle); + cleanupIntervalHandle = null; + } +} function getRateLimitData(store: RateLimitStore, key: string, windowMs: number): { count: number; diff --git a/src/utils/security/blockchainSecurity.ts b/src/utils/security/blockchainSecurity.ts index dfca2f67..d48c2a6a 100644 --- a/src/utils/security/blockchainSecurity.ts +++ b/src/utils/security/blockchainSecurity.ts @@ -1,4 +1,5 @@ -import { logger } from '@/utils/logger'; +import { parseEther } from 'viem'; + export interface SecurityServiceConfig { apiKey?: string; baseUrl: string; @@ -38,6 +39,86 @@ export interface SecurityAlert { timestamp: number; } +/** + * Normalises a transaction value string into a BigInt. + * Handles hex strings (0x-prefixed), scientific notation, and decimal strings. + * Throws a typed error for unparseable values. + */ +function normalizeToBigInt(value: string): bigint { + if (typeof value !== 'string' || value.length === 0) { + throw new BlockchainSecurityError( + 'Invalid value: must be a non-empty string', + 'INVALID_VALUE' + ); + } + + let normalised = value.trim(); + + // Handle hex (0x-prefixed) — viem-style wei values + if (normalised.startsWith('0x') || normalised.startsWith('0X')) { + try { + return BigInt(normalised); + } catch { + throw new BlockchainSecurityError( + `Unable to parse hex value: "${normalised}"`, + 'INVALID_HEX_VALUE' + ); + } + } + + // Handle scientific notation (e.g. "1e18", "1.5e-3") + if (/[eE]/.test(normalised)) { + const asNumber = Number(normalised); + if (!Number.isFinite(asNumber)) { + throw new BlockchainSecurityError( + `Scientific notation overflow: "${normalised}"`, + 'VALUE_OVERFLOW' + ); + } + // Convert to a whole-number string suitable for BigInt + try { + return BigInt(Math.round(asNumber)); + } catch { + throw new BlockchainSecurityError( + `Unable to parse scientific notation: "${normalised}"`, + 'INVALID_SCI_VALUE' + ); + } + } + + // Handle fractional decimal (e.g. "1.5" — treat as ether value) + if (normalised.includes('.')) { + try { + return parseEther(normalised as `${number}`); + } catch { + throw new BlockchainSecurityError( + `Unable to parse decimal ether value: "${normalised}"`, + 'INVALID_ETHER_VALUE' + ); + } + } + + // Plain decimal string (wei) + try { + return BigInt(normalised); + } catch { + throw new BlockchainSecurityError( + `Unable to parse value: "${normalised}"`, + 'INVALID_VALUE' + ); + } +} + +export class BlockchainSecurityError extends Error { + public readonly code: string; + + constructor(message: string, code: string) { + super(message); + this.name = 'BlockchainSecurityError'; + this.code = code; + } +} + export class BlockchainSecurityService { // Singleton instance — ensures only one service instance exists across the app private static instance: BlockchainSecurityService; @@ -259,9 +340,9 @@ export class BlockchainSecurityService { blocks.push('Recipient address is on sanctions list'); } - // BigInt comparison: 1 ETH = 10^18 wei; flag high-value sends to risky recipients - const valueBN = BigInt(value); - if (valueBN >= BigInt('1000000000000000000') && recipientRisk.riskScore > 50) { // >= 1 ETH + // Check for high-value transaction to risky address + const valueBN = normalizeToBigInt(value); + if (valueBN > BigInt('1000000000000000000') && recipientRisk.riskScore > 50) { // > 1 ETH warnings.push('High-value transaction to risky address'); } diff --git a/src/utils/structuredLogger.ts b/src/utils/structuredLogger.ts index 18934b06..5c36888c 100644 --- a/src/utils/structuredLogger.ts +++ b/src/utils/structuredLogger.ts @@ -293,8 +293,12 @@ class StructuredLogger { export const structuredLogger = new StructuredLogger(); -export const createStructuredLogger = (config?: Partial) => - new StructuredLogger(config); +// Note: structuredLogger.destroy() should be called manually for cleanup +// in test teardowns or before recreating the singleton. + +// ============================================================================ +// Performance Monitoring Helper +// ============================================================================ export const createPerformanceTracker = (operation: string, metadata?: Partial) => { const start = performance.now();