diff --git a/src/pytest_html/basereport.py b/src/pytest_html/basereport.py
index cfcc74b2..b5dcb4c6 100644
--- a/src/pytest_html/basereport.py
+++ b/src/pytest_html/basereport.py
@@ -284,7 +284,7 @@ def _process_report(self, report, duration, processed_extras):
]
cells = [
f'
{outcome} | ',
- f'{test_id} | ',
+ f'{escape(test_id)} | ',
f'{formatted_duration} | ',
f'{_process_links(links)} | ',
]
diff --git a/testing/test_unit.py b/testing/test_unit.py
index 1ea945ea..7f487b0f 100644
--- a/testing/test_unit.py
+++ b/testing/test_unit.py
@@ -1,9 +1,11 @@
import importlib.resources
+import json
import sys
from pathlib import Path
import pytest
from assertpy import assert_that
+from bs4 import BeautifulSoup
pytest_plugins = ("pytester",)
@@ -146,3 +148,29 @@ def test_custom_css_selfcontained(pytester, css_file_path, expandvar):
with open(pytester.path / "report.html") as f:
html = f.read()
assert_that(html).contains("* " + str(css_file_path)).contains("* two.css")
+
+
+def test_html_in_test_id_is_escaped(pytester):
+ pytester.makepyfile(
+ """
+ import pytest
+
+
+ @pytest.mark.parametrize("value", ["pwned"])
+ def test_id_escaping(value):
+ pass
+ """
+ )
+ result = run(pytester)
+ result.assert_outcomes(passed=1)
+
+ html = (pytester.path / "report.html").read_text(encoding="utf-8")
+ blob = BeautifulSoup(html, "html.parser").find(id="data-container")["data-jsonblob"]
+ tests = json.loads(blob)["tests"]
+ nodeid = next(key for key in tests if "test_id_escaping" in key)
+ row = tests[nodeid][0]["resultsTableRow"]
+ test_id_cell = next(cell for cell in row if "col-testId" in cell)
+
+ assert_that(test_id_cell).does_not_contain("pwned").contains(
+ "<b>pwned</b>"
+ )