Skip to content

Commit 69108fb

Browse files
authored
Merge pull request #378 from peopledoc/prevent-xss
Prevent XSS attacks by enabling a cleanup method
2 parents aabd8b5 + 4fc59d9 commit 69108fb

File tree

12 files changed

+395
-3
lines changed

12 files changed

+395
-3
lines changed

CHANGELOG.rst

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ master (unreleased)
66
===================
77

88
- Drop support for Python 2.7 (EOL: January 1st, 2020)
9+
- Added an XSS prevention mechanism. See the `security documentation <https://django-formidable.readthedocs.io/en/master/>`_ for more information and details on how to setup your own sanitization process.
910
- Removed ``tox.ini`` directive that skipped missing Python interpreters.
1011

1112
Release 3.3.0 (2019-11-29)

demo/demo/security.py

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""
2+
Dummy security module. Provides fake security functions
3+
"""
4+
import bleach
5+
6+
BLEACH_VALID_TAGS = ['p', 'b', 'strong', 'em', 'i', 'u', 'strike', 'ul', 'li',
7+
'ol', 'br', 'span', 'blockquote', 'hr', 'a', 'img', 'h1',
8+
'h2', 'h3', 'h4', 'h5', 'h6', 'table', 'caption', 'th',
9+
'tr', 'td', 'tbody']
10+
BLEACH_VALID_ATTRS = {
11+
'span': ['style', ],
12+
'p': ['align', 'style'],
13+
'a': ['href', 'rel', 'target', 'name'],
14+
'img': ['src', 'alt', 'style'],
15+
'div': ['style', ],
16+
}
17+
BLEACH_VALID_STYLES = ['color', 'cursor', 'float', 'margin', 'width',
18+
'background-color']
19+
20+
21+
def clean(obj):
22+
"""
23+
Use tags from settings to bleach an object.
24+
"""
25+
return bleach.clean(
26+
obj,
27+
BLEACH_VALID_TAGS,
28+
BLEACH_VALID_ATTRS,
29+
BLEACH_VALID_STYLES,
30+
)
31+
32+
33+
def clean_alert(input_string):
34+
"""
35+
Fake cleaner. Will remove "alert" in the input_string
36+
"""
37+
return input_string.replace("alert", "")

demo/demo/settings.py

+3
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,6 @@
133133
LOCALE_PATHS = [
134134
os.path.join(BASE_DIR, '..', 'formidable', 'locale'),
135135
]
136+
137+
138+
DJANGO_FORMIDABLE_SANITIZE_FUNCTION = "demo.security.clean"

demo/requirements-demo.pip

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ ipdb
33
django-extensions
44
freezegun
55
mock
6+
bleach
67
# added a typing module to fix https://github.com/django-extensions/django-extensions/issues/1176
78
typing
89
# A temporary workaround for DRF-related error:

demo/tests/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"slug": "textslug",
99
"type_id": "text",
1010
"placeholder": None,
11-
"help_text": None,
11+
"description": None,
1212
"default": None,
1313
"accesses": [
1414
{"access_id": "padawan", "level": "REQUIRED"},
@@ -28,7 +28,7 @@
2828
"slug": "dropdown_slug",
2929
"type_id": "dropdown",
3030
"placeholder": None,
31-
"help_text": "Lesfrites c'est bon",
31+
"description": "Lesfrites c'est bon",
3232
"default": None,
3333
"accesses": [
3434
{"access_id": "padawan", "level": "REQUIRED"},

demo/tests/test_integration_xss.py

+229
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import copy
2+
3+
from django.test import TestCase, override_settings
4+
from django.urls import reverse
5+
from django.conf import settings
6+
7+
from rest_framework.test import APITestCase
8+
9+
from formidable.models import Formidable
10+
from formidable.serializers import FormidableSerializer
11+
from formidable.security import get_clean_function
12+
13+
from . import form_data
14+
15+
16+
XSS = """<IMG SRC=/ onerror="alert(String.fromCharCode(88,83,83))"></img>"""
17+
XSS_RESULT = '<img src="/">'
18+
19+
20+
class XSSLoaderTestCase(TestCase):
21+
22+
@override_settings()
23+
def test_no_settings(self):
24+
# Deleting the settings
25+
del settings.DJANGO_FORMIDABLE_SANITIZE_FUNCTION
26+
27+
clean_func = get_clean_function()
28+
assert clean_func(XSS) == XSS
29+
30+
@override_settings(DJANGO_FORMIDABLE_SANITIZE_FUNCTION=None)
31+
def test_none_settings(self):
32+
clean_func = get_clean_function()
33+
assert clean_func(XSS) == XSS
34+
35+
@override_settings(DJANGO_FORMIDABLE_SANITIZE_FUNCTION="foo.bar")
36+
def test_unimportable_settings(self):
37+
clean_func = get_clean_function()
38+
assert clean_func(XSS) == XSS
39+
40+
@override_settings(
41+
DJANGO_FORMIDABLE_SANITIZE_FUNCTION="demo.security.clean_alert")
42+
def test_fake_cleaner_settings(self):
43+
clean_func = get_clean_function()
44+
assert clean_func(XSS) != XSS, clean_func(XSS)
45+
46+
47+
class XSSViewsTestCase(APITestCase):
48+
49+
def test_create_label_via_view(self):
50+
_form_data = copy.deepcopy(form_data)
51+
_form_data['label'] = XSS
52+
res = self.client.post(
53+
reverse('formidable:form_create'), _form_data, format='json'
54+
)
55+
self.assertEquals(res.status_code, 201)
56+
formidable = Formidable.objects.order_by('pk').last()
57+
assert formidable.label == XSS_RESULT
58+
59+
def test_create_description_via_view(self):
60+
_form_data = copy.deepcopy(form_data)
61+
_form_data['description'] = XSS
62+
res = self.client.post(
63+
reverse('formidable:form_create'), _form_data, format='json'
64+
)
65+
self.assertEquals(res.status_code, 201)
66+
formidable = Formidable.objects.order_by('pk').last()
67+
assert formidable.description == XSS_RESULT
68+
69+
def test_create_field_label_via_view(self):
70+
_form_data = copy.deepcopy(form_data)
71+
_form_data['fields'][0]['label'] = XSS
72+
res = self.client.post(
73+
reverse('formidable:form_create'), _form_data, format='json'
74+
)
75+
self.assertEquals(res.status_code, 201)
76+
formidable = Formidable.objects.order_by('pk').last()
77+
field = formidable.fields.first()
78+
assert field.label == XSS_RESULT
79+
80+
def test_create_field_description_via_view(self):
81+
_form_data = copy.deepcopy(form_data)
82+
_form_data['fields'][0]['description'] = XSS
83+
res = self.client.post(
84+
reverse('formidable:form_create'), _form_data, format='json'
85+
)
86+
self.assertEquals(res.status_code, 201)
87+
formidable = Formidable.objects.order_by('pk').last()
88+
field = formidable.fields.first()
89+
# For historical reasons, help_text is mapped to description
90+
assert field.help_text == XSS_RESULT
91+
92+
def test_create_field_defaults_via_view(self):
93+
_form_data = copy.deepcopy(form_data)
94+
_form_data['fields'][0]['defaults'] = [XSS]
95+
res = self.client.post(
96+
reverse('formidable:form_create'), _form_data, format='json'
97+
)
98+
self.assertEquals(res.status_code, 201)
99+
formidable = Formidable.objects.order_by('pk').last()
100+
field = formidable.fields.first()
101+
default = field.defaults.first()
102+
assert default.value == XSS_RESULT
103+
104+
def test_create_field_placeholder_via_view(self):
105+
_form_data = copy.deepcopy(form_data)
106+
_form_data['fields'][0]['placeholder'] = XSS
107+
res = self.client.post(
108+
reverse('formidable:form_create'), _form_data, format='json'
109+
)
110+
self.assertEquals(res.status_code, 201)
111+
formidable = Formidable.objects.order_by('pk').last()
112+
field = formidable.fields.first()
113+
assert field.placeholder == XSS_RESULT
114+
115+
116+
class XSSSerializerTestCase(TestCase):
117+
def test_create_label_via_serializer(self):
118+
_form_data = copy.deepcopy(form_data)
119+
_form_data['label'] = XSS
120+
121+
serializer = FormidableSerializer(data=_form_data)
122+
serializer.is_valid()
123+
serializer.save()
124+
formidable = serializer.instance
125+
assert formidable.label == XSS_RESULT
126+
127+
def test_create_description_via_serializer(self):
128+
_form_data = copy.deepcopy(form_data)
129+
_form_data['description'] = XSS
130+
131+
serializer = FormidableSerializer(data=_form_data)
132+
serializer.is_valid()
133+
serializer.save()
134+
formidable = serializer.instance
135+
assert formidable.description == XSS_RESULT
136+
137+
def test_create_field_label_via_serializer(self):
138+
_form_data = copy.deepcopy(form_data)
139+
_form_data['fields'][0]['label'] = XSS
140+
141+
serializer = FormidableSerializer(data=_form_data)
142+
serializer.is_valid()
143+
serializer.save()
144+
formidable = serializer.instance
145+
field = formidable.fields.first()
146+
assert field.label == XSS_RESULT
147+
148+
def test_create_field_description_via_serializer(self):
149+
_form_data = copy.deepcopy(form_data)
150+
_form_data['fields'][0]['description'] = XSS
151+
152+
serializer = FormidableSerializer(data=_form_data)
153+
serializer.is_valid()
154+
serializer.save()
155+
formidable = serializer.instance
156+
field = formidable.fields.first()
157+
# For historical reasons, help_text is mapped to description
158+
assert field.help_text == XSS_RESULT
159+
160+
def test_create_field_defaults_via_serializer(self):
161+
_form_data = copy.deepcopy(form_data)
162+
_form_data['fields'][0]['defaults'] = [XSS]
163+
164+
serializer = FormidableSerializer(data=_form_data)
165+
serializer.is_valid()
166+
serializer.save()
167+
formidable = serializer.instance
168+
field = formidable.fields.first()
169+
default = field.defaults.first()
170+
assert default.value == XSS_RESULT
171+
172+
def test_create_field_placeholder_via_serializer(self):
173+
_form_data = copy.deepcopy(form_data)
174+
_form_data['fields'][0]['placeholder'] = XSS
175+
176+
serializer = FormidableSerializer(data=_form_data)
177+
serializer.is_valid()
178+
serializer.save()
179+
formidable = serializer.instance
180+
field = formidable.fields.first()
181+
assert field.placeholder == XSS_RESULT
182+
183+
184+
class XSSInstructionFieldTestCase(APITestCase):
185+
"""
186+
Tests for XSS on Instruction fields.
187+
188+
The Instruction fields are an important attack vector, because they often
189+
carry HTML that has to be interpreted in the integration application.
190+
"""
191+
def test_create_field_via_serializer(self):
192+
_form_data = copy.deepcopy(form_data)
193+
BASE_INSTRUCTIONS = "<p>Instructions to fill the form</p>\n"
194+
_form_data['fields'][0] = {
195+
"validations": [],
196+
"slug": "instructions",
197+
"description": BASE_INSTRUCTIONS + XSS,
198+
"placeholder": None,
199+
"type_id": "help_text",
200+
"defaults": [],
201+
"accesses": []
202+
}
203+
204+
serializer = FormidableSerializer(data=_form_data)
205+
serializer.is_valid()
206+
serializer.save()
207+
formidable = serializer.instance
208+
field = formidable.fields.first()
209+
assert field.help_text == BASE_INSTRUCTIONS + XSS_RESULT
210+
211+
def test_create_field_via_view(self):
212+
_form_data = copy.deepcopy(form_data)
213+
BASE_INSTRUCTIONS = "<p>Instructions to fill the form</p>\n"
214+
_form_data['fields'] = [{
215+
"validations": [],
216+
"slug": "instructions",
217+
"description": BASE_INSTRUCTIONS + XSS,
218+
"placeholder": None,
219+
"type_id": "help_text",
220+
"defaults": [],
221+
"accesses": []
222+
}]
223+
res = self.client.post(
224+
reverse('formidable:form_create'), _form_data, format='json'
225+
)
226+
self.assertEquals(res.status_code, 201)
227+
formidable = Formidable.objects.order_by('pk').last()
228+
field = formidable.fields.first()
229+
assert field.help_text == BASE_INSTRUCTIONS + XSS_RESULT

docs/source/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Contents:
1111
forms
1212
form-specs
1313
api
14+
security
1415
callbacks
1516
dev
1617
translations

docs/source/security.rst

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
==============
2+
Security setup
3+
==============
4+
5+
As any other web application, Django Formidable might be targeted by pirates who would try to inject SQL or malicious code through Javascript or any other XSS method.
6+
7+
How to secure your django-formidable installation
8+
=================================================
9+
10+
Add the following settings: ``DJANGO_FORMIDABLE_SANITIZE_FUNCTION``. It should be a string that points at a function.
11+
12+
.. important::
13+
14+
We highly recommend to use `bleach <https://pypi.org/project/bleach/>`_, with dedicated adjustments in order to make sure you're sanitizing your content in a proper way.
15+
16+
See `bleach documentation <https://bleach.readthedocs.io/en/latest/>`_ for creating your own parameters when calling the ``clean()`` function.
17+
18+
Example
19+
=======
20+
21+
In your :file:`settings.py`, add the following:
22+
23+
.. code-block:: python
24+
25+
DJANGO_FORMIDABLE_SANITIZE_FUNCTION = "path.to.module.clean_func"
26+
27+
And then in the :file:`path/to/module.py` module, add a function that would look like this:
28+
29+
.. code-block:: python
30+
31+
import bleach
32+
33+
def clean_func(obj):
34+
"""
35+
Sanitize API text content
36+
"""
37+
return bleach.clean(obj, strip=True)
38+
39+
.. warning::
40+
41+
If you don't add this settings or if its value is not importable (typo, missing PYTHONPATH, etc.):
42+
43+
* an error log will be raised,
44+
* django-formidable won't sanitize your contents for you.
45+
46+
Secured fields
47+
==============
48+
49+
* Form label & description,
50+
* Field label, description (help text), defaults, placeholder.

formidable/security.py

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import logging
2+
3+
from django.conf import settings
4+
from django.utils.module_loading import import_string
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
def get_clean_function(*args, **kwargs):
10+
11+
def _no_clean(s):
12+
return s
13+
14+
if not hasattr(settings, 'DJANGO_FORMIDABLE_SANITIZE_FUNCTION'):
15+
return _no_clean
16+
if not settings.DJANGO_FORMIDABLE_SANITIZE_FUNCTION:
17+
return _no_clean
18+
19+
try:
20+
clean_function = import_string(
21+
settings.DJANGO_FORMIDABLE_SANITIZE_FUNCTION
22+
)
23+
except ImportError:
24+
logger.error("This application has no sanitization function. "
25+
"There's a risk of XSS attack")
26+
clean_function = _no_clean
27+
28+
return clean_function

0 commit comments

Comments
 (0)