diff --git a/backend/package.json b/backend/package.json index bf96ef5..81f4004 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/backtest/index.ts b/backend/src/backtest/index.ts index 6cd9fbb..3e30f2a 100644 --- a/backend/src/backtest/index.ts +++ b/backend/src/backtest/index.ts @@ -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 => { assertBacktestFeatureEnabled(); assertBacktestMode(request.mode); + assertBacktestStrategyConfigSafe(request.strategyConfig); const historical = await loadHistoricalData(request); return runBacktestReplay({ request, diff --git a/backend/src/backtest/strategySafety.ts b/backend/src/backtest/strategySafety.ts new file mode 100644 index 0000000..0259821 --- /dev/null +++ b/backend/src/backtest/strategySafety.ts @@ -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; + 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; + if (typeof candidate.strategyCode === 'string' && candidate.strategyCode.trim()) { + throw new UnsafeCodeStrategyError(); + } +}; diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index 992ecd8..566c3a8 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -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' }); diff --git a/backend/testBacktestStrategySafety.ts b/backend/testBacktestStrategySafety.ts new file mode 100644 index 0000000..965db50 --- /dev/null +++ b/backend/testBacktestStrategySafety.ts @@ -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');