From 4540eafc49b0edbc143eb3e824521b7aa7410a03 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:33:49 +0200 Subject: [PATCH 1/4] Honour env HTTP(S)_PROXY in webhook sink when non-global addresses allowed; PR #2995 rejected proxies outright, breaking proxy-egress deployments. Proxy rejection only protects IP pinning, which is moot when non-global destinations are allowed. Keep refusing proxies in hardened mode (ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES=false). --- .../core_steps/sinks/webhook/v1.py | 21 ++++++++---- .../roboflow_workflows/utils/url_input.py | 27 +++++++++++---- .../core_steps/sinks/test_webhook.py | 33 ++++++++++++++++++- .../tests/unit_tests/utils/test_url_input.py | 17 ++++++++++ 4 files changed, 85 insertions(+), 13 deletions(-) diff --git a/workflows/roboflow_workflows/core_steps/sinks/webhook/v1.py b/workflows/roboflow_workflows/core_steps/sinks/webhook/v1.py index e9fce211c5..ab4f61494d 100644 --- a/workflows/roboflow_workflows/core_steps/sinks/webhook/v1.py +++ b/workflows/roboflow_workflows/core_steps/sinks/webhook/v1.py @@ -73,6 +73,9 @@ `Location` header is not followed. * Non-global destinations are allowed by default to preserve existing self-hosted private-network webhooks. +* `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` environment variables are honoured + unless `ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES=false`, in which + case the proxy is refused and the request fails. ### Setting Query Parameters You can easily set query parameters for your request: @@ -563,11 +566,17 @@ def _execute_request( parsed = urllib.parse.urlsplit(request.url) if parsed.scheme not in {"http", "https"} or not parsed.hostname: raise ValueError("Webhook requires an HTTP(S) URL with a valid host") - # `proxies={}` and calling the adapter directly keep DNS resolution inside - # SSRFProtectedHTTPAdapter; environment HTTP(S) proxies must not steer the - # destination outside the validating adapter. `stream=True` avoids reading - # the body (the block does not consume it), and `allow_redirects` is not - # honoured by an adapter's `send()` so a 3xx surfaces here. + # Environment HTTP(S)_PROXY is honoured only when non-global destinations + # are allowed: a proxy resolves the destination itself, so in hardened + # mode `proxies={}` keeps DNS resolution inside SSRFProtectedHTTPAdapter. + # Calling the adapter directly: `stream=True` avoids reading the body (the + # block does not consume it), and `allow_redirects` is not honoured by an + # adapter's `send()` so a 3xx surfaces here. + proxies = ( + requests.utils.get_environ_proxies(request.url) + if ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES + else {} + ) with contextlib.closing( SSRFProtectedHTTPAdapter( allow_non_global_addresses=ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES @@ -578,7 +587,7 @@ def _execute_request( request, stream=True, timeout=timeout, - proxies={}, + proxies=proxies, ) ) as response: if 300 <= response.status_code < 400: diff --git a/workflows/roboflow_workflows/utils/url_input.py b/workflows/roboflow_workflows/utils/url_input.py index 91edebbc87..ec85a123fd 100644 --- a/workflows/roboflow_workflows/utils/url_input.py +++ b/workflows/roboflow_workflows/utils/url_input.py @@ -95,8 +95,10 @@ class SSRFProtectedHTTPAdapter(HTTPAdapter): targets it validates the literal directly and lets ``requests`` connect normally. - A configured proxy is rejected outright: a forward proxy would resolve - the destination outside this adapter, breaking the pinning guarantee. + A configured proxy is rejected in hardened mode (non-global addresses + not allowed): a forward proxy would resolve the destination outside this + adapter, breaking the pinning guarantee. When non-global addresses are + allowed the proxy is used as-is. """ def __init__(self, *, allow_non_global_addresses: bool, **kwargs): @@ -112,12 +114,21 @@ def send(self, request, **kwargs): request.headers["Host"] = host_header return super().send(request, **kwargs) - def _reject_proxy(self, url: str, proxies) -> None: - if select_proxy(url, proxies): + def _proxy_selected(self, url: str, proxies) -> bool: + """Return True when the request should go through a proxy. + + A proxy resolves the destination itself, so IP validation and pinning + cannot be enforced. That is only acceptable when non-global + destinations are allowed anyway; in hardened mode a proxy is refused. + """ + if not select_proxy(url, proxies): + return False + if not self._allow_non_global_addresses: raise URLAddressNotAllowedError( "Webhook transport refuses to use an HTTP(S) proxy: a proxy " "would resolve the destination outside the validating adapter." ) + return True def _resolve_pin_target(self, url: str) -> Optional[Tuple[str, str]]: parsed = urllib3.util.parse_url(url) @@ -161,7 +172,10 @@ def _build_pinned_pool( return pool def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None): - self._reject_proxy(request.url, proxies) + if self._proxy_selected(request.url, proxies): + return super().get_connection_with_tls_context( + request, verify, proxies=proxies, cert=cert + ) pin = self._resolve_pin_target(request.url) if pin is None: return super().get_connection_with_tls_context( @@ -174,7 +188,8 @@ def get_connection_with_tls_context(self, request, verify, proxies=None, cert=No return self._build_pinned_pool(host_params, pool_kwargs, hostname, pinned_ip) def get_connection(self, url, proxies=None): - self._reject_proxy(url, proxies) + if self._proxy_selected(url, proxies): + return super().get_connection(url, proxies) pin = self._resolve_pin_target(url) if pin is None: return super().get_connection(url, proxies) diff --git a/workflows/tests/unit_tests/core_steps/sinks/test_webhook.py b/workflows/tests/unit_tests/core_steps/sinks/test_webhook.py index dabd5daa60..4dabd3fd6a 100644 --- a/workflows/tests/unit_tests/core_steps/sinks/test_webhook.py +++ b/workflows/tests/unit_tests/core_steps/sinks/test_webhook.py @@ -349,12 +349,17 @@ def test_execute_request_treats_redirect_as_failure(monkeypatch) -> None: assert holder["request"].url.startswith("https://public.example/webhook") -def test_execute_request_ignores_environment_proxies(monkeypatch) -> None: +def test_execute_request_ignores_environment_proxies_in_hardened_mode( + monkeypatch, +) -> None: monkeypatch.setenv("HTTP_PROXY", "http://proxy.local:3128") monkeypatch.setenv("HTTPS_PROXY", "http://proxy.local:3128") monkeypatch.setenv("NO_PROXY", "") response = _FakeResponse(status_code=200) holder = _install_capturing_adapter(monkeypatch, response) + monkeypatch.setattr( + v1, "ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES", False + ) ok, _ = execute_request( url="https://public.example/webhook", @@ -370,6 +375,32 @@ def test_execute_request_ignores_environment_proxies(monkeypatch) -> None: assert holder["kwargs"]["proxies"] == {} +def test_execute_request_honours_environment_proxies_when_non_global_allowed( + monkeypatch, +) -> None: + monkeypatch.setenv("HTTP_PROXY", "http://proxy.local:3128") + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.local:3128") + monkeypatch.setenv("NO_PROXY", "") + response = _FakeResponse(status_code=200) + holder = _install_capturing_adapter(monkeypatch, response) + monkeypatch.setattr( + v1, "ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES", True + ) + + ok, _ = execute_request( + url="https://public.example/webhook", + method="POST", + query_parameters={}, + headers={}, + json_payload={"a": 1}, + form_data={}, + multi_part_encoded_files={}, + timeout=1, + ) + assert ok is False + assert holder["kwargs"]["proxies"]["https"] == "http://proxy.local:3128" + + @pytest.mark.parametrize( "url", [ diff --git a/workflows/tests/unit_tests/utils/test_url_input.py b/workflows/tests/unit_tests/utils/test_url_input.py index 2ae1f5dcd9..25c1b627ac 100644 --- a/workflows/tests/unit_tests/utils/test_url_input.py +++ b/workflows/tests/unit_tests/utils/test_url_input.py @@ -205,6 +205,23 @@ def test_adapter_rejects_proxy() -> None: ) +def test_adapter_uses_proxy_when_non_global_allowed(monkeypatch) -> None: + monkeypatch.setattr( + url_input.socket, + "getaddrinfo", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not resolve")), + ) + adapter = SSRFProtectedHTTPAdapter(allow_non_global_addresses=True) + pool = adapter.get_connection( + "https://example.com/x", + proxies={"https": "http://proxy.local:3128"}, + ) + # Stock HTTPAdapter path: pool tunnels through the proxy, not pinned. + assert pool.host == "example.com" + assert pool.proxy.host == "proxy.local" + assert pool.proxy.port == 3128 + + @pytest.mark.skipif(not _HAS_TLS_CONTEXT, reason="requests < 2.32") def test_tls_context_blocks_hostname_resolving_to_non_global(monkeypatch) -> None: monkeypatch.setattr( From 29dd146552c0068dcd4e9db908909199083d207a Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:44:50 +0200 Subject: [PATCH 2/4] Gate webhook sink SSRF hardening behind ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES; Default (flag true) restores the pre-#2995 transport verbatim: plain requests, env proxies honoured, redirects followed. Flag false keeps the hardened SSRF-protected adapter unchanged. Supersedes previous commit. --- .../core_steps/sinks/webhook/v1.py | 79 +++++++++++----- .../roboflow_workflows/utils/url_input.py | 27 ++---- .../core_steps/sinks/test_webhook.py | 91 +++++++++++-------- .../tests/unit_tests/utils/test_url_input.py | 17 ---- 4 files changed, 116 insertions(+), 98 deletions(-) diff --git a/workflows/roboflow_workflows/core_steps/sinks/webhook/v1.py b/workflows/roboflow_workflows/core_steps/sinks/webhook/v1.py index ab4f61494d..f6baa59e0a 100644 --- a/workflows/roboflow_workflows/core_steps/sinks/webhook/v1.py +++ b/workflows/roboflow_workflows/core_steps/sinks/webhook/v1.py @@ -64,18 +64,21 @@ ### Supported destinations +By default (`ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES=true`) requests +are sent with plain `requests` semantics: environment proxies are honoured, +redirects are followed, and any destination is allowed. This preserves existing +self-hosted private-network webhooks. + +Set `ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES=false` to enable the +hardened transport: + * Only `http://` and `https://` URLs are accepted. -* Set `ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES=false` to require - destinations to resolve exclusively to public, globally routable unicast addresses. - This rejects loopback, private (RFC1918), link-local (including cloud metadata - endpoints), CGNAT, reserved, and multicast targets. +* Destinations must resolve exclusively to public, globally routable unicast + addresses. This rejects loopback, private (RFC1918), link-local (including + cloud metadata endpoints), CGNAT, reserved, and multicast targets. * HTTP redirects are rejected and reported as a failed notification; the `Location` header is not followed. -* Non-global destinations are allowed by default to preserve existing self-hosted - private-network webhooks. -* `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` environment variables are honoured - unless `ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES=false`, in which - case the proxy is refused and the request fails. +* Environment HTTP(S) proxies are refused. ### Setting Query Parameters You can easily set query parameters for your request: @@ -535,6 +538,7 @@ def execute_request( ALLOWED_METHODS = ("GET", "POST", "PUT") +METHOD_TO_HANDLER = {"GET": requests.get, "POST": requests.post, "PUT": requests.put} def _execute_request( @@ -549,6 +553,17 @@ def _execute_request( ) -> None: if method not in ALLOWED_METHODS: raise ValueError(f"Handler for HTTP method `{method}` not registered") + if ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES: + return _execute_request_legacy( + url=url, + method=method, + query_parameters=query_parameters, + headers=headers, + json_payload=json_payload, + form_data=form_data, + multi_part_encoded_files=multi_part_encoded_files, + timeout=timeout, + ) # Reject a backslash in the raw authority before Requests normalises it. # `urlsplit` accepts \ in the netloc, but urllib3 later interprets it as a # path separator, which can smuggle a target past a scheme/host review. @@ -566,30 +581,46 @@ def _execute_request( parsed = urllib.parse.urlsplit(request.url) if parsed.scheme not in {"http", "https"} or not parsed.hostname: raise ValueError("Webhook requires an HTTP(S) URL with a valid host") - # Environment HTTP(S)_PROXY is honoured only when non-global destinations - # are allowed: a proxy resolves the destination itself, so in hardened - # mode `proxies={}` keeps DNS resolution inside SSRFProtectedHTTPAdapter. - # Calling the adapter directly: `stream=True` avoids reading the body (the - # block does not consume it), and `allow_redirects` is not honoured by an - # adapter's `send()` so a 3xx surfaces here. - proxies = ( - requests.utils.get_environ_proxies(request.url) - if ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES - else {} - ) + # `proxies={}` and calling the adapter directly keep DNS resolution inside + # SSRFProtectedHTTPAdapter; environment HTTP(S) proxies must not steer the + # destination outside the validating adapter. `stream=True` avoids reading + # the body (the block does not consume it), and `allow_redirects` is not + # honoured by an adapter's `send()` so a 3xx surfaces here. with contextlib.closing( - SSRFProtectedHTTPAdapter( - allow_non_global_addresses=ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES - ) + SSRFProtectedHTTPAdapter(allow_non_global_addresses=False) ) as adapter: with contextlib.closing( adapter.send( request, stream=True, timeout=timeout, - proxies=proxies, + proxies={}, ) ) as response: if 300 <= response.status_code < 400: raise requests.HTTPError("Webhook redirects are not allowed") response.raise_for_status() + + +def _execute_request_legacy( + url: str, + method: Literal["GET", "POST", "PUT"], + query_parameters: Dict[str, Any], + headers: Dict[str, Any], + json_payload: Dict[str, Any], + form_data: Dict[str, Any], + multi_part_encoded_files: Dict[str, Any], + timeout: int, +) -> None: + # Pre-SSRF-hardening transport, kept verbatim: plain `requests`, env proxies + # honoured, redirects followed, no destination validation. + response = METHOD_TO_HANDLER[method]( + url, + params=query_parameters, + headers=headers, + json=json_payload, + files=multi_part_encoded_files, + data=form_data, + timeout=timeout, + ) + response.raise_for_status() diff --git a/workflows/roboflow_workflows/utils/url_input.py b/workflows/roboflow_workflows/utils/url_input.py index ec85a123fd..91edebbc87 100644 --- a/workflows/roboflow_workflows/utils/url_input.py +++ b/workflows/roboflow_workflows/utils/url_input.py @@ -95,10 +95,8 @@ class SSRFProtectedHTTPAdapter(HTTPAdapter): targets it validates the literal directly and lets ``requests`` connect normally. - A configured proxy is rejected in hardened mode (non-global addresses - not allowed): a forward proxy would resolve the destination outside this - adapter, breaking the pinning guarantee. When non-global addresses are - allowed the proxy is used as-is. + A configured proxy is rejected outright: a forward proxy would resolve + the destination outside this adapter, breaking the pinning guarantee. """ def __init__(self, *, allow_non_global_addresses: bool, **kwargs): @@ -114,21 +112,12 @@ def send(self, request, **kwargs): request.headers["Host"] = host_header return super().send(request, **kwargs) - def _proxy_selected(self, url: str, proxies) -> bool: - """Return True when the request should go through a proxy. - - A proxy resolves the destination itself, so IP validation and pinning - cannot be enforced. That is only acceptable when non-global - destinations are allowed anyway; in hardened mode a proxy is refused. - """ - if not select_proxy(url, proxies): - return False - if not self._allow_non_global_addresses: + def _reject_proxy(self, url: str, proxies) -> None: + if select_proxy(url, proxies): raise URLAddressNotAllowedError( "Webhook transport refuses to use an HTTP(S) proxy: a proxy " "would resolve the destination outside the validating adapter." ) - return True def _resolve_pin_target(self, url: str) -> Optional[Tuple[str, str]]: parsed = urllib3.util.parse_url(url) @@ -172,10 +161,7 @@ def _build_pinned_pool( return pool def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None): - if self._proxy_selected(request.url, proxies): - return super().get_connection_with_tls_context( - request, verify, proxies=proxies, cert=cert - ) + self._reject_proxy(request.url, proxies) pin = self._resolve_pin_target(request.url) if pin is None: return super().get_connection_with_tls_context( @@ -188,8 +174,7 @@ def get_connection_with_tls_context(self, request, verify, proxies=None, cert=No return self._build_pinned_pool(host_params, pool_kwargs, hostname, pinned_ip) def get_connection(self, url, proxies=None): - if self._proxy_selected(url, proxies): - return super().get_connection(url, proxies) + self._reject_proxy(url, proxies) pin = self._resolve_pin_target(url) if pin is None: return super().get_connection(url, proxies) diff --git a/workflows/tests/unit_tests/core_steps/sinks/test_webhook.py b/workflows/tests/unit_tests/core_steps/sinks/test_webhook.py index 4dabd3fd6a..3da8a463b1 100644 --- a/workflows/tests/unit_tests/core_steps/sinks/test_webhook.py +++ b/workflows/tests/unit_tests/core_steps/sinks/test_webhook.py @@ -70,6 +70,10 @@ def close(self) -> None: holder["adapter_closed"] = True monkeypatch.setattr(v1, "SSRFProtectedHTTPAdapter", _FakeAdapter) + # Adapter path only exists in hardened mode. + monkeypatch.setattr( + v1, "ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES", False + ) return holder @@ -117,9 +121,6 @@ def test_manifest_parsing_when_the_input_is_valid() -> None: def test_execute_request_forwards_payload_to_adapter(monkeypatch) -> None: response = _FakeResponse(status_code=200) holder = _install_capturing_adapter(monkeypatch, response) - monkeypatch.setattr( - v1, "ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES", True - ) result = execute_request( url="https://public.example/webhook", @@ -143,7 +144,7 @@ def test_execute_request_forwards_payload_to_adapter(monkeypatch) -> None: # steer resolution outside the validating adapter. assert holder["kwargs"]["proxies"] == {} assert holder["kwargs"]["stream"] is True - assert holder["allow_non_global_addresses"] is True + assert holder["allow_non_global_addresses"] is False assert response.closed is True assert holder["adapter_closed"] is True @@ -248,6 +249,9 @@ def _explode(*a, **k): def test_execute_request_pins_first_dns_result_against_rebinding(monkeypatch) -> None: + monkeypatch.setattr( + v1, "ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES", False + ) calls = {"n": 0} responses = [ [ @@ -349,17 +353,12 @@ def test_execute_request_treats_redirect_as_failure(monkeypatch) -> None: assert holder["request"].url.startswith("https://public.example/webhook") -def test_execute_request_ignores_environment_proxies_in_hardened_mode( - monkeypatch, -) -> None: +def test_execute_request_ignores_environment_proxies(monkeypatch) -> None: monkeypatch.setenv("HTTP_PROXY", "http://proxy.local:3128") monkeypatch.setenv("HTTPS_PROXY", "http://proxy.local:3128") monkeypatch.setenv("NO_PROXY", "") response = _FakeResponse(status_code=200) holder = _install_capturing_adapter(monkeypatch, response) - monkeypatch.setattr( - v1, "ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES", False - ) ok, _ = execute_request( url="https://public.example/webhook", @@ -375,32 +374,6 @@ def test_execute_request_ignores_environment_proxies_in_hardened_mode( assert holder["kwargs"]["proxies"] == {} -def test_execute_request_honours_environment_proxies_when_non_global_allowed( - monkeypatch, -) -> None: - monkeypatch.setenv("HTTP_PROXY", "http://proxy.local:3128") - monkeypatch.setenv("HTTPS_PROXY", "http://proxy.local:3128") - monkeypatch.setenv("NO_PROXY", "") - response = _FakeResponse(status_code=200) - holder = _install_capturing_adapter(monkeypatch, response) - monkeypatch.setattr( - v1, "ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES", True - ) - - ok, _ = execute_request( - url="https://public.example/webhook", - method="POST", - query_parameters={}, - headers={}, - json_payload={"a": 1}, - form_data={}, - multi_part_encoded_files={}, - timeout=1, - ) - assert ok is False - assert holder["kwargs"]["proxies"]["https"] == "http://proxy.local:3128" - - @pytest.mark.parametrize( "url", [ @@ -412,6 +385,9 @@ def test_execute_request_honours_environment_proxies_when_non_global_allowed( ], ) def test_execute_request_rejects_malformed_or_non_http_urls(url, monkeypatch) -> None: + monkeypatch.setattr( + v1, "ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES", False + ) # None of these may reach a socket; if getaddrinfo or urlopen runs, fail loudly. monkeypatch.setattr( url_input.socket, @@ -727,6 +703,49 @@ def test_sending_webhook_notification_asynchronously_in_thread_pool_executor() - thread_pool_executor.submit.assert_called_once() +def test_execute_request_uses_plain_requests_when_non_global_allowed( + monkeypatch, +) -> None: + monkeypatch.setattr( + v1, "ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES", True + ) + monkeypatch.setattr( + v1, + "SSRFProtectedHTTPAdapter", + lambda **_: (_ for _ in ()).throw(AssertionError("adapter must not run")), + ) + calls = {} + + def _fake_post(url, **kwargs): + calls["url"] = url + calls["kwargs"] = kwargs + return _FakeResponse(status_code=200) + + monkeypatch.setitem(v1.METHOD_TO_HANDLER, "POST", _fake_post) + + result = execute_request( + url="http://127.0.0.1/webhook", + method="POST", + query_parameters={"a": "b"}, + headers={"c": "d"}, + json_payload={"e": "f"}, + form_data={"field": "value"}, + multi_part_encoded_files={"file": b"data"}, + timeout=3, + ) + + assert result == (False, "Notification sent successfully") + assert calls["url"] == "http://127.0.0.1/webhook" + assert calls["kwargs"] == { + "params": {"a": "b"}, + "headers": {"c": "d"}, + "json": {"e": "f"}, + "files": {"file": b"data"}, + "data": {"field": "value"}, + "timeout": 3, + } + + def test_execute_request_forwards_deny_non_global_when_flag_disabled( monkeypatch, ) -> None: diff --git a/workflows/tests/unit_tests/utils/test_url_input.py b/workflows/tests/unit_tests/utils/test_url_input.py index 25c1b627ac..2ae1f5dcd9 100644 --- a/workflows/tests/unit_tests/utils/test_url_input.py +++ b/workflows/tests/unit_tests/utils/test_url_input.py @@ -205,23 +205,6 @@ def test_adapter_rejects_proxy() -> None: ) -def test_adapter_uses_proxy_when_non_global_allowed(monkeypatch) -> None: - monkeypatch.setattr( - url_input.socket, - "getaddrinfo", - lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not resolve")), - ) - adapter = SSRFProtectedHTTPAdapter(allow_non_global_addresses=True) - pool = adapter.get_connection( - "https://example.com/x", - proxies={"https": "http://proxy.local:3128"}, - ) - # Stock HTTPAdapter path: pool tunnels through the proxy, not pinned. - assert pool.host == "example.com" - assert pool.proxy.host == "proxy.local" - assert pool.proxy.port == 3128 - - @pytest.mark.skipif(not _HAS_TLS_CONTEXT, reason="requests < 2.32") def test_tls_context_blocks_hostname_resolving_to_non_global(monkeypatch) -> None: monkeypatch.setattr( From c88f55d507307566bfee5a3b7d3c0c6a09168894 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:47:52 +0200 Subject: [PATCH 3/4] stop hardcoding wheel version in CI --- .../integration_e2e_tests_inference_sdk_x86.yml | 2 +- ...on_tests_inference_cli_depending_on_inference_x86.yml | 2 +- .github/workflows/integration_tests_inference_models.yml | 2 +- .../integration_tests_workflows_tensor_native_x86.yml | 2 +- .github/workflows/integration_tests_workflows_x86.yml | 2 +- .github/workflows/test_package_install_inference.yml | 2 +- .github/workflows/test_package_install_inference_gpu.yml | 2 +- .../test_package_install_inference_gpu_with_extras.yml | 2 +- .../test_package_install_inference_with_extras.yml | 2 +- .github/workflows/unit_tests_inference_cli_x86.yml | 2 +- .github/workflows/unit_tests_inference_x86.yml | 2 +- .../workflows/unit_tests_workflows_tensor_native_x86.yml | 9 +++++---- .github/workflows/unit_tests_workflows_x86.yml | 9 +++++---- 13 files changed, 21 insertions(+), 19 deletions(-) diff --git a/.github/workflows/integration_e2e_tests_inference_sdk_x86.yml b/.github/workflows/integration_e2e_tests_inference_sdk_x86.yml index 4c50651b45..09a8bcea3e 100644 --- a/.github/workflows/integration_e2e_tests_inference_sdk_x86.yml +++ b/.github/workflows/integration_e2e_tests_inference_sdk_x86.yml @@ -105,7 +105,7 @@ jobs: run: | make create_isolation_wheels pip install --find-links "${{ github.workspace }}/dist" \ - "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl[enterprise]" \ + "$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)[enterprise]" \ "${{ github.workspace }}"/dist/inference_sdk-*.whl \ "${{ github.workspace }}"/dist/inference_models-*.whl diff --git a/.github/workflows/integration_tests_inference_cli_depending_on_inference_x86.yml b/.github/workflows/integration_tests_inference_cli_depending_on_inference_x86.yml index c73b1a36e2..f83701db9a 100644 --- a/.github/workflows/integration_tests_inference_cli_depending_on_inference_x86.yml +++ b/.github/workflows/integration_tests_inference_cli_depending_on_inference_x86.yml @@ -52,6 +52,6 @@ jobs: - name: ๐Ÿ—๏ธ Build and install roboflow-workflows wheel run: | make create_workflows_wheel - pip install --find-links "${{ github.workspace }}/dist" "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl[enterprise]" + pip install --find-links "${{ github.workspace }}/dist" "$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)[enterprise]" - name: ๐Ÿงช Integration Tests of Inference CLI run: RUN_TESTS_WITH_INFERENCE_PACKAGE=True INFERENCE_CLI_TESTS_API_KEY=${{ secrets.LOAD_TEST_PRODUCTION_API_KEY }} python -m pytest tests/inference_cli/integration_tests/test_workflows.py diff --git a/.github/workflows/integration_tests_inference_models.yml b/.github/workflows/integration_tests_inference_models.yml index 377a1ee99b..1c06c89ecf 100644 --- a/.github/workflows/integration_tests_inference_models.yml +++ b/.github/workflows/integration_tests_inference_models.yml @@ -53,7 +53,7 @@ jobs: - name: ๐Ÿ—๏ธ Build and install roboflow-workflows wheel run: | make create_workflows_wheel - pip install --find-links "${{ github.workspace }}/dist" "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl" + pip install --find-links "${{ github.workspace }}/dist" "${{ github.workspace }}"/dist/roboflow_workflows-*.whl - name: ๐Ÿงช Integration Tests of Inference models timeout-minutes: 45 run: USE_INFERENCE_MODELS=False MAX_BATCH_SIZE=6 python -m pytest tests/inference/models_predictions_tests diff --git a/.github/workflows/integration_tests_workflows_tensor_native_x86.yml b/.github/workflows/integration_tests_workflows_tensor_native_x86.yml index 9fd12db4db..bbe40d32f9 100644 --- a/.github/workflows/integration_tests_workflows_tensor_native_x86.yml +++ b/.github/workflows/integration_tests_workflows_tensor_native_x86.yml @@ -67,7 +67,7 @@ jobs: - name: ๐Ÿ—๏ธ Build and install roboflow-workflows wheel (with enterprise extra) run: | make create_workflows_wheel - pip install --find-links "${{ github.workspace }}/dist" "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl[enterprise]" + pip install --find-links "${{ github.workspace }}/dist" "$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)[enterprise]" - name: ๐Ÿงช Integration Tests of Workflows (tensor-native) # Tensor-native path only: ENABLE_TENSOR_DATA_REPRESENTATION is AND-gated with # USE_INFERENCE_MODELS, which defaults to True on Linux runners, so it is not set here. diff --git a/.github/workflows/integration_tests_workflows_x86.yml b/.github/workflows/integration_tests_workflows_x86.yml index e4c8d53b9f..350652dc69 100644 --- a/.github/workflows/integration_tests_workflows_x86.yml +++ b/.github/workflows/integration_tests_workflows_x86.yml @@ -103,7 +103,7 @@ jobs: - name: ๐Ÿ—๏ธ Build and install roboflow-workflows wheel (with enterprise extra) run: | make create_workflows_wheel - pip install --find-links "${{ github.workspace }}/dist" "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl[enterprise]" + pip install --find-links "${{ github.workspace }}/dist" "$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)[enterprise]" - name: ๐Ÿงช Integration Tests of Workflows env: POSTGRESQL_TEST_DSN: "host=localhost port=5432 dbname=postgres user=postgres password=postgres-test-only sslmode=disable" diff --git a/.github/workflows/test_package_install_inference.yml b/.github/workflows/test_package_install_inference.yml index 72a245a473..3ca538471b 100644 --- a/.github/workflows/test_package_install_inference.yml +++ b/.github/workflows/test_package_install_inference.yml @@ -54,7 +54,7 @@ jobs: ${{ runner.os }}-pip-${{ matrix.python-version }}- - name: ๐Ÿ“ฆ Installing `inference` package... run: | - pip install --find-links dist/ ./dist/inference-*-py3-none-any.whl ./dist/inference_sdk-*-py3-none-any.whl ./dist/inference_models-*-py3-none-any.whl "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl[enterprise]" + pip install --find-links dist/ ./dist/inference-*-py3-none-any.whl ./dist/inference_sdk-*-py3-none-any.whl ./dist/inference_models-*-py3-none-any.whl "$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)[enterprise]" # Dependencies were resolved above; now replace any same-version # build-environment installs with the exact checkout artifacts. pip install --force-reinstall --no-deps ./dist/roboflow_workflows-*.whl ./dist/inference_sdk-*.whl ./dist/inference_models-*.whl diff --git a/.github/workflows/test_package_install_inference_gpu.yml b/.github/workflows/test_package_install_inference_gpu.yml index 36d386378d..d1fec03015 100644 --- a/.github/workflows/test_package_install_inference_gpu.yml +++ b/.github/workflows/test_package_install_inference_gpu.yml @@ -75,7 +75,7 @@ jobs: export LD_LIBRARY_PATH=/usr/local/cuda-12.5/targets/x86_64-linux/lib64:$LD_LIBRARY_PATH export CPATH=/usr/local/cuda-12.5/targets/x86_64-linux/include:$CPATH export LIBRARY_PATH=/usr/local/cuda-12.5/targets/x86_64-linux/lib64:$LIBRARY_PATH - pip install --find-links dist/ ./dist/inference_gpu-*-py3-none-any.whl ./dist/inference_sdk-*-py3-none-any.whl ./dist/inference_models-*-py3-none-any.whl "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl[enterprise]" + pip install --find-links dist/ ./dist/inference_gpu-*-py3-none-any.whl ./dist/inference_sdk-*-py3-none-any.whl ./dist/inference_models-*-py3-none-any.whl "$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)[enterprise]" # Dependencies were resolved above; now replace any same-version # build-environment installs with the exact checkout artifacts. pip install --force-reinstall --no-deps ./dist/roboflow_workflows-*.whl ./dist/inference_sdk-*.whl ./dist/inference_models-*.whl diff --git a/.github/workflows/test_package_install_inference_gpu_with_extras.yml b/.github/workflows/test_package_install_inference_gpu_with_extras.yml index 6a2e4f180c..62719092df 100644 --- a/.github/workflows/test_package_install_inference_gpu_with_extras.yml +++ b/.github/workflows/test_package_install_inference_gpu_with_extras.yml @@ -78,7 +78,7 @@ jobs: export CPATH=/usr/local/cuda-12.5/targets/x86_64-linux/include:$CPATH export LIBRARY_PATH=/usr/local/cuda-12.5/targets/x86_64-linux/lib64:$LIBRARY_PATH wheel_name=`ls ./dist/inference_gpu-*-py3-none-any.whl | head -n 1` - pip install --find-links dist/ "${wheel_name}[clip,grounding-dino,sam,waf,yolo-world,http,hosted,transformers]" ./dist/inference_sdk-*-py3-none-any.whl ./dist/inference_models-*-py3-none-any.whl "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl[enterprise]" + pip install --find-links dist/ "${wheel_name}[clip,grounding-dino,sam,waf,yolo-world,http,hosted,transformers]" ./dist/inference_sdk-*-py3-none-any.whl ./dist/inference_models-*-py3-none-any.whl "$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)[enterprise]" # Dependencies were resolved above; now replace any same-version # build-environment installs with the exact checkout artifacts. pip install --force-reinstall --no-deps ./dist/roboflow_workflows-*.whl ./dist/inference_sdk-*.whl ./dist/inference_models-*.whl diff --git a/.github/workflows/test_package_install_inference_with_extras.yml b/.github/workflows/test_package_install_inference_with_extras.yml index 1054078953..4d0854f6f4 100644 --- a/.github/workflows/test_package_install_inference_with_extras.yml +++ b/.github/workflows/test_package_install_inference_with_extras.yml @@ -57,7 +57,7 @@ jobs: - name: ๐Ÿ“ฆ Installing `inference` package... run: | wheel_name=`ls ./dist/inference-*-py3-none-any.whl | head -n 1` - pip install --find-links dist/ "${wheel_name}[clip,grounding-dino,sam,waf,yolo-world,http,hosted,transformers]" ./dist/inference_sdk-*-py3-none-any.whl ./dist/inference_models-*-py3-none-any.whl "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl[enterprise]" + pip install --find-links dist/ "${wheel_name}[clip,grounding-dino,sam,waf,yolo-world,http,hosted,transformers]" ./dist/inference_sdk-*-py3-none-any.whl ./dist/inference_models-*-py3-none-any.whl "$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)[enterprise]" # Dependencies were resolved above; now replace any same-version # build-environment installs with the exact checkout artifacts. pip install --force-reinstall --no-deps ./dist/roboflow_workflows-*.whl ./dist/inference_sdk-*.whl ./dist/inference_models-*.whl diff --git a/.github/workflows/unit_tests_inference_cli_x86.yml b/.github/workflows/unit_tests_inference_cli_x86.yml index 4004f87d20..2c9bd39ebe 100644 --- a/.github/workflows/unit_tests_inference_cli_x86.yml +++ b/.github/workflows/unit_tests_inference_cli_x86.yml @@ -54,7 +54,7 @@ jobs: - name: ๐Ÿ—๏ธ Build and install roboflow-workflows wheel run: | make create_workflows_wheel - pip install --find-links "${{ github.workspace }}/dist" "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl[enterprise]" + pip install --find-links "${{ github.workspace }}/dist" "$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)[enterprise]" - name: ๐Ÿงช Unit Tests of Inference CLI timeout-minutes: 30 run: python -m pytest tests/inference_cli/unit_tests diff --git a/.github/workflows/unit_tests_inference_x86.yml b/.github/workflows/unit_tests_inference_x86.yml index 1eade694ef..6bdd9d7ba6 100644 --- a/.github/workflows/unit_tests_inference_x86.yml +++ b/.github/workflows/unit_tests_inference_x86.yml @@ -56,7 +56,7 @@ jobs: - name: ๐Ÿ—๏ธ Build and install roboflow-workflows wheel run: | make create_workflows_wheel - pip install --find-links "${{ github.workspace }}/dist" "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl[enterprise]" + pip install --find-links "${{ github.workspace }}/dist" "$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)[enterprise]" - name: ๐Ÿงช Unit Tests of Inference timeout-minutes: 30 run: python -m pytest tests/inference/unit_tests tests/unit/core/cache/test_air_gapped.py diff --git a/.github/workflows/unit_tests_workflows_tensor_native_x86.yml b/.github/workflows/unit_tests_workflows_tensor_native_x86.yml index 505cca14ec..1e0faf612b 100644 --- a/.github/workflows/unit_tests_workflows_tensor_native_x86.yml +++ b/.github/workflows/unit_tests_workflows_tensor_native_x86.yml @@ -56,7 +56,7 @@ jobs: - name: ๐Ÿ—๏ธ Build roboflow-workflows + SDK + inference-models wheels run: make create_isolation_wheels - name: ๐Ÿ“ฆ Install roboflow-workflows (test + enterprise extras) from local wheel - run: pip install --find-links "${{ github.workspace }}/dist" "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl[test,enterprise]" "${{ github.workspace }}"/dist/inference_sdk-*.whl "${{ github.workspace }}"/dist/inference_models-*.whl + run: pip install --find-links "${{ github.workspace }}/dist" "$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)[test,enterprise]" "${{ github.workspace }}"/dist/inference_sdk-*.whl "${{ github.workspace }}"/dist/inference_models-*.whl - name: ๐Ÿงช Server unit tests (tensor-native) # ENABLE_TENSOR_DATA_REPRESENTATION is AND-gated with USE_INFERENCE_MODELS, # which defaults to True on Linux runners, so the tensor-native code path is exercised. @@ -92,13 +92,14 @@ jobs: - name: Install only standalone package dependencies run: | pip install --find-links "${{ github.workspace }}/dist" \ - "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl[test,enterprise]" \ + "$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)[test,enterprise]" \ "${{ github.workspace }}"/dist/inference_sdk-*.whl \ "${{ github.workspace }}"/dist/inference_models-*.whl pip check - name: Package unit and installed-wheel isolation tests working-directory: workflows env: - WORKFLOWS_ISOLATION_WHEEL: ${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl WORKFLOWS_ISOLATION_FIND_LINKS: ${{ github.workspace }}/dist - run: python -m pytest tests/unit_tests tests/isolation + run: | + WORKFLOWS_ISOLATION_WHEEL="$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)" \ + python -m pytest tests/unit_tests tests/isolation diff --git a/.github/workflows/unit_tests_workflows_x86.yml b/.github/workflows/unit_tests_workflows_x86.yml index b16c6a8759..46510d7e02 100644 --- a/.github/workflows/unit_tests_workflows_x86.yml +++ b/.github/workflows/unit_tests_workflows_x86.yml @@ -56,7 +56,7 @@ jobs: - name: ๐Ÿ—๏ธ Build roboflow-workflows + SDK + inference-models wheels run: make create_isolation_wheels - name: ๐Ÿ“ฆ Install roboflow-workflows (test + enterprise extras) from local wheel - run: pip install --find-links "${{ github.workspace }}/dist" "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl[test,enterprise]" "${{ github.workspace }}"/dist/inference_sdk-*.whl "${{ github.workspace }}"/dist/inference_models-*.whl + run: pip install --find-links "${{ github.workspace }}/dist" "$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)[test,enterprise]" "${{ github.workspace }}"/dist/inference_sdk-*.whl "${{ github.workspace }}"/dist/inference_models-*.whl - name: ๐Ÿงช Server unit tests timeout-minutes: 30 run: python -m pytest tests/workflows/unit_tests @@ -90,13 +90,14 @@ jobs: - name: Install only standalone package dependencies run: | pip install --find-links "${{ github.workspace }}/dist" \ - "${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl[test,enterprise]" \ + "$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)[test,enterprise]" \ "${{ github.workspace }}"/dist/inference_sdk-*.whl \ "${{ github.workspace }}"/dist/inference_models-*.whl pip check - name: Package unit and installed-wheel isolation tests working-directory: workflows env: - WORKFLOWS_ISOLATION_WHEEL: ${{ github.workspace }}/dist/roboflow_workflows-0.1.1-py3-none-any.whl WORKFLOWS_ISOLATION_FIND_LINKS: ${{ github.workspace }}/dist - run: python -m pytest tests/unit_tests tests/isolation + run: | + WORKFLOWS_ISOLATION_WHEEL="$(ls "${{ github.workspace }}"/dist/roboflow_workflows-*.whl)" \ + python -m pytest tests/unit_tests tests/isolation From 37489de9663705ca6aa69f902cb0a3c1ee79282d Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:48:46 +0200 Subject: [PATCH 4/4] Bump roboflow-workflows to 0.1.2 and inference to 1.6.2 --- inference/core/version.py | 2 +- requirements/requirements.workflows.txt | 2 +- workflows/CHANGELOG.md | 8 ++++++++ workflows/pyproject.toml | 2 +- workflows/uv.lock | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/inference/core/version.py b/inference/core/version.py index 66791410b0..e91e20458e 100644 --- a/inference/core/version.py +++ b/inference/core/version.py @@ -1,4 +1,4 @@ -__version__ = "1.6.1" +__version__ = "1.6.2" if __name__ == "__main__": diff --git a/requirements/requirements.workflows.txt b/requirements/requirements.workflows.txt index 6b5387021d..28b3ebbee9 100644 --- a/requirements/requirements.workflows.txt +++ b/requirements/requirements.workflows.txt @@ -1,2 +1,2 @@ # keep in sync with workflows/pyproject.toml version -roboflow-workflows[enterprise]==0.1.1 +roboflow-workflows[enterprise]==0.1.2 diff --git a/workflows/CHANGELOG.md b/workflows/CHANGELOG.md index d6a467ecbf..61913de8ed 100644 --- a/workflows/CHANGELOG.md +++ b/workflows/CHANGELOG.md @@ -4,6 +4,14 @@ --- +## `0.1.2` + +### Fixed + +- Webhook sink: SSRF hardening (destination validation, DNS pinning, redirect and proxy refusal) now applies only when `ALLOW_WEBHOOK_WORKFLOWS_SINK_TO_NON_GLOBAL_ADDRESSES=false`. The default restores the previous plain `requests` transport, so environment `HTTP_PROXY` / `HTTPS_PROXY` work again. + +--- + ## `0.1.1` ### Fixed diff --git a/workflows/pyproject.toml b/workflows/pyproject.toml index ec8bb6ea36..52a2dad475 100644 --- a/workflows/pyproject.toml +++ b/workflows/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "roboflow-workflows" -version = "0.1.1" +version = "0.1.2" description = "Roboflow Workflows execution engine and block library" readme = "README.md" license = {file = "LICENSE"} diff --git a/workflows/uv.lock b/workflows/uv.lock index ef4cada3d9..b5e1a8fae3 100644 --- a/workflows/uv.lock +++ b/workflows/uv.lock @@ -3401,7 +3401,7 @@ wheels = [ [[package]] name = "roboflow-workflows" -version = "0.1.1" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "anthropic" },