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:
parent
fe8ab839a2
commit
6aa001a530
@ -5,7 +5,7 @@
|
||||
"description": "ByteLyst Trading backend and execution control service",
|
||||
"main": "index.js",
|
||||
"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",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@ -31,6 +31,7 @@
|
||||
"check:audit-repository": "node --import tsx verifyAuditRepository.ts",
|
||||
"check:market-data-endpoints": "node --import tsx verifyMarketDataEndpoints.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",
|
||||
"coverage:run": "node --loader ts-node/esm runCoverageSuite.ts",
|
||||
"coverage:full": "npm run coverage:integration",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { assertBacktestFeatureEnabled, assertBacktestMode } from './guards.js';
|
||||
import { loadHistoricalData } from './data/loadHistoricalData.js';
|
||||
import { runBacktestReplay } from './engine/BacktestRunner.js';
|
||||
import { assertBacktestStrategyConfigSafe } from './strategySafety.js';
|
||||
import type { BacktestRequest, BacktestResult } from './types.js';
|
||||
|
||||
export interface RunBacktestOptions {
|
||||
@ -13,6 +14,7 @@ export const runBacktest = async (
|
||||
): Promise<BacktestResult> => {
|
||||
assertBacktestFeatureEnabled();
|
||||
assertBacktestMode(request.mode);
|
||||
assertBacktestStrategyConfigSafe(request.strategyConfig);
|
||||
const historical = await loadHistoricalData(request);
|
||||
return runBacktestReplay({
|
||||
request,
|
||||
|
||||
34
backend/src/backtest/strategySafety.ts
Normal file
34
backend/src/backtest/strategySafety.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
@ -42,6 +42,11 @@ import * as runtimeOrderRepository from './runtimeOrderRepository.js';
|
||||
import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js';
|
||||
import { OperationalEvent } from '../domain/operationalEvents.js';
|
||||
import { runBacktest } from '../backtest/index.js';
|
||||
import {
|
||||
assertBacktestBodyDoesNotContainInlineCode,
|
||||
assertBacktestStrategyConfigSafe,
|
||||
UnsafeCodeStrategyError
|
||||
} from '../backtest/strategySafety.js';
|
||||
import type { BacktestRequest, BacktestTimeframe } from '../backtest/types.js';
|
||||
import { fetchFmpJson, FmpFetchError } from './fmpCache.js';
|
||||
import {
|
||||
@ -2051,6 +2056,7 @@ export class ApiServer {
|
||||
|
||||
try {
|
||||
const body = req.body || {};
|
||||
assertBacktestBodyDoesNotContainInlineCode(body);
|
||||
const profileId = String(body.profileId || '').trim();
|
||||
let profileSettings: any = undefined;
|
||||
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).' });
|
||||
return;
|
||||
}
|
||||
assertBacktestStrategyConfigSafe(strategyConfig);
|
||||
|
||||
this.enforceBacktestPayloadGuards(body.dataSource);
|
||||
const timeframe = this.normalizeBacktestTimeframe(body.timeframe);
|
||||
@ -2117,11 +2124,12 @@ export class ApiServer {
|
||||
|
||||
res.json({ success: true, result });
|
||||
} catch (error: any) {
|
||||
const isUnsafeCodeStrategy = error instanceof UnsafeCodeStrategyError;
|
||||
this.auditTradeEvent({
|
||||
event: 'backtest_run',
|
||||
userId: authUserId,
|
||||
profileId: String(req.body?.profileId || '').trim() || undefined,
|
||||
outcome: 'error',
|
||||
outcome: isUnsafeCodeStrategy ? 'rejected' : 'error',
|
||||
reason: error.message
|
||||
});
|
||||
res.status(400).json({ success: false, error: error.message || 'Backtest run failed' });
|
||||
|
||||
72
backend/testBacktestStrategySafety.ts
Normal file
72
backend/testBacktestStrategySafety.ts
Normal 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');
|
||||
Loading…
Reference in New Issue
Block a user