Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions keep/providers/http_provider/http_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
104 changes: 104 additions & 0 deletions tests/providers/http_provider/test_http_provider_json_body.py
Original file line number Diff line number Diff line change
@@ -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.<method> 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 = "<alert><id>123</id></alert>"
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
Loading