diff --git a/api/dify_graph/nodes/human_input/entities.py b/api/dify_graph/nodes/human_input/entities.py
index 7936e47213..2a33b4a0a8 100644
--- a/api/dify_graph/nodes/human_input/entities.py
+++ b/api/dify_graph/nodes/human_input/entities.py
@@ -8,6 +8,8 @@ from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta
from typing import Annotated, Any, ClassVar, Literal, Self
+import bleach
+import markdown
from pydantic import BaseModel, Field, field_validator, model_validator
from dify_graph.entities.base_node_data import BaseNodeData
@@ -58,6 +60,39 @@ class EmailDeliveryConfig(BaseModel):
"""Configuration for email delivery method."""
URL_PLACEHOLDER: ClassVar[str] = "{{#url#}}"
+ _SUBJECT_NEWLINE_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"[\r\n]+")
+ _ALLOWED_HTML_TAGS: ClassVar[list[str]] = [
+ "a",
+ "blockquote",
+ "br",
+ "code",
+ "em",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "hr",
+ "li",
+ "ol",
+ "p",
+ "pre",
+ "strong",
+ "table",
+ "tbody",
+ "td",
+ "th",
+ "thead",
+ "tr",
+ "ul",
+ ]
+ _ALLOWED_HTML_ATTRIBUTES: ClassVar[dict[str, list[str]]] = {
+ "a": ["href", "title"],
+ "td": ["align"],
+ "th": ["align"],
+ }
+ _ALLOWED_PROTOCOLS: ClassVar[list[str]] = ["http", "https", "mailto"]
recipients: EmailRecipients
@@ -98,6 +133,43 @@ class EmailDeliveryConfig(BaseModel):
return templated_body
return variable_pool.convert_template(templated_body).text
+ @classmethod
+ def render_markdown_body(cls, body: str) -> str:
+ """Render markdown to safe HTML for email delivery."""
+ sanitized_markdown = bleach.clean(
+ body,
+ tags=[],
+ attributes={},
+ strip=True,
+ strip_comments=True,
+ )
+ rendered_html = markdown.markdown(
+ sanitized_markdown,
+ extensions=["nl2br", "tables"],
+ extension_configs={"tables": {"use_align_attribute": True}},
+ )
+ return bleach.clean(
+ rendered_html,
+ tags=cls._ALLOWED_HTML_TAGS,
+ attributes=cls._ALLOWED_HTML_ATTRIBUTES,
+ protocols=cls._ALLOWED_PROTOCOLS,
+ strip=True,
+ strip_comments=True,
+ )
+
+ @classmethod
+ def sanitize_subject(cls, subject: str) -> str:
+ """Sanitize email subject to plain text and prevent CRLF injection."""
+ sanitized_subject = bleach.clean(
+ subject,
+ tags=[],
+ attributes={},
+ strip=True,
+ strip_comments=True,
+ )
+ sanitized_subject = cls._SUBJECT_NEWLINE_PATTERN.sub(" ", sanitized_subject)
+ return " ".join(sanitized_subject.split())
+
class _DeliveryMethodBase(BaseModel):
"""Base delivery method configuration."""
diff --git a/api/pyproject.toml b/api/pyproject.toml
index 57d58ce5b8..841a877328 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -40,7 +40,7 @@ dependencies = [
"numpy~=1.26.4",
"openpyxl~=3.1.5",
"opik~=1.10.37",
- "litellm==1.82.2", # Pinned to avoid madoka dependency issue
+ "litellm==1.82.2", # Pinned to avoid madoka dependency issue
"opentelemetry-api==1.28.0",
"opentelemetry-distro==0.49b0",
"opentelemetry-exporter-otlp==1.28.0",
@@ -91,6 +91,7 @@ dependencies = [
"apscheduler>=3.11.0",
"weave>=0.52.16",
"fastopenapi[flask]>=0.7.0",
+ "bleach~=6.2.0",
]
# Before adding new dependency, consider place it in
# alphabet order (a-z) and suitable group.
@@ -251,10 +252,7 @@ ignore_errors = true
[tool.pyrefly]
project-includes = ["."]
-project-excludes = [
- ".venv",
- "migrations/",
-]
+project-excludes = [".venv", "migrations/"]
python-platform = "linux"
python-version = "3.11.0"
infer-with-first-use = false
diff --git a/api/services/human_input_delivery_test_service.py b/api/services/human_input_delivery_test_service.py
index 80deb37a56..229e6608da 100644
--- a/api/services/human_input_delivery_test_service.py
+++ b/api/services/human_input_delivery_test_service.py
@@ -155,13 +155,15 @@ class EmailDeliveryTestHandler:
context=context,
recipient_email=recipient_email,
)
- subject = render_email_template(method.config.subject, substitutions)
+ subject_template = render_email_template(method.config.subject, substitutions)
+ subject = EmailDeliveryConfig.sanitize_subject(subject_template)
templated_body = EmailDeliveryConfig.render_body_template(
body=method.config.body,
url=substitutions.get("form_link"),
variable_pool=context.variable_pool,
)
body = render_email_template(templated_body, substitutions)
+ body = EmailDeliveryConfig.render_markdown_body(body)
mail.send(
to=recipient_email,
diff --git a/api/tasks/mail_human_input_delivery_task.py b/api/tasks/mail_human_input_delivery_task.py
index bded4cea2b..d241783359 100644
--- a/api/tasks/mail_human_input_delivery_task.py
+++ b/api/tasks/mail_human_input_delivery_task.py
@@ -111,7 +111,7 @@ def _render_body(
url=form_link,
variable_pool=variable_pool,
)
- return body
+ return EmailDeliveryConfig.render_markdown_body(body)
def _load_variable_pool(workflow_run_id: str | None) -> VariablePool | None:
@@ -173,10 +173,11 @@ def dispatch_human_input_email_task(form_id: str, node_title: str | None = None,
for recipient in job.recipients:
form_link = _build_form_link(recipient.token)
body = _render_body(job.body, form_link, variable_pool=variable_pool)
+ subject = EmailDeliveryConfig.sanitize_subject(job.subject)
mail.send(
to=recipient.email,
- subject=job.subject,
+ subject=subject,
html=body,
)
diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py
index d4939b1071..d52dfa2a65 100644
--- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py
+++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py
@@ -14,3 +14,64 @@ def test_render_body_template_replaces_variable_values():
result = config.render_body_template(body=config.body, url="https://example.com", variable_pool=variable_pool)
assert result == "Hello World https://example.com"
+
+
+def test_render_markdown_body_renders_markdown_to_html():
+ rendered = EmailDeliveryConfig.render_markdown_body("**Bold** and [link](https://example.com)")
+
+ assert "Bold" in rendered
+ assert 'link' in rendered
+
+
+def test_render_markdown_body_sanitizes_unsafe_html():
+ rendered = EmailDeliveryConfig.render_markdown_body(
+ 'Click'
+ )
+
+ assert "