Skip to content

Commit ae0a48f

Browse files
fix(ssrf): honor connector loopback policy for A2A agent and PaddleOCR base URLs (#14265)
* fix(ssrf): honor connector loopback policy for A2A agent and PaddleOCR base URLs The A2A Agent's agent_url and the PaddleOCR bundle's base_url are flow-author-configured connector endpoints (like the Ollama / LM Studio base URLs fixed in #14146), but both still used the raw SSRF validator, so pointing them at a local server (http://localhost:...) was blocked by default. Route them through validate_and_resolve_connector_url so the literal-loopback exemption (connector_ssrf_allow_loopback, default on) applies, while RFC1918 / link-local / cloud-metadata targets stay blocked and non-exempt hosts keep DNS pinning. Remote-controlled URLs keep the strict validator: A2A card-declared off-origin hops (including the toggle-independent floor) and the PaddleOCR result_url from the job-status response are unchanged, so a remote response cannot ride the loopback exemption. Related to #14264 (same regression family as the LM Studio report); follow-up to #14146. * [autofix.ci] apply automated fixes * chore(paddle): bump lfx-paddle to 0.1.2 so the SSRF fix ships lfx-paddle 0.1.0 and 0.1.1 are already published on PyPI (0.1.1 from the 1.12 line, without this fix), and release_bundles.yml skips already-published versions — without a bump the connector-loopback fix would never reach the published wheel that langflow resolves at install time. 0.1.1 is skipped because that version number is already taken on PyPI by a build without this change. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 48e551d commit ae0a48f

9 files changed

Lines changed: 154 additions & 24 deletions

File tree

src/bundles/paddle/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "lfx-paddle"
3-
version = "0.1.0"
3+
version = "0.1.2"
44
description = "PaddleOCR component (OCR and layout-aware document parsing via the AI Studio async Job API) as a standalone Langflow Extension Bundle."
55
readme = "README.md"
66
requires-python = ">=3.10,<3.15"

src/bundles/paddle/src/lfx_paddle/components/paddle/paddleocr.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
from lfx.base.data.base_file import BaseFileComponent
99
from lfx.inputs.inputs import BoolInput, DropdownInput, FloatInput, IntInput, MessageTextInput, SecretStrInput
1010
from lfx.schema.data import Data
11-
from lfx.utils.ssrf_protection import is_ssrf_protection_enabled, validate_and_resolve_url
11+
from lfx.utils.ssrf_protection import (
12+
is_ssrf_protection_enabled,
13+
validate_and_resolve_connector_url,
14+
validate_and_resolve_url,
15+
)
1216
from lfx.utils.ssrf_transport import create_ssrf_protected_sync_client
1317

1418
if TYPE_CHECKING:
@@ -209,12 +213,16 @@ def process_files(self, file_list: list[BaseFileComponent.BaseFile]) -> list[Bas
209213
}
210214
poll_timeout = int(self.poll_timeout or 600)
211215

212-
# ``base_url`` is operator-configurable, so the submit and poll requests
213-
# (which carry the bearer token and the uploaded file) are validated for
214-
# SSRF up front and DNS-pinned for the rest of the run. Like
215-
# ``_fetch_result``, this is a no-op when SSRF protection is disabled
216-
# (the default), so default behavior is unchanged.
217-
_validated_url, base_ips = validate_and_resolve_url(base_url)
216+
# ``base_url`` is configured by the flow author (a self-hosted PaddleOCR
217+
# service on localhost is a legitimate deployment), so the submit and
218+
# poll requests (which carry the bearer token and the uploaded file)
219+
# follow the connector SSRF policy: a literal loopback host is allowed
220+
# by default (``connector_ssrf_allow_loopback``) while RFC1918 /
221+
# link-local / cloud-metadata targets stay blocked; non-exempt hosts
222+
# are DNS-pinned for the rest of the run. ``result_url`` in
223+
# ``_fetch_result`` comes from the remote response instead and keeps
224+
# the strict validator.
225+
_validated_url, base_ips = validate_and_resolve_connector_url(base_url)
218226

219227
try:
220228
for file in file_list:

src/bundles/paddle/src/lfx_paddle/extension.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schemas.langflow.org/extension/v1.json",
33
"id": "lfx-paddle",
4-
"version": "0.1.0",
4+
"version": "0.1.2",
55
"name": "PaddleOCR",
66
"description": "PaddleOCR component (OCR and layout-aware document parsing via the AI Studio async Job API) as a standalone Langflow Extension Bundle.",
77
"lfx": {

src/bundles/paddle/tests/test_paddleocr_component.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,66 @@ def fake_validate(url):
276276
component._fetch_result("http://169.254.169.254/latest/meta-data/")
277277

278278

279+
def test_paddleocr_loopback_base_url_allowed_by_default(monkeypatch, tmp_path):
280+
# SSRF protection on, no allowlist: a self-hosted PaddleOCR service on
281+
# localhost passes the connector policy (literal-loopback exemption), so
282+
# processing proceeds to the (stubbed) submit/poll calls. Regression guard
283+
# for the #14264 family (local services blocked at loopback by the raw
284+
# validator).
285+
monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "true")
286+
monkeypatch.delenv("LANGFLOW_SSRF_ALLOWED_HOSTS", raising=False)
287+
monkeypatch.delenv("LANGFLOW_CONNECTOR_SSRF_ALLOW_LOOPBACK", raising=False)
288+
289+
file_path = tmp_path / "sample.png"
290+
file_path.write_bytes(b"fake image")
291+
292+
monkeypatch.setattr(PaddleOCRComponent, "_submit_job", lambda _self, **_kwargs: "job-id")
293+
monkeypatch.setattr(
294+
PaddleOCRComponent,
295+
"_poll_job",
296+
lambda _self, **_kwargs: [{"result": {"ocrResults": [{"prunedResult": {"rec_texts": ["ok"]}}]}}],
297+
)
298+
299+
component = PaddleOCRComponent()
300+
component.set_attributes(
301+
{
302+
"access_token": "token",
303+
"base_url": "http://127.0.0.1:8868",
304+
"task_type": "ocr",
305+
"model": "PP-OCRv6",
306+
"poll_timeout": 60,
307+
}
308+
)
309+
result = component.process_files([make_base_file(file_path)])
310+
assert result[0].data[0].data["text"] == "ok"
311+
312+
313+
def test_paddleocr_loopback_base_url_blocked_when_connector_loopback_disabled(monkeypatch, tmp_path):
314+
# Multi-tenant posture: connector_ssrf_allow_loopback=false blocks the
315+
# loopback base_url before any request is made.
316+
from lfx.utils.ssrf_protection import SSRFProtectionError
317+
318+
monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "true")
319+
monkeypatch.setenv("LANGFLOW_CONNECTOR_SSRF_ALLOW_LOOPBACK", "false")
320+
monkeypatch.delenv("LANGFLOW_SSRF_ALLOWED_HOSTS", raising=False)
321+
322+
file_path = tmp_path / "sample.png"
323+
file_path.write_bytes(b"fake image")
324+
325+
component = PaddleOCRComponent()
326+
component.set_attributes(
327+
{
328+
"access_token": "token",
329+
"base_url": "http://127.0.0.1:8868",
330+
"task_type": "ocr",
331+
"model": "PP-OCRv6",
332+
"poll_timeout": 60,
333+
}
334+
)
335+
with pytest.raises(SSRFProtectionError, match="blocked"):
336+
component.process_files([make_base_file(file_path)])
337+
338+
279339
def test_fetch_result_parses_json_list(monkeypatch):
280340
component = PaddleOCRComponent()
281341

src/lfx/src/lfx/_assets/component_index.json

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

src/lfx/src/lfx/components/models_and_agents/a2a_agent.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
is_host_allowed,
3131
is_ip_blocked,
3232
resolve_hostname,
33+
validate_and_resolve_connector_url,
3334
validate_and_resolve_url,
3435
)
3536
from lfx.utils.ssrf_transport import SSRFProtectedTransport
@@ -409,8 +410,10 @@ async def _fetch_card(self, url: str) -> dict | None:
409410
"""
410411
base = _agent_base_url(url)
411412
try:
412-
# to_thread: validate_and_resolve_url does blocking DNS; keep it off the event loop.
413-
_validated_url, validated_ips = await asyncio.to_thread(validate_and_resolve_url, base)
413+
# Connector policy (the URL is flow-author-configured, like the Ollama / LM Studio
414+
# base URLs): literal loopback allowed by default, internal ranges still blocked.
415+
# to_thread: the validator does blocking DNS; keep it off the event loop.
416+
_validated_url, validated_ips = await asyncio.to_thread(validate_and_resolve_connector_url, base)
414417
except SSRFProtectionError:
415418
return None
416419
try:
@@ -622,11 +625,15 @@ async def _call_external_agent(self) -> Message:
622625
# from <base>/.well-known/agent-card.json, so normalize first to avoid double-appending.
623626
agent_url = _agent_base_url(self.agent_url)
624627

625-
# Validate + DNS-pin the agent URL before any outbound call (blocks loopback, RFC1918,
626-
# link-local / cloud metadata, etc.); mirrors the API Request component.
628+
# Validate + DNS-pin the agent URL before any outbound call. The agent URL is configured
629+
# by the flow author (like the Ollama / LM Studio base URLs), so it follows the connector
630+
# policy: a literal loopback host is allowed by default (connector_ssrf_allow_loopback)
631+
# while RFC1918 / link-local / cloud-metadata targets stay blocked. Card-declared
632+
# off-origin hops are remote-controlled and keep the strict validator plus the
633+
# toggle-independent floor (see build_a2a_client).
627634
try:
628-
# to_thread: validate_and_resolve_url does blocking DNS; keep it off the event loop.
629-
_validated_url, validated_ips = await asyncio.to_thread(validate_and_resolve_url, agent_url)
635+
# to_thread: the validator does blocking DNS; keep it off the event loop.
636+
_validated_url, validated_ips = await asyncio.to_thread(validate_and_resolve_connector_url, agent_url)
630637
except SSRFProtectionError as e:
631638
msg = f"SSRF Protection: {e}"
632639
raise ValueError(msg) from e

src/lfx/src/lfx/services/settings/groups/security.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ class SecuritySettings(BaseModel):
3636
"""SSRF validation for CONNECTOR components that take a tenant-controlled host/URL:
3737
vector stores (Chroma/Qdrant/Elasticsearch/OpenSearch/Milvus/Weaviate/Supabase/Upstash/
3838
ClickHouse), the SQL Database components, the Glean and AstraDB-CQL tools, model-provider
39-
model discovery (LiteLLM/HuggingFace/xAI/DeepSeek/Groq/watsonx), and the Ollama / LM Studio /
40-
Home Assistant base-URL fields.
39+
model discovery (LiteLLM/HuggingFace/xAI/DeepSeek/Groq/watsonx), the Ollama / LM Studio /
40+
Home Assistant base-URL fields, the A2A Agent agent URL, and the PaddleOCR base URL.
4141
4242
Default True: connector host validation follows ssrf_protection_enabled / ssrf_allowed_hosts
4343
so tenant-controlled connector URLs cannot reach internal/cloud-metadata hosts by default.

