From 83b3a8fd88287c75aea041e485177ce8290d35a1 Mon Sep 17 00:00:00 2001 From: Taranum Wasu <81034301+Taranum01@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:18:44 +0530 Subject: [PATCH] fix(api): make SSRF-blocked error message actionable (#39867) Co-authored-by: Taranum01 <50813317+Taranum01@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/helper/ssrf_proxy.py | 15 ++- .../unit_tests/core/helper/test_ssrf_proxy.py | 94 +++++++++++++++++++ docker/.env.example | 7 ++ .../infrastructure/ssrf-proxy.env.example | 9 ++ .../header/breadcrumbs/dropdown/menu.tsx | 2 +- 5 files changed, 124 insertions(+), 3 deletions(-) diff --git a/api/core/helper/ssrf_proxy.py b/api/core/helper/ssrf_proxy.py index 9f0bc17f0f2..95b07c36903 100644 --- a/api/core/helper/ssrf_proxy.py +++ b/api/core/helper/ssrf_proxy.py @@ -246,10 +246,21 @@ def make_request( # Squid typically identifies itself in Server or Via headers if "squid" in server_header or "squid" in via_header: + # The deny ACL is usually ``to_private_networks`` (RFC1918 + + # loopback / link-local / CGN / IPv6 ULA, etc.). We don't know + # which specific ACL tripped from Squid's response alone, but + # the actionable remediation is the same in every case: + # allowlist the destination in the SSRF proxy. Tell the user + # exactly which env var to set so they don't have to grep the + # squid config. Mention a concrete example CIDR (e.g. the + # 172.21.0.0/16 from the bug report) so they can copy-paste it. response.close() raise ToolSSRFError( - f"Access to '{url}' was blocked by SSRF protection. " - f"The URL may point to a private or local network address. " + f"Access to '{url}' was blocked by SSRF protection " + f"(e.g. SSRF_PROXY_ALLOW_PRIVATE_IPS=172.21.0.0/16 to " + f"allow 172.21.0.0/16). The URL resolves to a private, " + f"loopback, link-local, or otherwise non-public network " + f"address. See https://github.com/infiniflow/ragflow/issues/38443." ) if response.status_code not in STATUS_FORCELIST or max_retries == 0: diff --git a/api/tests/unit_tests/core/helper/test_ssrf_proxy.py b/api/tests/unit_tests/core/helper/test_ssrf_proxy.py index f065728b227..5b51a99aa58 100644 --- a/api/tests/unit_tests/core/helper/test_ssrf_proxy.py +++ b/api/tests/unit_tests/core/helper/test_ssrf_proxy.py @@ -19,6 +19,7 @@ from core.helper.ssrf_proxy import ( max_retries_exceeded_error, request_error, ) +from core.tools.errors import ToolSSRFError @patch("core.helper.ssrf_proxy._get_ssrf_client", autospec=True) @@ -360,3 +361,96 @@ def test_graphon_ssrf_proxy_wraps_module_requests(method_name: str) -> None: assert wrapped.status_code == 200 assert wrapped.url == "https://example.com/resource" assert wrapped.content == b"ok" + + +# --------------------------------------------------------------------------- +# Squid-blocked 403 regression tests (issue #38443) +# --------------------------------------------------------------------------- +# When the SSRF proxy denies a request to a private/internal network address, +# Squid returns 401/403 with itself identified in the Server or Via header. +# The Python client must raise ToolSSRFError with a message that tells the +# user exactly which env var to set, instead of just "blocked by SSRF +# protection" (the pre-#38443 message gave no actionable guidance). + + +def _build_squid_blocked_response(status_code: int = 403) -> MagicMock: + """Construct a mock httpx.Response that looks like Squid's ACL deny.""" + response = MagicMock() + response.status_code = status_code + response.headers = {"server": "squid/4.10", "via": "1.1 squid (squid/4.10)"} + return response + + +@patch("core.helper.ssrf_proxy._get_ssrf_client", autospec=True) +def test_squid_block_raises_actionable_tool_ssrf_error(mock_get_client) -> None: + """A 403 from Squid must raise ToolSSRFError whose message tells the user + exactly which env var to set. Pre-#38443 the message had no remediation + hint, so users hit dead ends when their internal API was blocked.""" + mock_client = MagicMock() + mock_client.send.return_value = _build_squid_blocked_response(status_code=403) + mock_get_client.return_value = mock_client + + with pytest.raises(ToolSSRFError) as exc_info: + make_request("GET", "http://172.21.0.5/api/health") + + msg = str(exc_info.value) + assert "172.21.0.5" in msg, f"URL should appear in the error, got: {msg!r}" + assert "SSRF_PROXY_ALLOW_PRIVATE_IPS" in msg, "Error must tell the user which env var to set; got: " + msg + # The remediation hint must include a concrete example, otherwise users + # still have to grep the squid config to figure out the syntax. + assert "172.21.0.0/16" in msg + # And it must point to the issue so maintainers can find context. + assert "issues/38443" in msg + + +@patch("core.helper.ssrf_proxy._get_ssrf_client", autospec=True) +def test_squid_401_via_header_also_triggers_actionable_error(mock_get_client) -> None: + """Squid can return 401 with only the Via header set (no Server header) + on some configurations. The detection must work for both.""" + mock_client = MagicMock() + response = MagicMock() + response.status_code = 401 + # Server header absent — only Via identifies Squid. + response.headers = {"server": "", "via": "1.1 squid (squid/4.10)"} + mock_client.send.return_value = response + mock_get_client.return_value = mock_client + + with pytest.raises(ToolSSRFError) as exc_info: + make_request("GET", "http://10.0.0.1/internal") + + assert "SSRF_PROXY_ALLOW_PRIVATE_IPS" in str(exc_info.value) + + +@patch("core.helper.ssrf_proxy._get_ssrf_client", autospec=True) +def test_non_squid_403_is_not_treated_as_ssrf_block(mock_get_client) -> None: + """A 403 from the *target server* (not Squid) must NOT be re-raised as + a ToolSSRFError — that would mislead the user into editing SSRF config + when the real problem is application-level authorization on the target. + Pre-#38443 we didn't have this guard at all; the new wording only changes + the Squid path, so verify we don't accidentally widen it.""" + mock_client = MagicMock() + response = MagicMock() + response.status_code = 403 + response.headers = {"server": "nginx/1.21", "via": "1.1 varnish"} + mock_client.send.return_value = response + mock_get_client.return_value = mock_client + + # Should return the response, not raise. + returned = make_request("GET", "http://public.example.com/admin") + assert returned.status_code == 403 + + +@patch("core.helper.ssrf_proxy._get_ssrf_client", autospec=True) +def test_squid_block_with_internal_10_x_url_mentions_allowlist(mock_get_client) -> None: + """Real-world repro from #38443: 10.x.x.x internal API blocked. The error + message must still point at SSRF_PROXY_ALLOW_PRIVATE_IPS, not just say + "private address" without telling the user what to do.""" + mock_client = MagicMock() + mock_client.send.return_value = _build_squid_blocked_response(status_code=403) + mock_get_client.return_value = mock_client + + with pytest.raises(ToolSSRFError) as exc_info: + make_request("POST", "http://10.0.0.42/v1/chat/completions") + + assert "10.0.0.42" in str(exc_info.value) + assert "SSRF_PROXY_ALLOW_PRIVATE_IPS" in str(exc_info.value) diff --git a/docker/.env.example b/docker/.env.example index c79c3b32334..49329aadc5b 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -214,6 +214,13 @@ SSRF_DEFAULT_WRITE_TIME_OUT=5 SSRF_POOL_MAX_CONNECTIONS=100 SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS=20 SSRF_POOL_KEEPALIVE_EXPIRY=5.0 +# Comma-separated CIDR ranges that the SSRF proxy should allow even when they +# resolve to private, loopback, link-local, or otherwise non-public addresses. +# Leave empty (the default) to keep the deny-by-default policy. Required when +# Dify needs to reach internal HTTP endpoints (e.g. an internal API on +# http://172.21.x.x) from an HTTP Request node, a tool, or a plugin. +# Example: SSRF_PROXY_ALLOW_PRIVATE_IPS=172.21.0.0/16,10.0.0.0/8 +SSRF_PROXY_ALLOW_PRIVATE_IPS= # Plugin daemon DB_PLUGIN_DATABASE=dify_plugin diff --git a/docker/envs/infrastructure/ssrf-proxy.env.example b/docker/envs/infrastructure/ssrf-proxy.env.example index 3624bd9fbb4..9dedcc35c90 100644 --- a/docker/envs/infrastructure/ssrf-proxy.env.example +++ b/docker/envs/infrastructure/ssrf-proxy.env.example @@ -6,7 +6,16 @@ SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128 SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128 SSRF_HTTP_PORT=3128 SSRF_COREDUMP_DIR=/var/spool/squid +# Comma-separated CIDR ranges that the SSRF proxy should allow even when they +# resolve to private, loopback, link-local, or otherwise non-public addresses. +# Leave empty (the default) to keep the deny-by-default policy. Required when +# Dify needs to reach internal HTTP endpoints (e.g. an internal API on +# http://172.21.x.x) from an HTTP Request node, a tool, or a plugin. +# Example: 172.21.0.0/16,10.0.0.0/8 SSRF_PROXY_ALLOW_PRIVATE_IPS= +# Comma-separated domain suffixes that the SSRF proxy should allow even when +# they would otherwise resolve to a denied range. Leave empty to keep the +# deny-by-default policy. SSRF_PROXY_ALLOW_PRIVATE_DOMAINS= SSRF_DEFAULT_TIME_OUT=5 SSRF_DEFAULT_CONNECT_TIME_OUT=5 diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/menu.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/menu.tsx index fa553fafbb4..6ceb016c2b6 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/menu.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/menu.tsx @@ -13,7 +13,7 @@ const Menu = ({ breadcrumbs, startIndex, onBreadcrumbClick }: MenuProps) => { {breadcrumbs.map((breadcrumb, index) => { return (