From 4f45978cd9c6716a9f215f2afafb5228072aeef6 Mon Sep 17 00:00:00 2001 From: Yunlu Wen Date: Thu, 18 Sep 2025 16:45:34 +0800 Subject: [PATCH] fix: remote code execution in email endpoints (#25753) Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> --- api/configs/feature/__init__.py | 25 +++++++++++++++++++++++++ api/tasks/mail_inner_task.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index db6f1e592c..b17f30210c 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1,3 +1,4 @@ +from enum import StrEnum from typing import Literal from pydantic import ( @@ -711,11 +712,35 @@ class ToolConfig(BaseSettings): ) +class TemplateMode(StrEnum): + # unsafe mode allows flexible operations in templates, but may cause security vulnerabilities + UNSAFE = "unsafe" + + # sandbox mode restricts some unsafe operations like accessing __class__. + # however, it is still not 100% safe, for example, cpu exploitation can happen. + SANDBOX = "sandbox" + + # templating is disabled + DISABLED = "disabled" + + class MailConfig(BaseSettings): """ Configuration for email services """ + MAIL_TEMPLATING_MODE: TemplateMode = Field( + description="Template mode for email services", + default=TemplateMode.SANDBOX, + ) + + MAIL_TEMPLATING_TIMEOUT: int = Field( + description=""" + Timeout for email templating in seconds. Used to prevent infinite loops in malicious templates. + Only available in sandbox mode.""", + default=3, + ) + MAIL_TYPE: str | None = Field( description="Email service provider type ('smtp' or 'resend' or 'sendGrid), default to None.", default=None, diff --git a/api/tasks/mail_inner_task.py b/api/tasks/mail_inner_task.py index 8149bfb156..294f6c3e25 100644 --- a/api/tasks/mail_inner_task.py +++ b/api/tasks/mail_inner_task.py @@ -1,17 +1,46 @@ import logging import time from collections.abc import Mapping +from typing import Any import click from celery import shared_task from flask import render_template_string +from jinja2.runtime import Context +from jinja2.sandbox import ImmutableSandboxedEnvironment +from configs import dify_config +from configs.feature import TemplateMode from extensions.ext_mail import mail from libs.email_i18n import get_email_i18n_service logger = logging.getLogger(__name__) +class SandboxedEnvironment(ImmutableSandboxedEnvironment): + def __init__(self, timeout: int, *args: Any, **kwargs: Any): + self._timeout_time = time.time() + timeout + super().__init__(*args, **kwargs) + + def call(self, context: Context, obj: Any, *args: Any, **kwargs: Any) -> Any: + if time.time() > self._timeout_time: + raise TimeoutError("Template rendering timeout") + return super().call(context, obj, *args, **kwargs) + + +def _render_template_with_strategy(body: str, substitutions: Mapping[str, str]) -> str: + mode = dify_config.MAIL_TEMPLATING_MODE + timeout = dify_config.MAIL_TEMPLATING_TIMEOUT + if mode == TemplateMode.UNSAFE: + return render_template_string(body, **substitutions) + if mode == TemplateMode.SANDBOX: + tmpl = SandboxedEnvironment(timeout=timeout).from_string(body) + return tmpl.render(substitutions) + if mode == TemplateMode.DISABLED: + return body + raise ValueError(f"Unsupported mail templating mode: {mode}") + + @shared_task(queue="mail") def send_inner_email_task(to: list[str], subject: str, body: str, substitutions: Mapping[str, str]): if not mail.is_inited(): @@ -21,7 +50,7 @@ def send_inner_email_task(to: list[str], subject: str, body: str, substitutions: start_at = time.perf_counter() try: - html_content = render_template_string(body, **substitutions) + html_content = _render_template_with_strategy(body, substitutions) email_service = get_email_i18n_service() email_service.send_raw_email(to=to, subject=subject, html_content=html_content)