Skip to content

Commit 5a42641

Browse files
committed
Security: escape HTML-breakout sequences in injected JSON-LD
The MkDocs hook embedded schema.org Dataset JSON-LD into page HTML via json.dumps, which does not escape < > &. A concept whose title/description/ tags contained a literal </script> could close the JSON-LD block and inject executable markup — stored XSS on any site built from a bundle with third-party-contributed concepts (the tutorial ships this hook as a template to copy). Escape < > & to their \uXXXX JSON forms (still valid JSON-LD). Regression test in tests/test_hooks.py.
1 parent dfe2cf9 commit 5a42641

2 files changed

Lines changed: 41 additions & 2 deletions

File tree

docs/hooks/lokf_hooks.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,27 @@ def _graph_json() -> str:
5353
return _cache["graph_json"]
5454

5555

56+
def _ldjson_script(doc: dict) -> str:
57+
"""A JSON-LD ``<script>`` block, escaped so string content can't break out.
58+
59+
``json.dumps`` does not escape ``<``/``>``/``&``, so a concept field
60+
containing ``</script>`` would otherwise terminate the block and inject
61+
markup into the page. The ``\\uXXXX`` forms stay valid JSON, so the
62+
JSON-LD remains parseable.
63+
"""
64+
payload = (
65+
json.dumps(doc)
66+
.replace("<", "\\u003c")
67+
.replace(">", "\\u003e")
68+
.replace("&", "\\u0026")
69+
)
70+
return '<script type="application/ld+json">' + payload + "</script>"
71+
72+
5673
def _jsonld_blocks() -> str:
5774
if "jsonld_blocks" not in _cache:
5875
_cache["jsonld_blocks"] = "\n".join(
59-
'<script type="application/ld+json">' + json.dumps(doc) + "</script>"
60-
for doc in dataset_search_jsonld(_bundle())
76+
_ldjson_script(doc) for doc in dataset_search_jsonld(_bundle())
6177
)
6278
return _cache["jsonld_blocks"]
6379

tests/test_hooks.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""The JSON-LD injection escapes HTML-breakout sequences."""
2+
import importlib.util
3+
import pathlib
4+
5+
ROOT = pathlib.Path(__file__).parent.parent
6+
_spec = importlib.util.spec_from_file_location(
7+
"lokf_hooks", ROOT / "docs" / "hooks" / "lokf_hooks.py"
8+
)
9+
hooks = importlib.util.module_from_spec(_spec)
10+
_spec.loader.exec_module(hooks)
11+
12+
13+
def test_ldjson_script_escapes_script_breakout():
14+
doc = {"name": "</script><script>alert(1)</script>", "@type": "Dataset"}
15+
block = hooks._ldjson_script(doc)
16+
# The raw closing tag must not survive inside the payload.
17+
inner = block[len('<script type="application/ld+json">'):-len("</script>")]
18+
assert "</script>" not in inner
19+
assert "<" not in inner and ">" not in inner
20+
# Still valid JSON, and round-trips back to the original string.
21+
import json
22+
23+
assert json.loads(inner)["name"] == "</script><script>alert(1)</script>"

0 commit comments

Comments
 (0)