From 5ad6f1fa6dd76a0af03e70e25341407d85bdda4c Mon Sep 17 00:00:00 2001 From: Denis Gregor <184754722+dngr2@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:45:18 +0300 Subject: [PATCH] fix(http-provider): don't double-encode a templated JSON body _query() normalised `headers` when they arrived as a string but never did the same for `body`. A workflow using `body: "{{ alert }}"` renders to a JSON string, which was then passed to requests as `json=body` and serialised a second time, so the endpoint received a quoted string rather than an object. Parse a string body before sending, mirroring the existing `headers` handling three lines above. Only an object or array is accepted: json.loads ("123") succeeds and returns an int, and a bare scalar was almost certainly meant as a plain-text body. A body that is still a string afterwards goes out as `data=` with the caller's Content-Type, since `json=` would re-serialise it. Note this is a behaviour change for anyone currently sending a JSON string and compensating for the double encoding downstream. The current behaviour contradicts both the documented Content-Type and the handling of `headers` in the same function, so it looks like the bug rather than the contract. Adds tests for templated and nested JSON strings on POST/PUT/DELETE, plain text and XML bodies going out as data, a bare numeric string not being treated as JSON, and regression guards for dict, list and None bodies. Fixes #6547 --- keep/providers/http_provider/http_provider.py | 24 +++- .../test_http_provider_json_body.py | 104 ++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 tests/providers/http_provider/test_http_provider_json_body.py diff --git a/keep/providers/http_provider/http_provider.py b/keep/providers/http_provider/http_provider.py index 2897e1b387..fe2d7e8806 100644 --- a/keep/providers/http_provider/http_provider.py +++ b/keep/providers/http_provider/http_provider.py @@ -96,9 +96,27 @@ def _query( headers = json.loads(headers) if body is None: body = {} + if isinstance(body, str): + # A templated body such as `body: "{{ alert }}"` renders to a JSON + # string. Parse it so requests sends an object rather than + # serialising the string a second time (#6547). Mirrors the + # handling of `headers` above. + # Only an object or array is taken: json.loads("123") succeeds and + # returns an int, and a bare scalar was almost certainly meant as a + # plain-text body rather than as JSON. + try: + parsed = json.loads(body) + except ValueError: + parsed = None + if isinstance(parsed, (dict, list)): + body = parsed if params is None: params = {} + # `json=` re-serialises, so a body that is still a string after the + # parse above must go out as raw data with the caller's Content-Type. + body_kwarg = {"data": body} if isinstance(body, str) else {"json": body} + extra_args = copy.deepcopy(kwargs) # todo: this might be problematic if params/body/headers contain sensitive data @@ -124,27 +142,27 @@ def _query( response = requests.post( url, headers=headers, - json=body, proxies=proxies, verify=verify, + **body_kwarg, **extra_args, ) elif method == "PUT": response = requests.put( url, headers=headers, - json=body, proxies=proxies, verify=verify, + **body_kwarg, **extra_args, ) elif method == "DELETE": response = requests.delete( url, headers=headers, - json=body, proxies=proxies, verify=verify, + **body_kwarg, **extra_args, ) else: diff --git a/tests/providers/http_provider/test_http_provider_json_body.py b/tests/providers/http_provider/test_http_provider_json_body.py new file mode 100644 index 0000000000..9d0f3f4fb2 --- /dev/null +++ b/tests/providers/http_provider/test_http_provider_json_body.py @@ -0,0 +1,104 @@ +"""Tests for HTTP provider double-encoding a templated JSON body (issue #6547). + +Bug: _query() normalises `headers` when it arrives as a string, but never does +the same for `body`. A workflow using `body: "{{ alert }}"` renders to a JSON +*string*, which was then handed to requests as `json=body` and serialised a +second time — so the receiving endpoint got a quoted string like +"\\"{\\\\\"id\\\\\": ...}\\"" instead of a JSON object. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from keep.contextmanager.contextmanager import ContextManager +from keep.providers.http_provider.http_provider import HttpProvider +from keep.providers.models.provider_config import ProviderConfig + +RENDERED_ALERT = '{"id": "123", "name": "cpu high"}' +ALERT_OBJECT = {"id": "123", "name": "cpu high"} + + +def _build_provider() -> HttpProvider: + config = ProviderConfig(description="HTTP Provider", authentication={}) + return HttpProvider(ContextManager(tenant_id="test"), "http-test", config) + + +def _response(payload, ok=True, status_code=200): + response = MagicMock() + response.ok = ok + response.status_code = status_code + response.reason = "OK" + response.text = "body" + response.json = MagicMock(return_value=payload) + return response + + +def _call_kwargs(method, body): + """Run a request and return the kwargs requests. was called with.""" + provider = _build_provider() + with patch(f"requests.{method.lower()}", return_value=_response({})) as request: + provider._query( + url="http://example.com/api", + method=method, + headers={"Content-Type": "application/json"}, + body=body, + ) + return request.call_args.kwargs + + +class TestTemplatedJsonStringBody: + """Bug #6547: a rendered {{ alert }} string must reach the endpoint as JSON.""" + + @pytest.mark.parametrize("method", ["POST", "PUT", "DELETE"]) + def test_json_string_is_parsed_before_sending(self, method): + kwargs = _call_kwargs(method, RENDERED_ALERT) + assert kwargs.get("json") == ALERT_OBJECT + assert "data" not in kwargs + + def test_nested_json_string_survives(self): + body = '{"labels": {"severity": "critical"}, "values": [1, 2]}' + kwargs = _call_kwargs("POST", body) + assert kwargs.get("json") == { + "labels": {"severity": "critical"}, + "values": [1, 2], + } + + +class TestExistingBehaviourIsPreserved: + """The fix must not change how bodies already work today.""" + + @pytest.mark.parametrize("method", ["POST", "PUT", "DELETE"]) + def test_dict_body_unchanged(self, method): + kwargs = _call_kwargs(method, ALERT_OBJECT) + assert kwargs.get("json") == ALERT_OBJECT + assert "data" not in kwargs + + def test_none_body_becomes_empty_dict(self): + kwargs = _call_kwargs("POST", None) + assert kwargs.get("json") == {} + + def test_list_body_unchanged(self): + kwargs = _call_kwargs("POST", [{"a": 1}]) + assert kwargs.get("json") == [{"a": 1}] + + +class TestNonJsonStringBody: + """A plain-text body must not be JSON-encoded just because it is a string.""" + + def test_plain_text_is_sent_as_data(self): + kwargs = _call_kwargs("POST", "hello world") + assert kwargs.get("data") == "hello world" + assert "json" not in kwargs + + def test_xml_is_sent_as_data(self): + xml = "123" + kwargs = _call_kwargs("POST", xml) + assert kwargs.get("data") == xml + assert "json" not in kwargs + + def test_bare_number_string_is_not_treated_as_json(self): + """json.loads('123') returns an int; sending that as a body is wrong.""" + kwargs = _call_kwargs("POST", "123") + assert kwargs.get("data") == "123" + assert "json" not in kwargs