Commit 109a1f18 by Archer Committed by GitHub

perf: codex-sandbox check (#6851)

* perf: codex-sandbox check

* perf: codex sandbox

* fix: build

* add log in httperror

* perf: sandbox

* package version
parent 1fd5eed8
......@@ -27,10 +27,6 @@ jobs:
node-version: '20'
cache: 'pnpm'
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: actions/setup-python@v5
with:
python-version: '3.11'
......
......@@ -16,6 +16,7 @@ description: 'FastGPT V4.15.0 更新说明'
3. 非管理员/访客,触发余额不足时候,提示优化。
4. 无创建权限时,隐藏模板功能。
5. 加强第三方知识库请求的 SSRF 防护。
6. codex-sandbox 加强 AST 检查,防止绕过安全检查。
## 🐛 修复
......
......@@ -252,7 +252,7 @@
"content/self-host/upgrading/4-14/41481.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-15/4150.mdx": "2026-04-28T21:35:13+08:00",
"content/self-host/upgrading/4-15/4150.mdx": "2026-04-28T22:44:51+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
......@@ -266,7 +266,7 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H
Object.keys(results).length > 0 ? results : rawResponse
};
} catch (error) {
logger.warn('HTTP tool request failed', { error });
logger.warn('HTTP tool request failed', { error, httpReqUrl });
// @adapt
if (node.catchError === undefined) {
......
packages:
- packages/*
- projects/*
- scripts/icon
- sdk/*
- projects/app
- projects/code-sandbox
- projects/marketplace
- projects/mcp_server
- pro/admin
- pro/sso
- scripts/icon
- sdk/*
catalog:
"@fastgpt-sdk/logger": 0.1.2
"@fastgpt-sdk/otel": 0.1.2
"@fastgpt-sdk/storage": 0.6.15
"@modelcontextprotocol/sdk": ^1
"@node-rs/jieba": 2.0.1
"@svgr/webpack": ^6.5.1
"@tanstack/react-query": ^4.24.10
"@types/js-yaml": ^4.0.9
"@types/jsonwebtoken": ^9.0.3
"@types/lodash": ^4
"@types/mime-types": ^3.0.1
"@types/node": ^20
"@types/react": ^18
"@types/react-dom": ^18
"@types/request-ip": ^0.0.38
"@typescript-eslint/eslint-plugin": ^6.21.0
"@typescript-eslint/parser": ^6.21.0
"@vitest/coverage-v8": ^4.1.5
'@fastgpt-sdk/logger': 0.1.2
'@fastgpt-sdk/otel': 0.1.2
'@fastgpt-sdk/storage': 0.6.15
'@modelcontextprotocol/sdk': ^1
'@node-rs/jieba': 2.0.1
'@svgr/webpack': ^6.5.1
'@tanstack/react-query': ^4.24.10
'@types/js-yaml': ^4.0.9
'@types/jsonwebtoken': ^9.0.3
'@types/lodash': ^4
'@types/mime-types': ^3.0.1
'@types/node': ^20
'@types/react': ^18
'@types/react-dom': ^18
'@types/request-ip': ^0.0.38
'@typescript-eslint/eslint-plugin': ^6.21.0
'@typescript-eslint/parser': ^6.21.0
'@vitest/coverage-v8': ^4.1.5
ahooks: ^3.9.5
"@chakra-ui/anatomy": ^2
"@chakra-ui/icons": ^2
"@chakra-ui/next-js": ^2
"@chakra-ui/react": ^2
"@chakra-ui/styled-system": ^2
"@chakra-ui/system": ^2
"@emotion/react": ^11
"@emotion/styled": ^11
'@chakra-ui/anatomy': ^2
'@chakra-ui/icons': ^2
'@chakra-ui/next-js': ^2
'@chakra-ui/react': ^2
'@chakra-ui/styled-system': ^2
'@chakra-ui/system': ^2
'@emotion/react': ^11
'@emotion/styled': ^11
axios: 1.13.6
chalk: ^5.6.2
date-fns: ^3.6.0
......@@ -72,7 +76,7 @@ catalog:
zod: ^4
onlyBuiltDependencies:
- "@parcel/watcher"
- '@parcel/watcher'
- bufferutil
- canvas
- core-js
......@@ -86,7 +90,7 @@ onlyBuiltDependencies:
- vue-demi
overrides:
"@types/react": ^18
"@types/react-dom": ^18
'@types/react': ^18
'@types/react-dom': ^18
react: ^18
react-dom: ^18
{
"name": "@fastgpt/app",
"version": "4.14.16",
"version": "4.15.0",
"private": false,
"scripts": {
"dev": "pnpm run build:workers && next dev",
......
# --------- Build Stage -----------
FROM oven/bun:1-alpine AS builder
FROM node:24-alpine AS builder
WORKDIR /app
ARG proxy
# 安装 pnpm
RUN apk add --no-cache nodejs npm && npm install -g pnpm@10.33.2
RUN npm install -g pnpm@10.33.2
# 复制 workspace 配置和依赖包
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./
COPY packages/global ./packages/global
COPY packages/service ./packages/service
COPY sdk ./sdk
......@@ -24,22 +24,38 @@ RUN if [ -z "$proxy" ]; then \
pnpm install --frozen-lockfile --ignore-scripts --registry=https://registry.npmmirror.com; \
fi
# 先构建 SDK workspace 包,确保 dist 入口可被 bun build 解析
# 先构建 SDK workspace 包,确保 dist 入口可被打包工具解析
RUN pnpm --filter @fastgpt-sdk/logger --filter @fastgpt-sdk/otel --filter @fastgpt-sdk/storage build
# 编译主入口文件
RUN cd /app/projects/code-sandbox && pnpm build
# ===== Runner Stage =====
FROM oven/bun:1-alpine AS runner
WORKDIR /app
FROM node:24-alpine AS runner
WORKDIR /app/code-sandbox
ARG proxy
# 复制编译产物(包含 worker 文件,不需要 node_modules)
RUN [ -z "$proxy" ] || sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
# 安装 pnpm(用于 runner 阶段安装 prod 依赖)
RUN npm install -g pnpm@10.33.2
# 复制编译产物(index/worker 已 bundle 自身静态依赖,自包含)
COPY --from=builder /app/projects/code-sandbox/dist /app/code-sandbox
RUN [ -z "$proxy" ] || sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
# 安装 worker 运行时白名单模块
# worker 通过 safeRequire(name) 按用户白名单动态加载(lodash/dayjs/moment/uuid/crypto-js/qs),
# 这是变量调用,bundler 无法静态分析,必须在 runner 中以 node_modules 形式存在。
# 单独的 runtime.package.json 与主 package.json 解耦,避免 catalog: 引用无法解析。
COPY projects/code-sandbox/runtime.package.json ./package.json
RUN if [ -z "$proxy" ]; then \
pnpm install --prod --no-frozen-lockfile --ignore-scripts; \
else \
pnpm install --prod --no-frozen-lockfile --ignore-scripts --registry=https://registry.npmmirror.com; \
fi && \
pnpm store prune || true
# 安装 Python、依赖包及工具
RUN apk add --no-cache python3 py3-pip libffi util-linux && \
......@@ -60,4 +76,4 @@ ENV SANDBOX_PORT=3000
EXPOSE 3000
CMD ["bun", "/app/code-sandbox/index.js"]
CMD ["node", "/app/code-sandbox/index.js"]
# FastGPT Code Sandbox
基于 Bun + Hono 的代码执行沙盒,支持 JS 和 Python。采用进程池架构,预热长驻 worker 进程,通过 stdin/stdout JSON 协议通信,消除每次请求的进程启动开销。
基于 Node + Hono 的代码执行沙盒,支持 JS 和 Python。采用进程池架构,预热长驻 worker 进程,通过 stdin/stdout JSON 协议通信,消除每次请求的进程启动开销。
## 架构
......@@ -8,14 +8,14 @@
HTTP Request → Hono Server → Process Pool → Worker (long-lived) → Result
┌──────────────┐
│ JS Workers │ bun run worker.ts (×N)
│ JS Workers │ node worker.js (×N)
│ Py Workers │ python3 worker.py (×N)
└──────────────┘
stdin: JSON task → stdout: JSON result
```
- **进程池**:启动时预热 N 个 worker 进程(默认 20),请求到达时直接分配空闲 worker,执行完归还池中
- **JS 执行**Bun worker 进程 + 安全 shim(禁用 Bun API、冻结 Function 构造器、require 白名单)
- **JS 执行**Node worker 进程 + 安全 shim(冻结 Function 构造器、危险全局对象遮蔽、require 白名单)
- **Python 执行**:python3 worker 进程 + `__import__` 拦截 + resource 资源限制
- **网络请求**:统一通过 `SystemHelper.httpRequest()` / `system_helper.http_request()` 收口,内置 SSRF 防护
- **并发控制**:请求数超过池大小时自动排队,worker 崩溃自动重启补充
......@@ -40,14 +40,17 @@ HTTP Request → Hono Server → Process Pool → Worker (long-lived) → Result
## 快速开始
```bash
# 安装依赖
bun install
# 安装依赖(在 monorepo 根目录执行)
pnpm install
# 开发运行
bun run src/index.ts
# 开发运行(带 watch)
cd projects/code-sandbox && pnpm dev
# 运行测试
bun run test
cd projects/code-sandbox && pnpm test
# 构建
cd projects/code-sandbox && pnpm build && pnpm start
```
## Docker
......@@ -194,7 +197,7 @@ test/
```bash
cd projects/code-sandbox
bun add <package-name>
pnpm add <package-name>
```
2. **加入白名单**(环境变量 `SANDBOX_JS_ALLOWED_MODULES`):
......@@ -246,7 +249,7 @@ your-new-package
### JS
- `require()` 白名单,非白名单模块直接拒绝
- `Bun.spawn``Bun.write``Bun.serve` 等 API 禁用
- 危险全局对象(`process``globalThis``global``Bun` 等)通过函数参数遮蔽,用户代码无法访问
- `Function` 构造器冻结,阻止 `constructor.constructor` 逃逸
- `process.env` 清理,仅保留必要变量
- `fetch``XMLHttpRequest``WebSocket` 禁用
......@@ -283,13 +286,13 @@ your-new-package
```bash
# 全部测试(332 cases)
bun run test
pnpm test
# 单个文件
bunx vitest run test/security/security.test.ts
pnpm exec vitest run test/unit/security.test.ts
# 带详细输出
bunx vitest run --reporter=verbose
pnpm exec vitest run --reporter=verbose
# 压测(需先启动服务)
bash test/benchmark/bench-sandbox.sh
......
......@@ -6,23 +6,27 @@ echo "Building sandbox..."
# 清理旧的构建产物
rm -rf dist
# 编译主入口文件,打包所有依赖
echo "Building main entry..."
bun build src/index.ts --outdir dist --target bun --minify --packages=bundle
# 编译入口(配置见 tsdown.config.ts):
# - 同时打包 index 和 worker 两个独立 bundle
# - 所有 npm 依赖均打入 bundle(noExternal),仅保留 Node 内置模块外部化
# - 扁平输出到 dist 根目录
echo "Building entries..."
pnpm exec tsdown
# 编译 JS worker,打包所有依赖
echo "Building JS worker..."
bun build src/pool/worker.ts --outdir dist --target bun --minify --packages=bundle
mv dist/worker.js dist/worker.ts
# package.json 已声明 type:module,直接改后缀为 .js
mv dist/index.mjs dist/index.js
mv dist/worker.mjs dist/worker.js
# 复制 Python worker(Python 不需要编译)
# Python worker 不需要编译,直接复制
echo "Copying Python worker..."
cp src/pool/worker.py dist/worker.py
echo ""
echo "Build complete!"
echo " - index.js: $(du -h dist/index.js | cut -f1)"
echo " - worker.ts: $(du -h dist/worker.ts | cut -f1)"
echo " - index.js: $(du -h dist/index.js | cut -f1)"
echo " - worker.js: $(du -h dist/worker.js | cut -f1)"
echo " - worker.py: $(du -h dist/worker.py | cut -f1)"
echo ""
echo "✅ dist 目录现在是完全独立的,不需要 node_modules"
echo "ℹ️ worker 通过 safeRequire(name) 在运行时动态加载白名单模块"
echo " (lodash/dayjs/moment/uuid/crypto-js/qs),这些依赖必须以 node_modules"
echo " 形式存在于运行时,由 runtime.package.json 在 runner 阶段安装。"
{
"name": "@fastgpt/code-sandbox",
"version": "5.0.0",
"description": "FastGPT Code Sandbox - Bun + Hono + 统一子进程模型",
"description": "FastGPT Code Sandbox - Node + Hono + 统一子进程模型",
"author": "",
"private": true,
"license": "UNLICENSED",
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"build": "sh build.sh",
"test": "vitest run",
"test:watch": "vitest"
......@@ -17,8 +18,11 @@
"pnpm": "10.x"
},
"dependencies": {
"@fastgpt-sdk/logger": "workspace:*",
"@fastgpt/service": "workspace:*",
"@fastgpt-sdk/logger": "^0.1.2",
"@hono/node-server": "^1.13.7",
"ipaddr.js": "^2.3.0",
"acorn": "^8.15.0",
"acorn-walk": "^8.3.4",
"axios": "catalog:",
"crypto-js": "^4.2.0",
"dayjs": "catalog:",
......@@ -32,10 +36,11 @@
"zod": "catalog:"
},
"devDependencies": {
"@types/bun": "^1.2.4",
"@types/node": "catalog:",
"tsdown": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:"
"@vitest/coverage-v8": "catalog:"
}
}
{
"name": "@fastgpt/code-sandbox-runtime",
"version": "5.0.0",
"description": "Runtime-only deps for code-sandbox worker. Index/worker bundle are self-contained; this package.json exists solely to install whitelisted modules that the worker loads at runtime via safeRequire(name).",
"private": true,
"license": "UNLICENSED",
"type": "module",
"dependencies": {
"crypto-js": "4.2.0",
"dayjs": "1.11.19",
"lodash": "4.17.23",
"moment": "2.30.1",
"qs": "6.13.1",
"uuid": "9.0.1"
}
}
......@@ -6,7 +6,18 @@
import dotenv from 'dotenv';
import { z } from 'zod';
dotenv.config();
// 匹配 Bun 的 .env 加载顺序:.env.{NODE_ENV}.local > .env.local > .env.{NODE_ENV} > .env
// dotenv 数组优先级:先出现者优先(不被后续覆盖),与 Bun 的 override 语义一致。
// quiet:true 抑制 dotenv 17.x 默认向 stdout 打印的注入横幅,
// 避免被 worker 子进程透传到 IPC 首行导致 base-process-pool 解析 init 响应失败。
const nodeEnv = process.env.NODE_ENV;
const envFiles = [
nodeEnv ? `.env.${nodeEnv}.local` : null,
'.env.local',
nodeEnv ? `.env.${nodeEnv}` : null,
'.env'
].filter((f): f is string => Boolean(f));
dotenv.config({ path: envFiles, quiet: true });
/** coerce 数字,带默认值 */
const int = (defaultValue: number) => z.coerce.number().int().default(defaultValue);
......
import './env'; // dotenv 最先加载
import { env } from './env';
import { Hono } from 'hono';
import { bearerAuth } from 'hono/bearer-auth';
import { serve } from '@hono/node-server';
import { z } from 'zod';
import { config } from './config';
import { ProcessPool } from './pool/process-pool';
......@@ -164,11 +165,17 @@ app.get('/sandbox/modules', (c) => {
});
/** 启动服务 */
serverLogger.info(`Sandbox server starting on port ${config.port}...`);
serverLogger.info(`Sandbox server starting on port ${env.port}...`);
if (process.env.NODE_ENV !== 'test') {
serve({ fetch: app.fetch, port: env.port }, (info) => {
serverLogger.info(`Sandbox server listening on port ${info.port}`);
});
}
/** 导出 app 和 poolReady 供测试使用 */
export { app, poolReady };
export default {
port: config.port,
port: env.port,
fetch: app.fetch
};
/**
* ProcessPool - JS (Bun) 子进程池
* ProcessPool - JS 子进程池
*
* 继承 BaseProcessPool,提供 Bun worker 的 spawn 配置。
* 继承 BaseProcessPool,提供 JS worker 的 spawn 配置。
* dev(tsx 直接跑 .ts 源码):worker.ts + tsx
* prod(tsdown 打包后):worker.js + node
*/
import { join } from 'path';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { config } from '../config';
import { BaseProcessPool } from './base-process-pool';
const WORKER_SCRIPT = join(
typeof import.meta.dir === 'string' ? import.meta.dir : new URL('.', import.meta.url).pathname,
'worker.ts'
);
const __dirname = dirname(fileURLToPath(import.meta.url));
const isCompiled = import.meta.url.endsWith('.js');
const WORKER_SCRIPT = join(__dirname, isCompiled ? 'worker.js' : 'worker.ts');
const SPAWN_RUNTIME = isCompiled ? 'node' : 'tsx';
export class ProcessPool extends BaseProcessPool {
constructor(poolSize?: number) {
super(poolSize, {
name: 'JS',
workerScript: WORKER_SCRIPT,
spawnCommand: (script) => `exec bun run ${script}`,
spawnCommand: (script) => `exec ${SPAWN_RUNTIME} ${script}`,
allowedModules: config.jsAllowedModules
});
}
......
......@@ -3,14 +3,13 @@
*
* 继承 BaseProcessPool,提供 Python worker 的 spawn 配置。
*/
import { join } from 'path';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { config } from '../config';
import { BaseProcessPool } from './base-process-pool';
const WORKER_SCRIPT = join(
typeof import.meta.dir === 'string' ? import.meta.dir : new URL('.', import.meta.url).pathname,
'worker.py'
);
const __dirname = dirname(fileURLToPath(import.meta.url));
const WORKER_SCRIPT = join(__dirname, 'worker.py');
export class PythonProcessPool extends BaseProcessPool {
constructor(poolSize?: number) {
......
import ipaddr from 'ipaddr.js';
import { isIPv6 } from 'net';
import dns from 'dns/promises';
const isDevEnv = process.env.NODE_ENV === 'development';
const SERVICE_LOCAL_PORT = `${process.env.PORT || 3000}`;
const SERVICE_LOCAL_HOST =
process.env.HOSTNAME && isIPv6(process.env.HOSTNAME)
? `[${process.env.HOSTNAME}]:${SERVICE_LOCAL_PORT}`
: `${process.env.HOSTNAME || 'localhost'}:${SERVICE_LOCAL_PORT}`;
// 云厂商元数据服务 IP(除 169.254.0.0/16 段外的特殊地址)
// 预先归一化为 ipaddr.js 的 normalizedString 形式以便比对
const METADATA_IPS = new Set<string>(
[
'100.100.100.200', // 阿里云
'fd00:ec2::254' // AWS IPv6
].map((ip) => ipaddr.parse(ip).toNormalizedString().toLowerCase())
);
// 云厂商元数据服务主机名(归一化:小写、去尾部点)
const METADATA_HOSTNAMES = new Set<string>([
'metadata.google.internal',
'metadata',
'metadata.tencentyun.com',
'kubernetes.default.svc',
'kubernetes.default',
'kubernetes'
]);
const LOCALHOST_HOSTNAMES = new Set<string>(['localhost']);
/**
* 把 URL hostname 尝试解析成 ipaddr.js 的地址对象
* - 处理 IPv6 方括号
* - 处理 IPv4-mapped IPv6 (::ffff:a.b.c.d / ::ffff:xxxx:xxxx) → 解包为 IPv4
* - 处理十进制/十六进制/八进制/短点分 IPv4 字面量
* 非 IP 字面量返回 null
*/
const parseHostAsIP = (rawHostname: string): ipaddr.IPv4 | ipaddr.IPv6 | null => {
const host = rawHostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
if (!host) return null;
// ipaddr.process 会自动把 IPv4-mapped IPv6 解包为 IPv4,处理常规字面量
if (ipaddr.isValid(host)) {
try {
return ipaddr.process(host);
} catch {
return null;
}
}
// ipaddr.js 不支持十进制/十六进制/八进制 IPv4 短写,手动兜底
const numeric = parseNumericIPv4(host);
if (numeric) return ipaddr.parse(numeric) as ipaddr.IPv4;
return null;
};
/**
* 解析 inet_aton 兼容的 IPv4 字面量:十进制 2852039166、十六进制 0xa9fea9fe、
* 八进制、1-4 段形式(含 dec/hex/oct 混合)。返回标准点分十进制或 null
*/
const parseNumericIPv4 = (host: string): string | null => {
const parts = host.split('.');
if (parts.length === 0 || parts.length > 4) return null;
const nums: number[] = [];
for (const part of parts) {
if (!part) return null;
let n: number;
if (/^0x[0-9a-f]+$/i.test(part)) n = parseInt(part, 16);
else if (/^0[0-7]+$/.test(part)) n = parseInt(part, 8);
else if (/^\d+$/.test(part)) n = parseInt(part, 10);
else return null;
if (!Number.isFinite(n) || n < 0) return null;
nums.push(n);
}
const maxLast = [0xffffffff, 0xffffff, 0xffff, 0xff][parts.length - 1];
if (nums[nums.length - 1] > maxLast) return null;
for (let i = 0; i < nums.length - 1; i++) if (nums[i] > 0xff) return null;
let ipInt = 0;
for (let i = 0; i < nums.length - 1; i++) ipInt = (ipInt + nums[i]) * 256;
ipInt += nums[nums.length - 1];
if (ipInt > 0xffffffff) return null;
return [(ipInt >>> 24) & 0xff, (ipInt >>> 16) & 0xff, (ipInt >>> 8) & 0xff, ipInt & 0xff].join(
'.'
);
};
const normalizeDomain = (rawHostname: string): string =>
rawHostname
.replace(/^\[|\]$/g, '')
.replace(/\.$/, '')
.toLowerCase();
/**
* ipaddr.js range() 返回的所有非 'unicast' 分类都视为内部地址。
* 主要范围:private / loopback / linkLocal / uniqueLocal / reserved /
* multicast / broadcast / unspecified / carrierGradeNat 等
*/
const isInternalIPAddress = (addr: ipaddr.IPv4 | ipaddr.IPv6): boolean => {
return addr.range() !== 'unicast';
};
/**
* 对已解析出的 IP 复检(防 DNS rebinding TOCTOU)。
* 调用方先用 isInternalAddress(url) 通过预检,再用 dns.lookup 拿到将要连接的 IP,
* 在真正建连前用此函数二次校验,确保两次解析的 IP 都在策略允许范围内。
*/
export const isInternalResolvedIP = (rawIP: string): boolean => {
if (isDevEnv) return false;
if (!ipaddr.isValid(rawIP)) return false;
const addr = ipaddr.process(rawIP);
if (isMetadataIPAddress(addr)) return true;
const range = addr.range();
if (range === 'loopback' || range === 'unspecified') return true;
const checkFullInternal = process.env.CHECK_INTERNAL_IP === 'true';
if (checkFullInternal && isInternalIPAddress(addr)) return true;
return false;
};
/**
* 元数据端点:
* - 169.254.0.0/16 link-local 段全部视为元数据
* - 显式列表里的 IP(阿里云 100.100.100.200、AWS IPv6 fd00:ec2::254)
*/
const isMetadataIPAddress = (addr: ipaddr.IPv4 | ipaddr.IPv6): boolean => {
if (addr.kind() === 'ipv4' && addr.range() === 'linkLocal') return true;
return METADATA_IPS.has(addr.toNormalizedString().toLowerCase());
};
export const isInternalAddress = async (url: string): Promise<boolean> => {
if (isDevEnv) return false;
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
return false;
}
const hostDomain = normalizeDomain(parsedUrl.hostname);
const localHost = SERVICE_LOCAL_HOST.split(':')[0].toLowerCase();
// 1. localhost / 本机
if (LOCALHOST_HOSTNAMES.has(hostDomain) || hostDomain === localHost) {
return true;
}
// 2. 云元数据主机名
if (METADATA_HOSTNAMES.has(hostDomain)) {
return true;
}
// 3. IP 字面量(含各种编码变体)
const ip = parseHostAsIP(parsedUrl.hostname);
const checkFullInternal = process.env.CHECK_INTERNAL_IP === 'true';
if (ip) {
if (isMetadataIPAddress(ip)) return true;
// loopback/unspecified 等始终阻止(这些是显而易见的错误配置或攻击)
const range = ip.range();
if (range === 'loopback' || range === 'unspecified') return true;
if (checkFullInternal) return isInternalIPAddress(ip);
return false;
}
// 4. 域名:解析 DNS;元数据命中始终阻止,私有段受 CHECK_INTERNAL_IP 控制
try {
const [v4Res, v6Res] = await Promise.allSettled([
dns.resolve4(hostDomain),
dns.resolve6(hostDomain)
]);
const resolvedIPs = [
...(v4Res.status === 'fulfilled' ? v4Res.value : []),
...(v6Res.status === 'fulfilled' ? v6Res.value : [])
];
for (const raw of resolvedIPs) {
if (!ipaddr.isValid(raw)) continue;
const addr = ipaddr.process(raw);
if (isMetadataIPAddress(addr)) return true;
const r = addr.range();
if (r === 'loopback' || r === 'unspecified') return true;
if (checkFullInternal && isInternalIPAddress(addr)) return true;
}
return false;
} catch {
return false;
}
};
export const PRIVATE_URL_TEXT = 'Request to private network not allowed';
......@@ -9,10 +9,10 @@ BASE="${CODE_SANDBOX_URL:-http://localhost:3000}"
TOKEN="${SANDBOX_TOKEN:-}"
DURATION="${BENCH_DURATION:-10}"
# 构建 npx autocannon 认证参数
AUTH_ARGS=""
# 构建 npx autocannon 认证参数(autocannon 的 -H 不会 URL-decode,必须传字面空格)
AUTH_ARGS=()
if [ -n "$TOKEN" ]; then
AUTH_ARGS="-H Authorization=Bearer%20${TOKEN}"
AUTH_ARGS=(-H "Authorization=Bearer ${TOKEN}")
fi
echo "========================================"
......@@ -35,7 +35,7 @@ echo " 并发: 50 持续: ${DURATION}s"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
npx autocannon -c 50 -d "$DURATION" -m POST \
-H "Content-Type=application/json" \
$AUTH_ARGS \
"${AUTH_ARGS[@]}" \
-b '{"code":"def main(variables):\n return 1 + 1","variables":{}}' \
"${BASE}/sandbox/python"
......@@ -46,7 +46,7 @@ echo " 并发: 50 持续: ${DURATION}s"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
npx autocannon -c 50 -d "$DURATION" -m POST \
-H "Content-Type=application/json" \
$AUTH_ARGS \
"${AUTH_ARGS[@]}" \
-b '{"code":"import time\ndef main(variables):\n time.sleep(0.5)\n return \"done\"","variables":{}}' \
"${BASE}/sandbox/python"
......@@ -57,7 +57,7 @@ echo " 并发: 10 持续: ${DURATION}s"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
npx autocannon -c 10 -d "$DURATION" -m POST \
-H "Content-Type=application/json" \
$AUTH_ARGS \
"${AUTH_ARGS[@]}" \
-b '{"code":"import math\ndef main(variables):\n s=0\n for i in range(5000000):\n s+=math.sqrt(i)\n return s","variables":{}}' \
"${BASE}/sandbox/python"
......@@ -68,7 +68,7 @@ echo " 并发: 10 持续: ${DURATION}s"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
npx autocannon -c 10 -d "$DURATION" -m POST \
-H "Content-Type=application/json" \
$AUTH_ARGS \
"${AUTH_ARGS[@]}" \
-b '{"code":"def main(variables):\n arr = [i*i for i in range(2000000)]\n return len(arr)","variables":{}}' \
"${BASE}/sandbox/python"
......
......@@ -9,10 +9,10 @@ BASE="${CODE_SANDBOX_URL:-http://localhost:3000}"
TOKEN="${SANDBOX_TOKEN:-}"
DURATION="${BENCH_DURATION:-10}"
# 构建 npx autocannon 认证参数
AUTH_ARGS=""
# 构建 npx autocannon 认证参数(autocannon 的 -H 不会 URL-decode,必须传字面空格)
AUTH_ARGS=()
if [ -n "$TOKEN" ]; then
AUTH_ARGS="-H Authorization=Bearer%20${TOKEN}"
AUTH_ARGS=(-H "Authorization=Bearer ${TOKEN}")
fi
echo "========================================"
......@@ -35,7 +35,7 @@ echo " 并发: 50 持续: ${DURATION}s"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
npx autocannon -c 50 -d "$DURATION" -m POST \
-H "Content-Type=application/json" \
$AUTH_ARGS \
"${AUTH_ARGS[@]}" \
-b '{"code":"function main() { return 1 + 1; }","variables":{}}' \
"${BASE}/sandbox/js"
......@@ -46,7 +46,7 @@ echo " 并发: 50 持续: ${DURATION}s"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
npx autocannon -c 50 -d "$DURATION" -m POST \
-H "Content-Type=application/json" \
$AUTH_ARGS \
"${AUTH_ARGS[@]}" \
-b '{"code":"async function main() { await delay(500); return \"done\"; }","variables":{}}' \
"${BASE}/sandbox/js"
......@@ -57,7 +57,7 @@ echo " 并发: 10 持续: ${DURATION}s"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
npx autocannon -c 10 -d "$DURATION" -m POST \
-H "Content-Type=application/json" \
$AUTH_ARGS \
"${AUTH_ARGS[@]}" \
-b '{"code":"function main() { let s=0; for(let i=0;i<50000000;i++) s+=Math.sqrt(i); return s; }","variables":{}}' \
"${BASE}/sandbox/js"
......@@ -68,7 +68,7 @@ echo " 并发: 10 持续: ${DURATION}s"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
npx autocannon -c 10 -d "$DURATION" -m POST \
-H "Content-Type=application/json" \
$AUTH_ARGS \
"${AUTH_ARGS[@]}" \
-b '{"code":"function main() { const arr = new Array(5000000).fill(0).map((_,i)=>i*i); return arr.length; }","variables":{}}' \
"${BASE}/sandbox/js"
......
......@@ -15,6 +15,16 @@ function headers(extra: Record<string, string> = {}): Record<string, string> {
return h;
}
async function executeJs(code: string, variables: Record<string, any> = {}) {
const res = await app.request('/sandbox/js', {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ code, variables })
});
return res.json();
}
describe('API Routes', () => {
beforeAll(async () => {
await poolReady;
......@@ -70,6 +80,130 @@ describe('API Routes', () => {
expect(data.message).toContain('not allowed');
});
it('POST /sandbox/js 拦截动态 import 语法变体', async () => {
const payloads = [
'async function main() { await import("child_process"); return {} }',
'async function main() { await import/**/("child_process"); return {} }',
'async function main() { await import/* comment */("child_process"); return {} }',
'async function main() { await import/* comment */\n("child_process"); return {} }',
'async function main() { await import// comment\n("child_process"); return {} }'
];
for (const code of payloads) {
const data = await executeJs(code);
expect(data.success).toBe(false);
expect(data.message).toContain('Dynamic import() is not allowed');
}
});
it('POST /sandbox/js 允许字符串和注释中出现 import 文本', async () => {
const data = await executeJs(`
async function main() {
const text = "import/**/('child_process')";
/* import("child_process") */
return { text };
}
`);
expect(data.success).toBe(true);
expect(data.data.codeReturn.text).toBe("import/**/('child_process')");
});
it('POST /sandbox/js 禁止 eval 生成代码', async () => {
const data = await executeJs('async function main() { return eval("1 + 1"); }');
expect(data.success).toBe(false);
expect(data.message).toContain('eval() is not allowed');
});
it('POST /sandbox/js 禁止通过 constructor 链恢复代码生成能力', async () => {
const payloads = [
`async function main() {
try { Object.constructor.constructor('return process')(); return { escaped: true }; }
catch (e) { return { escaped: false }; }
}`,
`async function main() {
try { require.__proto__.constructor('return process')(); return { escaped: true }; }
catch (e) { return { escaped: false }; }
}`,
`async function main() {
try { await (async function(){}).constructor('return import("child_process")')(); return { escaped: true }; }
catch (e) { return { escaped: false }; }
}`,
`async function main() {
try { (function*(){}).constructor('yield 1')(); return { escaped: true }; }
catch (e) { return { escaped: false }; }
}`
];
for (const code of payloads) {
const data = await executeJs(code);
expect(data.success).toBe(true);
expect(data.data.codeReturn.escaped).toBe(false);
}
});
it('POST /sandbox/js 禁止 setTimeout 字符串代码执行', async () => {
const data = await executeJs(`async function main() {
setTimeout('return process', 0);
return {};
}`);
expect(data.success).toBe(false);
expect(data.message).toContain('setTimeout expects a function');
});
it('POST /sandbox/js 禁止通过 require.cache 拿到原始 require', async () => {
const data = await executeJs(`
async function main() {
const moduleWithRequire = Object.values(require.cache ?? {}).find(
(item) => item && typeof item.require === 'function'
);
if (!moduleWithRequire) {
return {
escaped: false,
cacheType: typeof require.cache,
extensionsType: typeof require.extensions,
mainType: typeof require.main
};
}
const cp = moduleWithRequire.require('child_process');
return { escaped: true, out: cp.execSync('id').toString() };
}
`);
expect(data.success).toBe(true);
expect(data.data.codeReturn).toEqual({
escaped: false,
cacheType: 'undefined',
extensionsType: 'undefined',
mainType: 'undefined'
});
});
it('POST /sandbox/js require.resolve 同样遵循模块白名单', async () => {
const data = await executeJs(
'async function main() { return require.resolve("child_process"); }'
);
expect(data.success).toBe(false);
expect(data.message).toContain("Module 'child_process' is not allowed");
});
it('POST /sandbox/js 禁止篡改 SystemHelper', async () => {
const data = await executeJs(`
async function main() {
try {
SystemHelper.httpRequest = async () => ({ status: 200, data: 'polluted' });
} catch {}
return { same: SystemHelper.httpRequest === httpRequest };
}
`);
expect(data.success).toBe(true);
expect(data.data.codeReturn.same).toBe(true);
});
// ===== Python =====
it('POST /sandbox/python 正常执行', async () => {
const res = await app.request('/sandbox/python', {
......
......@@ -398,6 +398,208 @@ async function main() { return recurse(); }`,
);
});
// --- 静态 import() AST 检测的负样本 ---
// 验证新版 AST 检测不会把"看起来像 import()"的合法代码误判
describe('动态 import 检测负样本(正常代码)', () => {
runMatrix(
() => pool,
[
{
name: '行内注释包含 import(',
code: `async function main() {
// import('fs') is forbidden in sandbox
return { ok: 1 };
}`,
expect: { success: true, codeReturn: { ok: 1 } }
},
{
name: '块注释包含 import(',
code: `async function main() {
/* note: never call import('child_process') here */
return { ok: 2 };
}`,
expect: { success: true, codeReturn: { ok: 2 } }
},
{
name: 'JSDoc 包含 import(',
code: `/** @example const x = await import('fs'); */
async function main() { return { ok: 3 }; }`,
expect: { success: true, codeReturn: { ok: 3 } }
},
{
name: '字符串字面量包含 import(',
code: `async function main() {
const tip = "use require not import('xx')";
return { tip };
}`,
expect: { success: true, codeReturn: { tip: "use require not import('xx')" } }
},
{
name: '模板字符串包含 import(',
code: `async function main() {
const name = 'fs';
const tpl = \`forbidden: import('\${name}')\`;
return { tpl };
}`,
expect: { success: true, codeReturn: { tpl: "forbidden: import('fs')" } }
},
{
name: '正则字面量包含 import(',
code: `async function main() {
const re = /\\bimport\\s*\\(/;
return { match: re.test("import('x')") };
}`,
expect: { success: true, codeReturn: { match: true } }
},
{
name: '变量名以 import 开头',
code: `async function main() {
const importPath = '/etc/hosts';
const importedAt = Date.now();
return { len: importPath.length, type: typeof importedAt };
}`,
expect: { success: true, codeReturnMatch: { len: 10, type: 'number' } }
},
{
name: '对象属性名为 import',
code: `async function main() {
const conf = { import: 'allowed', export: 'ok' };
return { keys: Object.keys(conf).sort() };
}`,
expect: { success: true, codeReturn: { keys: ['export', 'import'] } }
},
{
name: 'lodash 多函数组合',
code: `async function main() {
const _ = require('lodash');
const grouped = _.groupBy([6.1, 4.2, 6.3], Math.floor);
const chunked = _.chunk(['a','b','c','d','e'], 2);
return { grouped, chunked };
}`,
expect: {
success: true,
codeReturn: {
grouped: { 4: [4.2], 6: [6.1, 6.3] },
chunked: [['a', 'b'], ['c', 'd'], ['e']]
}
}
},
{
name: 'dayjs 时间格式化',
code: `async function main() {
const dayjs = require('dayjs');
const d = dayjs('2026-04-29T08:00:00Z');
return { year: d.year(), iso: d.toISOString() };
}`,
expect: {
success: true,
codeReturnMatch: { year: 2026, iso: '2026-04-29T08:00:00.000Z' }
}
},
{
name: 'crypto-js HMAC SHA256',
code: `async function main() {
const CryptoJS = require('crypto-js');
const hex = CryptoJS.HmacSHA256('msg', 'key').toString();
return { len: hex.length };
}`,
expect: { success: true, codeReturn: { len: 64 } }
},
{
name: 'qs 序列化嵌套对象',
code: `async function main() {
const qs = require('qs');
const s = qs.stringify({ a: { b: 1, c: [2, 3] } });
return { s };
}`,
expect: {
success: true,
codeReturn: { s: 'a%5Bb%5D=1&a%5Bc%5D%5B0%5D=2&a%5Bc%5D%5B1%5D=3' }
}
},
{
name: 'uuid v4 生成',
code: `async function main() {
const { v4 } = require('uuid');
const id = v4();
return { len: id.length, isString: typeof id === 'string' };
}`,
expect: { success: true, codeReturn: { len: 36, isString: true } }
},
{
name: 'async/await 串行 + Promise.all 并行',
code: `async function main() {
const seq = [];
for (const x of [1, 2, 3]) { await delay(1); seq.push(x); }
const par = await Promise.all([Promise.resolve('a'), Promise.resolve('b')]);
return { seq, par };
}`,
expect: { success: true, codeReturn: { seq: [1, 2, 3], par: ['a', 'b'] } }
},
{
name: '解构 / 默认参数 / 扩展运算符',
code: `async function main() {
const fn = ({ a = 1, b = 2 } = {}, ...rest) => ({ a, b, rest });
return fn({ b: 9 }, 'x', 'y');
}`,
expect: { success: true, codeReturn: { a: 1, b: 9, rest: ['x', 'y'] } }
},
{
name: 'class + 私有字段 + getter',
code: `async function main() {
class Counter {
#n = 0;
inc() { this.#n++; return this; }
get value() { return this.#n; }
}
const c = new Counter().inc().inc().inc();
return { value: c.value };
}`,
expect: { success: true, codeReturn: { value: 3 } }
},
{
name: 'Map / Set 操作',
code: `async function main() {
const m = new Map([['a', 1], ['b', 2]]);
const s = new Set([1, 2, 2, 3]);
return { mapSize: m.size, setArr: [...s] };
}`,
expect: { success: true, codeReturn: { mapSize: 2, setArr: [1, 2, 3] } }
},
{
name: 'try/catch + 自定义错误类',
code: `async function main() {
class AppError extends Error {
constructor(msg, code) { super(msg); this.code = code; }
}
try { throw new AppError('boom', 42); }
catch (e) { return { msg: e.message, code: e.code, isErr: e instanceof Error }; }
}`,
expect: { success: true, codeReturn: { msg: 'boom', code: 42, isErr: true } }
},
{
name: '生成器函数 + iterator 协议',
code: `async function main() {
function* gen(n) { for (let i = 0; i < n; i++) yield i * i; }
return { squares: [...gen(4)] };
}`,
expect: { success: true, codeReturn: { squares: [0, 1, 4, 9] } }
},
{
name: 'BigInt + Number 互操作',
code: `async function main() {
const big = 9007199254740991n;
return { str: big.toString(), num: Number(big - 1n), kind: typeof big };
}`,
expect: {
success: true,
codeReturn: { str: '9007199254740991', num: 9007199254740990, kind: 'bigint' }
}
}
]
);
});
// --- 网络请求 ---
describe('网络请求', () => {
runMatrix(
......@@ -406,7 +608,7 @@ async function main() { return recurse(); }`,
{
name: 'httpRequest GET',
code: `async function main() {
const res = await httpRequest('https://www.baidu.com');
const res = await httpRequest('https://1.1.1.1/cdn-cgi/trace');
return { status: res.status, hasData: res.data.length > 0 };
}`,
expect: { success: true, codeReturnMatch: { status: 200, hasData: true } }
......@@ -414,7 +616,7 @@ async function main() { return recurse(); }`,
{
name: 'httpRequest POST JSON',
code: `async function main() {
const res = await httpRequest('https://www.baidu.com', {
const res = await httpRequest('https://1.1.1.1/cdn-cgi/trace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { message: 'hello' }
......@@ -701,12 +903,12 @@ describe('Python 功能测试', () => {
[
{
name: 'http_request GET',
code: `import json\ndef main():\n res = http_request('https://www.baidu.com')\n return {'status': res['status'], 'hasData': len(res['data']) > 0}`,
code: `import json\ndef main():\n res = http_request('https://1.1.1.1/cdn-cgi/trace')\n return {'status': res['status'], 'hasData': len(res['data']) > 0}`,
expect: { success: true, codeReturnMatch: { status: 200, hasData: true } }
},
{
name: 'http_request POST JSON',
code: `import json\ndef main():\n res = http_request('https://www.baidu.com', method='POST', body={'message': 'hello'})\n return {'hasStatus': type(res['status']) == int}`,
code: `import json\ndef main():\n res = http_request('https://1.1.1.1/cdn-cgi/trace', method='POST', body={'message': 'hello'})\n return {'hasStatus': type(res['status']) == int}`,
expect: { success: true, codeReturnMatch: { hasStatus: true } }
}
]
......
......@@ -503,7 +503,7 @@ describe('JS 请求体大小限制', () => {
code: `async function main() {
const bigBody = 'x'.repeat(${sizeMB} * 1024 * 1024 + 1);
try {
await httpRequest('https://example.com', { method: 'POST', body: bigBody });
await httpRequest('https://1.1.1.1/cdn-cgi/trace', { method: 'POST', body: bigBody });
return { blocked: false };
} catch(e) {
return { blocked: true, msg: e.message };
......@@ -524,7 +524,7 @@ describe('JS 请求体大小限制', () => {
code: `async function main() {
const smallBody = JSON.stringify({ data: 'hello' });
try {
await httpRequest('https://example.com', { method: 'POST', body: smallBody });
await httpRequest('https://1.1.1.1/cdn-cgi/trace', { method: 'POST', body: smallBody });
return { sizeOk: true };
} catch(e) {
// 网络错误可以接受,但不应该是 body too large
......@@ -553,7 +553,7 @@ describe('Python 请求体大小限制', () => {
const sizeMB = config.maxRequestBodySize;
const result = await pool.execute({
code: `def main():\n big_body = 'x' * (${sizeMB} * 1024 * 1024 + 1)\n try:\n http_request('https://example.com', method='POST', body=big_body)\n return {'blocked': False}\n except Exception as e:\n return {'blocked': True, 'msg': str(e)}`,
code: `def main():\n big_body = 'x' * (${sizeMB} * 1024 * 1024 + 1)\n try:\n http_request('https://1.1.1.1/cdn-cgi/trace', method='POST', body=big_body)\n return {'blocked': False}\n except Exception as e:\n return {'blocked': True, 'msg': str(e)}`,
variables: {}
});
expect(result.success).toBe(true);
......@@ -566,7 +566,7 @@ describe('Python 请求体大小限制', () => {
await pool.init();
const result = await pool.execute({
code: `def main():\n try:\n http_request('https://example.com', method='POST', body='hello')\n return {'size_ok': True}\n except Exception as e:\n return {'size_ok': 'too large' not in str(e).lower(), 'msg': str(e)}`,
code: `def main():\n try:\n http_request('https://1.1.1.1/cdn-cgi/trace', method='POST', body='hello')\n return {'size_ok': True}\n except Exception as e:\n return {'size_ok': 'too large' not in str(e).lower(), 'msg': str(e)}`,
variables: {}
});
expect(result.success).toBe(true);
......
......@@ -11,7 +11,7 @@
"rootDir": "./src",
"declaration": true,
"resolveJsonModule": true,
"types": ["@types/bun"]
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
......
import { defineConfig } from 'tsdown';
/**
* 默认情况下 tsdown 会把 package.json 的 dependencies 都标记为 external,
* 导致运行时仍需要 node_modules。这里强制把所有 npm 依赖打进 bundle,
* 只保留 Node 内置模块外部化。
*
* 例外:worker.ts 通过 safeRequire(name) 在运行时按用户白名单动态加载
* 模块(lodash/dayjs/moment/uuid/crypto-js/qs 等),这些是变量调用,
* 打包工具静态分析不会触及,因此白名单模块仍需在 runner 阶段以
* node_modules 形式存在。
*/
export default defineConfig({
entry: {
index: 'src/index.ts',
worker: 'src/pool/worker.ts'
},
format: 'esm',
platform: 'node',
target: 'node20',
minify: true,
outDir: 'dist',
noExternal: [/.*/]
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment