From 6aa001a53037b2d7a951202085d96649601fe6dd Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Mon, 4 May 2026 16:31:04 -0700 Subject: [PATCH] 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 --- backend/package.json | 3 +- backend/src/backtest/index.ts | 2 + backend/src/backtest/strategySafety.ts | 34 ++++++++++++ backend/src/services/apiServer.ts | 10 +++- backend/testBacktestStrategySafety.ts | 72 ++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 backend/src/backtest/strategySafety.ts create mode 100644 backend/testBacktestStrategySafety.ts 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');