-
Notifications
You must be signed in to change notification settings - Fork 19
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'html_field' into health_master
- Loading branch information
Showing
8 changed files
with
328 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from .html_field import HtmlField # noqa: F401 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
from typing import List | ||
|
||
from django.db.models import TextField | ||
from html.parser import HTMLParser | ||
from django.core.exceptions import ValidationError | ||
from django.utils.translation import gettext as _ | ||
|
||
ALLOWED_LINK_PREFIXES = [ | ||
'http://', | ||
'https://', | ||
'mailto:' | ||
] | ||
|
||
|
||
def link_rel_validator(tag, attribute_name, attribute_value) -> List[ValidationError]: | ||
validation_errors = [] | ||
|
||
rels = attribute_value.split(' ') | ||
|
||
if 'noopener' not in rels: | ||
|
||
validation_errors.append(ValidationError( | ||
_('Link needs rel="noopener"'), | ||
code='invalid_attribute', | ||
params={ | ||
'tag': tag, | ||
}, | ||
)) | ||
|
||
if 'noreferrer' not in rels: | ||
validation_errors.append(ValidationError( | ||
_('Link needs rel="noreferer"'), | ||
code='invalid_attribute', | ||
params={ | ||
'tag': tag, | ||
}, | ||
)) | ||
|
||
|
||
return validation_errors | ||
|
||
|
||
def link_validator(tag, attribute_name, attribute_value) -> List[ValidationError]: | ||
validation_errors = [] | ||
if not any(map(lambda prefix: attribute_value.startswith(prefix), ALLOWED_LINK_PREFIXES)): | ||
validation_errors.append(ValidationError( | ||
_('Link is not valid'), | ||
code='invalid_attribute', | ||
params={ | ||
'tag': tag, | ||
}, | ||
)) | ||
return validation_errors | ||
|
||
|
||
class HtmlValidator(HTMLParser): | ||
allowed_tags = [ | ||
# General setup | ||
'p', 'br', | ||
# Headers | ||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'h7', | ||
|
||
# text decoration | ||
'b', 'strong', 'i', 'em', 'u', | ||
# Lists | ||
'ol', 'ul', 'li', | ||
|
||
# Special | ||
'a', | ||
] | ||
|
||
allowed_attributes = { | ||
'a': ['href', 'rel', 'target'] | ||
} | ||
|
||
required_attributes = { | ||
'a': ['rel'], | ||
} | ||
|
||
special_validators = { | ||
('a', 'href'): link_validator, | ||
('a', 'rel'): link_rel_validator, | ||
} | ||
|
||
error_messages = { | ||
'invalid_tag': _('Tag %(tag)s is not allowed'), | ||
'missing_attribute': _('Attribute %(attribute)s is required for tag %(tag)s'), | ||
'invalid_attribute': _('Attribute %(attribute)s not allowed for tag %(tag)s'), | ||
} | ||
|
||
def validate(self, value: str) -> List[ValidationError]: | ||
""" | ||
Validates html, and gives a list of validation errors | ||
""" | ||
|
||
self.errors = [] | ||
|
||
self.feed(value) | ||
|
||
return self.errors | ||
|
||
def handle_starttag(self, tag: str, attrs: list) -> None: | ||
tag_errors = [] | ||
if tag not in self.allowed_tags: | ||
tag_errors.append(ValidationError( | ||
self.error_messages['invalid_tag'], | ||
code='invalid_tag', | ||
params={ | ||
'tag': tag | ||
}, | ||
)) | ||
|
||
set_attributes = set(map(lambda attr: attr[0], attrs)) | ||
required_attributes = set(self.required_attributes.get(tag, [])) | ||
missing_attributes = required_attributes - set_attributes | ||
for missing_attribute in missing_attributes: | ||
tag_errors.append( | ||
ValidationError( | ||
self.error_messages['missing_attribute'], | ||
code='missing_attribute', | ||
params={ | ||
'tag': tag, | ||
'attribute': missing_attribute | ||
}, | ||
) | ||
) | ||
|
||
allowed_attributes_for_tag = self.allowed_attributes.get(tag, []) | ||
|
||
for (attribute_name, attribute_content) in attrs: | ||
if attribute_name not in allowed_attributes_for_tag: | ||
tag_errors.append(ValidationError( | ||
self.error_messages['invalid_attribute'], | ||
code='invalid_attribute', | ||
params={ | ||
'tag': tag, | ||
'attribute': attribute_name | ||
}, | ||
)) | ||
if (tag, attribute_name) in self.special_validators: | ||
tag_errors += self.special_validators[(tag, attribute_name)](tag, attribute_name, attribute_content) | ||
|
||
self.errors += tag_errors | ||
|
||
|
||
class HtmlField(TextField): | ||
""" | ||
Determine a safe way to save "secure" user provided HTML input, and prevent XSS injections | ||
""" | ||
|
||
def validate(self, value: str, _): | ||
# Validate all html tags | ||
validator = HtmlValidator() | ||
errors = validator.validate(value) | ||
|
||
if errors: | ||
raise ValidationError(errors) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
# HTML Field | ||
|
||
The HTML field provides a django model field optimized for user posted HTML code. Its aim is to provide a safe | ||
way to implement a CMS system, where the end user can create pages, but cannot do XSS injections. | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,142 @@ | ||
from django.contrib.auth.models import User | ||
from django.test import TestCase, Client | ||
|
||
import json | ||
from .testapp.models import Zoo, WebPage | ||
|
||
|
||
class HtmlFieldTestCase(TestCase): | ||
|
||
def setUp(self): | ||
super().setUp() | ||
u = User(username='testuser', is_active=True, is_superuser=True) | ||
u.set_password('test') | ||
u.save() | ||
self.client = Client() | ||
r = self.client.login(username='testuser', password='test') | ||
self.assertTrue(r) | ||
|
||
self.zoo = Zoo(name='Apenheul') | ||
self.zoo.save() | ||
|
||
self.webpage = WebPage.objects.create(zoo=self.zoo, content='') | ||
|
||
|
||
|
||
def test_save_normal_text_ok(self): | ||
response = self.client.put(f'/web_page/{self.webpage.id}/', data=json.dumps({'content': 'Artis'})) | ||
self.assertEqual(response.status_code, 200) | ||
|
||
def test_simple_html_is_ok(self): | ||
response = self.client.put(f'/web_page/{self.webpage.id}/', | ||
data=json.dumps({'content': '<h1>Artis</h1><b><p>Artis is a zoo in amsterdam</a>'})) | ||
self.assertEqual(response.status_code, 200) | ||
|
||
def test_wrong_attribute_not_ok(self): | ||
response = self.client.put(f'/web_page/{self.webpage.id}/', | ||
data=json.dumps({'content': '<b onclick="">test</b>'})) | ||
self.assertEqual(response.status_code, 400) | ||
|
||
parsed_response = json.loads(response.content) | ||
self.assertEqual('ValidationError', parsed_response['code']) | ||
self.assertEqual('invalid_attribute', parsed_response['errors']['web_page'][f'{self.webpage.id}']['content'][0]['code']) | ||
|
||
def test_simple_link_is_ok(self): | ||
response = self.client.put(f'/web_page/{self.webpage.id}/', data=json.dumps( | ||
{'content': '<a href="https://www.artis.nl/en/" rel="noreferrer noopener">Visit artis website</a>'})) | ||
|
||
self.assertEqual(response.status_code, 200) | ||
|
||
|
||
|
||
def test_javascript_link_is_not_ok(self): | ||
response = self.client.put(f'/web_page/{self.webpage.id}/', | ||
data=json.dumps({ | ||
'content': '<a href="javascrt:alert(document.cookie)" rel="noreferrer noopener">Visit artis website</a>'})) | ||
self.assertEqual(response.status_code, 400) | ||
|
||
parsed_response = json.loads(response.content) | ||
self.assertEqual('ValidationError', parsed_response['code']) | ||
|
||
self.assertEqual('invalid_attribute', parsed_response['errors']['web_page'][f'{self.webpage.id}']['content'][0]['code']) | ||
|
||
|
||
|
||
def test_script_is_not_ok(self): | ||
response = self.client.put(f'/web_page/{self.webpage.id}/', | ||
data=json.dumps({'content': '<script>alert(\'hoi\');</script>'})) | ||
|
||
self.assertEqual(response.status_code, 400) | ||
|
||
parsed_response = json.loads(response.content) | ||
self.assertEqual('ValidationError', parsed_response['code']) | ||
self.assertEqual('invalid_tag', parsed_response['errors']['web_page'][f'{self.webpage.id}']['content'][0]['code']) | ||
|
||
def test_script_is_not_ok_nested(self): | ||
response = self.client.put(f'/web_page/{self.webpage.id}/', | ||
data=json.dumps({'content': '<b><script>alert(\'hoi\');</script></b>'})) | ||
self.assertEqual(response.status_code, 400) | ||
|
||
parsed_response = json.loads(response.content) | ||
self.assertEqual('ValidationError', parsed_response['code']) | ||
self.assertEqual('invalid_tag', parsed_response['errors']['web_page'][f'{self.webpage.id}']['content'][0]['code']) | ||
|
||
|
||
def test_can_handle_reallife_data(self): | ||
""" | ||
This is the worst case that we could produce on the WYIWYG edittor | ||
""" | ||
content = '<p>normal text</p><p><br></p><h1>HEADing 1</h1><p><br></p><h2>HEADING 2</h2><h3><br></h3><h3>HEADING 3</h3><p><br></p><p><strong>bold</strong></p><p><br></p><p><em>italic</em></p><p><br></p><p><u>underlined</u></p><p><br></p><p><a href=\"http://codeyellow.nl\" rel=\"noopener noreferrer\" target=\"_blank\">Link</a></p><p><br></p><ol><li>ol1</li><li>ol2</li></ol><ul><li>ul1</li><li>ul2</li></ul><p><br></p><p>subscripttgege</p><p>g</p>"' | ||
response = self.client.put(f'/web_page/{self.webpage.id}/', | ||
data=json.dumps({'content': content})) | ||
|
||
self.assertEqual(response.status_code, 200) | ||
|
||
def test_multiple_errors(self): | ||
response = self.client.put(f'/web_page/{self.webpage.id}/', | ||
data=json.dumps({ | ||
'content': '<foo><bar>Visit artis website</foo></bar>'})) | ||
self.assertEqual(response.status_code, 400) | ||
|
||
parsed_response = json.loads(response.content) | ||
self.assertEqual('ValidationError', parsed_response['code']) | ||
|
||
|
||
self.assertEqual('invalid_tag', | ||
parsed_response['errors']['web_page'][f'{self.webpage.id}']['content'][0]['code']) | ||
self.assertEqual('invalid_tag', | ||
parsed_response['errors']['web_page'][f'{self.webpage.id}']['content'][1]['code']) | ||
|
||
|
||
def test_link_no_rel_errors(self): | ||
response = self.client.put(f'/web_page/{self.webpage.id}/', | ||
data=json.dumps({'content': '<a href="https://codeyellow.nl">bla</a>'})) | ||
self.assertEqual(response.status_code, 400) | ||
|
||
parsed_response = json.loads(response.content) | ||
|
||
self.assertEqual('ValidationError', parsed_response['code']) | ||
self.assertEqual('missing_attribute', | ||
parsed_response['errors']['web_page'][f'{self.webpage.id}']['content'][0]['code']) | ||
|
||
def test_link_noopener_required(self): | ||
response = self.client.put(f'/web_page/{self.webpage.id}/', | ||
data=json.dumps({'content': '<a href="https://codeyellow.nl" rel="noreferrer">bla</a>'})) | ||
self.assertEqual(response.status_code, 400) | ||
|
||
parsed_response = json.loads(response.content) | ||
|
||
self.assertEqual('ValidationError', parsed_response['code']) | ||
self.assertEqual('invalid_attribute', | ||
parsed_response['errors']['web_page'][f'{self.webpage.id}']['content'][0]['code']) | ||
|
||
def test_link_noreferrer_required(self): | ||
response = self.client.put(f'/web_page/{self.webpage.id}/', | ||
data=json.dumps({'content': '<a href="https://codeyellow.nl" rel="noopener">bla</a>'})) | ||
self.assertEqual(response.status_code, 400) | ||
|
||
parsed_response = json.loads(response.content) | ||
|
||
self.assertEqual('ValidationError', parsed_response['code']) | ||
self.assertEqual('invalid_attribute', | ||
parsed_response['errors']['web_page'][f'{self.webpage.id}']['content'][0]['code']) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
|
||
from binder.models import BinderModel | ||
from django.db import models | ||
|
||
from binder.plugins.models import HtmlField | ||
|
||
|
||
class WebPage(BinderModel): | ||
""" | ||
Every zoo has a webpage containing some details about the zoo | ||
""" | ||
zoo = models.OneToOneField('Zoo', related_name='web_page', on_delete=models.CASCADE) | ||
content = HtmlField() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
from binder.views import ModelView | ||
|
||
from ..models import WebPage | ||
|
||
# From the api docs | ||
class WebPageView(ModelView): | ||
model = WebPage |