From 109a1f1898e4756deca0dace7a06685a83a146d3 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 29 Apr 2026 14:53:45 +0800 Subject: [PATCH] perf: codex-sandbox check (#6851) * perf: codex-sandbox check * perf: codex sandbox * fix: build * add log in httperror * perf: sandbox * package version --- .github/workflows/test-sandbox.yaml | 4 - .../content/self-host/upgrading/4-15/4150.mdx | 1 + document/data/doc-last-modified.json | 2 +- .../core/workflow/dispatch/tools/http468.ts | 2 +- pnpm-lock.yaml | 125 ++-- pnpm-workspace.yaml | 68 +- projects/app/package.json | 2 +- projects/code-sandbox/Dockerfile | 34 +- projects/code-sandbox/README.md | 29 +- projects/code-sandbox/build.sh | 26 +- projects/code-sandbox/package.json | 21 +- projects/code-sandbox/runtime.package.json | 16 + projects/code-sandbox/src/env.ts | 13 +- projects/code-sandbox/src/index.ts | 13 +- .../code-sandbox/src/pool/process-pool.ts | 20 +- .../src/pool/python-process-pool.ts | 9 +- projects/code-sandbox/src/pool/worker.ts | 587 ++++++++++++++++-- .../code-sandbox/src/utils/ipCheck.util.ts | 197 ++++++ .../test/benchmark/bench-sandbox-python.sh | 14 +- .../test/benchmark/bench-sandbox.sh | 14 +- .../code-sandbox/test/integration/api.test.ts | 134 ++++ .../test/integration/functional.test.ts | 210 ++++++- .../code-sandbox/test/unit/ipCheck.test.ts | 344 ++++++++++ .../test/unit/resource-limits.test.ts | 8 +- .../code-sandbox/test/unit/security.test.ts | 156 ++++- projects/code-sandbox/tsconfig.json | 2 +- projects/code-sandbox/tsdown.config.ts | 24 + 27 files changed, 1791 insertions(+), 284 deletions(-) create mode 100644 projects/code-sandbox/runtime.package.json create mode 100644 projects/code-sandbox/src/utils/ipCheck.util.ts create mode 100644 projects/code-sandbox/test/unit/ipCheck.test.ts create mode 100644 projects/code-sandbox/tsdown.config.ts diff --git a/.github/workflows/test-sandbox.yaml b/.github/workflows/test-sandbox.yaml index 9885f180d2..439b38a0e7 100644 --- a/.github/workflows/test-sandbox.yaml +++ b/.github/workflows/test-sandbox.yaml @@ -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' diff --git a/document/content/self-host/upgrading/4-15/4150.mdx b/document/content/self-host/upgrading/4-15/4150.mdx index 6a9550c91e..0e7d2afabd 100644 --- a/document/content/self-host/upgrading/4-15/4150.mdx +++ b/document/content/self-host/upgrading/4-15/4150.mdx @@ -16,6 +16,7 @@ description: 'FastGPT V4.15.0 更新说明' 3. 非管理员/访客,触发余额不足时候,提示优化。 4. 无创建权限时,隐藏模板功能。 5. 加强第三方知识库请求的 SSRF 防护。 +6. codex-sandbox 加强 AST 检查,防止绕过安全检查。 ## 🐛 修复 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index e6c74b4cac..7454a2a93d 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -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", diff --git a/packages/service/core/workflow/dispatch/tools/http468.ts b/packages/service/core/workflow/dispatch/tools/http468.ts index c3f01de965..60dc0ea08a 100644 --- a/packages/service/core/workflow/dispatch/tools/http468.ts +++ b/packages/service/core/workflow/dispatch/tools/http468.ts @@ -266,7 +266,7 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise 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) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1683633929..16bd6e323f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,7 +217,7 @@ importers: version: 10.1.4(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.8.4) next-i18next: specifier: 'catalog:' - version: 15.4.2(i18next@23.16.8)(next@16.2.4(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 15.4.2(i18next@23.16.8)(next@16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) prettier: specifier: 3.2.4 version: 3.2.4 @@ -549,7 +549,7 @@ importers: version: 2.1.1(@chakra-ui/system@2.6.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(react@18.3.1))(react@18.3.1) '@chakra-ui/next-js': specifier: 'catalog:' - version: 2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@16.2.4(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1) + version: 2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1) '@chakra-ui/react': specifier: 'catalog:' version: 2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -630,7 +630,7 @@ importers: version: 16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1) next-i18next: specifier: 'catalog:' - version: 15.4.2(i18next@23.16.8)(next@16.2.4(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 15.4.2(i18next@23.16.8)(next@16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) papaparse: specifier: ^5.4.1 version: 5.4.1 @@ -709,7 +709,7 @@ importers: version: 2.1.1(@chakra-ui/system@2.6.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(react@18.3.1))(react@18.3.1) '@chakra-ui/next-js': specifier: 'catalog:' - version: 2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@16.2.4(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1) + version: 2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1) '@chakra-ui/react': specifier: 'catalog:' version: 2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -841,7 +841,7 @@ importers: version: 16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1) next-i18next: specifier: 'catalog:' - version: 15.4.2(i18next@23.16.8)(next@16.2.4(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 15.4.2(i18next@23.16.8)(next@16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) nodemailer: specifier: ^7.0.11 version: 7.0.13 @@ -1020,7 +1020,7 @@ importers: version: 2.1.1(@chakra-ui/system@2.6.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(react@18.3.1))(react@18.3.1) '@chakra-ui/next-js': specifier: 'catalog:' - version: 2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@16.2.4(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1) + version: 2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1) '@chakra-ui/react': specifier: 'catalog:' version: 2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1155,7 +1155,7 @@ importers: version: 16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1) next-i18next: specifier: 'catalog:' - version: 15.4.2(i18next@23.16.8)(next@16.2.4(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 15.4.2(i18next@23.16.8)(next@16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) nprogress: specifier: ^0.2.0 version: 0.2.0 @@ -1293,11 +1293,17 @@ importers: projects/code-sandbox: dependencies: '@fastgpt-sdk/logger': - specifier: workspace:* - version: link:../../sdk/logger - '@fastgpt/service': - specifier: workspace:* - version: link:../../packages/service + specifier: ^0.1.2 + version: 0.1.2 + '@hono/node-server': + specifier: ^1.13.7 + version: 1.19.9(hono@4.11.7) + acorn: + specifier: ^8.15.0 + version: 8.15.0 + acorn-walk: + specifier: ^8.3.4 + version: 8.3.4 axios: specifier: 'catalog:' version: 1.13.6 @@ -1309,10 +1315,13 @@ importers: version: 1.11.19 dotenv: specifier: ^17.3.1 - version: 17.3.1 + version: 17.4.2 hono: specifier: ^4.7.6 version: 4.11.7 + ipaddr.js: + specifier: ^2.3.0 + version: 2.3.0 lodash: specifier: 'catalog:' version: 4.17.23 @@ -1321,7 +1330,7 @@ importers: version: 2.30.1 qs: specifier: ^6.13.1 - version: 6.14.1 + version: 6.15.1 tiktoken: specifier: 1.0.17 version: 1.0.17 @@ -1332,15 +1341,18 @@ importers: specifier: 'catalog:' version: 4.1.12 devDependencies: - '@types/bun': - specifier: ^1.2.4 - version: 1.3.11 '@types/node': specifier: 'catalog:' version: 20.17.24 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.5(vitest@4.1.5) + tsdown: + specifier: 'catalog:' + version: 0.21.4(typescript@5.9.3) + tsx: + specifier: 'catalog:' + version: 4.20.6 typescript: specifier: 'catalog:' version: 5.9.3 @@ -1358,7 +1370,7 @@ importers: version: 2.1.1(@chakra-ui/system@2.6.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(react@18.3.1))(react@18.3.1) '@chakra-ui/next-js': specifier: 'catalog:' - version: 2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@16.2.4(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1) + version: 2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1) '@chakra-ui/react': specifier: 'catalog:' version: 2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1394,7 +1406,7 @@ importers: version: 16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1) next-i18next: specifier: 'catalog:' - version: 15.4.2(i18next@23.16.8)(next@16.2.4(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 15.4.2(i18next@23.16.8)(next@16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) react: specifier: ^18 version: 18.3.1 @@ -1464,22 +1476,6 @@ importers: specifier: ^5.0.1 version: 5.0.1 - projects/volume-manager: - dependencies: - hono: - specifier: ^4.6.0 - version: 4.11.7 - zod: - specifier: 'catalog:' - version: 4.1.12 - devDependencies: - '@types/bun': - specifier: latest - version: 1.3.13 - vitest: - specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.0.13)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@6.2.2(@types/node@24.0.13)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1)) - scripts/icon: dependencies: express: @@ -3152,6 +3148,10 @@ packages: resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==} engines: {node: '>=18.0.0', npm: '>=9.0.0'} + '@fastgpt-sdk/logger@0.1.2': + resolution: {integrity: sha512-nt1qCq7frcRiR+406vEERWC1vEPVIKPUGH/ZRP/mlBxvNJp1RycWQT8RhK7/tHmW6xPNZoRL/q2WfhM4Q+L7eg==} + engines: {node: '>=20', pnpm: '>=9'} + '@fastgpt-sdk/plugin@0.6.0': resolution: {integrity: sha512-xvjWj3+WLVqI1lggZEMlHLrl5c90JPomXOS5DZlWqgrmcgdoUlE1yzmWC2iXCoG2+IQTcOKaooqE6ITVqURgOg==} @@ -5355,12 +5355,6 @@ packages: '@types/body-parser@1.19.5': resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} - '@types/bun@1.3.11': - resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==} - - '@types/bun@1.3.13': - resolution: {integrity: sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -6522,12 +6516,6 @@ packages: bullmq@5.52.2: resolution: {integrity: sha512-fK/dKIv8ymyys4K+zeNEPA+yuYWzRPmBWUmwIMz8DvYekadl8VG19yUx94Na0n0cLAi+spdn3a/+ufkYK7CBUg==} - bun-types@1.3.11: - resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==} - - bun-types@1.3.13: - resolution: {integrity: sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==} - bundle-n-require@1.1.2: resolution: {integrity: sha512-bEk2jakVK1ytnZ9R2AAiZEeK/GxPUM8jvcRxHZXifZDMcjkI4EG/GlsJ2YGSVYT9y/p/gA9/0yDY8rCGsSU6Tg==} @@ -7484,8 +7472,8 @@ packages: resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} engines: {node: '>=12'} - dotenv@17.3.1: - resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} dts-resolver@2.1.3: @@ -14774,7 +14762,7 @@ snapshots: '@chakra-ui/system': 2.6.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(react@18.3.1) react: 18.3.1 - '@chakra-ui/next-js@2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@16.2.4(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1)': + '@chakra-ui/next-js@2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1)': dependencies: '@chakra-ui/react': 2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@emotion/cache': 11.14.0 @@ -15522,6 +15510,17 @@ snapshots: '@faker-js/faker@9.9.0': {} + '@fastgpt-sdk/logger@0.1.2': + dependencies: + '@logtape/logtape': 2.0.2 + '@logtape/pretty': 2.0.2(@logtape/logtape@2.0.2) + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/exporter-logs-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + '@fastgpt-sdk/plugin@0.6.0': dependencies: '@fortaine/fetch-event-source': 3.0.6 @@ -18157,14 +18156,6 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 20.17.24 - '@types/bun@1.3.11': - dependencies: - bun-types: 1.3.11 - - '@types/bun@1.3.13': - dependencies: - bun-types: 1.3.13 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -19207,7 +19198,7 @@ snapshots: mime: 2.6.0 platform: 1.3.6 pump: 3.0.2 - qs: 6.14.1 + qs: 6.15.1 sdk-base: 2.0.1 stream-http: 2.8.2 stream-wormhole: 1.1.0 @@ -19712,14 +19703,6 @@ snapshots: transitivePeerDependencies: - supports-color - bun-types@1.3.11: - dependencies: - '@types/node': 20.17.24 - - bun-types@1.3.13: - dependencies: - '@types/node': 20.17.24 - bundle-n-require@1.1.2: dependencies: esbuild: 0.25.11 @@ -20711,7 +20694,7 @@ snapshots: dotenv@16.5.0: {} - dotenv@17.3.1: {} + dotenv@17.4.2: {} dts-resolver@2.1.3: {} @@ -24197,7 +24180,7 @@ snapshots: transitivePeerDependencies: - supports-color - next-i18next@15.4.2(i18next@23.16.8)(next@16.2.4(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + next-i18next@15.4.2(i18next@23.16.8)(next@16.2.4(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.26.10 '@types/hoist-non-react-statics': 3.3.6 @@ -26922,7 +26905,7 @@ snapshots: humanize-ms: 1.2.1 iconv-lite: 0.6.3 pump: 3.0.2 - qs: 6.14.1 + qs: 6.15.1 statuses: 1.5.0 utility: 1.18.0 optionalDependencies: @@ -26933,7 +26916,7 @@ snapshots: form-data: 4.0.5 formstream: 1.5.2 mime-types: 2.1.35 - qs: 6.14.1 + qs: 6.15.1 type-fest: 4.41.0 undici: 7.18.2 ylru: 2.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4cef937fb1..726e0ad313 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,40 +1,44 @@ 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 diff --git a/projects/app/package.json b/projects/app/package.json index b9ee588a07..e2fd8d61dd 100644 --- a/projects/app/package.json +++ b/projects/app/package.json @@ -1,6 +1,6 @@ { "name": "@fastgpt/app", - "version": "4.14.16", + "version": "4.15.0", "private": false, "scripts": { "dev": "pnpm run build:workers && next dev", diff --git a/projects/code-sandbox/Dockerfile b/projects/code-sandbox/Dockerfile index 9f4edb03ac..d03a0d1121 100644 --- a/projects/code-sandbox/Dockerfile +++ b/projects/code-sandbox/Dockerfile @@ -1,14 +1,14 @@ # --------- 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"] diff --git a/projects/code-sandbox/README.md b/projects/code-sandbox/README.md index 4c4a6ec0c8..4b56cafddc 100644 --- a/projects/code-sandbox/README.md +++ b/projects/code-sandbox/README.md @@ -1,6 +1,6 @@ # 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 +pnpm add ``` 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 diff --git a/projects/code-sandbox/build.sh b/projects/code-sandbox/build.sh index 9b8647fec6..b0c733e0fb 100755 --- a/projects/code-sandbox/build.sh +++ b/projects/code-sandbox/build.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 阶段安装。" diff --git a/projects/code-sandbox/package.json b/projects/code-sandbox/package.json index 5d110d7924..28d8bd84d6 100644 --- a/projects/code-sandbox/package.json +++ b/projects/code-sandbox/package.json @@ -1,13 +1,14 @@ { "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:" } } diff --git a/projects/code-sandbox/runtime.package.json b/projects/code-sandbox/runtime.package.json new file mode 100644 index 0000000000..f10afc8e5c --- /dev/null +++ b/projects/code-sandbox/runtime.package.json @@ -0,0 +1,16 @@ +{ + "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" + } +} diff --git a/projects/code-sandbox/src/env.ts b/projects/code-sandbox/src/env.ts index 402f380265..7c3a938e11 100644 --- a/projects/code-sandbox/src/env.ts +++ b/projects/code-sandbox/src/env.ts @@ -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); diff --git a/projects/code-sandbox/src/index.ts b/projects/code-sandbox/src/index.ts index 9007532620..52ca30ed48 100644 --- a/projects/code-sandbox/src/index.ts +++ b/projects/code-sandbox/src/index.ts @@ -1,6 +1,7 @@ -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 }; diff --git a/projects/code-sandbox/src/pool/process-pool.ts b/projects/code-sandbox/src/pool/process-pool.ts index 36c1757fc4..b93ef28371 100644 --- a/projects/code-sandbox/src/pool/process-pool.ts +++ b/projects/code-sandbox/src/pool/process-pool.ts @@ -1,23 +1,27 @@ /** - * 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 }); } diff --git a/projects/code-sandbox/src/pool/python-process-pool.ts b/projects/code-sandbox/src/pool/python-process-pool.ts index 40400a20ac..162155679c 100644 --- a/projects/code-sandbox/src/pool/python-process-pool.ts +++ b/projects/code-sandbox/src/pool/python-process-pool.ts @@ -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) { diff --git a/projects/code-sandbox/src/pool/worker.ts b/projects/code-sandbox/src/pool/worker.ts index 1078717841..405be128ea 100644 --- a/projects/code-sandbox/src/pool/worker.ts +++ b/projects/code-sandbox/src/pool/worker.ts @@ -10,12 +10,157 @@ * 每行输出:{"success":true,"data":{...}} 或 {"success":false,"message":"..."} */ import { createInterface } from 'readline'; +import { createRequire } from 'module'; +import { isIP } from 'net'; import * as crypto from 'crypto'; import * as http from 'http'; import * as https from 'https'; import * as dns from 'dns'; -import { isInternalAddress } from '@fastgpt/service/common/system/utils'; +import { parse } from 'acorn'; +import { simple as walk } from 'acorn-walk'; +import { isInternalAddress, isInternalResolvedIP } from '../utils/ipCheck.util'; + +const require = createRequire(import.meta.url); const _OriginalFunction = Function; +const _JSONParse = JSON.parse.bind(JSON); +const _JSONStringify = JSON.stringify.bind(JSON); +const _ObjectFreeze = Object.freeze; +const _ObjectDefineProperty = Object.defineProperty; +const _ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const _ObjectKeys = Object.keys; +const _OriginalProxy = Proxy; +const _ReflectOwnKeys = Reflect.ownKeys; +const _ReflectGet = Reflect.get.bind(Reflect); +const _ReflectApply = Reflect.apply.bind(Reflect); +const _ReflectConstruct = Reflect.construct.bind(Reflect); +const _OriginalPromise = Promise; +const _PromiseRace = Promise.race.bind(Promise); +const _OriginalError = Error; +const _workerSetTimeout = setTimeout.bind(globalThis); +const _workerClearTimeout = clearTimeout.bind(globalThis); +const _workerSetInterval = setInterval.bind(globalThis); +const _workerClearInterval = clearInterval.bind(globalThis); +type WorkerTimeoutHandle = ReturnType; +type WorkerIntervalHandle = ReturnType; +type WorkerTimerHandle = WorkerTimeoutHandle | WorkerIntervalHandle | number; +const DYNAMIC_IMPORT_ERROR_MESSAGE = + 'Dynamic import() is not allowed in sandbox. Use require() instead.'; +const EVAL_ERROR_MESSAGE = 'Code generation with eval() is not allowed in sandbox.'; + +function lockGlobal(name: string, value = (globalThis as any)[name]): void { + try { + _ObjectDefineProperty(globalThis, name, { + value, + writable: false, + configurable: false + }); + } catch {} +} + +function assertNoDynamicImport(code: string): void { + const ast = parse(code, { + ecmaVersion: 'latest', + sourceType: 'script', + allowReturnOutsideFunction: true + }); + + // 通过 AST 识别真实 import() 表达式,避免注释/换行绕过正则检查。 + walk(ast, { + ImportExpression() { + throw new Error(DYNAMIC_IMPORT_ERROR_MESSAGE); + } + }); +} + +function deepFreeze(value: T, seen = new WeakSet()): T { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + return value; + } + + const obj = value as object; + if (seen.has(obj)) return value; + seen.add(obj); + + for (const key of _ReflectOwnKeys(obj)) { + try { + const descriptor = _ObjectGetOwnPropertyDescriptor(obj, key); + if (descriptor && 'value' in descriptor) { + deepFreeze(descriptor.value, seen); + } + } catch {} + } + + try { + _ObjectFreeze(obj); + } catch {} + + return value; +} + +const readonlyViews = new WeakMap(); +const readonlyRawValues = new WeakMap(); +function unwrapReadonly(value: T): T { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + return value; + } + return (readonlyRawValues.get(value as object) as T) || value; +} +function readonlyView(value: T): T { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + return value; + } + + const obj = value as object; + const existing = readonlyViews.get(obj); + if (existing) return existing; + + const proxy = new _OriginalProxy(obj as any, { + get(target, prop) { + return readonlyView(_ReflectGet(target, prop, target)); + }, + getOwnPropertyDescriptor(target, prop) { + const descriptor = _ObjectGetOwnPropertyDescriptor(target, prop); + if (!descriptor || !('value' in descriptor) || descriptor.configurable === false) { + return descriptor; + } + return { ...descriptor, value: readonlyView(descriptor.value) }; + }, + set() { + throw new Error('Sandbox module exports are read-only'); + }, + defineProperty() { + throw new Error('Sandbox module exports are read-only'); + }, + deleteProperty() { + throw new Error('Sandbox module exports are read-only'); + }, + setPrototypeOf() { + throw new Error('Sandbox module exports are read-only'); + }, + apply(target, thisArg, argArray) { + return readonlyView( + _ReflectApply( + target, + unwrapReadonly(thisArg), + Array.from(argArray, (item) => unwrapReadonly(item)) + ) + ); + }, + construct(target, argArray, newTarget) { + return readonlyView( + _ReflectConstruct( + target, + Array.from(argArray, (item) => unwrapReadonly(item)), + newTarget + ) + ); + } + }); + + readonlyViews.set(obj, proxy); + readonlyRawValues.set(proxy, obj); + return proxy; +} // ===== 安全 shim ===== // 只拦截对 Function 相关原型的访问,防止通过原型链拿到原始构造器 @@ -42,6 +187,9 @@ if ((Error as any).captureStackTrace) delete (Error as any).captureStackTrace; const _SafeFunction = function (..._args: any[]) { throw new Error('Function constructor is not allowed in sandbox'); } as unknown as FunctionConstructor; +const _SafeEval = function (..._args: any[]) { + throw new Error(EVAL_ERROR_MESSAGE); +} as typeof eval; Object.defineProperty(_SafeFunction, 'prototype', { value: _OriginalFunction.prototype, writable: false, @@ -52,7 +200,8 @@ Object.defineProperty(_OriginalFunction.prototype, 'constructor', { writable: false, configurable: false }); -(globalThis as any).Function = _SafeFunction; +lockGlobal('Function', _SafeFunction); +lockGlobal('eval', _SafeEval); // 锁定 AsyncFunction、GeneratorFunction、AsyncGeneratorFunction 构造器 // 防止通过 (async function(){}).constructor("...") 绕过沙盒 @@ -68,45 +217,163 @@ for (const FnCtor of [_AsyncFunction, _GeneratorFunction, _AsyncGeneratorFunctio }); } +const hardenedIntrinsics = [ + Object, + Object.prototype, + Array, + Array.prototype, + Function.prototype, + _AsyncFunction.prototype, + _GeneratorFunction.prototype, + _AsyncGeneratorFunction.prototype, + JSON, + Math, + Reflect, + Promise, + Promise.prototype, + Proxy, + RegExp, + RegExp.prototype, + Date, + Date.prototype, + Error, + Error.prototype, + Map, + Map.prototype, + Set, + Set.prototype, + WeakMap, + WeakMap.prototype, + WeakSet, + WeakSet.prototype, + String, + String.prototype, + Number, + Number.prototype, + Boolean, + Boolean.prototype, + Symbol, + Symbol.prototype, + BigInt, + BigInt.prototype, + ArrayBuffer, + ArrayBuffer.prototype, + DataView, + DataView.prototype, + Uint8Array, + Uint8Array.prototype, + Uint16Array, + Uint16Array.prototype, + Uint32Array, + Uint32Array.prototype, + Int8Array, + Int8Array.prototype, + Int16Array, + Int16Array.prototype, + Int32Array, + Int32Array.prototype, + Float32Array, + Float32Array.prototype, + Float64Array, + Float64Array.prototype, + URL, + URL.prototype, + URLSearchParams, + URLSearchParams.prototype, + TextEncoder, + TextEncoder.prototype, + TextDecoder, + TextDecoder.prototype, + Buffer, + Buffer.prototype, + setTimeout, + clearTimeout, + setInterval, + clearInterval +] as const; + +const lockedGlobalNames = [ + 'Object', + 'Array', + 'Function', + 'JSON', + 'Math', + 'Reflect', + 'Promise', + 'Proxy', + 'RegExp', + 'Date', + 'Error', + 'Map', + 'Set', + 'WeakMap', + 'WeakSet', + 'String', + 'Number', + 'Boolean', + 'Symbol', + 'BigInt', + 'ArrayBuffer', + 'DataView', + 'Uint8Array', + 'Uint16Array', + 'Uint32Array', + 'Int8Array', + 'Int16Array', + 'Int32Array', + 'Float32Array', + 'Float64Array', + 'Buffer', + 'URL', + 'URLSearchParams', + 'TextEncoder', + 'TextDecoder', + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'process' +] as const; + // C2: Bun API 在用户代码中通过函数参数遮蔽来阻止访问(见 userFn 构造处) // 不在全局移除,因为 Bun 运行时自身依赖 globalThis.Bun -(globalThis as any).fetch = undefined; -(globalThis as any).XMLHttpRequest = undefined; -(globalThis as any).WebSocket = undefined; +lockGlobal('fetch', undefined); +lockGlobal('XMLHttpRequest', undefined); +lockGlobal('WebSocket', undefined); // ===== process 对象加固 ===== // 保存 worker 自身需要的引用 const _workerStdout = process.stdout; const _workerStdin = process.stdin; -if (typeof process !== 'undefined') { - // 删除危险方法 - const dangerousMethods = [ - 'binding', - 'dlopen', - '_linkedBinding', - 'kill', - 'chdir', - 'exit', - 'emitWarning', - 'send', - 'disconnect', - '_debugProcess', - '_debugEnd', - '_startProfilerIdleNotifier', - '_stopProfilerIdleNotifier', - 'reallyExit', - 'abort', - 'umask', - 'setuid', - 'setgid', - 'seteuid', - 'setegid', - 'setgroups', - 'initgroups' - ]; - for (const method of dangerousMethods) { +// 启动期立即删除:与 worker 自身/白名单模块无依赖关系 +const earlyDangerousMethods = [ + 'binding', + 'dlopen', + '_linkedBinding', + 'chdir', + 'send', + 'disconnect', + '_debugProcess', + '_debugEnd', + '_startProfilerIdleNotifier', + '_stopProfilerIdleNotifier', + 'reallyExit', + 'umask', + 'setuid', + 'setgid', + 'seteuid', + 'setegid', + 'setgroups', + 'initgroups' +]; + +// 延迟删除:会被 https/dns/tsx 等内部使用,要等 hardenRuntime 预加载完白名单后再删 +const lateDangerousMethods = ['kill', 'exit', 'emitWarning', 'abort']; + +function deleteProcessMethods(methods: readonly string[]): void { + for (const method of methods) { try { Object.defineProperty(process, method, { value: undefined, @@ -115,6 +382,10 @@ if (typeof process !== 'undefined') { }); } catch {} } +} + +if (typeof process !== 'undefined') { + deleteProcessMethods(earlyDangerousMethods); // 清理 env 敏感变量并冻结 const sensitivePatterns = [ @@ -134,7 +405,14 @@ if (typeof process !== 'undefined') { delete process.env[key]; } } - Object.freeze(process.env); + // Node 的 process.env 是 host-protected 的 Proxy,不能直接 freeze。 + // 用 try/catch 兜底:Bun 下能 freeze(保留原行为),Node 下静默跳过 + // (后续 process.env 已通过函数参数遮蔽,用户代码本就拿不到) + try { + _ObjectFreeze(process.env); + } catch { + /* ignore: Node process.env not freezable */ + } } // ===== 网络安全 ===== @@ -178,7 +456,7 @@ function createHmac(algorithm: string, secret: string) { } function delay(ms: number): Promise { if (ms > 10000) throw new Error('Delay must be <= 10000ms'); - return new Promise((r) => setTimeout(r, ms)); + return new Promise((r) => _workerSetTimeout(r, ms)); } // ===== SystemHelper ===== @@ -196,13 +474,17 @@ const SystemHelper = { throw new Error('Request to private network not allowed'); } const ips = await dnsResolve(parsed.hostname); + // 防 DNS rebinding TOCTOU:对真正用于建连的 IP 再次校验 + if (ips.length === 0 || ips.some((ip) => isInternalResolvedIP(ip))) { + throw new Error('Request to private network not allowed'); + } const method = (opts.method || 'GET').toUpperCase(); const headers = opts.headers || {}; const body = opts.body != null ? typeof opts.body === 'string' ? opts.body - : JSON.stringify(opts.body) + : _JSONStringify(opts.body) : null; if (body && body.length > REQUEST_LIMITS.maxRequestBodySize) { throw new Error('Request body too large'); @@ -229,7 +511,8 @@ const SystemHelper = { hostname: resolvedIP, port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), path: parsed.pathname + parsed.search, - servername: parsed.hostname + // RFC 6066 禁止把 IP 当作 SNI;hostname 是 IP 时省略 servername + ...(isIP(parsed.hostname) ? {} : { servername: parsed.hostname }) }, (res: any) => { const chunks: Buffer[] = []; @@ -262,24 +545,70 @@ const SystemHelper = { }); } }; +_ObjectFreeze(SystemHelper); const httpRequest = SystemHelper.httpRequest; // ===== 模块白名单(启动后由 init 消息设置)===== let allowedModules = new Set(); const origRequire = require; -const safeRequire = new Proxy(origRequire, { - apply(target, thisArg, args) { - if (!allowedModules.has(args[0])) { - throw new Error(`Module '${args[0]}' is not allowed in sandbox`); - } - return Reflect.apply(target, thisArg, args); +let runtimeHardened = false; +function hardenRuntime(): void { + if (runtimeHardened) return; + + for (const moduleName of allowedModules) { + try { + origRequire(moduleName); + } catch {} } + + // 白名单模块已加载完毕,此时再删除 kill/exit/emitWarning/abort: + // 这些方法仅在模块初始化时被 https/dns/tsx 等使用,预加载后不再需要。 + deleteProcessMethods(lateDangerousMethods); + + for (const intrinsic of hardenedIntrinsics) { + if (intrinsic) _ObjectFreeze(intrinsic); + } + + for (const name of lockedGlobalNames) { + lockGlobal(name); + } + + runtimeHardened = true; +} +function getRequireCacheKeys(): Set { + return new Set(_ObjectKeys(origRequire.cache || {})); +} +function cleanupUserRequireCache(cacheKeysBeforeTask: Set): void { + for (const key of _ObjectKeys(origRequire.cache || {})) { + if (!cacheKeysBeforeTask.has(key)) { + delete origRequire.cache[key]; + } + } +} +function safeRequire(moduleName: string) { + if (!allowedModules.has(moduleName)) { + throw new Error(`Module '${moduleName}' is not allowed in sandbox`); + } + return readonlyView(origRequire(moduleName)); +} +const safeRequireResolve = _ObjectFreeze((moduleName: string) => { + if (!allowedModules.has(moduleName)) { + throw new Error(`Module '${moduleName}' is not allowed in sandbox`); + } + return origRequire.resolve(moduleName); }); +Object.defineProperty(safeRequire, 'resolve', { + value: safeRequireResolve, + writable: false, + configurable: false +}); +_ObjectFreeze(safeRequire.prototype); +_ObjectFreeze(safeRequire); // ===== 输出辅助 ===== function writeLine(obj: any): void { - _workerStdout.write(JSON.stringify(obj) + '\n'); + _workerStdout.write(_JSONStringify(obj) + '\n'); } // ===== 主循环 ===== @@ -289,7 +618,7 @@ let initialized = false; rl.on('line', async (line: string) => { let msg: any; try { - msg = JSON.parse(line); + msg = _JSONParse(line); } catch { writeLine({ success: false, message: 'Invalid JSON input' }); return; @@ -310,6 +639,7 @@ rl.on('line', async (line: string) => { if (msg.requestLimits.maxRequestBodySize != null) REQUEST_LIMITS.maxRequestBodySize = msg.requestLimits.maxRequestBodySize; } + hardenRuntime(); writeLine({ type: 'ready' }); initialized = true; } else { @@ -332,7 +662,7 @@ rl.on('line', async (line: string) => { let logSize = 0; const MAX_LOG_SIZE = 1024 * 1024; // 1MB const _consoleLog = (...args: any[]) => { - const line = args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '); + const line = args.map((a) => (typeof a === 'object' ? _JSONStringify(a) : String(a))).join(' '); if (logSize + line.length <= MAX_LOG_SIZE) { logs.push(line); logSize += line.length; @@ -348,23 +678,69 @@ rl.on('line', async (line: string) => { dir: _consoleLog, table: _consoleLog }; + const activeTimeouts = new Set(); + const activeIntervals = new Set(); + const safeSetTimeout = (handler: TimerHandler, timeout?: number, ...args: any[]) => { + if (typeof handler !== 'function') { + throw new Error('setTimeout expects a function'); + } + const timer = _workerSetTimeout( + (...callbackArgs: any[]) => { + activeTimeouts.delete(timer); + try { + handler(...callbackArgs); + } catch (err: any) { + _consoleLog(err?.message ?? String(err)); + } + }, + timeout, + ...args + ); + activeTimeouts.add(timer); + return timer; + }; + const safeClearTimeout = (timer: WorkerTimerHandle) => { + activeTimeouts.delete(timer); + _workerClearTimeout(timer as WorkerTimeoutHandle); + }; + const safeSetInterval = (handler: TimerHandler, timeout?: number, ...args: any[]) => { + if (typeof handler !== 'function') { + throw new Error('setInterval expects a function'); + } + const timer = _workerSetInterval(() => { + try { + handler(...args); + } catch (err: any) { + _consoleLog(err?.message ?? String(err)); + } + }, timeout); + activeIntervals.add(timer); + return timer; + }; + const safeClearInterval = (timer: WorkerTimerHandle) => { + activeIntervals.delete(timer); + _workerClearInterval(timer as WorkerIntervalHandle); + }; + const cleanupUserTimers = () => { + for (const timer of activeTimeouts) { + _workerClearTimeout(timer as WorkerTimeoutHandle); + } + activeTimeouts.clear(); + for (const timer of activeIntervals) { + _workerClearInterval(timer as WorkerIntervalHandle); + } + activeIntervals.clear(); + }; let timer: ReturnType | undefined; + const requireCacheKeysBeforeTask = getRequireCacheKeys(); try { - // 静态检查:拦截 import() 动态导入,防止绕过 require 白名单 - // 匹配 import( 但排除注释中的(简单启发式) - if (/\bimport\s*\(/.test(code)) { - writeLine({ - success: false, - message: 'Dynamic import() is not allowed in sandbox. Use require() instead.' - }); - return; - } + assertNoDynamicImport(code); const resultPromise = (async () => { // C2 + #19: 通过函数参数遮蔽危险全局对象,用户代码无法访问 Bun/process/globalThis - const _sandboxProcess = Object.freeze({ - env: Object.freeze({}), + const _sandboxProcess = _ObjectFreeze({ + env: _ObjectFreeze({}), cwd: () => '/sandbox', version: process.version, platform: process.platform @@ -383,7 +759,47 @@ rl.on('line', async (line: string) => { 'process', 'globalThis', 'global', - code + '\nreturn main;' + 'fetch', + 'XMLHttpRequest', + 'WebSocket', + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'Object', + 'Array', + 'JSON', + 'Reflect', + 'Promise', + 'Math', + 'RegExp', + 'Date', + 'Error', + 'Map', + 'Set', + 'WeakMap', + 'WeakSet', + 'String', + 'Number', + 'Boolean', + 'Symbol', + 'BigInt', + 'ArrayBuffer', + 'DataView', + 'Uint8Array', + 'Uint16Array', + 'Uint32Array', + 'Int8Array', + 'Int16Array', + 'Int32Array', + 'Float32Array', + 'Float64Array', + 'Buffer', + 'URL', + 'URLSearchParams', + 'TextEncoder', + 'TextDecoder', + '"use strict";\n' + code + '\nreturn main;' ); const main = userFn( safeRequire, @@ -398,26 +814,69 @@ rl.on('line', async (line: string) => { undefined, _sandboxProcess, undefined, - undefined + undefined, + undefined, + undefined, + undefined, + safeSetTimeout, + safeClearTimeout, + safeSetInterval, + safeClearInterval, + Object, + Array, + JSON, + Reflect, + Promise, + Math, + RegExp, + Date, + Error, + Map, + Set, + WeakMap, + WeakSet, + String, + Number, + Boolean, + Symbol, + BigInt, + ArrayBuffer, + DataView, + Uint8Array, + Uint16Array, + Uint32Array, + Int8Array, + Int16Array, + Int32Array, + Float32Array, + Float64Array, + Buffer, + URL, + URLSearchParams, + TextEncoder, + TextDecoder ); return await main(variables || {}); })(); - const timeoutPromise = new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error(`Script execution timed out after ${timeoutMs}ms`)), + const timeoutPromise = new _OriginalPromise((_, reject) => { + timer = _workerSetTimeout( + () => reject(new _OriginalError(`Script execution timed out after ${timeoutMs}ms`)), timeoutMs || 10000 ); }); - const result = await Promise.race([resultPromise, timeoutPromise]); - clearTimeout(timer); + const result = await _PromiseRace([resultPromise, timeoutPromise]); + _workerClearTimeout(timer); writeLine({ success: true, data: { codeReturn: result === undefined ? null : result, log: logs.join('\n') } }); } catch (err: any) { - clearTimeout(timer); + _workerClearTimeout(timer); writeLine({ success: false, message: err?.message ?? String(err) }); + } finally { + cleanupUserTimers(); + cleanupUserRequireCache(requireCacheKeysBeforeTask); } }); diff --git a/projects/code-sandbox/src/utils/ipCheck.util.ts b/projects/code-sandbox/src/utils/ipCheck.util.ts new file mode 100644 index 0000000000..e4ba2bb1cf --- /dev/null +++ b/projects/code-sandbox/src/utils/ipCheck.util.ts @@ -0,0 +1,197 @@ +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( + [ + '100.100.100.200', // 阿里云 + 'fd00:ec2::254' // AWS IPv6 + ].map((ip) => ipaddr.parse(ip).toNormalizedString().toLowerCase()) +); + +// 云厂商元数据服务主机名(归一化:小写、去尾部点) +const METADATA_HOSTNAMES = new Set([ + 'metadata.google.internal', + 'metadata', + 'metadata.tencentyun.com', + 'kubernetes.default.svc', + 'kubernetes.default', + 'kubernetes' +]); + +const LOCALHOST_HOSTNAMES = new Set(['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 => { + 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'; diff --git a/projects/code-sandbox/test/benchmark/bench-sandbox-python.sh b/projects/code-sandbox/test/benchmark/bench-sandbox-python.sh index f49a5bdf2a..6242d799ae 100644 --- a/projects/code-sandbox/test/benchmark/bench-sandbox-python.sh +++ b/projects/code-sandbox/test/benchmark/bench-sandbox-python.sh @@ -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" diff --git a/projects/code-sandbox/test/benchmark/bench-sandbox.sh b/projects/code-sandbox/test/benchmark/bench-sandbox.sh index 812bce01fb..0f24d0b1d9 100644 --- a/projects/code-sandbox/test/benchmark/bench-sandbox.sh +++ b/projects/code-sandbox/test/benchmark/bench-sandbox.sh @@ -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" diff --git a/projects/code-sandbox/test/integration/api.test.ts b/projects/code-sandbox/test/integration/api.test.ts index 34fdd3ebb5..35355cc9d3 100644 --- a/projects/code-sandbox/test/integration/api.test.ts +++ b/projects/code-sandbox/test/integration/api.test.ts @@ -15,6 +15,16 @@ function headers(extra: Record = {}): Record { return h; } +async function executeJs(code: string, variables: Record = {}) { + 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', { diff --git a/projects/code-sandbox/test/integration/functional.test.ts b/projects/code-sandbox/test/integration/functional.test.ts index 2c862a3800..4684fd5b95 100644 --- a/projects/code-sandbox/test/integration/functional.test.ts +++ b/projects/code-sandbox/test/integration/functional.test.ts @@ -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 } } } ] diff --git a/projects/code-sandbox/test/unit/ipCheck.test.ts b/projects/code-sandbox/test/unit/ipCheck.test.ts new file mode 100644 index 0000000000..2eb0934cc2 --- /dev/null +++ b/projects/code-sandbox/test/unit/ipCheck.test.ts @@ -0,0 +1,344 @@ +/** + * ipCheck.util 单元测试 + * + * 覆盖: + * - isInternalResolvedIP:DNS rebinding TOCTOU 二次校验 + * - 元数据 IP(169.254.169.254、100.100.100.200、fd00:ec2::254) + * - loopback/unspecified(127.0.0.1、0.0.0.0、::1、::) + * - 私网(10/172.16/192.168/fd00::/7)受 CHECK_INTERNAL_IP 控制 + * - 公网 IP 永远放行 + * - dev 环境直接放行 + * - 非法 IP 字面量 + * + * - isInternalAddress:URL 级预检 + * - localhost / 本机 hostname + * - 云元数据主机名(metadata.google.internal、kubernetes.default 等) + * - IP 字面量(含 IPv4-mapped、十进制/十六进制/八进制变体) + * - 域名 DNS 解析(私网 / 公网 / 元数据) + * - 非法 URL 兜底 + * - dev 环境直接放行 + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// 用 vi.hoisted 让 mock 在 import 之前生效 +const { resolve4, resolve6 } = vi.hoisted(() => ({ + resolve4: vi.fn<(host: string) => Promise>(), + resolve6: vi.fn<(host: string) => Promise>() +})); + +vi.mock('dns/promises', () => ({ + default: { resolve4, resolve6 }, + resolve4, + resolve6 +})); + +// 注意:模块顶层会读取 process.env.NODE_ENV / HOSTNAME / PORT, +// 必须在 import ipCheck 之前确保环境变量是预期值。 +const ORIGINAL_NODE_ENV = process.env.NODE_ENV; +const ORIGINAL_CHECK_INTERNAL_IP = process.env.CHECK_INTERNAL_IP; + +// 直接 require 在 vi.mock 注册后才 import +let isInternalResolvedIP: typeof import('../../src/utils/ipCheck.util').isInternalResolvedIP; +let isInternalAddress: typeof import('../../src/utils/ipCheck.util').isInternalAddress; + +beforeEach(async () => { + process.env.NODE_ENV = 'production'; + process.env.CHECK_INTERNAL_IP = 'false'; + resolve4.mockReset(); + resolve6.mockReset(); + // 默认所有 DNS 解析失败 + resolve4.mockRejectedValue(new Error('ENODATA')); + resolve6.mockRejectedValue(new Error('ENODATA')); + + // 每次重新加载,让模块顶层常量按当前 env 重新计算 + vi.resetModules(); + const mod = await import('../../src/utils/ipCheck.util'); + isInternalResolvedIP = mod.isInternalResolvedIP; + isInternalAddress = mod.isInternalAddress; +}); + +afterEach(() => { + process.env.NODE_ENV = ORIGINAL_NODE_ENV; + process.env.CHECK_INTERNAL_IP = ORIGINAL_CHECK_INTERNAL_IP; +}); + +describe('isInternalResolvedIP', () => { + describe('元数据 IP 永远阻止', () => { + it('169.254.169.254 (link-local 元数据段)', () => { + expect(isInternalResolvedIP('169.254.169.254')).toBe(true); + }); + it('169.254.0.1 (link-local 段任意 IP)', () => { + expect(isInternalResolvedIP('169.254.0.1')).toBe(true); + }); + it('100.100.100.200 (阿里云元数据)', () => { + expect(isInternalResolvedIP('100.100.100.200')).toBe(true); + }); + it('fd00:ec2::254 (AWS IPv6 元数据)', () => { + expect(isInternalResolvedIP('fd00:ec2::254')).toBe(true); + }); + }); + + describe('loopback / unspecified 永远阻止', () => { + it('127.0.0.1', () => { + expect(isInternalResolvedIP('127.0.0.1')).toBe(true); + }); + it('127.255.255.255 (loopback 段任意 IP)', () => { + expect(isInternalResolvedIP('127.255.255.255')).toBe(true); + }); + it('::1 (IPv6 loopback)', () => { + expect(isInternalResolvedIP('::1')).toBe(true); + }); + it('0.0.0.0 (unspecified)', () => { + expect(isInternalResolvedIP('0.0.0.0')).toBe(true); + }); + it(':: (IPv6 unspecified)', () => { + expect(isInternalResolvedIP('::')).toBe(true); + }); + }); + + describe('私网段:CHECK_INTERNAL_IP 控制', () => { + it('CHECK_INTERNAL_IP=false 时放行 10.0.0.1', async () => { + process.env.CHECK_INTERNAL_IP = 'false'; + vi.resetModules(); + const mod = await import('../../src/utils/ipCheck.util'); + expect(mod.isInternalResolvedIP('10.0.0.1')).toBe(false); + }); + it('CHECK_INTERNAL_IP=true 时阻止 10.0.0.1', async () => { + process.env.CHECK_INTERNAL_IP = 'true'; + vi.resetModules(); + const mod = await import('../../src/utils/ipCheck.util'); + expect(mod.isInternalResolvedIP('10.0.0.1')).toBe(true); + }); + it('CHECK_INTERNAL_IP=true 时阻止 172.16.0.1', async () => { + process.env.CHECK_INTERNAL_IP = 'true'; + vi.resetModules(); + const mod = await import('../../src/utils/ipCheck.util'); + expect(mod.isInternalResolvedIP('172.16.0.1')).toBe(true); + }); + it('CHECK_INTERNAL_IP=true 时阻止 192.168.1.1', async () => { + process.env.CHECK_INTERNAL_IP = 'true'; + vi.resetModules(); + const mod = await import('../../src/utils/ipCheck.util'); + expect(mod.isInternalResolvedIP('192.168.1.1')).toBe(true); + }); + it('CHECK_INTERNAL_IP=true 时阻止 IPv6 ULA fc00::1', async () => { + process.env.CHECK_INTERNAL_IP = 'true'; + vi.resetModules(); + const mod = await import('../../src/utils/ipCheck.util'); + expect(mod.isInternalResolvedIP('fc00::1')).toBe(true); + }); + }); + + describe('公网 IP 永远放行', () => { + it('1.1.1.1', () => { + expect(isInternalResolvedIP('1.1.1.1')).toBe(false); + }); + it('8.8.8.8', () => { + expect(isInternalResolvedIP('8.8.8.8')).toBe(false); + }); + it('2001:4860:4860::8888 (Google IPv6 DNS)', () => { + expect(isInternalResolvedIP('2001:4860:4860::8888')).toBe(false); + }); + }); + + describe('开发环境', () => { + it('NODE_ENV=development 时放行任何 IP(包括 127.0.0.1)', async () => { + process.env.NODE_ENV = 'development'; + vi.resetModules(); + const mod = await import('../../src/utils/ipCheck.util'); + expect(mod.isInternalResolvedIP('127.0.0.1')).toBe(false); + expect(mod.isInternalResolvedIP('169.254.169.254')).toBe(false); + expect(mod.isInternalResolvedIP('1.1.1.1')).toBe(false); + }); + }); + + describe('非法输入', () => { + it('空字符串放行', () => { + expect(isInternalResolvedIP('')).toBe(false); + }); + it('非 IP 字符串放行', () => { + expect(isInternalResolvedIP('not-an-ip')).toBe(false); + }); + it('域名(不是 IP 字面量)放行', () => { + // 这个函数只校验 IP 字面量,域名走 isInternalAddress + expect(isInternalResolvedIP('example.com')).toBe(false); + }); + }); +}); + +describe('isInternalAddress', () => { + describe('localhost / 本机', () => { + it('http://localhost 阻止', async () => { + expect(await isInternalAddress('http://localhost')).toBe(true); + }); + it('http://localhost:8080 阻止', async () => { + expect(await isInternalAddress('http://localhost:8080')).toBe(true); + }); + it('https://localhost/path 阻止', async () => { + expect(await isInternalAddress('https://localhost/path')).toBe(true); + }); + }); + + describe('云元数据主机名(始终阻止,不受 CHECK_INTERNAL_IP 影响)', () => { + it('metadata.google.internal', async () => { + expect(await isInternalAddress('http://metadata.google.internal/')).toBe(true); + }); + it('metadata.tencentyun.com', async () => { + expect(await isInternalAddress('http://metadata.tencentyun.com/')).toBe(true); + }); + it('kubernetes.default.svc', async () => { + expect(await isInternalAddress('http://kubernetes.default.svc/')).toBe(true); + }); + it('kubernetes.default', async () => { + expect(await isInternalAddress('http://kubernetes.default/')).toBe(true); + }); + it('kubernetes', async () => { + expect(await isInternalAddress('http://kubernetes/')).toBe(true); + }); + it('大小写不敏感: METADATA.google.INTERNAL', async () => { + expect(await isInternalAddress('http://METADATA.google.INTERNAL/')).toBe(true); + }); + it('尾部点号兼容: metadata.google.internal.', async () => { + expect(await isInternalAddress('http://metadata.google.internal./')).toBe(true); + }); + }); + + describe('IP 字面量', () => { + it('http://127.0.0.1 阻止', async () => { + expect(await isInternalAddress('http://127.0.0.1/')).toBe(true); + }); + it('http://169.254.169.254 阻止 (AWS metadata)', async () => { + expect(await isInternalAddress('http://169.254.169.254/')).toBe(true); + }); + it('http://100.100.100.200 阻止 (阿里云 metadata)', async () => { + expect(await isInternalAddress('http://100.100.100.200/')).toBe(true); + }); + it('http://[::1] 阻止 (IPv6 loopback)', async () => { + expect(await isInternalAddress('http://[::1]/')).toBe(true); + }); + it('http://[fd00:ec2::254] 阻止 (AWS IPv6 metadata)', async () => { + expect(await isInternalAddress('http://[fd00:ec2::254]/')).toBe(true); + }); + it('http://0.0.0.0 阻止 (unspecified)', async () => { + expect(await isInternalAddress('http://0.0.0.0/')).toBe(true); + }); + it('CHECK_INTERNAL_IP=false 时放行私网 10.0.0.1', async () => { + expect(await isInternalAddress('http://10.0.0.1/')).toBe(false); + }); + it('CHECK_INTERNAL_IP=true 时阻止私网 10.0.0.1', async () => { + process.env.CHECK_INTERNAL_IP = 'true'; + vi.resetModules(); + const mod = await import('../../src/utils/ipCheck.util'); + expect(await mod.isInternalAddress('http://10.0.0.1/')).toBe(true); + }); + it('http://1.1.1.1 公网放行', async () => { + expect(await isInternalAddress('http://1.1.1.1/')).toBe(false); + }); + }); + + describe('IP 字面量绕过变体(必须能识别为内网)', () => { + it('十进制 IPv4: http://2130706433/ (=127.0.0.1)', async () => { + expect(await isInternalAddress('http://2130706433/')).toBe(true); + }); + it('十六进制 IPv4: http://0x7f000001/ (=127.0.0.1)', async () => { + expect(await isInternalAddress('http://0x7f000001/')).toBe(true); + }); + it('八进制 IPv4: http://0177.0.0.01/ (=127.0.0.1)', async () => { + expect(await isInternalAddress('http://0177.0.0.01/')).toBe(true); + }); + it('短点分形式: http://127.1/ (=127.0.0.1)', async () => { + expect(await isInternalAddress('http://127.1/')).toBe(true); + }); + it('IPv4-mapped IPv6: http://[::ffff:127.0.0.1]/', async () => { + expect(await isInternalAddress('http://[::ffff:127.0.0.1]/')).toBe(true); + }); + it('元数据十进制: http://2852039166/ (=169.254.169.254)', async () => { + expect(await isInternalAddress('http://2852039166/')).toBe(true); + }); + }); + + describe('域名 DNS 解析', () => { + it('解析到公网 IP 放行', async () => { + resolve4.mockResolvedValue(['1.1.1.1']); + expect(await isInternalAddress('http://example.com/')).toBe(false); + }); + it('解析到 loopback 阻止', async () => { + resolve4.mockResolvedValue(['127.0.0.1']); + expect(await isInternalAddress('http://evil.com/')).toBe(true); + }); + it('解析到云元数据 IP 阻止(不需要 CHECK_INTERNAL_IP)', async () => { + resolve4.mockResolvedValue(['169.254.169.254']); + expect(await isInternalAddress('http://aws-metadata-rebind.com/')).toBe(true); + }); + it('CHECK_INTERNAL_IP=false 时解析到私网放行', async () => { + resolve4.mockResolvedValue(['10.0.0.1']); + expect(await isInternalAddress('http://intra.example.com/')).toBe(false); + }); + it('CHECK_INTERNAL_IP=true 时解析到私网阻止', async () => { + process.env.CHECK_INTERNAL_IP = 'true'; + vi.resetModules(); + const mod = await import('../../src/utils/ipCheck.util'); + resolve4.mockResolvedValue(['10.0.0.1']); + expect(await mod.isInternalAddress('http://intra.example.com/')).toBe(true); + }); + it('多 IP 中任意一个内网即阻止(DNS rebinding)', async () => { + resolve4.mockResolvedValue(['1.1.1.1', '127.0.0.1']); + expect(await isInternalAddress('http://rebind.example.com/')).toBe(true); + }); + it('IPv6 解析到 loopback 阻止', async () => { + resolve6.mockResolvedValue(['::1']); + expect(await isInternalAddress('http://ipv6evil.com/')).toBe(true); + }); + it('DNS 解析失败放行(域名不存在等)', async () => { + resolve4.mockRejectedValue(new Error('ENOTFOUND')); + resolve6.mockRejectedValue(new Error('ENOTFOUND')); + expect(await isInternalAddress('http://nonexistent.example.com/')).toBe(false); + }); + }); + + describe('非法输入', () => { + it('非法 URL 放行', async () => { + expect(await isInternalAddress('not a url')).toBe(false); + }); + it('空字符串放行', async () => { + expect(await isInternalAddress('')).toBe(false); + }); + it('hostname 含字母不像 IP(例如 host.example)走域名解析', async () => { + resolve4.mockResolvedValue(['1.1.1.1']); + expect(await isInternalAddress('http://host.example/')).toBe(false); + }); + it('数字段含非法字符(如 127.foo.0.1)放行(无法解析为 IP,DNS 也会失败)', async () => { + resolve4.mockRejectedValue(new Error('ENOTFOUND')); + resolve6.mockRejectedValue(new Error('ENOTFOUND')); + expect(await isInternalAddress('http://127.foo.0.1/')).toBe(false); + }); + it('数字段超过 0xff(如 256.0.0.1)放行(无效 IP)', async () => { + resolve4.mockRejectedValue(new Error('ENOTFOUND')); + resolve6.mockRejectedValue(new Error('ENOTFOUND')); + expect(await isInternalAddress('http://256.0.0.1/')).toBe(false); + }); + it('IPv4 整数溢出(>0xffffffff)放行', async () => { + resolve4.mockRejectedValue(new Error('ENOTFOUND')); + resolve6.mockRejectedValue(new Error('ENOTFOUND')); + // 2^32 = 4294967296 超过 0xffffffff + expect(await isInternalAddress('http://4294967296/')).toBe(false); + }); + it('IPv4 段数过多(5 段)放行', async () => { + resolve4.mockRejectedValue(new Error('ENOTFOUND')); + resolve6.mockRejectedValue(new Error('ENOTFOUND')); + expect(await isInternalAddress('http://1.2.3.4.5/')).toBe(false); + }); + }); + + describe('开发环境', () => { + it('NODE_ENV=development 时放行所有目标(包括 localhost / metadata)', async () => { + process.env.NODE_ENV = 'development'; + vi.resetModules(); + const mod = await import('../../src/utils/ipCheck.util'); + expect(await mod.isInternalAddress('http://localhost/')).toBe(false); + expect(await mod.isInternalAddress('http://127.0.0.1/')).toBe(false); + expect(await mod.isInternalAddress('http://169.254.169.254/')).toBe(false); + expect(await mod.isInternalAddress('http://metadata.google.internal/')).toBe(false); + }); + }); +}); diff --git a/projects/code-sandbox/test/unit/resource-limits.test.ts b/projects/code-sandbox/test/unit/resource-limits.test.ts index c8046d244f..2a75ba754d 100644 --- a/projects/code-sandbox/test/unit/resource-limits.test.ts +++ b/projects/code-sandbox/test/unit/resource-limits.test.ts @@ -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); diff --git a/projects/code-sandbox/test/unit/security.test.ts b/projects/code-sandbox/test/unit/security.test.ts index 1616609024..34a2c94434 100644 --- a/projects/code-sandbox/test/unit/security.test.ts +++ b/projects/code-sandbox/test/unit/security.test.ts @@ -442,28 +442,23 @@ describe('逃逸攻击', () => { } }); - it('AsyncFunction 构造器绕过 _SafeFunction(env 已清理)', async () => { + it('AsyncFunction constructor 被锁定', async () => { const result = await runner.execute({ code: `async function main() { try { const AsyncFn = (async function(){}).constructor; const fn = new AsyncFn('return process.env'); - const env = await fn(); - const keys = Object.keys(env); - return { escaped: true, keys }; + await fn(); + return { escaped: true }; } catch(e) { return { escaped: false }; } }`, variables: {} }); expect(result.success).toBe(true); - if (result.data?.codeReturn.escaped) { - const keys: string[] = result.data.codeReturn.keys || []; - expect(keys).not.toContain('SECRET_KEY'); - expect(keys).not.toContain('API_KEY'); - } + expect(result.data?.codeReturn.escaped).toBe(false); }); - it('GeneratorFunction 构造器绕过 _SafeFunction', async () => { + it('GeneratorFunction constructor 被锁定', async () => { const result = await runner.execute({ code: `async function main() { try { const GenFn = (function*(){}).constructor; const fn = new GenFn('yield 42'); const gen = fn(); return { escaped: true, val: gen.next().value }; } @@ -472,9 +467,7 @@ describe('逃逸攻击', () => { variables: {} }); expect(result.success).toBe(true); - if (result.data?.codeReturn.escaped) { - expect(result.data.codeReturn.val).toBe(42); - } + expect(result.data?.codeReturn.escaped).toBe(false); }); }); @@ -877,7 +870,7 @@ describe('网络请求安全', () => { it('httpRequest GET 公网地址正常', async () => { const result = await runner.execute({ - code: `async function main() { const res = await SystemHelper.httpRequest('https://www.baidu.com'); return { status: res.status, hasData: res.data.length > 0 }; }`, + code: `async function main() { const res = await SystemHelper.httpRequest('https://1.1.1.1/cdn-cgi/trace'); return { status: res.status, hasData: res.data.length > 0 }; }`, variables: {} }); expect(result.success).toBe(true); @@ -888,7 +881,7 @@ describe('网络请求安全', () => { it('httpRequest POST 带 body', async () => { const result = await runner.execute({ code: `async function main() { - const res = await SystemHelper.httpRequest('https://www.baidu.com', { method: 'POST', body: { key: 'value' } }); + const res = await SystemHelper.httpRequest('https://1.1.1.1/cdn-cgi/trace', { method: 'POST', body: { key: 'value' } }); return { hasStatus: typeof res.status === 'number' }; }`, variables: {} @@ -899,7 +892,7 @@ describe('网络请求安全', () => { it('全局函数 httpRequest 可用', async () => { const result = await runner.execute({ - code: `async function main() { const res = await httpRequest('https://www.baidu.com'); return { status: res.status }; }`, + code: `async function main() { const res = await httpRequest('https://1.1.1.1/cdn-cgi/trace'); return { status: res.status }; }`, variables: {} }); expect(result.success).toBe(true); @@ -944,7 +937,7 @@ describe('网络请求安全', () => { it('http_request GET 公网地址正常', async () => { const result = await runner.execute({ - code: `def main():\n res = system_helper.http_request('https://www.baidu.com')\n return {'status': res['status'], 'hasData': len(res['data']) > 0}`, + code: `def main():\n res = system_helper.http_request('https://1.1.1.1/cdn-cgi/trace')\n return {'status': res['status'], 'hasData': len(res['data']) > 0}`, variables: {} }); expect(result.success).toBe(true); @@ -954,7 +947,7 @@ describe('网络请求安全', () => { it('http_request POST 带 body', async () => { const result = await runner.execute({ - code: `import json\ndef main():\n res = system_helper.http_request('https://www.baidu.com', method='POST', body={'key': 'value'})\n return {'hasStatus': type(res['status']) == int}`, + code: `import json\ndef main():\n res = system_helper.http_request('https://1.1.1.1/cdn-cgi/trace', method='POST', body={'key': 'value'})\n return {'hasStatus': type(res['status']) == int}`, variables: {} }); expect(result.success).toBe(true); @@ -963,7 +956,7 @@ describe('网络请求安全', () => { it('全局函数 http_request 可用', async () => { const result = await runner.execute({ - code: `def main():\n res = http_request('https://www.baidu.com')\n return {'status': res['status']}`, + code: `def main():\n res = http_request('https://1.1.1.1/cdn-cgi/trace')\n return {'status': res['status']}`, variables: {} }); expect(result.success).toBe(true); @@ -1328,7 +1321,7 @@ describe('worker 状态隔离', () => { } catch {} }); - it('上一次执行设置的全局变量,下一次读不到(已知限制:隐式全局变量会泄露)', async () => { + it('上一次执行设置的全局变量,下一次读不到', async () => { pool = new ProcessPool(1); await pool.init(); @@ -1343,8 +1336,6 @@ describe('worker 状态隔离', () => { expect(r1.success).toBe(true); // 第二次:尝试读取上一次写入的数据 - // 注意:JS worker 复用进程,隐式全局变量(未用 var/let/const 声明)会泄露 - // 这是已知限制,因为 JS 使用 Function constructor 而非 VM 隔离 const r2 = await pool.execute({ code: `async function main() { let found = []; @@ -1354,11 +1345,10 @@ describe('worker 状态隔离', () => { variables: {} }); expect(r2.success).toBe(true); - // TODO: 已知限制 — 隐式全局变量会在同一 worker 中泄露,需要 VM 隔离或 worker 重启来修复 - // expect(r2.data?.codeReturn.leaked).toBe(false); + expect(r2.data?.codeReturn.leaked).toBe(false); }); - it('上一次修改的 prototype 不影响下一次(已知限制:prototype 修改会泄露)', async () => { + it('上一次修改的 prototype 不影响下一次', async () => { pool = new ProcessPool(1); await pool.init(); @@ -1372,8 +1362,6 @@ describe('worker 状态隔离', () => { }); // 第二次:检查 Array.prototype 是否干净 - // 注意:JS worker 复用进程,prototype 修改会持久化 - // 这是已知限制,Object.setPrototypeOf 已被禁用,但直接赋值无法阻止 const r2 = await pool.execute({ code: `async function main() { return { hasHacked: typeof [].hacked === 'function' }; @@ -1381,8 +1369,118 @@ describe('worker 状态隔离', () => { variables: {} }); expect(r2.success).toBe(true); - // TODO: 已知限制 — prototype 修改在同一 worker 中持久化 - // expect(r2.data?.codeReturn.hasHacked).toBe(false); + expect(r2.data?.codeReturn.hasHacked).toBe(false); + }); + + it('上一次修改的 JS 允许模块不影响下一次', async () => { + pool = new ProcessPool(1); + await pool.init(); + + await pool.execute({ + code: `async function main() { + try { require('lodash').chunk = () => ['polluted']; } catch(e) {} + try { Object.getOwnPropertyDescriptor(require('lodash'), 'chunk').value.polluted = true; } catch(e) {} + return {}; + }`, + variables: {} + }); + + const r2 = await pool.execute({ + code: `async function main() { + const _ = require('lodash'); + return { chunk: _.chunk([1, 2], 1), polluted: _.chunk.polluted === true }; + }`, + variables: {} + }); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.chunk).toEqual([[1], [2]]); + expect(r2.data?.codeReturn.polluted).toBe(false); + }); + + it('上一次污染 Object.prototype 不影响 SystemHelper', async () => { + pool = new ProcessPool(1); + await pool.init(); + + await pool.execute({ + code: `async function main() { + try { Object.getPrototypeOf(SystemHelper).pwned = () => 'pwned'; } catch(e) {} + return {}; + }`, + variables: {} + }); + + const r2 = await pool.execute({ + code: `async function main() { + return { pwnedType: typeof SystemHelper.pwned }; + }`, + variables: {} + }); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.pwnedType).toBe('undefined'); + }); + + it('上一次篡改 JSON 不影响 worker 协议解析和后续任务', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const r1 = await pool.execute({ + code: `async function main() { + try { + JSON.parse = () => ({ polluted: true }); + } catch(e) {} + try { + JSON = { parse: () => ({ rebound: true }) }; + } catch(e) {} + return { ok: true }; + }`, + variables: {} + }); + expect(r1.success).toBe(true); + + const r2 = await pool.execute({ + code: `async function main() { + return { value: JSON.parse('{"a":1}').a }; + }`, + variables: {} + }); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.value).toBe(1); + }); + + it('上一次篡改 timer 和 Promise 不影响 worker 超时保护', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const r1 = await pool.execute({ + code: `async function main() { + try { + setTimeout = () => 0; + clearTimeout = () => {}; + Promise = { race: () => ({ bypass: true }) }; + Buffer = { from: () => 'polluted' }; + Reflect = { getPrototypeOf: () => ({ polluted: true }) }; + } catch(e) {} + return { ok: true }; + }`, + variables: {} + }); + expect(r1.success).toBe(true); + + const r2 = await pool.execute({ + code: `async function main() { + await delay(20); + return { + ok: true, + bufferFromType: typeof Buffer.from, + reflectType: typeof Reflect.getPrototypeOf + }; + }`, + variables: {} + }); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.ok).toBe(true); + expect(r2.data?.codeReturn.bufferFromType).toBe('function'); + expect(r2.data?.codeReturn.reflectType).toBe('function'); }); it('上一次的 console.log 不泄露到下一次', async () => { diff --git a/projects/code-sandbox/tsconfig.json b/projects/code-sandbox/tsconfig.json index 72e6daefc1..2e8b785d3d 100644 --- a/projects/code-sandbox/tsconfig.json +++ b/projects/code-sandbox/tsconfig.json @@ -11,7 +11,7 @@ "rootDir": "./src", "declaration": true, "resolveJsonModule": true, - "types": ["@types/bun"] + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "test"] diff --git a/projects/code-sandbox/tsdown.config.ts b/projects/code-sandbox/tsdown.config.ts new file mode 100644 index 0000000000..66ace437a9 --- /dev/null +++ b/projects/code-sandbox/tsdown.config.ts @@ -0,0 +1,24 @@ +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: [/.*/] +});