Commit 109a1f18 by Archer Committed by GitHub

perf: codex-sandbox check (#6851)

* perf: codex-sandbox check

* perf: codex sandbox

* fix: build

* add log in httperror

* perf: sandbox

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