Commit ffc8ef45 by Archer Committed by GitHub

fix: harden code sandbox resource limits (#6997)

parent 484a7654
......@@ -185,17 +185,27 @@ services:
networks:
- fastgpt
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -203,7 +213,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -185,17 +185,27 @@ services:
networks:
- fastgpt
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -203,7 +213,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -185,17 +185,27 @@ services:
networks:
- fastgpt
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -203,7 +213,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -204,17 +204,27 @@ ${{vec.db}}
networks:
- codesandbox
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -222,7 +232,7 @@ ${{vec.db}}
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -266,9 +266,11 @@ These variables are loaded and validated by `projects/code-sandbox/src/env.ts`.
| `SANDBOX_PORT` | `3000` | Code Sandbox listening port. |
| `SANDBOX_TOKEN` | Empty | Bearer token for the `/sandbox` endpoint. Empty disables API authentication. It only allows printable ASCII characters and cannot contain spaces. |
| `SANDBOX_POOL_SIZE` | `20` | Number of pre-warmed JS/Python workers, from `1` to `100`. |
| `SANDBOX_API_MAX_BODY_MB` | `8` | Maximum `/sandbox` API JSON body size, including `variables`, in MB. Range: `1` to `100`. |
| `SANDBOX_MAX_TIMEOUT` | `60000` | Timeout for one code execution, in milliseconds. Range: `1000` to `600000`. |
| `SANDBOX_MAX_MEMORY_MB` | `256` | Maximum memory for one sandbox, in MB. Range: `32` to `4096`. |
| `CHECK_INTERNAL_IP` | `false` | Whether internal IP checks are enabled for sandbox network requests. |
| `SANDBOX_MAX_MEMORY_MB` | `256` | Maximum memory for one sandbox, in MB. Range: `32` to `4096`. The runtime reserves an extra `50` MB for overhead. |
| `SANDBOX_MAX_OUTPUT_MB` | `10` | Maximum output JSON size for one code execution, including return values and logs, in MB. Range: `1` to `100`. |
| `CHECK_INTERNAL_IP` | `true` | Whether internal IP checks are enabled for sandbox network requests. |
| `SANDBOX_REQUEST_MAX_COUNT` | `30` | Maximum number of network requests allowed during one code execution. Range: `1` to `1000`. |
| `SANDBOX_REQUEST_TIMEOUT` | `60000` | Timeout for one network request from inside the sandbox, in milliseconds. Range: `1000` to `300000`. |
| `SANDBOX_REQUEST_MAX_RESPONSE_MB` | `10` | Maximum response body size for one sandbox network request, in MB. Range: `1` to `100`. |
......
......@@ -266,9 +266,11 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说
| `SANDBOX_PORT` | `3000` | Code Sandbox 服务监听端口。 |
| `SANDBOX_TOKEN` | 空 | `/sandbox` 接口 Bearer Token;为空时不启用接口认证。仅允许 ASCII 可打印字符且不能包含空格。 |
| `SANDBOX_POOL_SIZE` | `20` | JS/Python 预热 worker 数量,范围 `1` 到 `100`。 |
| `SANDBOX_API_MAX_BODY_MB` | `8` | `/sandbox` API JSON 请求体总大小上限,包含 `variables`,单位 MB,范围 `1` 到 `100`。 |
| `SANDBOX_MAX_TIMEOUT` | `60000` | 单次代码执行超时时间,单位毫秒,范围 `1000` 到 `600000`。 |
| `SANDBOX_MAX_MEMORY_MB` | `256` | 单个沙箱最大内存,单位 MB,范围 `32` 到 `4096`。 |
| `CHECK_INTERNAL_IP` | `false` | 是否在沙箱网络请求中启用内网 IP 检查。 |
| `SANDBOX_MAX_MEMORY_MB` | `256` | 单个沙箱最大内存,单位 MB,范围 `32` 到 `4096`;运行时会额外预留 `50` MB 开销。 |
| `SANDBOX_MAX_OUTPUT_MB` | `10` | 单次代码执行输出 JSON 大小上限,包含返回值和日志,单位 MB,范围 `1` 到 `100`。 |
| `CHECK_INTERNAL_IP` | `true` | 是否在沙箱网络请求中启用内网 IP 检查。 |
| `SANDBOX_REQUEST_MAX_COUNT` | `30` | 单次代码执行允许发起的最大网络请求数,范围 `1` 到 `1000`。 |
| `SANDBOX_REQUEST_TIMEOUT` | `60000` | 沙箱内单次网络请求超时时间,单位毫秒,范围 `1000` 到 `300000`。 |
| `SANDBOX_REQUEST_MAX_RESPONSE_MB` | `10` | 沙箱内单次网络响应体最大大小,单位 MB,范围 `1` 到 `100`。 |
......
......@@ -5,10 +5,40 @@ description: 'FastGPT V4.15.0-beta3 Release Notes'
## 🚀 New Features
1. Multimodal models now support audio and video input.
2. Shared links and portal pages now support language switching, and no longer force browser-language auto switching.
## 🔧 Environment Variable Changes
Code Sandbox adds security-related environment variables such as `SANDBOX_API_MAX_BODY_MB` and `SANDBOX_MAX_OUTPUT_MB`. The full defaults are listed below:
| Variable | Default | Description |
| --------------------------------- | ------- | ------------------------------------------------------------------------------------------ |
| `SANDBOX_API_MAX_BODY_MB` | `8` | Maximum `/sandbox` API JSON body size, including `variables`, in MB. |
| `SANDBOX_MAX_OUTPUT_MB` | `10` | Maximum output JSON size for one code execution, including return values and logs, in MB. |
| `CHECK_INTERNAL_IP` | `true` | Enables internal IP checks for sandbox network requests by default to reduce SSRF risk. |
| `SANDBOX_MAX_TIMEOUT` | `60000` | Timeout for one code execution, in milliseconds. |
| `SANDBOX_MAX_MEMORY_MB` | `256` | Memory limit for one sandbox, in MB. The runtime reserves an extra `50` MB for overhead. |
| `SANDBOX_POOL_SIZE` | `20` | Number of pre-warmed JS/Python workers. |
| `SANDBOX_REQUEST_MAX_COUNT` | `30` | Maximum number of network requests allowed during one code execution. |
| `SANDBOX_REQUEST_TIMEOUT` | `60000` | Timeout for one network request from inside the sandbox, in milliseconds. |
| `SANDBOX_REQUEST_MAX_RESPONSE_MB` | `10` | Maximum response body size for one sandbox network request, in MB. |
| `SANDBOX_REQUEST_MAX_BODY_MB` | `5` | Maximum request body size for one sandbox network request, in MB. |
## ⚙️ Improvements
1. Improved styles for Skill module dialogs.
2. Improved Skill list API performance.
3. Improved workflow node name and description inputs.
## 🐛 Bug Fixes
1. Adapted TTS audio playback to the latest OpenAI SDK to avoid errors.
## 🛠️ Code Improvements
1. Updated the token calculation dependency to improve performance.
2. Rewrote dialog-related code with more modular structure.
3. Improved unit test performance, reducing full runs from about 10 minutes to 5 minutes.
4. Upgraded to TypeScript 6.
5. Improved GitHub Actions security.
......@@ -8,6 +8,23 @@ description: 'FastGPT V4.15.0-beta3 更新说明'
1. 多模态模型支持音视频输入。
2. 分享链接/门户页,支持语言切换,不再强制自动识别浏览器语言切换。
## 🔧 环境变量变更
Code Sandbox 新增 `SANDBOX_API_MAX_BODY_MB`、`SANDBOX_MAX_OUTPUT_MB` 等安全相关环境变量;完整默认值如下:
| 变量 | 默认值 | 说明 |
| --------------------------------- | ------- | ----------------------------------------------------------------- |
| `SANDBOX_API_MAX_BODY_MB` | `8` | `/sandbox` API JSON 请求体总大小上限,包含 `variables`,单位 MB。 |
| `SANDBOX_MAX_OUTPUT_MB` | `10` | 单次代码执行输出 JSON 大小上限,包含返回值和日志,单位 MB。 |
| `CHECK_INTERNAL_IP` | `true` | 沙箱网络请求默认开启内网 IP 检查,降低 SSRF 风险。 |
| `SANDBOX_MAX_TIMEOUT` | `60000` | 单次代码执行超时时间,单位毫秒。 |
| `SANDBOX_MAX_MEMORY_MB` | `256` | 单个沙箱内存上限,单位 MB;运行时会额外预留 `50` MB 开销。 |
| `SANDBOX_POOL_SIZE` | `20` | JS/Python 预热 worker 数量。 |
| `SANDBOX_REQUEST_MAX_COUNT` | `30` | 单次代码执行允许发起的最大网络请求数。 |
| `SANDBOX_REQUEST_TIMEOUT` | `60000` | 沙箱内单次网络请求超时时间,单位毫秒。 |
| `SANDBOX_REQUEST_MAX_RESPONSE_MB` | `10` | 沙箱内单次网络响应体最大大小,单位 MB。 |
| `SANDBOX_REQUEST_MAX_BODY_MB` | `5` | 沙箱内单次网络请求体最大大小,单位 MB。 |
## ⚙️ 优化
1. Skill 模块相关弹窗样式。
......
......@@ -261,17 +261,27 @@ services:
networks:
- codesandbox
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -279,7 +289,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -239,17 +239,27 @@ services:
networks:
- codesandbox
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -257,7 +267,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -223,17 +223,27 @@ services:
networks:
- codesandbox
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -241,7 +251,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -221,17 +221,27 @@ services:
networks:
- codesandbox
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -239,7 +249,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -226,17 +226,27 @@ services:
networks:
- codesandbox
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -244,7 +254,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -205,17 +205,27 @@ services:
networks:
- codesandbox
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -223,7 +233,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -261,17 +261,27 @@ services:
networks:
- codesandbox
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -279,7 +289,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -239,17 +239,27 @@ services:
networks:
- codesandbox
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -257,7 +267,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -223,17 +223,27 @@ services:
networks:
- codesandbox
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -241,7 +251,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -221,17 +221,27 @@ services:
networks:
- codesandbox
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -239,7 +249,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -226,17 +226,27 @@ services:
networks:
- codesandbox
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -244,7 +254,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -205,17 +205,27 @@ services:
networks:
- codesandbox
restart: always
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
<<: [*x-log-config, *x-no-proxy-config]
LOG_OTEL_SERVICE_NAME: fastgpt-code-sandbox
SANDBOX_TOKEN: *x-code-sandbox-token
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB: 8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT: 60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB: 256
SANDBOX_MAX_OUTPUT_MB: 10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -223,7 +233,7 @@ services:
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP: false
CHECK_INTERNAL_IP: true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT: 30
# Timeout for each outbound HTTP request (ms)
......
......@@ -40,6 +40,10 @@ export class CodeSandbox {
return response.data;
},
(error) => {
const message = error?.response?.data?.message;
if (typeof message === 'string' && message) {
return Promise.reject(new Error(message));
}
return Promise.reject(error);
}
);
......
......@@ -14,12 +14,16 @@ LOG_OTEL_SERVICE_NAME=fastgpt-client
LOG_OTEL_URL=http://localhost:4318/v1/logs
# ===== Resource Limits =====
# Maximum API JSON body size (MB), including variables
SANDBOX_API_MAX_BODY_MB=8
# Execution timeout per request (ms)
SANDBOX_MAX_TIMEOUT=60000
# Maximum allowed memory per user code execution (MB)
# Note: System automatically adds 50MB for runtime overhead
# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB
SANDBOX_MAX_MEMORY_MB=256
# Maximum output JSON size per execution (MB), including return value and logs
SANDBOX_MAX_OUTPUT_MB=10
# ===== Process Pool =====
# Number of pre-warmed worker processes (JS + Python)
......@@ -27,7 +31,7 @@ SANDBOX_POOL_SIZE=20
# ===== Network Request Limits =====
# Whether to check if the request is to a private network
CHECK_INTERNAL_IP=false
CHECK_INTERNAL_IP=true
# Maximum number of HTTP requests per execution
SANDBOX_REQUEST_MAX_COUNT=30
# Timeout for each outbound HTTP request (ms)
......
......@@ -73,6 +73,10 @@ USER sandbox
ENV NODE_ENV=production
ENV SANDBOX_PORT=3000
ENV HOME=/tmp
ENV XDG_CACHE_HOME=/tmp/.cache
ENV MPLCONFIGDIR=/tmp/matplotlib
ENV PYTHONDONTWRITEBYTECODE=1
EXPOSE 3000
......
......@@ -145,13 +145,16 @@ docker run -p 3000:3000 \
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `SANDBOX_API_MAX_BODY_MB` | API JSON 请求体总大小上限(包含 variables) | `8` |
| `SANDBOX_MAX_TIMEOUT` | 超时上限(ms),请求不可超过此值 | `60000` |
| `SANDBOX_MAX_MEMORY_MB` | 内存上限(MB) | `256` |
| `SANDBOX_MAX_OUTPUT_MB` | 单次执行输出 JSON 大小上限(包含返回值和日志) | `10` |
### 网络请求限制
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `CHECK_INTERNAL_IP` | 是否阻止访问内网、回环、链路本地等地址 | `true` |
| `SANDBOX_REQUEST_MAX_COUNT` | 单次执行最大 HTTP 请求数 | `30` |
| `SANDBOX_REQUEST_TIMEOUT` | 单次 HTTP 请求超时(ms) | `60000` |
| `SANDBOX_REQUEST_MAX_RESPONSE_MB` | 最大响应体大小(MB) | `10` |
......@@ -214,6 +217,7 @@ SANDBOX_JS_ALLOWED_MODULES=lodash,dayjs,moment,uuid,crypto-js,qs,url,querystring
- 只添加纯计算类的包,不要添加有网络/文件系统/子进程能力的包
- 包会被打入 Docker 镜像,注意体积
- 网络请求统一走 `SystemHelper.httpRequest()`,不要放行 `axios``node-fetch` 等网络库
- 如果显式放行 `child_process``worker_threads``cluster`,worker 会在每次任务后回收,以清理潜在后台执行残留
## 添加 Python 包
......@@ -233,7 +237,7 @@ your-new-package
2. **加入白名单**(环境变量 `SANDBOX_PYTHON_ALLOWED_MODULES`):
在逗号分隔列表中添加包名。如果新包依赖了黑名单中的模块(如 `os`),标准库路径的间接导入会自动放行,无需额外配置
在逗号分隔列表中添加包名。用户代码能否直接 import 某个模块完全由 `SANDBOX_PYTHON_ALLOWED_MODULES` 控制;第三方包和标准库内部依赖会按调用栈放行,避免误伤包自身初始化
3. **重新构建 Docker 镜像**
......@@ -241,7 +245,8 @@ your-new-package
- Python 的模块黑名单通过 `__import__` 拦截实现,只拦截用户代码的直接 import
- 标准库和第三方包的内部间接 import 不受影响
- 危险模块(`os``sys``subprocess``socket` 等)始终被拦截
- 默认白名单不包含 `os``sys``subprocess``socket` 等高危模块;如果显式加入环境变量白名单,用户代码会按配置获得对应能力
- 如果显式放行 `subprocess``multiprocessing``threading``concurrent`,worker 会在每次任务后回收,以清理潜在后台执行残留
## 安全机制
......@@ -255,7 +260,7 @@ your-new-package
### Python
- `__import__` 黑名单拦截:用户代码无法 import 危险模块(`os``sys``subprocess` 等)
- `__import__` 白名单控制:默认不允许用户代码 import `os``sys``subprocess` 等高危模块;显式加入 `SANDBOX_PYTHON_ALLOWED_MODULES` 后按配置放行
- `exec()`/`eval()` 内的 import 同样被拦截(基于调用栈帧检测)
- `builtins.__import__` 通过代理对象保护,用户无法覆盖
- `signal.SIGALRM` 超时保护
......
......@@ -49,11 +49,13 @@ export const env = createEnv({
SANDBOX_POOL_SIZE: IntSchema.min(1).max(100).default(20),
// ===== 资源限制 =====
SANDBOX_API_MAX_BODY_MB: IntSchema.min(1).max(100).default(8),
SANDBOX_MAX_TIMEOUT: IntSchema.min(1000).max(600000).default(60000),
SANDBOX_MAX_MEMORY_MB: IntSchema.min(32).max(4096).default(256),
SANDBOX_MAX_OUTPUT_MB: IntSchema.min(1).max(100).default(10),
// ===== 网络请求限制 =====
CHECK_INTERNAL_IP: BoolSchema.default(false),
CHECK_INTERNAL_IP: BoolSchema.default(true),
SANDBOX_REQUEST_MAX_COUNT: IntSchema.min(1).max(1000).default(30),
SANDBOX_REQUEST_TIMEOUT: IntSchema.min(1000).max(300000).default(60000),
SANDBOX_REQUEST_MAX_RESPONSE_MB: IntSchema.min(1).max(100).default(10),
......
import { env } from './env';
import { Hono } from 'hono';
import { Hono, type Context } from 'hono';
import { bearerAuth } from 'hono/bearer-auth';
import { serve } from '@hono/node-server';
import { z } from 'zod';
......@@ -13,6 +13,62 @@ await configureLogger();
const serverLogger = getLogger(LogCategories.MODULE.SANDBOX.SERVER);
const apiLogger = getLogger(LogCategories.MODULE.SANDBOX.API);
const maxApiBodyBytes = env.SANDBOX_API_MAX_BODY_MB * 1024 * 1024;
class ApiBodyError extends Error {
constructor(
message: string,
readonly status: 400 | 413
) {
super(message);
}
}
/**
* 流式读取并限制 API JSON body 总大小。
*
* `c.req.json()` 会先把完整 body 读入内存;这里在进入 JSON.parse/zod 前按字节数
* 截断,防止攻击者通过超大的 variables 字段绕过 code 字段长度限制造成内存压力。
*/
async function readLimitedJsonBody(c: Context): Promise<unknown> {
const contentLength = Number(c.req.header('content-length') || 0);
if (Number.isFinite(contentLength) && contentLength > maxApiBodyBytes) {
throw new ApiBodyError(`Request body too large, max ${env.SANDBOX_API_MAX_BODY_MB}MB`, 413);
}
const body = c.req.raw.body;
if (!body) {
throw new ApiBodyError('Request body is empty', 400);
}
const reader = body.getReader();
const decoder = new TextDecoder();
let size = 0;
let text = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > maxApiBodyBytes) {
await reader.cancel();
throw new ApiBodyError(`Request body too large, max ${env.SANDBOX_API_MAX_BODY_MB}MB`, 413);
}
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
} catch (err) {
if (err instanceof ApiBodyError) throw err;
throw new ApiBodyError(getErrText(err, 'Invalid request body'), 400);
}
try {
return JSON.parse(text);
} catch (err) {
throw new ApiBodyError(`Invalid JSON body: ${getErrText(err)}`, 400);
}
}
/** 请求体校验 schema */
const executeSchema = z.object({
......@@ -106,7 +162,7 @@ if (env.SANDBOX_TOKEN) {
/** JS 执行 */
app.post('/sandbox/js', async (c) => {
try {
const raw = await c.req.json();
const raw = await readLimitedJsonBody(c);
const parsed = executeSchema.safeParse(raw);
if (!parsed.success) {
return c.json(
......@@ -120,17 +176,21 @@ app.post('/sandbox/js', async (c) => {
const result = await jsPool.execute(parsed.data as ExecuteOptions);
return c.json(result);
} catch (err: any) {
return c.json({
success: false,
message: getErrText(err)
});
const status = err instanceof ApiBodyError ? err.status : 200;
return c.json(
{
success: false,
message: getErrText(err)
},
status
);
}
});
/** Python 执行 */
app.post('/sandbox/python', async (c) => {
try {
const raw = await c.req.json();
const raw = await readLimitedJsonBody(c);
const parsed = executeSchema.safeParse(raw);
if (!parsed.success) {
return c.json(
......@@ -144,10 +204,14 @@ app.post('/sandbox/python', async (c) => {
const result = await pythonPool.execute(parsed.data as ExecuteOptions);
return c.json(result);
} catch (err: any) {
return c.json({
success: false,
message: getErrText(err)
});
const status = err instanceof ApiBodyError ? err.status : 200;
return c.json(
{
success: false,
message: getErrText(err)
},
status
);
}
});
......
......@@ -4,10 +4,9 @@
* 预热 N 个长驻 worker 进程,通过 stdin/stdout 行协议通信。
* JS / Python 进程池继承此类,仅需提供 spawn 命令和 init 配置。
*/
import { spawn, type ChildProcess } from 'child_process';
import { spawn, execFile, execFileSync, type ChildProcess } from 'child_process';
import { createInterface, type Interface } from 'readline';
import { exec } from 'child_process';
import { readFile } from 'fs/promises';
import { readdirSync, readFileSync } from 'fs';
import { promisify } from 'util';
import { platform } from 'os';
import { env, RUNTIME_MEMORY_OVERHEAD_MB } from '../env';
......@@ -15,10 +14,11 @@ import type { ExecuteOptions, ExecuteResult } from '../types';
import { getLogger, LogCategories } from '../utils/logger';
const serverLogger = getLogger(LogCategories.MODULE.SANDBOX.SERVER);
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
/** RSS 轮询间隔(毫秒) */
const RSS_POLL_INTERVAL = 500;
const PROCESS_GROUP_SUPPORTED = platform() !== 'win32';
export type PoolWorker = {
proc: ChildProcess;
......@@ -38,6 +38,8 @@ export type ProcessPoolOptions = {
spawnCommand: (script: string) => string;
/** init 消息中的模块白名单 */
allowedModules: readonly string[];
/** 白名单显式放开后台执行能力时,每次任务结束后回收 worker,清理潜在子进程/线程 */
recycleAfterTask?: boolean;
};
export abstract class BaseProcessPool {
......@@ -117,6 +119,7 @@ export abstract class BaseProcessPool {
const cmd = this.options.spawnCommand(this.options.workerScript);
const proc = spawn('sh', ['-c', cmd], {
stdio: ['pipe', 'pipe', 'pipe'],
detached: PROCESS_GROUP_SUPPORTED,
env: {
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
CHECK_INTERNAL_IP: String(env.CHECK_INTERNAL_IP)
......@@ -138,7 +141,7 @@ export abstract class BaseProcessPool {
if (settled) return;
settled = true;
rl.removeAllListeners('line');
proc.kill('SIGKILL');
this.killWorkerProcessTree(worker);
const stderr = this.formatStderr(worker);
reject(
new Error(
......@@ -202,7 +205,8 @@ export abstract class BaseProcessPool {
maxRequests: env.SANDBOX_REQUEST_MAX_COUNT,
timeoutMs: env.SANDBOX_REQUEST_TIMEOUT,
maxResponseSize: env.SANDBOX_REQUEST_MAX_RESPONSE_MB * 1024 * 1024,
maxRequestBodySize: env.SANDBOX_REQUEST_MAX_BODY_MB * 1024 * 1024
maxRequestBodySize: env.SANDBOX_REQUEST_MAX_BODY_MB * 1024 * 1024,
maxOutputSize: env.SANDBOX_MAX_OUTPUT_MB * 1024 * 1024
}
}) + '\n'
);
......@@ -291,19 +295,31 @@ export abstract class BaseProcessPool {
let timer: ReturnType<typeof setTimeout>;
let rssTimer: ReturnType<typeof setInterval> | undefined;
const settle = (result: ExecuteResult) => {
const settle = (result: ExecuteResult, opts: { recycleWorker?: boolean } = {}) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (rssTimer) clearInterval(rssTimer);
worker.rl.removeListener('line', onLine);
worker.proc.removeListener('exit', onExit);
const recycleWorker = opts.recycleWorker || this.options.recycleAfterTask;
if (recycleWorker) {
this.killAndRespawn(worker);
}
resolve(result);
};
const onLine = (line: string) => {
try {
settle(JSON.parse(line));
const result = JSON.parse(line) as ExecuteResult & { workerRecycle?: string };
const recycleReason = result.workerRecycle;
delete result.workerRecycle;
if (recycleReason) {
serverLogger.warn(
`${this.tag}: recycling worker ${worker.id} after task result: ${recycleReason}`
);
}
settle(result, { recycleWorker: Boolean(recycleReason) });
} catch {
settle({ success: false, message: 'Invalid worker response' });
}
......@@ -425,28 +441,115 @@ export abstract class BaseProcessPool {
// ============================================================
/**
* 读取子进程的物理内存(RSS)
* @param pid 进程 ID
* 读取 worker 进程树的物理内存(RSS)。
*
* 如果环境变量显式放开 child_process/subprocess 这类能力,用户任务可以创建子进程。
* 这里只统计 worker 本体会漏掉子进程内存,因此按进程树求和。
*
* @param pid worker 进程 ID
* @returns RSS(MB),失败返回 null
*/
protected async getWorkerRSSMB(pid: number): Promise<number | null> {
const pids = [pid, ...this.getDescendantPids(pid)];
if (pids.length === 0) return null;
try {
const plat = platform();
if (plat === 'linux' || plat === 'freebsd') {
// Linux/BSD: 读取 /proc/{pid}/status 中的 VmRSS(单位 kB)
const status = await readFile(`/proc/${pid}/status`, 'utf-8');
const match = status.match(/VmRSS:\s+(\d+)\s+kB/);
if (match) return parseInt(match[1], 10) / 1024;
} else {
// macOS/other: 使用 ps 命令获取 RSS(单位 kB)
const { stdout } = await execAsync(`ps -o rss= -p ${pid}`, { timeout: 2000 });
const rssKB = parseInt(stdout.trim(), 10);
if (!isNaN(rssKB)) return rssKB / 1024;
if (platform() === 'linux') {
let totalKB = 0;
for (const currentPid of pids) {
const rssKB = this.readLinuxRSSKB(currentPid);
if (rssKB !== null) totalKB += rssKB;
}
return totalKB > 0 ? totalKB / 1024 : null;
}
// macOS/other: 使用 ps 获取多个 PID 的 RSS(单位 kB),不经过 shell。
const { stdout } = await execFileAsync(
'ps',
['-o', 'rss=', '-p', pids.join(',')],
{ timeout: 2000 }
);
const totalKB = stdout
.split('\n')
.map((line) => parseInt(line.trim(), 10))
.filter((rssKB) => !isNaN(rssKB))
.reduce((sum, rssKB) => sum + rssKB, 0);
return totalKB > 0 ? totalKB / 1024 : null;
} catch {
// 进程可能已退出,忽略错误
return null;
}
return null;
}
protected readLinuxRSSKB(pid: number): number | null {
try {
const status = readFileSync(`/proc/${pid}/status`, 'utf-8');
const match = status.match(/VmRSS:\s+(\d+)\s+kB/);
return match ? parseInt(match[1], 10) : null;
} catch {
return null;
}
}
/**
* 获取进程树后代 PID,用于内存求和和回收清理。
*
* Linux 优先读 /proc,macOS/其他系统退回 pgrep -P;失败时返回已知结果。
*/
protected getDescendantPids(rootPid: number): number[] {
const descendants: number[] = [];
const queue = [rootPid];
const seen = new Set<number>(queue);
while (queue.length > 0) {
const parentPid = queue.shift()!;
for (const childPid of this.getChildPids(parentPid)) {
if (seen.has(childPid)) continue;
seen.add(childPid);
descendants.push(childPid);
queue.push(childPid);
}
}
return descendants;
}
protected getChildPids(pid: number): number[] {
if (platform() === 'linux') {
return this.getLinuxChildPids(pid);
}
try {
const stdout = execFileSync('pgrep', ['-P', String(pid)], {
encoding: 'utf-8',
timeout: 1000
});
return stdout
.split(/\s+/)
.map((value) => Number(value))
.filter((value) => Number.isInteger(value) && value > 0);
} catch {
return [];
}
}
protected getLinuxChildPids(pid: number): number[] {
const children = new Set<number>();
try {
const taskIds = readdirSync(`/proc/${pid}/task`);
for (const taskId of taskIds) {
try {
const content = readFileSync(`/proc/${pid}/task/${taskId}/children`, 'utf-8');
for (const child of content.trim().split(/\s+/)) {
const childPid = Number(child);
if (Number.isInteger(childPid) && childPid > 0) {
children.add(childPid);
}
}
} catch {}
}
} catch {}
return [...children];
}
/** 从池中移除 worker,kill 进程,并在 ready 时 respawn */
......@@ -477,15 +580,46 @@ export abstract class BaseProcessPool {
// 清空缓冲区
worker.stderrBuf = [];
// Kill 进程
if (!worker.proc.killed) {
worker.proc.kill('SIGKILL');
}
this.killWorkerProcessTree(worker);
} catch (err) {
// 忽略清理错误
}
}
/**
* 按进程树和进程组清理 worker。
*
* 显式放开 child_process/subprocess 后,用户代码可能创建后台子进程。
* 先杀当前能枚举到的后代,再杀 worker 所在进程组,最后兜底杀 worker PID。
*/
protected killWorkerProcessTree(worker: PoolWorker): void {
const pid = worker.proc.pid;
if (!pid) return;
const descendantPids = this.getDescendantPids(pid).reverse();
for (const childPid of descendantPids) {
try {
process.kill(childPid, 'SIGKILL');
} catch {}
}
if (PROCESS_GROUP_SUPPORTED) {
try {
process.kill(-pid, 'SIGKILL');
} catch (err: any) {
if (err?.code !== 'ESRCH') {
serverLogger.warn(
`${this.tag}: failed to kill process group ${pid}: ${err?.message ?? String(err)}`
);
}
}
}
try {
worker.proc.kill('SIGKILL');
} catch {}
}
/** 格式化 stderr 缓冲区用于错误信息 */
protected formatStderr(worker: PoolWorker): string {
return worker.stderrBuf.length > 0 ? ` | stderr: ${worker.stderrBuf.join('\n')}` : '';
......
......@@ -15,6 +15,18 @@ const isCompiled = import.meta.url.endsWith('.js');
const WORKER_SCRIPT = join(__dirname, isCompiled ? 'worker.js' : 'worker.ts');
const SPAWN_RUNTIME = isCompiled ? 'node' : 'tsx';
const RECYCLE_AFTER_TASK_MODULES = new Set([
'child_process',
'node:child_process',
'cluster',
'node:cluster',
'worker_threads',
'node:worker_threads'
]);
function shouldRecycleAfterTask(allowedModules: readonly string[]): boolean {
return allowedModules.some((moduleName) => RECYCLE_AFTER_TASK_MODULES.has(moduleName));
}
export class ProcessPool extends BaseProcessPool {
constructor(poolSize?: number) {
......@@ -22,7 +34,8 @@ export class ProcessPool extends BaseProcessPool {
name: 'JS',
workerScript: WORKER_SCRIPT,
spawnCommand: (script) => `exec ${SPAWN_RUNTIME} ${script}`,
allowedModules: env.SANDBOX_JS_ALLOWED_MODULES
allowedModules: env.SANDBOX_JS_ALLOWED_MODULES,
recycleAfterTask: shouldRecycleAfterTask(env.SANDBOX_JS_ALLOWED_MODULES)
});
}
}
......@@ -10,6 +10,16 @@ import { BaseProcessPool } from './base-process-pool';
const __dirname = dirname(fileURLToPath(import.meta.url));
const WORKER_SCRIPT = join(__dirname, 'worker.py');
const RECYCLE_AFTER_TASK_MODULES = new Set([
'subprocess',
'multiprocessing',
'threading',
'concurrent'
]);
function shouldRecycleAfterTask(allowedModules: readonly string[]): boolean {
return allowedModules.some((moduleName) => RECYCLE_AFTER_TASK_MODULES.has(moduleName));
}
export class PythonProcessPool extends BaseProcessPool {
constructor(poolSize?: number) {
......@@ -17,7 +27,8 @@ export class PythonProcessPool extends BaseProcessPool {
name: 'Python',
workerScript: WORKER_SCRIPT,
spawnCommand: (script) => `exec python3 -u ${script}`,
allowedModules: env.SANDBOX_PYTHON_ALLOWED_MODULES
allowedModules: env.SANDBOX_PYTHON_ALLOWED_MODULES,
recycleAfterTask: shouldRecycleAfterTask(env.SANDBOX_PYTHON_ALLOWED_MODULES)
});
}
}
......@@ -64,6 +64,7 @@ _REQUEST_LIMITS = {
'timeout': 60,
'max_response_size': 10 * 1024 * 1024,
'max_request_body_size': 5 * 1024 * 1024,
'max_output_size': 10 * 1024 * 1024,
'allowed_protocols': ['http:', 'https:']
}
......@@ -80,6 +81,8 @@ def _init_request_limits(limits):
_REQUEST_LIMITS['max_response_size'] = limits['maxResponseSize']
if 'maxRequestBodySize' in limits:
_REQUEST_LIMITS['max_request_body_size'] = limits['maxRequestBodySize']
if 'maxOutputSize' in limits:
_REQUEST_LIMITS['max_output_size'] = limits['maxOutputSize']
def _is_blocked_ip(ip_str):
......@@ -326,8 +329,23 @@ _builtins_proxy = None # init 后创建
_import_guard = False
def _safe_import(name, *args, **kwargs):
def _is_direct_user_import_call():
"""判断 import 是否由用户代码直接触发,避免误伤标准库/三方包内部依赖。"""
global _import_guard
_import_guard = True
try:
stack = _tb.extract_stack()
finally:
_import_guard = False
if len(stack) < 3:
return False
caller_fn = stack[-3].filename or ''
return caller_fn in ('<string>', '<test>', '<module>')
def _safe_import(name, *args, **kwargs):
# 重入保护:避免 extract_stack / _original_import 内部触发 import 时无限递归
if _import_guard:
return _original_import(name, *args, **kwargs)
......@@ -342,17 +360,10 @@ def _safe_import(name, *args, **kwargs):
if top_level in _allowed_modules:
return _original_import(name, *args, **kwargs)
# 不在白名单中的模块(含危险 stdlib):检查是否由用户代码直接触发
_import_guard = True
try:
stack = _tb.extract_stack()
finally:
_import_guard = False
# 只拦截直接调用者是用户代码的情况(<string>/<test>/<module>)
# stdlib 内部的间接 import(如 locale -> os)放行
if len(stack) >= 2:
caller_fn = stack[-2].filename or ''
if caller_fn in ('<string>', '<test>', '<module>'):
raise ImportError(f"Module '{name}' is not in the allowlist.")
if _is_direct_user_import_call():
raise ImportError(f"Module '{name}' is not in the allowlist.")
return _original_import(name, *args, **kwargs)
......@@ -425,7 +436,23 @@ def _safe_print(*args, **kwargs):
# ===== 输出 =====
def write_line(obj):
sys.stdout.write(json.dumps(obj, ensure_ascii=False, default=str) + '\n')
try:
payload = json.dumps(obj, ensure_ascii=False, default=str)
except Exception as e:
payload = json.dumps({
"success": False,
"message": f"Failed to serialize output: {e}",
"workerRecycle": "output_serialize"
}, ensure_ascii=False)
if len(payload.encode('utf-8')) > _REQUEST_LIMITS['max_output_size']:
payload = json.dumps({
"success": False,
"message": f"Output too large (limit: {_REQUEST_LIMITS['max_output_size']} bytes)",
"workerRecycle": "output_limit"
}, ensure_ascii=False)
sys.stdout.write(payload + '\n')
sys.stdout.flush()
......@@ -486,7 +513,7 @@ def _restore_modules(snapshots):
# ===== 主循环 =====
def main_loop():
global _allowed_modules, _request_count, _logs
global _allowed_modules, _request_count, _logs, _log_size, _timeout_stage
initialized = False
......@@ -628,7 +655,12 @@ def main_loop():
except (Exception, SystemExit) as e:
signal.alarm(0)
write_line({"success": False, "message": str(e)})
result = {"success": False, "message": str(e)}
if isinstance(e, TimeoutError) or (
isinstance(e, SystemExit) and 'timed out' in str(e).lower()
):
result["workerRecycle"] = "timeout"
write_line(result)
finally:
signal.alarm(0)
......
......@@ -435,6 +435,7 @@ const REQUEST_LIMITS = {
timeoutMs: 60000,
maxResponseSize: 10 * 1024 * 1024,
maxRequestBodySize: 5 * 1024 * 1024,
maxOutputSize: 10 * 1024 * 1024,
allowedProtocols: ['http:', 'https:']
};
......@@ -454,10 +455,6 @@ function createHmac(algorithm: string, secret: string) {
hmac.update(stringToSign, 'utf8');
return { timestamp, sign: encodeURIComponent(hmac.digest('base64')) };
}
function delay(ms: number): Promise<void> {
if (ms > 10000) throw new Error('Delay must be <= 10000ms');
return new Promise((r) => _workerSetTimeout(r, ms));
}
// ===== SystemHelper =====
const SystemHelper = {
......@@ -608,7 +605,26 @@ _ObjectFreeze(safeRequire);
// ===== 输出辅助 =====
function writeLine(obj: any): void {
_workerStdout.write(_JSONStringify(obj) + '\n');
let line: string;
try {
line = _JSONStringify(obj);
} catch (err: any) {
line = _JSONStringify({
success: false,
message: `Failed to serialize output: ${err?.message ?? String(err)}`,
workerRecycle: 'output_serialize'
});
}
if (Buffer.byteLength(line, 'utf8') > REQUEST_LIMITS.maxOutputSize) {
line = _JSONStringify({
success: false,
message: `Output too large (limit: ${REQUEST_LIMITS.maxOutputSize} bytes)`,
workerRecycle: 'output_limit'
});
}
_workerStdout.write(line + '\n');
}
// ===== 主循环 =====
......@@ -638,6 +654,8 @@ rl.on('line', async (line: string) => {
REQUEST_LIMITS.maxResponseSize = msg.requestLimits.maxResponseSize;
if (msg.requestLimits.maxRequestBodySize != null)
REQUEST_LIMITS.maxRequestBodySize = msg.requestLimits.maxRequestBodySize;
if (msg.requestLimits.maxOutputSize != null)
REQUEST_LIMITS.maxOutputSize = msg.requestLimits.maxOutputSize;
}
hardenRuntime();
writeLine({ type: 'ready' });
......@@ -731,8 +749,15 @@ rl.on('line', async (line: string) => {
}
activeIntervals.clear();
};
const safeDelay = (ms: number): Promise<void> => {
if (ms > 10000) throw new Error('Delay must be <= 10000ms');
return new _OriginalPromise((resolve) => {
safeSetTimeout(resolve, ms);
});
};
let timer: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
const requireCacheKeysBeforeTask = getRequireCacheKeys();
try {
assertNoDynamicImport(code);
......@@ -808,7 +833,7 @@ rl.on('line', async (line: string) => {
countToken,
strToBase64,
createHmac,
delay,
safeDelay,
httpRequest,
variables || {},
undefined,
......@@ -861,7 +886,10 @@ rl.on('line', async (line: string) => {
const timeoutPromise = new _OriginalPromise((_, reject) => {
timer = _workerSetTimeout(
() => reject(new _OriginalError(`Script execution timed out after ${timeoutMs}ms`)),
() => {
timedOut = true;
reject(new _OriginalError(`Script execution timed out after ${timeoutMs}ms`));
},
timeoutMs || 10000
);
});
......@@ -874,7 +902,11 @@ rl.on('line', async (line: string) => {
});
} catch (err: any) {
_workerClearTimeout(timer);
writeLine({ success: false, message: err?.message ?? String(err) });
writeLine({
success: false,
message: err?.message ?? String(err),
...(timedOut ? { workerRecycle: 'timeout' } : {})
});
} finally {
cleanupUserTimers();
cleanupUserRequireCache(requireCacheKeysBeforeTask);
......
import { join } from 'path';
import { BaseProcessPool } from '../../src/pool/base-process-pool';
export type SandboxLanguage = 'JS' | 'Python';
export class CustomModuleProcessPool extends BaseProcessPool {
constructor(
language: SandboxLanguage,
allowedModules: readonly string[],
options: { recycleAfterTask?: boolean } = {}
) {
super(1, {
name: `Custom${language}`,
workerScript: join(process.cwd(), 'src/pool', language === 'JS' ? 'worker.ts' : 'worker.py'),
spawnCommand: (script) =>
language === 'JS' ? `exec tsx ${script}` : `exec python3 -u ${script}`,
allowedModules,
recycleAfterTask: options.recycleAfterTask
});
}
}
export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function waitForCondition(
predicate: () => boolean,
timeoutMs = 5000
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return true;
await wait(100);
}
return predicate();
}
export async function waitForPidExit(pid: number, timeoutMs = 5000): Promise<boolean> {
return waitForCondition(() => {
try {
process.kill(pid, 0);
return false;
} catch {
return true;
}
}, timeoutMs);
}
export function getWorkerPid(pool: BaseProcessPool): number | undefined {
return (pool as unknown as { workers?: Array<{ proc?: { pid?: number } }> }).workers?.[0]?.proc
?.pid;
}
......@@ -287,10 +287,25 @@ describe('API 错误处理安全', () => {
} else {
// catch 分支
expect(data.success).toBe(false);
console.log(data, 123213213);
expect(data.message).toContain('is not valid JSON');
}
});
it('超大 JSON body 在进入执行前返回 413', async () => {
const res = await app.request('/sandbox/js', {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
code: 'async function main() { return { ok: true } }',
variables: { text: 'x'.repeat(1024 * 1024 + 1) }
})
});
const data = await res.json();
expect(res.status).toBe(413);
expect(data.success).toBe(false);
expect(data.message).toMatch(/body too large/i);
});
});
// ===== Zod 校验失败(有效 JSON 但 schema 不匹配) =====
......
......@@ -11,7 +11,14 @@
import { describe, it, expect, afterEach, beforeAll } from 'vitest';
import { ProcessPool } from '../../src/pool/process-pool';
import { PythonProcessPool } from '../../src/pool/python-process-pool';
import { BaseProcessPool } from '../../src/pool/base-process-pool';
import { env, RUNTIME_MEMORY_OVERHEAD_MB } from '../../src/env';
import {
CustomModuleProcessPool,
getWorkerPid,
waitForCondition,
waitForPidExit
} from '../helpers/custom-process-pool';
beforeAll(async () => {
console.log(`\n=== Memory Limit Test Status ===`);
......@@ -40,9 +47,6 @@ describe('内存限制', () => {
await pool.init();
expect(pool.stats.total).toBe(1);
// 实际限制 = 用户配置 + 运行时开销(50MB)
const actualLimitMB = env.SANDBOX_MAX_MEMORY_MB + RUNTIME_MEMORY_OVERHEAD_MB;
const result = await pool.execute({
code: `async function main() {
const arr = [];
......@@ -144,6 +148,85 @@ describe('Python 内存限制', () => {
}, 30000);
});
describe('显式放开后台执行模块时的 worker 回收', () => {
let pool: BaseProcessPool;
afterEach(async () => {
try {
await pool?.shutdown();
} catch {}
});
it('JS 显式允许 child_process 后,任务返回即回收 worker 并清理子进程', async () => {
pool = new CustomModuleProcessPool('JS', ['child_process'], { recycleAfterTask: true });
await pool.init();
const firstPid = getWorkerPid(pool);
expect(firstPid).toBeTypeOf('number');
const result = await pool.execute({
code: `async function main() {
const { spawn } = require('child_process');
const child = spawn('node', ['-e', 'setInterval(() => {}, 1000)'], {
stdio: 'ignore'
});
child.unref();
return { childPid: child.pid };
}`,
variables: {}
});
expect(result.success).toBe(true);
const childPid = result.data?.codeReturn.childPid;
expect(childPid).toBeTypeOf('number');
const workerRecycled = await waitForCondition(() => {
const currentPid = getWorkerPid(pool);
return typeof currentPid === 'number' && currentPid !== firstPid;
});
expect(workerRecycled).toBe(true);
expect(await waitForPidExit(childPid)).toBe(true);
const recovery = await pool.execute({
code: `async function main() { return { ok: true }; }`,
variables: {}
});
expect(recovery.success).toBe(true);
expect(recovery.data?.codeReturn.ok).toBe(true);
}, 20000);
it('Python 显式允许 subprocess 后,任务返回即回收 worker 并清理子进程', async () => {
pool = new CustomModuleProcessPool('Python', ['subprocess'], { recycleAfterTask: true });
await pool.init();
const firstPid = getWorkerPid(pool);
expect(firstPid).toBeTypeOf('number');
const result = await pool.execute({
code: `import subprocess\ndef main():\n child = subprocess.Popen(['python3', '-c', 'import time; time.sleep(60)'])\n return {'childPid': child.pid}`,
variables: {}
});
expect(result.success).toBe(true);
const childPid = result.data?.codeReturn.childPid;
expect(childPid).toBeTypeOf('number');
const workerRecycled = await waitForCondition(() => {
const currentPid = getWorkerPid(pool);
return typeof currentPid === 'number' && currentPid !== firstPid;
});
expect(workerRecycled).toBe(true);
expect(await waitForPidExit(childPid)).toBe(true);
const recovery = await pool.execute({
code: `def main():\n return {'ok': True}`,
variables: {}
});
expect(recovery.success).toBe(true);
expect(recovery.data?.codeReturn.ok).toBe(true);
}, 20000);
});
// ============================================================
// 2. CPU 限制
// ============================================================
......@@ -285,6 +368,26 @@ describe('JS 运行时长限制', () => {
expect(elapsed).toBeLessThan(env.SANDBOX_MAX_TIMEOUT + 10000);
});
it('异步超时后回收 worker,避免超时任务残留到下一次执行', async () => {
pool = new ProcessPool(1);
await pool.init();
const pidBefore = (pool as any).workers[0].proc.pid;
const result = await pool.execute({
code: `async function main() {
await delay(${env.SANDBOX_MAX_TIMEOUT + 5000});
return { done: true };
}`,
variables: {}
});
await new Promise((r) => setTimeout(r, 1500));
const pidAfter = (pool as any).workers[0].proc.pid;
expect(result.success).toBe(false);
expect(result.message).toMatch(/timed out|timeout/i);
expect(pidAfter).not.toBe(pidBefore);
});
it('在超时范围内完成的代码正常返回', async () => {
pool = new ProcessPool(1);
await pool.init();
......@@ -341,6 +444,23 @@ describe('Python 运行时长限制', () => {
expect(elapsed).toBeLessThan(env.SANDBOX_MAX_TIMEOUT + 10000);
});
it('超时后回收 Python worker,避免信号和模块状态残留', async () => {
pool = new PythonProcessPool(1);
await pool.init();
const pidBefore = (pool as any).workers[0].proc.pid;
const result = await pool.execute({
code: `import time\ndef main():\n time.sleep(${Math.ceil(env.SANDBOX_MAX_TIMEOUT / 1000) + 5})\n return {'done': True}`,
variables: {}
});
await new Promise((r) => setTimeout(r, 1500));
const pidAfter = (pool as any).workers[0].proc.pid;
expect(result.success).toBe(false);
expect(result.message).toMatch(/timed out|timeout/i);
expect(pidAfter).not.toBe(pidBefore);
});
it('在超时范围内完成的代码正常返回', async () => {
pool = new PythonProcessPool(1);
await pool.init();
......@@ -366,7 +486,86 @@ describe('Python 运行时长限制', () => {
});
// ============================================================
// 4. 网络请求次数限制
// 4. 输出大小限制
// ============================================================
describe('JS 输出大小限制', () => {
let pool: ProcessPool;
afterEach(async () => {
try {
await pool?.shutdown();
} catch {}
});
it('返回值超过 maxOutputSize 被拒绝且 worker 可恢复', async () => {
pool = new ProcessPool(1);
await pool.init();
const pidBefore = getWorkerPid(pool);
const result = await pool.execute({
code: `async function main() {
return 'x'.repeat(${env.SANDBOX_MAX_OUTPUT_MB} * 1024 * 1024 + 1);
}`,
variables: {}
});
expect(result.success).toBe(false);
expect(result.message).toMatch(/output too large/i);
const workerRecycled = await waitForCondition(() => {
const currentPid = getWorkerPid(pool);
return typeof currentPid === 'number' && currentPid !== pidBefore;
});
expect(workerRecycled).toBe(true);
const recovery = await pool.execute({
code: `async function main() { return { ok: true }; }`,
variables: {}
});
expect(recovery.success).toBe(true);
expect(recovery.data?.codeReturn.ok).toBe(true);
}, 20000);
});
describe('Python 输出大小限制', () => {
let pool: PythonProcessPool;
afterEach(async () => {
try {
await pool?.shutdown();
} catch {}
});
it('返回值超过 maxOutputSize 被拒绝且 worker 可恢复', async () => {
pool = new PythonProcessPool(1);
await pool.init();
const pidBefore = getWorkerPid(pool);
const result = await pool.execute({
code: `def main():\n return 'x' * (${env.SANDBOX_MAX_OUTPUT_MB} * 1024 * 1024 + 1)`,
variables: {}
});
expect(result.success).toBe(false);
expect(result.message).toMatch(/output too large/i);
const workerRecycled = await waitForCondition(() => {
const currentPid = getWorkerPid(pool);
return typeof currentPid === 'number' && currentPid !== pidBefore;
});
expect(workerRecycled).toBe(true);
const recovery = await pool.execute({
code: `def main():\n return {'ok': True}`,
variables: {}
});
expect(recovery.success).toBe(true);
expect(recovery.data?.codeReturn.ok).toBe(true);
}, 20000);
});
// ============================================================
// 5. 网络请求次数限制
// ============================================================
describe('JS 网络请求次数限制', () => {
let pool: ProcessPool;
......@@ -482,7 +681,7 @@ describe('Python 网络请求次数限制', () => {
});
// ============================================================
// 5. 网络请求大小限制
// 6. 网络请求大小限制
// ============================================================
describe('JS 请求体大小限制', () => {
let pool: ProcessPool;
......@@ -575,7 +774,7 @@ describe('Python 请求体大小限制', () => {
});
// ============================================================
// 6. 网络协议限制
// 7. 网络协议限制
// ============================================================
describe('JS 网络协议限制', () => {
let pool: ProcessPool;
......
......@@ -13,6 +13,7 @@
import { afterEach, describe, it, expect, beforeAll, afterAll } from 'vitest';
import { ProcessPool } from '../../src/pool/process-pool';
import { PythonProcessPool } from '../../src/pool/python-process-pool';
import { CustomModuleProcessPool } from '../helpers/custom-process-pool';
let jsPool: ProcessPool;
let pyPool: PythonProcessPool;
......@@ -65,6 +66,35 @@ describe('模块拦截', () => {
expect(result.success).toBe(false);
});
it('显式加入白名单后允许 Node 内置模块', async () => {
const pool = new CustomModuleProcessPool('JS', [
'fs',
'node:fs',
'fs/promises',
'child_process',
'http',
'lodash'
]);
await pool.init();
try {
const payloads = [
`async function main() { const fs = require('fs'); return { ok: typeof fs.readFileSync === 'function' }; }`,
`async function main() { const fs = require('node:fs'); return { ok: typeof fs.readFileSync === 'function' }; }`,
`async function main() { const fs = require('fs/promises'); return { ok: typeof fs.readFile === 'function' }; }`,
`async function main() { const cp = require('child_process'); return { ok: typeof cp.execFile === 'function' }; }`,
`async function main() { const http = require('http'); return { ok: typeof http.request === 'function' }; }`
];
for (const code of payloads) {
const result = await pool.execute({ code, variables: {} });
expect(result.success).toBe(true);
expect(result.data?.codeReturn.ok).toBe(true);
}
} finally {
await pool.shutdown();
}
});
it('阻止 require https', async () => {
const result = await runner.execute({
code: `async function main() { const https = require('https'); return {}; }`,
......@@ -248,6 +278,33 @@ describe('模块拦截', () => {
expect(result.success).toBe(false);
});
it('显式加入白名单后允许危险 Python 标准库', async () => {
const pool = new CustomModuleProcessPool('Python', [
'os',
'subprocess',
'socket',
'pathlib',
'json'
]);
await pool.init();
try {
const payloads = [
`import os\ndef main():\n return {'ok': hasattr(os, 'getcwd')}`,
`import subprocess\ndef main():\n return {'ok': hasattr(subprocess, 'Popen')}`,
`import socket\ndef main():\n return {'ok': hasattr(socket, 'socket')}`,
`from pathlib import Path\ndef main():\n return {'ok': Path('/tmp').name == 'tmp'}`
];
for (const code of payloads) {
const result = await pool.execute({ code, variables: {} });
expect(result.success).toBe(true);
expect(result.data?.codeReturn.ok).toBe(true);
}
} finally {
await pool.shutdown();
}
});
it('阻止 import requests(预检)', async () => {
const result = await runner.execute({
code: `import requests\ndef main():\n return {}`,
......
......@@ -17,6 +17,7 @@ export default defineConfig({
isolate: false,
env: {
CHECK_INTERNAL_IP: 'true',
SANDBOX_API_MAX_BODY_MB: '1',
SANDBOX_MAX_TIMEOUT: '5000',
SANDBOX_TOKEN: 'test'
}
......
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