[IMP] *: script-safe JSON serialisation

* add a json_scriptsafe module, which re-exports the stdlib's json but
provides a script-safe `dumps` function, because of the way it works
the script-safe version is fine / safe to use outside script
contexts, so there's no need to provide different functions to qweb
contexts
* make the json.dumps in qweb contexts <script>-safe (should be
rendered using t-raw)
* update callsites to properly dump json:
- dump in template, not in the python code
- dump the entire structure at once, not bits and bobs inside a JSON
literal

Task 1999158

closes odoo/odoo#33682

Signed-off-by: Xavier Morel (xmo) <xmo@odoo.com>
This commit is contained in:
Xavier Morel
2019-06-19 13:47:35 +00:00
parent 411abff388
commit 05746b7068
8 changed files with 78 additions and 11 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ class PosController(http.Controller):
return werkzeug.utils.redirect('/web#action=point_of_sale.action_client_pos_menu')
pos_sessions.login()
context = {
'session_info': json.dumps(request.env['ir.http'].session_info())
'session_info': request.env['ir.http'].session_info()
}
return request.render('point_of_sale.index', qcontext=context)
+5 -5
View File
@@ -28,11 +28,11 @@
<link rel="shortcut icon" href="/web/static/src/img/favicon.ico" type="image/x-icon"/>
<script type="text/javascript">
var odoo = {
csrf_token: "<t t-esc="request.csrf_token(None)"/>",
session_info: <t t-raw="session_info"/>,
debug: "<t t-esc="debug"/>",
};
var odoo = <t t-raw="json.dumps({
'csrf_token': request.csrf_token(None),
'session_info': session_info,
'debug': debug,
})"/>;
</script>
<t t-call-assets="web.assets_common" t-css="false"/>
+1 -1
View File
@@ -18,7 +18,7 @@ class Http(models.AbstractModel):
def webclient_rendering_context(self):
return {
'menu_data': request.env['ir.ui.menu'].load_menus(request.session.debug),
'session_info': json.dumps(self.session_info()),
'session_info': self.session_info(),
}
def session_info(self):
+1 -1
View File
@@ -833,7 +833,7 @@
<t t-call="web.layout">
<t t-set="head_web">
<script type="text/javascript">
odoo.session_info = <t t-raw="session_info"/>;
odoo.session_info = <t t-raw="json.dumps(session_info)"/>;
odoo.reloadMenus = function () {
return new Promise(function(resolve, reject) {
function onLoaded() {
+2 -3
View File
@@ -17,7 +17,6 @@ from functools import partial
from difflib import HtmlDiff
from operator import itemgetter
import json
import werkzeug
from lxml import etree
from lxml.etree import LxmlError
@@ -30,7 +29,7 @@ from odoo.modules.module import get_resource_from_path, get_resource_path
from odoo.osv import orm
from odoo.tools import config, graph, ConstantMapping, SKIPPED_ELEMENT_TYPES, pycompat
from odoo.tools.convert import _fix_multiple_roots
from odoo.tools.parse_version import parse_version
from odoo.tools.json import scriptsafe as json_scriptsafe
from odoo.tools.safe_eval import safe_eval
from odoo.tools.view_validation import valid_view
from odoo.tools.translate import xml_translate, TRANSLATED_ATTRS
@@ -1358,7 +1357,7 @@ actual arch.
request=request, # might be unbound if we're not in an httprequest context
debug=request.session.debug if request else '',
test_mode_enabled=bool(config['test_enable'] or config['test_file']),
json=json,
json=json_scriptsafe,
quote_plus=werkzeug.url_quote_plus,
time=time,
datetime=datetime,
+17
View File
@@ -75,6 +75,23 @@ class TestQWebTField(TransactionCase):
text = etree.fromstring(view1.render()).find('span').text
self.assertEqual(text, u'5.0000')
def test_xss_breakout(self):
view = self.env['ir.ui.view'].create({
'name': 'dummy', 'type': 'qweb',
'arch': u"""
<t t-name="base.dummy">
<root>
<script type="application/javascript">
var s = <t t-raw="json.dumps({'key': malicious})"/>;
</script>
</root>
</t>
"""
})
rendered = view.render({'malicious': '1</script><script>alert("pwned")</script><script>'}).decode()
self.assertIn('alert', rendered, "%r doesn't seem to be rendered" % rendered)
doc = etree.fromstring(rendered)
self.assertEqual(len(doc.xpath('//script')), 1)
class TestQWebNS(TransactionCase):
def test_render_static_xml_with_namespace(self):
+1
View File
@@ -0,0 +1 @@
# -*- coding: utf-8 -*-
+50
View File
@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
import json as json_
import re
from json import *
__all__ = ['dumps', 'loads']
JSON_SCRIPTSAFE_MAPPER = {
'&': r'\u0026',
'<': r'\u003c',
'>': r'\u003e',
'\u2028': r'\u2028',
'\u2029': r'\u2029'
}
def dumps(*args, **kwargs):
""" JSON used as JS in HTML (script tags) is problematic: <script>
tags are a special context which only waits for </script> but doesn't
interpret anything else, this means standard htmlescaping does not
work (it breaks double quotes, and e.g. `<` will become `&lt;` *in
the resulting JSON/JS* not just inside the page).
However, failing to escape embedded json means the json strings could
contains `</script>` and thus become XSS vector.
The solution turns out to be very simple: use JSON-level unicode
escapes for HTML-unsafe characters (e.g. "<" -> "\u003C". This removes
the XSS issue without breaking the json, and there is no difference to
the end result once it's been parsed back from JSON. So it will work
properly even for HTML attributes or raw text.
Also handle U+2028 and U+2029 the same way just in case as these are
interpreted as newlines in javascript but not in JSON, which could
lead to oddities and issues.
.. warning::
except inside <script> elements, this should be escaped following
the normal rules of the containing format
Cf https://code.djangoproject.com/ticket/17419#comment:27
"""
# replacement can be done straight in the serialised JSON as the
# problematic characters are not JSON metacharacters (and can thus
# only occur in strings)
return re.sub(
r'[<>&\u2028\u2029]',
lambda m: JSON_SCRIPTSAFE_MAPPER[m[0]],
json_.dumps(*args, **kwargs),
)