Skip to content
Merged
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"lint": "eslint . --max-warnings=0 && node scripts/sort-package-json.mjs",
"perf:budgets": "node scripts/check-performance-budgets.mjs",
"perf:ci": "npm run build && npm run perf:budgets",
"sort-package-json": "node scripts/sort-package-json.mjs",
"start": "next start",
"security:check-globals": "node scripts/check-exposed-globals.mjs",
"validate:env": "node scripts/validate-env.js",
"storybook": "storybook dev -p 6006",
"test": "jest",
"test:ci": "jest --coverage --watchAll=false --ci",
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);
});
42 changes: 24 additions & 18 deletions src/app/api/security/address-check/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';

const CHAINALYSIS_API_KEY = process.env.CHAINALYSIS_API_KEY || '';
const CHAINALYSIS_API_URL = process.env.CHAINALYSIS_API_URL || 'https://api.chainalysis.com/api/v2';

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const address = searchParams.get('address')?.trim();
Expand All @@ -11,37 +8,46 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Address parameter required' }, { status: 400 });
}

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

if (!CHAINALYSIS_API_KEY) {
const apiKey = process.env.CHAINALYSIS_API_KEY;

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

try {
const response = await fetch(`${CHAINALYSIS_API_URL}/address/${address}`, {
headers: {
Authorization: `Bearer ${CHAINALYSIS_API_KEY}`,
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(10000),
});
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) {
throw new Error(`Chainalysis API returned ${response.status}`);
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: 'Failed to check address risk', risk_score: 50, risk_level: 'medium' },
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 502 }
);
}
Expand Down
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
78 changes: 42 additions & 36 deletions src/utils/security/blockchainSecurity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,35 +155,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(`${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 @@ -573,15 +580,14 @@ export class BlockchainSecurityService {
}
}

// Server-side singleton (only use this on the server; the API key stays server-side)
export function createServerSecurityService(config?: SecurityServiceConfig): BlockchainSecurityService {
const effectiveConfig = config ?? {
baseUrl: process.env.CHAINALYSIS_API_URL || 'https://api.chainalysis.com/api/v2',
timeout: 10000,
apiKey: process.env.CHAINALYSIS_API_KEY || undefined,
};
return BlockchainSecurityService.getInstance(effectiveConfig);
}
// Default configuration for development
const defaultConfig: SecurityServiceConfig = {
baseUrl: 'http://localhost:3000',
timeout: 10000,
// 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
};

// Client-side proxy: calls our own API endpoint so the API key never reaches the browser.
export async function checkAddressRiskViaProxy(address: string): Promise<AddressRiskScore> {
Expand Down
1 change: 0 additions & 1 deletion src/utils/security/transactionSecurity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ function getSessionSalt(): string {
}
return salt;
}
const sessionDeviceId = typeof crypto !== 'undefined' ? crypto.randomUUID() : 'server-device';

export function getSecurityDeviceId(): string {
if (typeof window === 'undefined') {
Expand Down