Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"lint": "eslint . --max-warnings=0",
"perf:budgets": "node scripts/check-performance-budgets.mjs",
"perf:ci": "npm run build && npm run perf:budgets",
"security:check-globals": "node scripts/check-exposed-globals.mjs",
"validate:env": "node scripts/validate-env.js",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
Expand Down
37 changes: 37 additions & 0 deletions scripts/check-exposed-globals.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { readFileSync, existsSync } from 'fs';
import { glob } from 'glob';

const SENSITIVE_PATTERNS = [
/__[A-Z][A-Z_]+__/g,
];

async function main() {
const files = await glob('src/**/*.{ts,tsx,js,jsx}', {
ignore: ['src/**/*.test.*', 'src/**/__tests__/**', 'node_modules/**'],
});

let hasError = false;

for (const file of files) {
if (!existsSync(file)) continue;
const content = readFileSync(file, 'utf-8');
const matches = content.match(SENSITIVE_PATTERNS[0]);
if (matches) {
for (const match of matches) {
console.error(`[FAIL] Found exposed global '${match}' in ${file}`);
hasError = true;
}
}
}

if (hasError) {
process.exit(1);
}

console.log('[PASS] No exposed globals found.');
}

main().catch((err) => {
console.error('Script failed:', err);
process.exit(1);
});
54 changes: 54 additions & 0 deletions src/app/api/security/address-check/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const address = searchParams.get('address')?.trim();

if (!address) {
return NextResponse.json({ error: 'Address parameter required' }, { status: 400 });
}

if (!/^0x[a-fA-F0-9]{40}$/.test(address)) {
return NextResponse.json({ error: 'Invalid Ethereum address' }, { status: 400 });
}

const apiKey = process.env.CHAINALYSIS_API_KEY;

if (!apiKey) {
return NextResponse.json({
address,
risk_score: 50,
categories: ['unknown'],
description: 'Risk check unavailable (service not configured)',
});
}

try {
const response = await fetch(
`https://api.chainalysis.com/api/v2/address/${address}`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(10000),
}
);

if (!response.ok) {
const errorText = await response.text();
return NextResponse.json(
{ error: `Upstream service error: ${response.status}`, detail: errorText },
{ status: response.status }
);
}

const data = await response.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 502 }
);
}
}
31 changes: 28 additions & 3 deletions src/utils/security/__tests__/blockchainSecurity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ global.fetch = jest.fn();
describe('BlockchainSecurityService', () => {
let service: BlockchainSecurityService;
const mockConfig: SecurityServiceConfig = {
baseUrl: 'https://api.test.com',
baseUrl: 'http://localhost:3000',
timeout: 5000,
apiKey: 'test-api-key'
apiKey: undefined
};

beforeEach(() => {
Expand Down Expand Up @@ -89,8 +89,33 @@ describe('BlockchainSecurityService', () => {
expect(result.riskScore).toBeGreaterThan(0);
});

it('should call the local proxy endpoint', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({ risk_score: 30, categories: ['low_risk'], labels: [], description: 'Normal' })
});

await service.checkAddressRisk(testAddress);

const fetchUrl = (global.fetch as jest.Mock).mock.calls[0][0];
expect(fetchUrl).toContain('/api/security/address-check');
expect(fetchUrl).toContain(encodeURIComponent(testAddress));
});

it('should fall back to simulation when proxy returns non-ok', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 502,
json: async () => ({ error: 'Bad gateway' })
});

const result = await service.checkAddressRisk(testAddress);
expect(result.riskScore).toBeGreaterThanOrEqual(0);
expect(result.riskScore).toBeLessThanOrEqual(100);
});

it('should return default risk score on API failure', async () => {
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('API Error'));
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));

