fix(C1): refuse unsafe code strategy backtests

Reject inline JavaScript strategy payloads before backtest execution, both at the API boundary and inside runBacktest, so saved profiles and direct internal calls cannot route unsandboxed code into replay handling.

Refs: docs/AUDIT_REDESIGN.md item C1.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
This commit is contained in:
Saravana Achu Mac 2026-05-04 16:31:04 -07:00
parent fe8ab839a2
commit 6aa001a530
5 changed files with 119 additions and 2 deletions

View File

@ -5,7 +5,7 @@
"description": "ByteLyst Trading backend and execution control service", "description": "ByteLyst Trading backend and execution control service",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "npm run check:websocket-contract && npm run check:session-rule-normalization && npm run check:api-contract && npm run check:audit-repository && npm run check:market-data-endpoints && npm run check:fmp-cache", "test": "npm run check:websocket-contract && npm run check:session-rule-normalization && npm run check:api-contract && npm run check:audit-repository && npm run check:market-data-endpoints && npm run check:fmp-cache && npm run check:backtest-strategy-safety",
"dev": "node --import tsx src/bootstrap.ts", "dev": "node --import tsx src/bootstrap.ts",
"build": "tsc", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@ -31,6 +31,7 @@
"check:audit-repository": "node --import tsx verifyAuditRepository.ts", "check:audit-repository": "node --import tsx verifyAuditRepository.ts",
"check:market-data-endpoints": "node --import tsx verifyMarketDataEndpoints.ts", "check:market-data-endpoints": "node --import tsx verifyMarketDataEndpoints.ts",
"check:fmp-cache": "node --import tsx testFmpCache.ts", "check:fmp-cache": "node --import tsx testFmpCache.ts",
"check:backtest-strategy-safety": "node --import tsx testBacktestStrategySafety.ts",
"check:websocket-contract": "node --import tsx src/scripts/verifyWebsocketContract.ts", "check:websocket-contract": "node --import tsx src/scripts/verifyWebsocketContract.ts",
"coverage:run": "node --loader ts-node/esm runCoverageSuite.ts", "coverage:run": "node --loader ts-node/esm runCoverageSuite.ts",
"coverage:full": "npm run coverage:integration", "coverage:full": "npm run coverage:integration",

View File

