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>
This commit is contained in:
Taranum Wasu 2026-08-02 08:18:44 +05:30 committed by GitHub
parent 8a2cf65ec5
commit 83b3a8fd88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 124 additions and 3 deletions

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -13,7 +13,7 @@ const Menu = ({ breadcrumbs, startIndex, onBreadcrumbClick }: MenuProps) => {
{breadcrumbs.map((breadcrumb, index) => {
return (
<Item
key={`${breadcrumb}-${index}`}
key={breadcrumb}
name={breadcrumb}
index={startIndex + index}
onBreadcrumbClick={onBreadcrumbClick}