const result = await service.checkAddressRisk(testAddress);
expect(result).toEqual({
Expand Down
83 changes: 82 additions & 1 deletion src/utils/security/__tests__/transactionSecurity.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { decideStepUpSecurity, ethToWei } from '../transactionSecurity';
import { decideStepUpSecurity, ethToWei, getSecurityDeviceId } from '../transactionSecurity';

describe('transactionSecurity helpers', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('requires step-up for values at or above the configured threshold', () => {
const decision = decideStepUpSecurity({
valueWei: ethToWei(3),
Expand Down Expand Up @@ -39,5 +43,82 @@ describe('transactionSecurity helpers', () => {
expect(decision.requiresStepUp).toBe(false);
expect(decision.reason).toBeNull();
});

describe('getSecurityDeviceId', () => {
let mockLocalStorage: Record<string, string>;
let mockSessionStorage: Record<string, string>;

beforeEach(() => {
mockLocalStorage = {};
mockSessionStorage = {};
jest.spyOn(globalThis, 'crypto', 'get').mockReturnValue({
randomUUID: () => '550e8400-e29b-41d4-a716-446655440000',
subtle: {} as SubtleCrypto,
getRandomValues: (arr: Uint8Array) => arr,
} as unknown as Crypto);
jest.spyOn(globalThis, 'localStorage', 'get').mockReturnValue({
getItem: (key: string) => mockLocalStorage[key] ?? null,
setItem: (key: string, value: string) => { mockLocalStorage[key] = value; },
removeItem: (key: string) => { delete mockLocalStorage[key]; },
clear: () => { mockLocalStorage = {}; },
get length() { return Object.keys(mockLocalStorage).length; },
key: (index: number) => Object.keys(mockLocalStorage)[index] ?? null,
} as Storage);
jest.spyOn(globalThis, 'sessionStorage', 'get').mockReturnValue({
getItem: (key: string) => mockSessionStorage[key] ?? null,
setItem: (key: string, value: string) => { mockSessionStorage[key] = value; },
removeItem: (key: string) => { delete mockSessionStorage[key]; },
clear: () => { mockSessionStorage = {}; },
get length() { return Object.keys(mockSessionStorage).length; },
key: (index: number) => Object.keys(mockSessionStorage)[index] ?? null,
} as Storage);
});

afterEach(() => {
jest.restoreAllMocks();
});

it('returns "server-device" when window is undefined', () => {
const windowSpy = jest.spyOn(globalThis, 'window', 'get').mockReturnValue(undefined as any);
expect(getSecurityDeviceId()).toBe('server-device');
windowSpy.mockRestore();
});

it('generates and stores a salted hash on first call', () => {
const result = getSecurityDeviceId();

expect(result).toBe('550e8400-e29b-41d4-a716-446655440000');
expect(mockSessionStorage['propchain-session-salt']).toBeDefined();
expect(mockLocalStorage['propchain-security-device-id-hash']).toBeDefined();
expect(mockLocalStorage['propchain-security-device-id-hash']).not.toBe(result);
});

it('reuses existing hash from localStorage on subsequent calls', () => {
const existingHash = 'a1b2c3d4';
mockLocalStorage['propchain-security-device-id-hash'] = existingHash;

const result = getSecurityDeviceId();

expect(result).toBe(existingHash);
expect(mockSessionStorage['propchain-session-salt']).toBeUndefined();
});

it('reuses session salt across calls within the same session', () => {
const sessionSalt = 'test-session-salt';
mockSessionStorage['propchain-session-salt'] = sessionSalt;

getSecurityDeviceId();

expect(mockSessionStorage['propchain-session-salt']).toBe(sessionSalt);
});

it('stores a hashed value, not the raw UUID', () => {
getSecurityDeviceId();

const storedValue = mockLocalStorage['propchain-security-device-id-hash'];
expect(storedValue).not.toBe('550e8400-e29b-41d4-a716-446655440000');
expect(storedValue).toMatch(/^[0-9a-f]{8,}$/);
});
});
});

68 changes: 38 additions & 30 deletions src/utils/security/blockchainSecurity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,35 +74,42 @@ export class BlockchainSecurityService {
if (cached) return cached;

try {
// Try calling a remote API if available. If `fetch` returns a Promise
// (for example when tests mock it), await it and use the response.
// Otherwise, fall back to the local simulation to preserve test behavior.
const fetchResult = typeof fetch === 'function' ? (fetch as any)(`${this.config.baseUrl}/address/${address}`, {
headers: this.config.apiKey ? { Authorization: `Bearer ${this.config.apiKey}` } : {}
}) : null;

if (fetchResult && typeof fetchResult.then === 'function') {
const response = await fetchResult;
if (response && response.ok) {
const body = await response.json();
const score = typeof body.risk_score === 'number' ? body.risk_score : 50;
const categories = Array.isArray(body.categories) ? body.categories : [];
const result: AddressRiskScore = {
address,
riskScore: score,
riskLevel: this.getRiskLevel(score),
categories,
labels: Array.isArray(body.labels) ? body.labels : [],
description: body.description || ''
};
this.setCache(cacheKey, result);
return result;
}
// If response not ok, throw to be caught below and return default
throw new Error('Remote service returned non-OK response');
// Call the local API proxy route which securely holds the API key server-side.
// This avoids exposing the key in the client bundle.
const baseUrl = typeof window !== 'undefined'
? window.location.origin
: this.config.baseUrl;

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);

let response;
try {
response = await fetch(
`${baseUrl}/api/security/address-check?address=${encodeURIComponent(address)}`,
{ signal: controller.signal }
);
} finally {
clearTimeout(timeoutId);
}

// No remote fetch available — use the internal simulation
if (response && response.ok) {
const body = await response.json();
const score = typeof body.risk_score === 'number' ? body.risk_score : 50;
const categories = Array.isArray(body.categories) ? body.categories : [];
const result: AddressRiskScore = {
address,
riskScore: score,
riskLevel: this.getRiskLevel(score),
categories,
labels: Array.isArray(body.labels) ? body.labels : [],
description: body.description || ''
};
this.setCache(cacheKey, result);
return result;
}

// If the proxy returned an error or is unavailable, fall back to simulated check
const riskScore = await this.simulateAddressRiskCheck(address);

const result: AddressRiskScore = {
Expand Down Expand Up @@ -494,10 +501,11 @@ export class BlockchainSecurityService {

// Default configuration for development
const defaultConfig: SecurityServiceConfig = {
baseUrl: 'https://api.chainalysis.com/api/v2',
baseUrl: 'http://localhost:3000',
timeout: 10000,
// In production, this would be set via environment variables
apiKey: typeof window !== 'undefined' ? (window as any).__CHAINALYSIS_API_KEY__ : undefined
// API key is now configured only on the server side via CHAINALYSIS_API_KEY env var.
// The browser never has access to this key.
apiKey: undefined
};

// Export singleton instance
Expand Down
27 changes: 25 additions & 2 deletions src/utils/security/transactionSecurity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,40 @@ export function createTrustedDeviceId(): string {
return `trusted_${crypto.randomUUID()}`;
}

function simpleHash(input: string): string {
let hash = 0;
for (let i = 0; i < input.length; i++) {
const char = input.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash |= 0;
}
return Math.abs(hash).toString(16).padStart(8, '0');
}

const SESSION_SALT_KEY = 'propchain-session-salt';

function getSessionSalt(): string {
let salt = window.sessionStorage.getItem(SESSION_SALT_KEY);
if (!salt) {
salt = crypto.randomUUID();
window.sessionStorage.setItem(SESSION_SALT_KEY, salt);
}
return salt;
}

export function getSecurityDeviceId(): string {
if (typeof window === 'undefined') {
return 'server-device';
}

const key = 'propchain-security-device-id';
const key = 'propchain-security-device-id-hash';
const existing = window.localStorage.getItem(key);
if (existing) return existing;

const deviceId = crypto.randomUUID();
window.localStorage.setItem(key, deviceId);
const salt = getSessionSalt();
const hashedId = simpleHash(`${deviceId}:${salt}`);
window.localStorage.setItem(key, hashedId);
return deviceId;
}

Expand Down