Skip to content
Merged
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
2 changes: 1 addition & 1 deletion testit-adapter-behave/setup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from setuptools import find_packages, setup

VERSION = "5.2.0"
VERSION = "5.2.1"

setup(
name='testit-adapter-behave',
Expand Down
2 changes: 1 addition & 1 deletion testit-adapter-nose/setup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from setuptools import setup, find_packages

VERSION = "5.2.0"
VERSION = "5.2.1"

setup(
name='testit-adapter-nose',
Expand Down
2 changes: 1 addition & 1 deletion testit-adapter-pytest/setup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from setuptools import find_packages, setup

VERSION = "5.2.0"
VERSION = "5.2.1"

setup(
name='testit-adapter-pytest',
Expand Down
39 changes: 39 additions & 0 deletions testit-adapter-pytest/tests/test_xss_parametrize_escape.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Ensure pytest parametrize XSS values are escaped in TestResult / API payload."""

from testit_adapter_pytest.models.executable_test import ExecutableTest
from testit_adapter_pytest.utils import convert_executable_test_to_test_result_model
from testit_python_commons.client.converter import Converter
from adapters_api import ApiClient


def test_parametrize_script_payload_escaped_in_parameters_and_external_key():
payload = '<script>alert("1")</script>'
executable = ExecutableTest(
external_id="xss_ext",
name=f"with xss {payload}",
duration=1,
parameters={"description": payload},
properties={},
namespace="tests",
classname=None,
title=None,
description=None,
links=[],
labels=[],
tags=[],
work_item_ids=[],
node_id=f"tests/test_xss_parametrize.py::test_05_with_chg_xss[{payload}]",
outcome="Passed",
status_type="Succeeded",
)

test_result = convert_executable_test_to_test_result_model(executable)
assert test_result.get_parameters()["description"] == '&lt;script&gt;alert("1")&lt;/script&gt;'
assert "<script>" not in test_result.get_autotest_name()
assert "&lt;script&gt;" in test_result.get_external_key()

model = Converter.test_result_to_testrun_result_post_model(
test_result, "cfg", ["PASSED"])
body = ApiClient.sanitize_for_serialization(model)
assert body["parameters"]["description"] == '&lt;script&gt;alert("1")&lt;/script&gt;'
assert "<script>" not in body["parameters"]["description"]
2 changes: 1 addition & 1 deletion testit-adapter-robotframework/setup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from setuptools import find_packages, setup

VERSION = "5.2.0"
VERSION = "5.2.1"

setup(
name='testit-adapter-robotframework',
Expand Down
2 changes: 1 addition & 1 deletion testit-python-commons/setup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from setuptools import find_packages, setup

VERSION = "5.2.0"
VERSION = "5.2.1"

setup(
name='testit-python-commons',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
retry,
retry_on_connection_error,
)
from testit_python_commons.utils.html_escape_utils import HtmlEscapeUtils


class ApiClientWorker:
Expand Down Expand Up @@ -469,6 +470,7 @@ def __create_auto_test(self, test_result: TestResult) -> str:
test_result,
self.__config.get_project_id())

HtmlEscapeUtils.escape_html_in_object(model)
autotest_response = self.__autotest_api.adapters_auto_tests_post(
adapters_auto_tests_post_request=model)

Expand Down Expand Up @@ -496,6 +498,7 @@ def __update_auto_test(self, test_result: TestResult, autotest: AutoTestApiResul
model = Converter.prepare_to_update_autotest(test_result, autotest, self.__config.get_project_id())

try:
HtmlEscapeUtils.escape_html_in_object(model)
self.__autotest_api.adapters_auto_tests_put(adapters_auto_tests_put_request=model)
except Exception as exc:
if is_retriable_connection_error(exc):
Expand Down Expand Up @@ -562,6 +565,7 @@ def __load_test_result(self, test_result: TestResult) -> str:
self.__config.get_configuration_id(),
self.__status_codes)

HtmlEscapeUtils.escape_html_in_object(model)
response = self.__test_run_api.adapters_test_runs_id_test_results_post(
id=self.__config.get_test_run_id(),
auto_test_results_for_test_run_model=[model])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ def __bulk_update(
def __create_tests(self, autotests_for_create: List[AutoTestCreateApiModel]):
# logging.debug(f'Creating autotests: "{autotests_for_create}')

autotests_for_create = HtmlEscapeUtils.escape_html_in_object(autotests_for_create)
autotests_for_create = HtmlEscapeUtils.escape_html_in_object_list(autotests_for_create)
self.__autotests_api.adapters_auto_tests_bulk_post(auto_test_create_api_model=autotests_for_create)

logging.debug(f'Autotests were created')
Expand All @@ -165,7 +165,7 @@ def __create_tests(self, autotests_for_create: List[AutoTestCreateApiModel]):
def __update_tests(self, autotests_for_update: List[AutoTestUpdateApiModel]):
# logging.debug(f'Updating autotests: {autotests_for_update}')

autotests_for_update = HtmlEscapeUtils.escape_html_in_object(autotests_for_update)
autotests_for_update = HtmlEscapeUtils.escape_html_in_object_list(autotests_for_update)
self.__autotests_api.adapters_auto_tests_bulk_put(auto_test_update_api_model=autotests_for_update)

logging.debug(f'Autotests were updated')
Expand All @@ -174,7 +174,7 @@ def __update_tests(self, autotests_for_update: List[AutoTestUpdateApiModel]):
def __load_test_results(self, test_results: List[AutoTestResultsForTestRunModel]):
# logging.debug(f'Loading test results: {test_results}')

test_results = HtmlEscapeUtils.escape_html_in_object(test_results)
test_results = HtmlEscapeUtils.escape_html_in_object_list(test_results)
self.__test_runs_api.adapters_test_runs_id_test_results_post(
id=self.__test_run_id,
auto_test_results_for_test_run_model=test_results)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ def get_automatic_creation_test_cases(self) -> bool:

@adapter_logger
def set_external_key(self, external_key: str):
self.__external_key = external_key
self.__external_key = HtmlEscapeUtils.escape_html_tags(external_key)

return self

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,12 @@ def escape_html_in_object(obj: Any) -> Any:
return obj

try:
HtmlEscapeUtils._process_object_attributes(obj)
if isinstance(obj, list):
HtmlEscapeUtils._process_list(obj)
elif isinstance(obj, dict):
HtmlEscapeUtils._process_dict(obj)
else:
HtmlEscapeUtils._process_object_attributes(obj)
except Exception as e:
# Silently ignore reflection errors
logging.debug(f"Error processing object attributes: {e}")
Expand All @@ -90,7 +95,7 @@ def escape_html_in_object(obj: Any) -> Any:
@staticmethod
def escape_html_in_object_list(obj_list: Optional[List[Any]]) -> Optional[List[Any]]:
"""
Escapes HTML tags in all string attributes of objects in a list using reflection.
Escapes HTML tags in list items: strings in-place, objects via reflection.
Can be disabled by setting NO_ESCAPE_HTML environment variable to "true".

Args:
Expand All @@ -106,47 +111,68 @@ def escape_html_in_object_list(obj_list: Optional[List[Any]]) -> Optional[List[A
no_escape_html = os.environ.get(HtmlEscapeUtils.NO_ESCAPE_HTML_ENV_VAR, "").lower()
if no_escape_html == "true":
return obj_list

for obj in obj_list:
HtmlEscapeUtils.escape_html_in_object(obj)


HtmlEscapeUtils._process_list(obj_list)
return obj_list

@staticmethod
def _process_dict(obj: dict) -> None:
for key, value in obj.items():
if isinstance(value, str):
obj[key] = HtmlEscapeUtils.escape_html_tags(value)
elif isinstance(value, list):
HtmlEscapeUtils._process_list(value)
elif isinstance(value, dict):
HtmlEscapeUtils._process_dict(value)
elif value is not None and not HtmlEscapeUtils._is_simple_type(type(value)):
HtmlEscapeUtils.escape_html_in_object(value)

@staticmethod
def _iter_object_attr_names(obj: Any):
"""
OpenAPI models store fields in attribute_map/_data_store; they are not listed by dir().
Prefer those maps, fall back to public dir() attributes for plain objects.
"""
attribute_map = getattr(obj, 'attribute_map', None)
if isinstance(attribute_map, dict) and attribute_map:
return attribute_map.keys()

data_store = getattr(obj, '_data_store', None)
if isinstance(data_store, dict) and data_store:
return data_store.keys()

return (
attr_name for attr_name in dir(obj)
if not attr_name.startswith('_') and not callable(getattr(obj, attr_name, None))
)

@staticmethod
def _should_skip_attr(attr_name: str) -> bool:
return (
attr_name.startswith('_')
or attr_name.startswith("external_id")
or attr_name.startswith("externalId")
or attr_name.startswith("auto_test_external_id")
or attr_name.startswith("autoTestExternalId")
)

@staticmethod
def _process_object_attributes(obj: Any) -> None:
"""
Process all attributes of an object for HTML escaping.
"""
# Handle dictionary-like objects (common in API models)
if hasattr(obj, '__dict__'):
for attr_name in dir(obj):
# Skip private/protected attributes and methods
if attr_name.startswith('_') or callable(getattr(obj, attr_name, None)):
continue
if (
attr_name.startswith("external_id")
or attr_name.startswith("externalId")
or attr_name.startswith("auto_test_external_id")
or attr_name.startswith("autoTestExternalId")
):
for attr_name in HtmlEscapeUtils._iter_object_attr_names(obj):
if HtmlEscapeUtils._should_skip_attr(attr_name):
continue

try:
value = getattr(obj, attr_name, None)
if callable(value):
continue

try:
value = getattr(obj, attr_name)
HtmlEscapeUtils._process_attribute_value(obj, attr_name, value)
except Exception as e:
# Silently ignore attribute errors
logging.debug(f"Error processing attribute {attr_name}: {e}")

# Handle dictionary objects
elif isinstance(obj, dict):
for key, value in obj.items():
if isinstance(value, str):
obj[key] = HtmlEscapeUtils.escape_html_tags(value)
elif isinstance(value, list):
HtmlEscapeUtils._process_list(value)
elif not HtmlEscapeUtils._is_simple_type(type(value)):
HtmlEscapeUtils.escape_html_in_object(value)
HtmlEscapeUtils._process_attribute_value(obj, attr_name, value)
except Exception as e:
# Silently ignore attribute errors
logging.debug(f"Error processing attribute {attr_name}: {e}")

@staticmethod
def _process_attribute_value(obj: Any, attr_name: str, value: Any) -> None:
Expand All @@ -162,6 +188,8 @@ def _process_attribute_value(obj: Any, attr_name: str, value: Any) -> None:
pass
elif isinstance(value, list):
HtmlEscapeUtils._process_list(value)
elif isinstance(value, dict):
HtmlEscapeUtils._process_dict(value)
elif value is not None and not HtmlEscapeUtils._is_simple_type(type(value)):
# Process nested objects (but not simple types)
HtmlEscapeUtils.escape_html_in_object(value)
Expand Down
45 changes: 45 additions & 0 deletions testit-python-commons/tests/test_html_escape_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,32 @@ def test_escape_html_in_object_list(self):
self.assertEqual(result[0].description, "&lt;div&gt;content&lt;/div&gt;")
self.assertEqual(result[1].description, "&lt;span&gt;more&lt;/span&gt;")

def test_escape_html_in_object_accepts_list(self):
"""Bulk send path passes a list — must escape each item, not no-op."""
test_list = [
SampleData("First", "<script>alert(1)</script>"),
SampleData("Second", "<img src=x onerror=alert(1)>"),
]
result = HtmlEscapeUtils.escape_html_in_object(test_list)

self.assertEqual(result[0].description, "&lt;script&gt;alert(1)&lt;/script&gt;")
self.assertEqual(result[1].description, "&lt;img src=x onerror=alert(1)&gt;")

def test_escape_html_in_list_of_strings(self):
tags = ["<script>alert(1)</script>", "safe", "<b>x</b>"]
result = HtmlEscapeUtils.escape_html_in_object(tags)

self.assertEqual(result[0], "&lt;script&gt;alert(1)&lt;/script&gt;")
self.assertEqual(result[1], "safe")
self.assertEqual(result[2], "&lt;b&gt;x&lt;/b&gt;")

def test_escape_html_in_list_of_dicts(self):
labels = [{"name": "<script>alert(1)</script>"}, {"name": "ok"}]
result = HtmlEscapeUtils.escape_html_in_object(labels)

self.assertEqual(result[0]["name"], "&lt;script&gt;alert(1)&lt;/script&gt;")
self.assertEqual(result[1]["name"], "ok")

def test_escape_disabled_by_env_var(self):
"""Test that escaping can be disabled via environment variable"""
os.environ[HtmlEscapeUtils.NO_ESCAPE_HTML_ENV_VAR] = "true"
Expand Down Expand Up @@ -131,6 +157,25 @@ def test_escape_html_in_dictionary(self):
self.assertEqual(result["tags"][0], "&lt;tag&gt;")
self.assertEqual(result["tags"][1], "normal_tag")

def test_escape_openapi_model_parameters(self):
"""OpenAPI models hide fields from dir(); parameters must still be escaped."""
from adapters_api.models import AutoTestResultsForTestRunModel, TestStatusType

model = AutoTestResultsForTestRunModel(
configuration_id="cfg",
auto_test_external_id="ext",
status_type=TestStatusType("Succeeded"),
parameters={"description": '<script>alert("1")</script>'},
message='<img src=x onerror=alert(1)>',
)
HtmlEscapeUtils.escape_html_in_object(model)

self.assertEqual(
model.parameters["description"],
'&lt;script&gt;alert("1")&lt;/script&gt;',
)
self.assertEqual(model.message, "&lt;img src=x onerror=alert(1)&gt;")


if __name__ == '__main__':
unittest.main()
Loading
Loading