src/lfx/tests/unit/components/models_and_agents/test_a2a_agent.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
- it strips the configured ``x-api-key`` on any off-origin hop (a card declaring an RPC url
1010
on a different host/port), so the key can never leak there, while still SSRF-validating that
1111
off-origin target (an internal/metadata target is blocked),
12-
- an internal/loopback ``agent_url`` is rejected by SSRF protection before any call.
12+
- an internal/metadata ``agent_url`` is rejected by SSRF protection before any call, while a
13+
literal-loopback ``agent_url`` follows the connector policy (allowed by default for local
14+
agents, blocked when ``connector_ssrf_allow_loopback`` is off) without weakening the strict
15+
validation of card-declared off-origin targets.
1316
"""
1417

1518
import contextlib
@@ -208,8 +211,12 @@ async def test_off_origin_idn_host_pinned_under_punycode_key(monkeypatch):
208211
assert "exämple.com" not in client._transport.pinned_ips
209212

210213

211-
async def test_loopback_agent_url_rejected_by_ssrf(monkeypatch):
212-
"""A loopback / metadata agent_url is rejected before any outbound call."""
214+
async def test_metadata_agent_url_rejected_by_ssrf(monkeypatch):
215+
"""An internal/metadata agent_url is rejected before any outbound call.
216+
217+
The connector loopback exemption only covers literal loopback hosts; cloud-metadata and
218+
RFC1918 targets stay blocked regardless.
219+
"""
213220
monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "true")
214221
monkeypatch.delenv("LANGFLOW_SSRF_ALLOWED_HOSTS", raising=False)
215222

@@ -221,6 +228,54 @@ async def test_loopback_agent_url_rejected_by_ssrf(monkeypatch):
221228
await component.send_to_agent()
222229

223230

231+
async def test_loopback_agent_url_allowed_by_default_connector_policy(monkeypatch):
232+
"""A literal-loopback agent_url follows the connector policy: allowed by default.
233+
234+
The agent URL is flow-author-configured (like the Ollama / LM Studio base URLs), so a local
235+
A2A server is reachable out of the box — no allowlist entry required. Regression guard for
236+
the #14264 family (local services blocked at loopback by the raw validator).
237+
"""
238+
monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "true")
239+
monkeypatch.delenv("LANGFLOW_SSRF_ALLOWED_HOSTS", raising=False)
240+
monkeypatch.delenv("LANGFLOW_CONNECTOR_SSRF_ALLOW_LOOPBACK", raising=False)
241+
242+
with _CardServer() as server:
243+
component = A2AAgentComponent(mode="External", agent_url=f"http://127.0.0.1:{server.port}", input_value="hi")
244+
card = await component._fetch_card(f"http://127.0.0.1:{server.port}")
245+
246+
assert card is not None
247+
assert card["name"] == "Echo"
248+
249+
250+
async def test_loopback_agent_url_blocked_when_connector_loopback_disabled(monkeypatch):
251+
"""Multi-tenant posture: connector_ssrf_allow_loopback=false blocks a loopback agent_url."""
252+
monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "true")
253+
monkeypatch.setenv("LANGFLOW_CONNECTOR_SSRF_ALLOW_LOOPBACK", "false")
254+
monkeypatch.delenv("LANGFLOW_SSRF_ALLOWED_HOSTS", raising=False)
255+
256+
component = A2AAgentComponent(agent_url="http://127.0.0.1:9", input_value="hi")
257+
with pytest.raises(ValueError, match="SSRF"):
258+
await component.send_to_agent()
259+
260+
261+
async def test_off_origin_loopback_still_blocked_despite_connector_exemption(monkeypatch):
262+
"""The connector loopback exemption applies only to the flow-author's agent_url.
263+
264+
A card-declared off-origin hop to loopback stays on the strict validator: without an
265+
allowlist entry it is blocked, so a remote card cannot ride the exemption to reach the
266+
server's loopback.
267+
"""
268+
monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "true")
269+
monkeypatch.delenv("LANGFLOW_SSRF_ALLOWED_HOSTS", raising=False)
270+
monkeypatch.delenv("LANGFLOW_CONNECTOR_SSRF_ALLOW_LOOPBACK", raising=False)
271+
272+
agent_url = "http://public-agent.example" # off-origin hop below differs by host
273+
client = build_a2a_client(agent_url, ["93.184.216.34"], api_key="super-secret", timeout=2)
274+
async with client:
275+
with pytest.raises(SSRFProtectionError):
276+
await client.post("http://127.0.0.1:9/rpc", json={"hello": "world"})
277+
278+
224279
# --- External mode: agent card preview -------------------------------------
225280

226281

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)