Skip to content

Commit d5f89d7

Browse files
committed
fix(security): confine writes, gate ${env.*}, and bind env creds to trusted endpoints
Fixes three privately-reported advisories: - GHSA-2956-977x-2w3r (Critical): arbitrary file write. Route every file-writing module through validate_path_with_env_config() so output is confined to FLYTO_SANDBOX_DIR; the caller can no longer supply the base its target is checked against. Modules: image.download/convert/resize/crop/ compress/rotate/watermark/qrcode_generate, document.excel_write/pdf_fill_form/ word_to_pdf/pdf_to_word, browser.pagination checkpoint. - GHSA-hr7p-wg7r-hg9m (High): ${env.VAR} interpolation bypassed the env.get denylist. New is_env_var_allowed() ties ${env.*} to the same policy as the env.get module: deny-by-default, opt-in via FLYTO_ENV_VAR_ALLOWLIST. - GHSA-qq9q-xgm3-xv9g (High): env-derived API key forwarded to a caller-controlled base_url. New assert_env_credential_endpoint_allowed() attaches an env key only to the official endpoint or FLYTO_TRUSTED_LLM_HOSTS, across llm.chat, ai.model, llm.agent and vector.connector; also adds the missing SSRF check to ai.model. Each fix verified against the reporter's own PoC; positive paths (allowlisted env vars, in-sandbox writes, trusted endpoints) confirmed still working.
1 parent 9103529 commit d5f89d7

21 files changed

Lines changed: 311 additions & 11 deletions

src/core/engine/variable_resolver.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
"""
88
import re
99
import os
10+
import logging
1011
from typing import Any, Dict, List, Optional
1112
from datetime import datetime
1213

14+
logger = logging.getLogger(__name__)
15+
1316

1417
class VariableResolver:
1518
"""
@@ -194,6 +197,19 @@ def _get_variable_value(self, var_path: str) -> Any:
194197
if len(parts) < 2:
195198
return None
196199
env_var = parts[1]
200+
# SECURITY: gate ${env.*} through the same policy as the `env.get`
201+
# module so denylisting env.get is not bypassable via interpolation
202+
# (GHSA-hr7p-wg7r-hg9m). Deny-by-default; opt in via
203+
# FLYTO_ENV_VAR_ALLOWLIST.
204+
from core.module_policy import is_env_var_allowed
205+
if not is_env_var_allowed(env_var):
206+
logger.warning(
207+
"Blocked ${env.%s} interpolation: env access is denied by "
208+
"policy. Allow the env.get module or add %s to "
209+
"FLYTO_ENV_VAR_ALLOWLIST to permit it.",
210+
env_var, env_var,
211+
)
212+
return None
197213
return os.getenv(env_var)
198214

199215
# Workflow parameters

src/core/module_policy.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,44 @@ def is_allowed(self, module_id: str) -> bool:
108108
module_filter = ModuleFilter()
109109

110110

111+
# ---------------------------------------------------------------------------
112+
# Environment-variable interpolation policy
113+
# ---------------------------------------------------------------------------
114+
#
115+
# The workflow engine expands ${env.VAR} in step parameters. That is the exact
116+
# capability the `env.get` module denylist exists to block (reading arbitrary
117+
# host env vars = secret exfil). Interpolation happens in the engine BEFORE the
118+
# module chokepoint, so denylisting `env.get` alone does not stop it
119+
# (GHSA-hr7p-wg7r-hg9m). We therefore gate ${env.*} through one shared policy:
120+
#
121+
# - If `env.get` is permitted by the module filter, env access is enabled and
122+
# ${env.VAR} resolves as before.
123+
# - Otherwise (the secure default), ${env.VAR} is DENIED unless VAR matches an
124+
# explicit allowlist the operator opts into via FLYTO_ENV_VAR_ALLOWLIST
125+
# (comma-separated names or fnmatch globs, e.g. "PUBLIC_*,APP_REGION").
126+
#
127+
# This makes engine interpolation and module execution enforce the same env
128+
# policy, deny-by-default.
129+
130+
def _env_var_allowlist() -> List[str]:
131+
raw = os.environ.get("FLYTO_ENV_VAR_ALLOWLIST", "")
132+
return [p.strip() for p in raw.split(",") if p.strip()]
133+
134+
135+
def is_env_var_allowed(name: str) -> bool:
136+
"""Whether ${env.<name>} interpolation is permitted by policy.
137+
138+
Ties ${env.*} to the same control as the `env.get` module: if `env.get` is
139+
allowed, env access is enabled; otherwise only names explicitly allowlisted
140+
via FLYTO_ENV_VAR_ALLOWLIST resolve, and everything else is denied.
141+
"""
142+
if not name:
143+
return False
144+
if module_filter.is_allowed("env.get"):
145+
return True
146+
return any(fnmatch.fnmatch(name, pat) for pat in _env_var_allowlist())
147+
148+
111149
# ---------------------------------------------------------------------------
112150
# Per-module capability permissions (a second lock beyond the module allowlist)
113151
# ---------------------------------------------------------------------------

