Skip to content

Commit 32c05ab

Browse files
authored
Merge commit from fork
fix(security): disable unsafe local code execution
2 parents ba3ec2c + 0e2924d commit 32c05ab

21 files changed

Lines changed: 294 additions & 138 deletions

File tree

console/backend/toolkit/src/main/java/com/iflytek/astron/console/toolkit/service/workflow/WorkflowService.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ public class WorkflowService extends ServiceImpl<WorkflowMapper, Workflow> {
177177
public static final String NODE_DEBUG_PATH = "/workflow/v1/node/debug/";
178178
public static final String PROTOCOL_BUILD_PATH = "/workflow/v1/protocol/build/";
179179
public static final String CODE_RUN_PATH = "/workflow/v1/run";
180+
private static final String WORKFLOW_INTERNAL_API_KEY_HEADER = "X-Workflow-Internal-Key";
181+
private static final String WORKFLOW_INTERNAL_API_KEY_PLACEHOLDER =
182+
"CHANGE_ME_WORKFLOW_INTERNAL_API_KEY";
180183
public static final String CLONED_SUFFIX_PATTERN = "[(]\\d+[)]$";
181184

182185
private static final String JSON_KEY_BOT_ID = "botId";
@@ -193,6 +196,8 @@ public class WorkflowService extends ServiceImpl<WorkflowMapper, Workflow> {
193196

194197
@Value("${spring.profiles.active}")
195198
String env;
199+
@Value("${workflow.internal-api-key:}")
200+
String workflowInternalApiKey;
196201
@org.springframework.beans.factory.annotation.Value("${mcp-server.file-path}")
197202
private String mcpServerFilePath;
198203

@@ -1482,7 +1487,7 @@ public ApiResult<Object> nodeDebug(String nodeId, WorkflowDebugDto debugDto) {
14821487
String body = JSON.toJSONString(protocol);
14831488

14841489
log.info("node debug, url = {}, body = {}", url, body);
1485-
String response = OkHttpUtil.post(url, body);
1490+
String response = OkHttpUtil.post(url, workflowInternalHeaders(), body);
14861491
log.info("node debug, response = {}", response);
14871492

14881493
NodeDebugResponse nodeDebugResponse = null;
@@ -3449,13 +3454,15 @@ public Object runCode(Object runCodeData) {
34493454

34503455
// body = StringEscapeUtils.unescapeJava(body);
34513456

3452-
String resp = OkHttpUtil.post(url, body);
3457+
String resp = OkHttpUtil.post(url, workflowInternalHeaders(), body);
34533458
log.info("code run, resp = {}", resp);
34543459
return JSON.parseObject(resp, Result.class);
34553460
}
34563461

34573462
private Object enrichCodeRunSandbox(Object runCodeData) {
34583463
JSONObject payload = JSON.parseObject(JSON.toJSONString(runCodeData));
3464+
// Never accept sandbox credentials or upload targets from the client.
3465+
payload.remove("sandbox");
34593466
JSONObject sandbox = buildRuntimeSandbox(
34603467
payload.getString("flow_id"),
34613468
payload.getString("node_id"));
@@ -3468,6 +3475,16 @@ private Object enrichCodeRunSandbox(Object runCodeData) {
34683475
return payload;
34693476
}
34703477

3478+
private Map<String, String> workflowInternalHeaders() {
3479+
if (StringUtils.isBlank(workflowInternalApiKey)
3480+
|| WORKFLOW_INTERNAL_API_KEY_PLACEHOLDER.equals(workflowInternalApiKey)) {
3481+
throw new IllegalStateException(
3482+
"WORKFLOW_INTERNAL_API_KEY must be configured before calling workflow debug APIs");
3483+
}
3484+
return Collections.singletonMap(
3485+
WORKFLOW_INTERNAL_API_KEY_HEADER, workflowInternalApiKey);
3486+
}
3487+
34713488
private void injectScriptSandboxIntoCodeNodes(List<BizWorkflowNode> nodes, String flowId) {
34723489
injectScriptSandboxIntoCodeNodes(nodes, flowId, null, null);
34733490
}

console/backend/toolkit/src/test/java/com/iflytek/astron/console/toolkit/service/workflow/WorkflowServiceSandboxConfigTest.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package com.iflytek.astron.console.toolkit.service.workflow;
22

3+
import com.alibaba.fastjson2.JSON;
34
import com.alibaba.fastjson2.JSONObject;
45
import com.iflytek.astron.console.toolkit.entity.biz.workflow.node.BizNodeData;
56
import com.iflytek.astron.console.toolkit.entity.dto.skill.SkillSandboxConfigDto;
67
import com.iflytek.astron.console.toolkit.entity.biz.workflow.BizWorkflowNode;
78
import com.iflytek.astron.console.toolkit.service.skill.SkillSandboxConfigService;
89
import java.util.List;
10+
import java.util.Map;
911
import org.junit.jupiter.api.BeforeEach;
1012
import org.junit.jupiter.api.Test;
1113
import org.junit.jupiter.api.extension.ExtendWith;
@@ -137,4 +139,37 @@ void injectScriptSandboxUsesExplicitScopeWithoutRequestContext() {
137139
assertThat(sandbox.getString("spaceId")).isEqualTo("200");
138140
verify(skillSandboxConfigService).toRuntimeDto("approval-user", 200L);
139141
}
142+
143+
@Test
144+
void enrichCodeRunSandboxDropsClientControlledSandbox() {
145+
SkillSandboxConfigDto config = new SkillSandboxConfigDto();
146+
config.setEnabled(Boolean.FALSE);
147+
config.setApiKey("");
148+
when(skillSandboxConfigService.toRuntimeDto()).thenReturn(config);
149+
150+
JSONObject request = new JSONObject();
151+
request.put("flow_id", "flow-1");
152+
request.put("node_id", "ifly-code::code-1");
153+
request.put("sandbox", new JSONObject()
154+
.fluentPut("apiKey", "attacker-controlled")
155+
.fluentPut("artifactUploadUrl", "http://internal-service"));
156+
157+
Object enriched = ReflectionTestUtils.invokeMethod(
158+
workflowService, "enrichCodeRunSandbox", request);
159+
160+
assertThat(JSON.parseObject(JSON.toJSONString(enriched)))
161+
.doesNotContainKey("sandbox");
162+
}
163+
164+
@Test
165+
void workflowInternalHeadersUseConfiguredSecret() {
166+
ReflectionTestUtils.setField(
167+
workflowService, "workflowInternalApiKey", "internal-secret");
168+
169+
Map<String, String> headers = ReflectionTestUtils.invokeMethod(
170+
workflowService, "workflowInternalHeaders");
171+
172+
assertThat(headers)
173+
.containsEntry("X-Workflow-Internal-Key", "internal-secret");
174+
}
140175
}

core/workflow/api/v1/chat/node_debug.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import json
99
from typing import Any, Dict
1010

11-
from fastapi import APIRouter
11+
from fastapi import APIRouter, Depends
1212
from starlette.responses import JSONResponse
1313

1414
from workflow.domain.entities.node_debug_vo import CodeRunVo, NodeDebugVo
@@ -17,11 +17,17 @@
1717
from workflow.engine.nodes.code.code_node import CodeNode
1818
from workflow.exception.e import CustomException
1919
from workflow.exception.errors.err_code import CodeEnum
20+
from workflow.extensions.fastapi.middleware.auth import (
21+
require_workflow_internal_api_key,
22+
)
2023
from workflow.extensions.otlp.metric.meter import Meter
2124
from workflow.extensions.otlp.trace.span import Span
2225
from workflow.service import flow_service
2326

24-
router = APIRouter(tags=["code_debug"])
27+
router = APIRouter(
28+
tags=["code_debug"],
29+
dependencies=[Depends(require_workflow_internal_api_key)],
30+
)
2531

2632

2733
@router.post("/run", status_code=200) # Legacy interface compatibility

core/workflow/config.env

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,14 @@ KAFKA_TOPIC=spark-agent-builder
136136
# =============================================================================
137137

138138
# Code Executor Settings
139-
# Supported types: local, ifly, ifly-v2, langchain, e2b (default: local)
140-
CODE_EXEC_TYPE=local
139+
# Supported types: disabled, ifly, ifly-v2, langchain, e2b (default: disabled)
140+
CODE_EXEC_TYPE=disabled
141141
CODE_EXEC_URL=
142142
# Code execution timeout in seconds, default: 10s
143143
CODE_EXEC_TIMEOUT_SEC=10
144144
CODE_EXEC_API_KEY=
145145
CODE_EXEC_API_SECRET=
146+
WORKFLOW_INTERNAL_API_KEY=CHANGE_ME_WORKFLOW_INTERNAL_API_KEY
146147

147148
# Image Understanding Model Configuration
148149
# Spark image model domain specifications for visual AI processing

core/workflow/configs/app_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ class CodeExecutorConfig(BaseSettings):
260260

261261
model_config = {"env_prefix": "", "case_sensitive": False}
262262

263-
exec_type: str = Field(default="local", alias="CODE_EXEC_TYPE")
263+
exec_type: str = Field(default="disabled", alias="CODE_EXEC_TYPE")
264264
url: str = Field(default="", alias="CODE_EXEC_URL")
265265
timeout: int = Field(default=10, alias="CODE_EXEC_TIMEOUT_SEC")
266266
api_key: str = Field(default="", alias="CODE_EXEC_API_KEY")

core/workflow/engine/nodes/code/code_node.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ async def execute_code(self, parameters: dict, span_context: Span) -> dict:
133133
# Create appropriate code executor based on environment configuration
134134
sandbox_config = self._runtime_sandbox_config(span_context)
135135
executor_type = (
136-
"e2b" if sandbox_config else os.getenv("CODE_EXEC_TYPE", "local")
136+
"e2b" if sandbox_config else os.getenv("CODE_EXEC_TYPE", "disabled")
137137
)
138138
code_executor = CodeExecutorFactory.create_executor(executor_type)
139139
# Execute code with timeout configuration

core/workflow/engine/nodes/code/executor/base_executor.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from abc import ABC, abstractmethod
22
from typing import Any
33

4+
from workflow.exception.e import CustomException
5+
from workflow.exception.errors.err_code import CodeEnum
46
from workflow.extensions.otlp.trace.span import Span
57

68

@@ -42,17 +44,18 @@ def create_executor(executor: str) -> BaseExecutor:
4244
"""
4345
Create a code executor instance based on the specified type.
4446
45-
:param executor: Executor type identifier ("local", "langchain", "ifly", "ifly-v2", or "e2b")
47+
:param executor: Executor type identifier ("langchain", "ifly", "ifly-v2", or "e2b")
4648
:return: Configured executor instance
4749
:raises Exception: If the specified executor type is not supported
4850
"""
49-
if executor == "local":
50-
# Local execution using RestrictedPython for security
51-
from workflow.engine.nodes.code.executor.local.local_executor import (
52-
LocalExecutor,
51+
if executor in {"", "disabled", "local"}:
52+
raise CustomException(
53+
err_code=CodeEnum.CODE_EXECUTION_ERROR,
54+
err_msg=(
55+
"No isolated code executor is configured. "
56+
"The local executor is disabled for security."
57+
),
5358
)
54-
55-
return LocalExecutor()
5659
elif executor == "langchain":
5760
# Langchain sandbox execution environment
5861
from workflow.engine.nodes.code.executor.langchain.langchain_executor import (
@@ -78,4 +81,7 @@ def create_executor(executor: str) -> BaseExecutor:
7881

7982
return E2BExecutor()
8083
else:
81-
raise Exception(f"Unsupported executor type: {executor}")
84+
raise CustomException(
85+
err_code=CodeEnum.CODE_EXECUTION_ERROR,
86+
err_msg=f"Unsupported code executor type: {executor}",
87+
)

core/workflow/engine/nodes/code/executor/langchain/langchain_executor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ async def execute(
3333
"""
3434
try:
3535
# Create Pyodide sandbox instance for secure code execution
36-
sandbox = PyodideSandbox(allow_net=True)
36+
# Code nodes do not need access to the deployment's internal network.
37+
sandbox = PyodideSandbox(allow_net=False)
3738
result = await sandbox.execute(code)
3839
if result.status == "success":
3940
return result.stdout if result.stdout else ""
Lines changed: 11 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,26 @@
1-
import ast
2-
import asyncio
3-
import builtins
4-
import multiprocessing
5-
import traceback
6-
from typing import Any, Dict
7-
8-
from pydantic import BaseModel
1+
from typing import Any
92

103
from workflow.engine.nodes.code.executor.base_executor import BaseExecutor
114
from workflow.exception.e import CustomException
125
from workflow.exception.errors.err_code import CodeEnum
136
from workflow.extensions.otlp.trace.span import Span
147

158

16-
class Modules(BaseModel):
17-
"""
18-
Modules that are allowed to be imported for security reasons
19-
"""
20-
21-
imports: list[str]
22-
from_imports: list[ast.ImportFrom]
23-
24-
class Config:
25-
arbitrary_types_allowed = True
26-
27-
289
class LocalExecutor(BaseExecutor):
29-
"""
30-
Local code executor using RestrictedPython for secure execution.
10+
"""Compatibility shim for the removed in-process code executor.
3111
32-
Executes Python code in a restricted environment with limited built-ins
33-
and forbidden modules to ensure security. Uses multiprocessing for isolation.
12+
User-provided code must never execute in the workflow service process. A
13+
child process and a timeout do not isolate the filesystem, credentials,
14+
network, or operating-system user from untrusted code.
3415
"""
3516

3617
async def execute(
3718
self, language: str, code: str, timeout: int, span: Span, **kwargs: Any
3819
) -> str:
39-
"""
40-
Execute code asynchronously using multiprocessing for isolation.
41-
42-
:param language: Programming language (currently only python supported)
43-
:param code: Code string to execute
44-
:param timeout: Maximum execution time in seconds
45-
:param span: Tracing span for logging
46-
:param kwargs: Additional execution parameters
47-
:return: Execution result as string
48-
"""
49-
loop = asyncio.get_running_loop()
50-
return await loop.run_in_executor(
51-
None, # Use default thread pool
52-
self._execute_in_process, # Wrapper for synchronous execution
53-
code,
54-
timeout,
20+
raise CustomException(
21+
err_code=CodeEnum.CODE_EXECUTION_ERROR,
22+
err_msg=(
23+
"The local code executor is disabled for security. "
24+
"Configure an isolated code executor before running code nodes."
25+
),
5526
)
56-
57-
def _execute_in_process(self, code: str, timeout: int) -> str:
58-
"""
59-
Execute code in a separate process with timeout control.
60-
61-
:param code: Code string to execute
62-
:param timeout: Maximum execution time in seconds
63-
:return: Execution result as string
64-
:raises CustomException: If execution times out or fails
65-
"""
66-
with multiprocessing.Manager() as manager:
67-
result_dict = manager.dict()
68-
proc = multiprocessing.Process(
69-
target=self._safe_exec, args=(code, result_dict)
70-
)
71-
proc.start()
72-
proc.join(timeout)
73-
if proc.is_alive():
74-
proc.terminate()
75-
raise CustomException(err_code=CodeEnum.CODE_EXECUTION_TIMEOUT_ERROR)
76-
if "error" in result_dict:
77-
raise CustomException(
78-
err_code=CodeEnum.CODE_EXECUTION_ERROR, err_msg=result_dict["error"]
79-
)
80-
return result_dict.get("output", "")
81-
82-
def _safe_exec(self, code: str, result_dict: dict) -> None:
83-
"""
84-
Safely execute code using RestrictedPython with limited built-ins.
85-
86-
:param code: Code string to execute
87-
:param result_dict: Shared dictionary to store execution results
88-
"""
89-
try:
90-
locals_dict: Dict[str, Any] = {}
91-
92-
modules = self._find_imports(code)
93-
import_code_lines = []
94-
95-
for module in modules.imports:
96-
import_code_lines.append(f"import {module}")
97-
98-
for from_module in modules.from_imports:
99-
imported_names = ", ".join(alias.name for alias in from_module.names)
100-
import_code_lines.append(
101-
f"from {from_module.module} import {imported_names}"
102-
)
103-
104-
import_code = "\n".join(import_code_lines)
105-
106-
sandbox_globals = {"__builtins__": builtins}
107-
108-
exec(import_code, sandbox_globals)
109-
exec(code, sandbox_globals, locals_dict)
110-
111-
result_dict["output"] = locals_dict.get("output", "")
112-
except Exception:
113-
result_dict["error"] = traceback.format_exc()
114-
115-
def _find_imports(self, code: str) -> Modules:
116-
"""
117-
Find imports and from imports in the code.
118-
119-
:param code: Code string to find imports and from imports
120-
:return: Modules object containing imports and from_imports
121-
"""
122-
imports: list[str] = []
123-
from_imports: list[ast.ImportFrom] = []
124-
parsed_code = ast.parse(code)
125-
for node in parsed_code.body:
126-
if isinstance(node, ast.Import):
127-
imports.extend(alias.name for alias in node.names)
128-
elif isinstance(node, ast.ImportFrom):
129-
from_imports.append(node)
130-
return Modules(imports=imports, from_imports=from_imports)

0 commit comments

Comments
 (0)