@ -1,6 +1,7 @@
import { assertBacktestFeatureEnabled, assertBacktestMode } from './guards.js'; import { assertBacktestFeatureEnabled, assertBacktestMode } from './guards.js';
import { loadHistoricalData } from './data/loadHistoricalData.js'; import { loadHistoricalData } from './data/loadHistoricalData.js';
import { runBacktestReplay } from './engine/BacktestRunner.js'; import { runBacktestReplay } from './engine/BacktestRunner.js';
import { assertBacktestStrategyConfigSafe } from './strategySafety.js';
import type { BacktestRequest, BacktestResult } from './types.js'; import type { BacktestRequest, BacktestResult } from './types.js';
export interface RunBacktestOptions { export interface RunBacktestOptions {
@ -13,6 +14,7 @@ export const runBacktest = async (
): Promise<BacktestResult> => { ): Promise<BacktestResult> => {
assertBacktestFeatureEnabled(); assertBacktestFeatureEnabled();
assertBacktestMode(request.mode); assertBacktestMode(request.mode);
assertBacktestStrategyConfigSafe(request.strategyConfig);
const historical = await loadHistoricalData(request); const historical = await loadHistoricalData(request);
return runBacktestReplay({ return runBacktestReplay({
request, request,

View File

@ -0,0 +1,34 @@
export class UnsafeCodeStrategyError extends Error {
statusCode = 400;
constructor() {
super('Code strategy backtests are not supported until a sandboxed evaluator is available. Use rule-based strategyConfig instead.');
this.name = 'UnsafeCodeStrategyError';
}
}
export const isUnsafeCodeStrategyConfig = (strategyConfig: unknown): boolean => {
if (!strategyConfig || typeof strategyConfig !== 'object') {
return false;
}
const candidate = strategyConfig as Record<string, unknown>;
return String(candidate.type || '').trim().toLowerCase() === 'code';
};
export const assertBacktestStrategyConfigSafe = (strategyConfig: unknown): void => {
if (isUnsafeCodeStrategyConfig(strategyConfig)) {
throw new UnsafeCodeStrategyError();
}
};
export const assertBacktestBodyDoesNotContainInlineCode = (body: unknown): void => {
if (!body || typeof body !== 'object') {
return;
}
const candidate = body as Record<string, unknown>;
if (typeof candidate.strategyCode === 'string' && candidate.strategyCode.trim()) {
throw new UnsafeCodeStrategyError();
}
};

View File

@ -42,6 +42,11 @@ import * as runtimeOrderRepository from './runtimeOrderRepository.js';
import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js'; import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js';
import { OperationalEvent } from '../domain/operationalEvents.js'; import { OperationalEvent } from '../domain/operationalEvents.js';
import { runBacktest } from '../backtest/index.js'; import { runBacktest } from '../backtest/index.js';
import {
assertBacktestBodyDoesNotContainInlineCode,
assertBacktestStrategyConfigSafe,
UnsafeCodeStrategyError
} from '../backtest/strategySafety.js';
import type { BacktestRequest, BacktestTimeframe } from '../backtest/types.js'; import type { BacktestRequest, BacktestTimeframe } from '../backtest/types.js';
import { fetchFmpJson, FmpFetchError } from './fmpCache.js'; import { fetchFmpJson, FmpFetchError } from './fmpCache.js';
import { import {
@ -2051,6 +2056,7 @@ export class ApiServer {
try { try {
const body = req.body || {}; const body = req.body || {};
assertBacktestBodyDoesNotContainInlineCode(body);
const profileId = String(body.profileId || '').trim(); const profileId = String(body.profileId || '').trim();
let profileSettings: any = undefined; let profileSettings: any = undefined;
if (profileId) { if (profileId) {
@ -2072,6 +2078,7 @@ export class ApiServer {
res.status(400).json({ success: false, error: 'strategyConfig is required (or supply profileId with saved strategy).' }); res.status(400).json({ success: false, error: 'strategyConfig is required (or supply profileId with saved strategy).' });
return; return;
} }
assertBacktestStrategyConfigSafe(strategyConfig);
this.enforceBacktestPayloadGuards(body.dataSource); this.enforceBacktestPayloadGuards(body.dataSource);
const timeframe = this.normalizeBacktestTimeframe(body.timeframe); const timeframe = this.normalizeBacktestTimeframe(body.timeframe);
@ -2117,11 +2124,12 @@ export class ApiServer {
res.json({ success: true, result }); res.json({ success: true, result });
} catch (error: any) { } catch (error: any) {
const isUnsafeCodeStrategy = error instanceof UnsafeCodeStrategyError;
this.auditTradeEvent({ this.auditTradeEvent({
event: 'backtest_run', event: 'backtest_run',
userId: authUserId, userId: authUserId,
profileId: String(req.body?.profileId || '').trim() || undefined, profileId: String(req.body?.profileId || '').trim() || undefined,
outcome: 'error', outcome: isUnsafeCodeStrategy ? 'rejected' : 'error',
reason: error.message reason: error.message
}); });
res.status(400).json({ success: false, error: error.message || 'Backtest run failed' }); res.status(400).json({ success: false, error: error.message || 'Backtest run failed' });

View File

@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { config } from './src/config/index.js';
import { runBacktest } from './src/backtest/index.js';
import {
assertBacktestBodyDoesNotContainInlineCode,
assertBacktestStrategyConfigSafe,
UnsafeCodeStrategyError,
} from './src/backtest/strategySafety.js';
import type { BacktestRequest } from './src/backtest/types.js';
const unsafeError = (error: unknown): boolean =>
error instanceof UnsafeCodeStrategyError &&
error.message.includes('sandboxed evaluator');
const buildUnsafeRequest = (): BacktestRequest => ({
mode: 'backtest',
symbols: ['BTC/USDT'],
timeframe: '15m',
dateRange: {
from: '2025-01-01T00:00:00.000Z',
to: '2025-01-02T00:00:00.000Z',
},
dataSource: {
type: 'json',
payload: {
candles: {
'BTC/USDT': {
'15m': [],
},
},
},
},
strategyConfig: {
type: 'code',
language: 'javascript',
code: 'return true;',
},
});
const originalFlag = config.ENABLE_BACKTEST;
config.ENABLE_BACKTEST = true;
try {
assert.doesNotThrow(
() => assertBacktestStrategyConfigSafe({ rules: [] }),
'rule-based strategy configs must stay accepted',
);
assert.throws(
() => assertBacktestStrategyConfigSafe({ type: ' CODE ', code: 'return true;' }),
unsafeError,
'strategyConfig.type=code must be refused before execution',
);
assert.throws(
() => assertBacktestBodyDoesNotContainInlineCode({ strategyCode: 'return true;' }),
unsafeError,
'legacy strategyCode payloads must be refused before execution',
);
assert.doesNotThrow(
() => assertBacktestBodyDoesNotContainInlineCode({ strategyCode: ' ' }),
'empty legacy strategyCode payloads should not mask normal validation errors',
);
await assert.rejects(
() => runBacktest(buildUnsafeRequest()),
unsafeError,
'runBacktest must refuse code strategies even when called outside the API route',
);
} finally {
config.ENABLE_BACKTEST = originalFlag;
}
console.log('[backtest-strategy-safety] OK: code strategy backtests are refused before execution');