src/core/modules/atomic/ai/model.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
from ...registry import register_module
1313
from ...schema import compose, field, presets
1414
from ...types import NodeType, EdgeType, DataType
15+
from ....utils import (
16+
validate_url_with_env_config,
17+
SSRFError,
18+
assert_env_credential_endpoint_allowed,
19+
CredentialEndpointError,
20+
)
1521

1622

1723
@register_module(
@@ -131,7 +137,19 @@ async def ai_model(context: Dict[str, Any]) -> Dict[str, Any]:
131137
base_url = params.get('base_url')
132138
max_tokens = params.get('max_tokens', 4096)
133139

140+
# SECURITY: validate custom base URL for SSRF (was previously unchecked here).
141+
if base_url:
142+
try:
143+
validate_url_with_env_config(base_url)
144+
except SSRFError as e:
145+
return {
146+
'ok': False,
147+
'error': str(e),
148+
'error_code': 'SSRF_BLOCKED'
149+
}
150+
134151
# Get API key from environment if not provided
152+
key_from_env = False
135153
if not api_key:
136154
env_vars = {
137155
'openai': 'OPENAI_API_KEY',
@@ -142,6 +160,18 @@ async def ai_model(context: Dict[str, Any]) -> Dict[str, Any]:
142160
env_var = env_vars.get(provider)
143161
if env_var:
144162
api_key = os.getenv(env_var)
163+
key_from_env = bool(api_key)
164+
165+
# SECURITY: never forward the operator's env-derived key to a caller-supplied
166+
# endpoint (GHSA-qq9q-xgm3-xv9g).
167+
try:
168+
assert_env_credential_endpoint_allowed(base_url, key_from_env)
169+
except CredentialEndpointError as e:
170+
return {
171+
'ok': False,
172+
'error': str(e),
173+
'error_code': 'ENV_KEY_UNTRUSTED_ENDPOINT'
174+
}
145175

146176
if not api_key:
147177
return {

src/core/modules/atomic/browser/pagination.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
from ...registry import register_module
1717
from ...schema import compose, field
1818
from ...schema.constants import FieldGroup
19+
from ...errors import ModuleError
20+
from ....utils import validate_path_with_env_config, PathTraversalError
1921

2022
logger = logging.getLogger(__name__)
2123

@@ -321,6 +323,12 @@ async def execute(self) -> Dict[str, Any]:
321323
resumed = False
322324

323325
if self.checkpoint_path:
326+
# SECURITY: confine the checkpoint write to FLYTO_SANDBOX_DIR
327+
# (GHSA-2956-977x-2w3r).
328+
try:
329+
self.checkpoint_path = validate_path_with_env_config(self.checkpoint_path)
330+
except PathTraversalError as e:
331+
raise ModuleError(str(e), code="PATH_TRAVERSAL")
324332
from core.browser.checkpoint import PaginationCheckpoint
325333
checkpoint = PaginationCheckpoint(
326334
self.checkpoint_path, self.item_selector, self.mode,

src/core/modules/atomic/document/excel_write.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from ...registry import register_module
1313
from ...schema import compose, presets
14+
from ...errors import ModuleError
15+
from ....utils import validate_path_with_env_config, PathTraversalError
1416

1517

1618
logger = logging.getLogger(__name__)
@@ -105,6 +107,12 @@ async def excel_write(context: Dict[str, Any]) -> Dict[str, Any]:
105107
if not data:
106108
raise ValueError("Data cannot be empty")
107109

110+
# SECURITY: confine the write to FLYTO_SANDBOX_DIR (GHSA-2956-977x-2w3r).
111+
try:
112+
path = validate_path_with_env_config(path)
113+
except PathTraversalError as e:
114+
raise ModuleError(str(e), code="PATH_TRAVERSAL")
115+
108116
Path(os.path.dirname(path)).mkdir(parents=True, exist_ok=True)
109117

110118
wb = openpyxl.Workbook()

src/core/modules/atomic/document/pdf_fill_form.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from ...registry import register_module
1313
from ...schema import compose, presets
14+
from ...errors import ModuleError
15+
from ....utils import validate_path_with_env_config, PathTraversalError
1416

1517

1618
logger = logging.getLogger(__name__)
@@ -122,6 +124,12 @@ async def pdf_fill_form(context: Dict[str, Any]) -> Dict[str, Any]:
122124
if not os.path.exists(template_path):
123125
return {'ok': False, 'error': f'Template file not found: {template_path}'}
124126

127+
# SECURITY: confine the write to FLYTO_SANDBOX_DIR (GHSA-2956-977x-2w3r).
128+
try:
129+
output_path = validate_path_with_env_config(output_path)
130+
except PathTraversalError as e:
131+
raise ModuleError(str(e), code="PATH_TRAVERSAL")
132+
125133
def _do_fill():
126134
try:
127135
from pypdf import PdfReader, PdfWriter

src/core/modules/atomic/document/pdf_to_word.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
from ...registry import register_module
1212
from ...schema import compose, presets
13+
from ...errors import ModuleError
14+
from ....utils import validate_path_with_env_config, PathTraversalError
1315

1416

1517
logger = logging.getLogger(__name__)
@@ -107,6 +109,12 @@ async def pdf_to_word(context: Dict[str, Any]) -> Dict[str, Any]:
107109
preserve_formatting = params.get('preserve_formatting', True)
108110
output_path = _resolve_pdf_output(params, input_path)
109111

112+
# SECURITY: confine the write to FLYTO_SANDBOX_DIR (GHSA-2956-977x-2w3r).
113+
try:
114+
output_path = validate_path_with_env_config(output_path)
115+
except PathTraversalError as e:
116+
raise ModuleError(str(e), code="PATH_TRAVERSAL")
117+
110118
if not os.path.exists(input_path):
111119
raise FileNotFoundError(f"PDF file not found: {input_path}")
112120
_ensure_output_dir(output_path)

src/core/modules/atomic/document/word_to_pdf.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from ...registry import register_module
1313
from ...schema import compose, presets
14+
from ...errors import ModuleError
15+
from ....utils import validate_path_with_env_config, PathTraversalError
1416

1517

1618
logger = logging.getLogger(__name__)
@@ -94,6 +96,13 @@ async def word_to_pdf(context: Dict[str, Any]) -> Dict[str, Any]:
9496
method = params.get('method', 'auto')
9597

9698
output_path = _resolve_output_path(params, input_path, '.pdf')
99+
100+
# SECURITY: confine the write to FLYTO_SANDBOX_DIR (GHSA-2956-977x-2w3r).
101+
try:
102+
output_path = validate_path_with_env_config(output_path)
103+
except PathTraversalError as e:
104+
raise ModuleError(str(e), code="PATH_TRAVERSAL")
105+
97106
_validate_input_and_prepare_output(input_path, output_path, 'Word')
98107

99108
success, method_used = await _run_conversion(method, input_path, output_path)

src/core/modules/atomic/image/compress.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from ...registry import register_module
1313
from ...schema import compose, presets
14+
from ...errors import ModuleError
15+
from ....utils import validate_path_with_env_config, PathTraversalError
1416

1517

1618
logger = logging.getLogger(__name__)
@@ -167,6 +169,12 @@ async def image_compress(context: Dict[str, Any]) -> Dict[str, Any]:
167169
original_size = os.path.getsize(input_path)
168170
output_path = _resolve_compress_output(input_path, output_path, output_format)
169171

172+
# SECURITY: confine the write to FLYTO_SANDBOX_DIR (GHSA-2956-977x-2w3r).
173+
try:
174+
output_path = validate_path_with_env_config(output_path)
175+
except PathTraversalError as e:
176+
raise ModuleError(str(e), code="PATH_TRAVERSAL")
177+
170178
def _compress():
171179
with Image.open(input_path) as img:
172180
if img.mode in ('RGBA', 'P') and output_format == 'jpeg':

src/core/modules/atomic/image/convert.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from ...registry import register_module
1313
from ...schema import compose, presets
14+
from ...errors import ModuleError
15+
from ....utils import validate_path_with_env_config, PathTraversalError
1416

1517

1618
logger = logging.getLogger(__name__)
@@ -53,7 +55,6 @@ def _resolve_convert_paths(input_path: str, output_path: Optional[str], output_f
5355
base_name = os.path.splitext(input_path)[0]
5456
extension = SUPPORTED_FORMATS[output_format][0]
5557
output_path = f"{base_name}{extension}"
56-
Path(os.path.dirname(output_path)).mkdir(parents=True, exist_ok=True)
5758
return output_path, output_format
5859

5960

@@ -176,6 +177,13 @@ async def image_convert(context: Dict[str, Any]) -> Dict[str, Any]:
176177

177178
output_path, output_format = _resolve_convert_paths(input_path, output_path, output_format)
178179

180+
# SECURITY: confine the write to FLYTO_SANDBOX_DIR (GHSA-2956-977x-2w3r).
181+
try:
182+
output_path = validate_path_with_env_config(output_path)
183+
except PathTraversalError as e:
184+
raise ModuleError(str(e), code="PATH_TRAVERSAL")
185+
Path(os.path.dirname(output_path)).mkdir(parents=True, exist_ok=True)
186+
179187
with Image.open(input_path) as img:
180188
if resize:
181189
img = _apply_resize(img, resize)

0 commit comments

Comments
 (0)