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
+ 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();