diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml
index 4f00a9101c..a19cb50abc 100644
--- a/.github/workflows/main-ci.yml
+++ b/.github/workflows/main-ci.yml
@@ -63,8 +63,9 @@ jobs:
if: needs.check-changes.outputs.web-changed == 'true'
uses: ./.github/workflows/web-tests.yml
with:
- base_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
- head_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+ base_sha: ${{ github.event.before || github.event.pull_request.base.sha }}
+ diff_range_mode: ${{ github.event.before && 'exact' || 'merge-base' }}
+ head_sha: ${{ github.event.after || github.event.pull_request.head.sha || github.sha }}
style-check:
name: Style Check
diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml
index 8999b8d544..ec9d1c98c8 100644
--- a/.github/workflows/web-tests.yml
+++ b/.github/workflows/web-tests.yml
@@ -6,6 +6,9 @@ on:
base_sha:
required: false
type: string
+ diff_range_mode:
+ required: false
+ type: string
head_sha:
required: false
type: string
@@ -89,6 +92,7 @@ jobs:
- name: Check app/components diff coverage
env:
BASE_SHA: ${{ inputs.base_sha }}
+ DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }}
HEAD_SHA: ${{ inputs.head_sha }}
run: node ./scripts/check-components-diff-coverage.mjs
diff --git a/api/.env.example b/api/.env.example
index 8195a3c074..40e1c2dfdf 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -737,24 +737,25 @@ SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000
-# Redis URL used for PubSub between API and
+# Redis URL used for event bus between API and
# celery worker
# defaults to url constructed from `REDIS_*`
# configurations
-PUBSUB_REDIS_URL=
-# Pub/sub channel type for streaming events.
-# valid options are:
+EVENT_BUS_REDIS_URL=
+# Event transport type. Options are:
#
-# - pubsub: for normal Pub/Sub
-# - sharded: for sharded Pub/Sub
+# - pubsub: normal Pub/Sub (at-most-once)
+# - sharded: sharded Pub/Sub (at-most-once)
+# - streams: Redis Streams (at-least-once, recommended to avoid subscriber races)
#
-# It's highly recommended to use sharded Pub/Sub AND redis cluster
-# for large deployments.
-PUBSUB_REDIS_CHANNEL_TYPE=pubsub
-# Whether to use Redis cluster mode while running
-# PubSub.
+# Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs.
+# Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce
+# the risk of data loss from Redis auto-eviction under memory pressure.
+# Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE.
+EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
+# Whether to use Redis cluster mode while use redis as event bus.
# It's highly recommended to enable this for large deployments.
-PUBSUB_REDIS_USE_CLUSTERS=false
+EVENT_BUS_REDIS_USE_CLUSTERS=false
# Whether to Enable human input timeout check task
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
diff --git a/api/configs/middleware/cache/redis_pubsub_config.py b/api/configs/middleware/cache/redis_pubsub_config.py
index 8cddc5677a..d30831a0ec 100644
--- a/api/configs/middleware/cache/redis_pubsub_config.py
+++ b/api/configs/middleware/cache/redis_pubsub_config.py
@@ -41,10 +41,10 @@ class RedisPubSubConfig(BaseSettings, RedisConfigDefaultsMixin):
)
PUBSUB_REDIS_USE_CLUSTERS: bool = Field(
- validation_alias=AliasChoices("EVENT_BUS_REDIS_CLUSTERS", "PUBSUB_REDIS_USE_CLUSTERS"),
+ validation_alias=AliasChoices("EVENT_BUS_REDIS_USE_CLUSTERS", "PUBSUB_REDIS_USE_CLUSTERS"),
description=(
"Enable Redis Cluster mode for pub/sub or streams transport. Recommended for large deployments. "
- "Also accepts ENV: EVENT_BUS_REDIS_CLUSTERS."
+ "Also accepts ENV: EVENT_BUS_REDIS_USE_CLUSTERS."
),
default=False,
)
diff --git a/api/core/mcp/auth/auth_flow.py b/api/core/mcp/auth/auth_flow.py
index aef1afb235..d015769b54 100644
--- a/api/core/mcp/auth/auth_flow.py
+++ b/api/core/mcp/auth/auth_flow.py
@@ -55,15 +55,31 @@ def build_protected_resource_metadata_discovery_urls(
"""
urls = []
+ parsed_server_url = urlparse(server_url)
+ base_url = f"{parsed_server_url.scheme}://{parsed_server_url.netloc}"
+ path = parsed_server_url.path.rstrip("/")
+
# First priority: URL from WWW-Authenticate header
if www_auth_resource_metadata_url:
- urls.append(www_auth_resource_metadata_url)
+ parsed_metadata_url = urlparse(www_auth_resource_metadata_url)
+ normalized_metadata_url = None
+ if parsed_metadata_url.scheme and parsed_metadata_url.netloc:
+ normalized_metadata_url = www_auth_resource_metadata_url
+ elif not parsed_metadata_url.scheme and parsed_metadata_url.netloc:
+ normalized_metadata_url = f"{parsed_server_url.scheme}:{www_auth_resource_metadata_url}"
+ elif (
+ not parsed_metadata_url.scheme
+ and not parsed_metadata_url.netloc
+ and parsed_metadata_url.path.startswith("/")
+ ):
+ first_segment = parsed_metadata_url.path.lstrip("/").split("/", 1)[0]
+ if first_segment == ".well-known" or "." not in first_segment:
+ normalized_metadata_url = urljoin(base_url, parsed_metadata_url.path)
+
+ if normalized_metadata_url:
+ urls.append(normalized_metadata_url)
# Fallback: construct from server URL
- parsed = urlparse(server_url)
- base_url = f"{parsed.scheme}://{parsed.netloc}"
- path = parsed.path.rstrip("/")
-
# Priority 2: With path insertion (e.g., /.well-known/oauth-protected-resource/public/mcp)
if path:
path_url = f"{base_url}/.well-known/oauth-protected-resource{path}"
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/test_containers_integration_tests/conftest.py b/api/tests/test_containers_integration_tests/conftest.py
index 2a23f1ea7d..0bdd3bdc47 100644
--- a/api/tests/test_containers_integration_tests/conftest.py
+++ b/api/tests/test_containers_integration_tests/conftest.py
@@ -186,7 +186,7 @@ class DifyTestContainers:
# Start Dify Plugin Daemon container for plugin management
# Dify Plugin Daemon provides plugin lifecycle management and execution
logger.info("Initializing Dify Plugin Daemon container...")
- self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.3.0-local").with_network(
+ self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.4-local").with_network(
self.network
)
self.dify_plugin_daemon.with_exposed_ports(5002)
diff --git a/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py b/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py
index abf3c60fe0..fe533e62af 100644
--- a/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py
+++ b/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py
@@ -801,6 +801,27 @@ class TestAuthOrchestration:
urls = build_protected_resource_metadata_discovery_urls(None, "https://api.example.com")
assert urls == ["https://api.example.com/.well-known/oauth-protected-resource"]
+ def test_build_protected_resource_metadata_discovery_urls_with_relative_hint(self):
+ urls = build_protected_resource_metadata_discovery_urls(
+ "/.well-known/oauth-protected-resource/tenant/mcp",
+ "https://api.example.com/tenant/mcp",
+ )
+ assert urls == [
+ "https://api.example.com/.well-known/oauth-protected-resource/tenant/mcp",
+ "https://api.example.com/.well-known/oauth-protected-resource",
+ ]
+
+ def test_build_protected_resource_metadata_discovery_urls_ignores_scheme_less_hint(self):
+ urls = build_protected_resource_metadata_discovery_urls(
+ "/openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource/tenant/mcp",
+ "https://openapi-mcp.cn-hangzhou.aliyuncs.com/tenant/mcp",
+ )
+
+ assert urls == [
+ "https://openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource/tenant/mcp",
+ "https://openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource",
+ ]
+
def test_build_oauth_authorization_server_metadata_discovery_urls(self):
# Case 1: with auth_server_url
urls = build_oauth_authorization_server_metadata_discovery_urls(
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 "