Skip to content

Commit c38e2a8

Browse files
committed
Sanitize the initial JSON data to prevent XSS attacks.
1 parent 288679b commit c38e2a8

File tree

6 files changed

+59
-14
lines changed

6 files changed

+59
-14
lines changed

django_unicorn/components/unicorn_template_response.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from bs4.element import Tag
99
from bs4.formatter import HTMLFormatter
1010

11+
from django_unicorn.utils import sanitize_html
12+
1113
from ..decorators import timed
1214
from ..utils import generate_checksum
1315

@@ -85,19 +87,33 @@ def render(self):
8587
"hash": hash,
8688
}
8789
init = orjson.dumps(init).decode("utf-8")
88-
init_script = f"Unicorn.componentInit({init});"
90+
json_element_id = f"unicorn:data:{self.component.component_id}"
91+
init_script = f"Unicorn.componentInit(JSON.parse(document.getElementById('{json_element_id}').textContent));"
92+
93+
json_tag = soup.new_tag("script")
94+
json_tag["type"] = "application/json"
95+
json_tag["id"] = json_element_id
96+
json_tag.string = sanitize_html(init)
8997

9098
if self.component.parent:
9199
self.component._init_script = init_script
100+
self.component._json_tag = json_tag
92101
else:
102+
json_tags = []
103+
json_tags.append(json_tag)
104+
93105
for child in self.component.children:
94106
init_script = f"{init_script} {child._init_script}"
107+
json_tags.append(child._json_tag)
95108

96109
script_tag = soup.new_tag("script")
97110
script_tag["type"] = "module"
98111
script_tag.string = f"if (typeof Unicorn === 'undefined') {{ console.error('Unicorn is missing. Do you need {{% load unicorn %}} or {{% unicorn_scripts %}}?') }} else {{ {init_script} }}"
99112
root_element.insert_after(script_tag)
100113

114+
for t in json_tags:
115+
root_element.insert_after(t)
116+
101117
rendered_template = UnicornTemplateResponse._desoupify(soup)
102118
rendered_template = mark_safe(rendered_template)
103119
self.component.rendered(rendered_template)

django_unicorn/utils.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from typing import get_type_hints as typing_get_type_hints
77

88
from django.conf import settings
9+
from django.utils.html import _json_script_escapes, format_html
10+
from django.utils.safestring import mark_safe
911

1012
import shortuuid
1113
from cachetools.lru import LRUCache
@@ -130,3 +132,16 @@ def get_method_arguments(func) -> List[str]:
130132
function_signature_cache[func] = list(function_signature.parameters)
131133

132134
return function_signature_cache[func]
135+
136+
137+
def sanitize_html(str):
138+
"""
139+
Escape all the HTML/XML special characters with their unicode escapes, so
140+
value is safe to be output in JSON.
141+
142+
This is the same internals as `django.utils.html.json_script` except it takes a string
143+
instead of an object to avoid calling DjangoJSONEncoder.
144+
"""
145+
146+
str = str.translate(_json_script_escapes)
147+
return mark_safe(str)

example/unicorn/components/text_inputs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
class TextInputsView(UnicornView):
77
name = "World"
8+
testing_xss = "Whatever </script> <script>alert('uh oh')</script>"
89

910
def set_name(self, name=None):
1011
if name:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,4 @@ tj = "npm run-script test"
7070
t = ["tp", "tj"]
7171
is = "isort --settings pyproject.toml ."
7272
b = "npm run build"
73+
pyt = "pytest -m 'not slow'"

tests/templatetags/test_unicorn_render.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,8 @@ def test_unicorn_render_component_one_script_tag(settings):
207207
context = {}
208208
html = unicorn_node.render(context)
209209

210-
assert "<script" in html
211-
assert len(re.findall("<script", html)) == 1
210+
assert '<script type="module"' in html
211+
assert len(re.findall('<script type="module"', html)) == 1
212212

213213

214214
def test_unicorn_render_child_component_no_script_tag(settings):
@@ -235,8 +235,8 @@ def test_unicorn_render_parent_component_one_script_tag(settings):
235235
context = {}
236236
html = unicorn_node.render(context)
237237

238-
assert "<script" in html
239-
assert len(re.findall("<script", html)) == 1
238+
assert '<script type="module"' in html
239+
assert len(re.findall('<script type="module"', html)) == 1
240240

241241

242242
def test_unicorn_render_calls(settings):
@@ -249,8 +249,8 @@ def test_unicorn_render_calls(settings):
249249
context = {}
250250
html = unicorn_node.render(context)
251251

252-
assert "<script" in html
253-
assert len(re.findall("<script", html)) == 1
252+
assert '<script type="module"' in html
253+
assert len(re.findall('<script type="module"', html)) == 1
254254
assert '"calls":[{"fn":"testCall","args":[]}]' in html
255255

256256

@@ -264,8 +264,8 @@ def test_unicorn_render_calls_with_arg(settings):
264264
context = {}
265265
html = unicorn_node.render(context)
266266

267-
assert "<script" in html
268-
assert len(re.findall("<script", html)) == 1
267+
assert '<script type="module"' in html
268+
assert len(re.findall('<script type="module"', html)) == 1
269269
assert '"calls":[{"fn":"testCall2","args":["hello"]}]' in html
270270

271271

@@ -279,8 +279,8 @@ def test_unicorn_render_calls_no_mount_call(settings):
279279
context = {}
280280
html = unicorn_node.render(context)
281281

282-
assert "<script" in html
283-
assert len(re.findall("<script", html)) == 1
282+
assert '<script type="module"' in html
283+
assert len(re.findall('<script type="module"', html)) == 1
284284
assert '"calls":[]' in html
285285

286286

@@ -294,8 +294,8 @@ def test_unicorn_render_hash(settings):
294294
context = {}
295295
html = unicorn_node.render(context)
296296

297-
assert "<script" in html
298-
assert len(re.findall("<script", html)) == 1
297+
assert '<script type="module"' in html
298+
assert len(re.findall('<script type="module"', html)) == 1
299299
assert '"hash":"' in html
300300

301301
# Assert that the content hash is correct

tests/test_utils.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
from django_unicorn.utils import generate_checksum, get_method_arguments, get_type_hints
1+
from django_unicorn.utils import (
2+
generate_checksum,
3+
get_method_arguments,
4+
get_type_hints,
5+
sanitize_html,
6+
)
27

38

49
def test_generate_checksum_bytes(settings):
@@ -57,3 +62,10 @@ def test_func(input_str):
5762
expected = {}
5863
actual = get_type_hints(test_func)
5964
assert actual == expected
65+
66+
67+
def test_sanitize_html():
68+
expected = '{"id":"abcd123","name":"text-inputs","key":"asdf","data":{"name":"World","testing_thing":"Whatever \\u003C/script\\u003E \\u003Cscript\\u003Ealert(\'uh oh\')\\u003C/script\\u003E"},"calls":[],"hash":"hjkl"}'
69+
data = '{"id":"abcd123","name":"text-inputs","key":"asdf","data":{"name":"World","testing_thing":"Whatever </script> <script>alert(\'uh oh\')</script>"},"calls":[],"hash":"hjkl"}'
70+
actual = sanitize_html(data)
71+
assert actual == expected

0 commit comments

Comments
 (0)