[IMP] web, *: remove explicit references to session_id

Before this rev. the `session_id` was provided by the server to the webclient (in
session_info, mostly) and was stored and accessible. This made XSS injection more
dangerous than they should be as it was very easy to steal the `session_id`.

As the browser automatically set the `session_id` on every request to the server,
the webclient shouldn't need any explicit reference.

Since rev. odoo/odoo@f4d541e the `session_id` cookie uses the `httponly` flag so
it cannot be accessed through client side script.

Task 1905359

closes odoo/odoo#30098
This commit is contained in:
Robot Odoo
2019-02-13 14:16:25 +01:00
committed by GitHub
18 changed files with 49 additions and 253 deletions
@@ -3,8 +3,6 @@
<t t-set="_id" t-value="_.uniqueId('export')"/>
<form action="" method="post" enctype="multipart/form-data" class="oe_import">
<input type="hidden" name="csrf_token" t-att-value="csrf_token"/>
<input type="hidden" name="session_id"
t-att-value="widget.session.session_id"/>
<input type="hidden" name="import_id"/>
<div class="oe_import_box col-lg-12 d-none">
<input accept=".csv, .xls, .xlsx, .xlsm, .ods" t-attf-id="file_#{_id}"
+1 -1
View File
@@ -27,7 +27,7 @@ class BusController(Controller):
request._cr = None
return dispatch.poll(dbname, channels, last, options)
@route('/longpolling/poll', type="json", auth="public")
@route('/longpolling/poll', type="json", auth="public", cors="*")
def poll(self, channels, last, options=None):
if options is None:
options = {}
+20 -6
View File
@@ -3,13 +3,16 @@
import base64
from odoo import http, _
from odoo import http,tools, _
from odoo.http import request
from odoo.addons.base.models.assetsbundle import AssetsBundle
class LivechatController(http.Controller):
# Note: the `cors` attribute on many routes is meant to allow the livechat
# to be embedded in an external website.
@http.route('/im_livechat/external_lib.<any(css,js):ext>', type='http', auth='none')
def livechat_lib(self, ext, **kwargs):
# _get_asset return the bundle html code (script and link list) but we want to use the attachment content
@@ -26,6 +29,17 @@ class LivechatController(http.Controller):
headers.append(('Content-Length', len(content_base64)))
return request.make_response(content_base64, headers)
@http.route('/im_livechat/load_templates', type='json', auth='none', cors="*")
def load_templates(self, **kwargs):
base_url = request.httprequest.base_url
templates = [
'mail/static/src/xml/abstract_thread_window.xml',
'mail/static/src/xml/discuss.xml',
'mail/static/src/xml/thread.xml',
'im_livechat/static/src/xml/im_livechat.xml',
]
return [tools.file_open(tmpl, 'rb').read() for tmpl in templates]
@http.route('/im_livechat/support/<int:channel_id>', type='http', auth='public')
def support_page(self, channel_id, **kwargs):
channel = request.env['im_livechat.channel'].sudo().browse(channel_id)
@@ -38,7 +52,7 @@ class LivechatController(http.Controller):
info = request.env['im_livechat.channel'].get_livechat_info(channel.id, username=username)
return request.render('im_livechat.loader', {'info': info, 'web_session_required': True}, headers=[('Content-Type', 'application/javascript')])
@http.route('/im_livechat/init', type='json', auth="public")
@http.route('/im_livechat/init', type='json', auth="public", cors="*")
def livechat_init(self, channel_id):
LivechatChannel = request.env['im_livechat.channel']
available = len(LivechatChannel.browse(channel_id).get_available_users())
@@ -66,7 +80,7 @@ class LivechatController(http.Controller):
'rule': rule,
}
@http.route('/im_livechat/get_session', type="json", auth='public')
@http.route('/im_livechat/get_session', type="json", auth='public', cors="*")
def get_session(self, channel_id, anonymous_name, **kwargs):
user_id = None
country_id = None
@@ -83,7 +97,7 @@ class LivechatController(http.Controller):
anonymous_name, country_id = _("%s (%s)") % (anonymous_name, country.name), country.id
return request.env["im_livechat.channel"].with_context(lang=False).get_mail_channel(channel_id, anonymous_name, user_id, country_id)
@http.route('/im_livechat/feedback', type='json', auth='public')
@http.route('/im_livechat/feedback', type='json', auth='public', cors="*")
def feedback(self, uuid, rate, reason=None, **kwargs):
Channel = request.env['mail.channel']
Rating = request.env['rating.rating']
@@ -114,7 +128,7 @@ class LivechatController(http.Controller):
return rating.id
return False
@http.route('/im_livechat/history', type="json", auth="public")
@http.route('/im_livechat/history', type="json", auth="public", cors="*")
def history_pages(self, pid, channel_uuid, page_history=None):
partner_ids = (pid, request.env.user.partner_id.id)
channel = request.env['mail.channel'].sudo().search([('uuid', '=', channel_uuid), ('channel_partner_ids', 'in', partner_ids)])
@@ -122,7 +136,7 @@ class LivechatController(http.Controller):
channel._send_history_message(pid, page_history)
return True
@http.route('/im_livechat/notify_typing', type='json', auth='public')
@http.route('/im_livechat/notify_typing', type='json', auth='public', cors="*")
def notify_typing(self, uuid, is_typing):
""" Broadcast the typing notification of the website user to other channel members
:param uuid: (string) the UUID of the livechat channel
@@ -190,15 +190,11 @@ var LivechatButton = Widget.extend({
* @private
*/
_loadQWebTemplate: function () {
var xml_files = ['/mail/static/src/xml/abstract_thread_window.xml',
'/mail/static/src/xml/thread.xml',
'/im_livechat/static/src/xml/im_livechat.xml'];
var defs = _.map(xml_files, function (tmpl) {
return session.rpc('/web/proxy/load', { path: tmpl }).then(function (xml) {
QWeb.add_template(xml);
return session.rpc('/im_livechat/load_templates').then(function (templates) {
_.each(templates, function (template) {
QWeb.add_template(template);
});
});
return $.when.apply($, defs);
},
/**
* @private
@@ -194,7 +194,7 @@
odoo.define('web.session', function (require) {
var Session = require('web.Session');
var modules = odoo._modules;
return new Session(undefined, "<t t-esc="info['server_url']"/>", {modules:modules, use_cors: false});
return new Session(undefined, "<t t-esc="info['server_url']"/>", {modules:modules, use_cors: true});
});
</t>
+2 -2
View File
@@ -34,7 +34,7 @@ class MailChatController(BusController):
# --------------------------
# Anonymous routes (Common Methods)
# --------------------------
@route('/mail/chat_post', type="json", auth="none")
@route('/mail/chat_post', type="json", auth="none", cors="*")
def mail_chat_post(self, uuid, message_content, **kwargs):
# find the author from the user session, which can be None
author_id = False # message_post accept 'False' author_id, but not 'None'
@@ -46,7 +46,7 @@ class MailChatController(BusController):
message = mail_channel.sudo().with_context(mail_create_nosubscribe=True).message_post(author_id=author_id, email_from=False, body=body, message_type='comment', subtype='mail.mt_comment')
return message and message.id or False
@route(['/mail/chat_history'], type="json", auth="none")
@route(['/mail/chat_history'], type="json", auth="none", cors="*")
def mail_chat_history(self, uuid, last_id=False, limit=20):
channel = request.env["mail.channel"].sudo().search([('uuid', '=', uuid)], limit=1)
if not channel:
-1
View File
@@ -98,7 +98,6 @@
<t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t>
<input type="hidden" name="model" t-att-value="activity.res_model"/>
<input type="hidden" name="id" t-att-value="activity.res_id"/>
<input type="hidden" name="session_id" t-att-value="session.session_id"/>
</t>
</span>
<a role="button" href="#" class="btn btn-link btn-secondary text-muted o_edit_activity o_activity_link" t-att-data-activity-id="activity.id">
-1
View File
@@ -37,7 +37,6 @@
<t t-set="multi_upload" t-value="true"/>
<input type="hidden" name="model" value="mail.compose.message"/>
<input type="hidden" name="id" value="0"/>
<input type="hidden" name="session_id" t-att-value="widget.getSession().session_id"/>
</t>
</span>
</div>
@@ -92,7 +92,6 @@
<t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t>
<input type="hidden" name="model" t-att-value="log.res_model"/>
<input type="hidden" name="id" t-att-value="log.res_id"/>
<input type="hidden" name="session_id" t-att-value="session.session_id"/>
</t>
</span>
</t>
@@ -15,7 +15,6 @@
<t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t>
<input type="hidden" name="model" t-att-value="widget.modelName" />
<input type="hidden" name="id" t-att-value="widget.id" />
<input type="hidden" name="session_id" t-att-value="widget.getSession().session_id" />
</t>
</span>
</t>
-16
View File
@@ -640,22 +640,6 @@ class WebClient(http.Controller):
class Proxy(http.Controller):
@http.route('/web/proxy/load', type='json', auth="none")
def load(self, path):
""" Proxies an HTTP request through a JSON request.
It is strongly recommended to not request binary files through this,
as the result will be a binary data blob as well.
:param path: actual request path
:return: file content
"""
from werkzeug.test import Client
from werkzeug.wrappers import BaseResponse
base_url = request.httprequest.base_url
return Client(request.httprequest.app, BaseResponse).get(path, base_url=base_url).data
@http.route('/web/proxy/post/<path:path>', type='http', auth='user', methods=['GET'])
def post(self, path):
"""Effectively execute a POST request that was hooked through user login"""
-1
View File
@@ -23,7 +23,6 @@ class Http(models.AbstractModel):
display_switch_company_menu = user.has_group('base.group_multi_company') and len(user.company_ids) > 1
version_info = odoo.service.common.exp_version()
return {
"session_id": request.session.sid,
"uid": request.session.uid,
"is_system": user._is_system() if request.session.uid else False,
"is_admin": user._is_admin() if request.session.uid else False,
+3 -79
View File
@@ -45,7 +45,6 @@ function genericJsonRpc (fct_name, params, settings, fct) {
var def = $.Deferred();
return def.reject.apply(def, ["communication"].concat(_.toArray(arguments)));
});
// FIXME: jsonp?
deferred.abort = function () {
deferred.reject({message: "XmlHttpRequestError abort"}, $.Event('abort'));
if (xhr.abort) {
@@ -106,80 +105,6 @@ function jsonRpc(url, fct_name, params, settings) {
});
}
function jsonpRpc(url, fct_name, params, settings) {
settings = settings || {};
return genericJsonRpc(fct_name, params, settings, function(data) {
var payload_str = JSON.stringify(data, time.date_to_utc);
var payload_url = $.param({r:payload_str});
var force2step = settings.force2step || false;
delete settings.force2step;
var session_id = settings.session_id || null;
delete settings.session_id;
if (payload_url.length < 2000 && ! force2step) {
return $.ajax(url, _.extend({}, settings, {
url: url,
dataType: 'jsonp',
jsonp: 'jsonp',
type: 'GET',
cache: false,
data: {r: payload_str, session_id: session_id}
}));
} else {
var args = {session_id: session_id, id: data.id};
var ifid = _.uniqueId('oe_rpc_iframe');
var html = "<iframe src='javascript:false;' name='" + ifid + "' id='" + ifid + "' style='display:none'></iframe>";
var $iframe = $(html);
var nurl = 'jsonp=1&' + $.param(args);
nurl = url.indexOf("?") !== -1 ? url + "&" + nurl : url + "?" + nurl;
var $form = $('<form>')
.attr('method', 'POST')
.attr('target', ifid)
.attr('enctype', "multipart/form-data")
.attr('action', nurl)
.append($('<input type="hidden" name="r" />').attr('value', payload_str))
.hide()
.appendTo($('body'));
var cleanUp = function() {
if ($iframe) {
$iframe.unbind("load").remove();
}
$form.remove();
};
var deferred = $.Deferred();
// the first bind is fired up when the iframe is added to the DOM
$iframe.bind('load', function() {
// the second bind is fired up when the result of the form submission is received
$iframe.unbind('load').bind('load', function() {
$.ajax({
url: url,
dataType: 'jsonp',
jsonp: 'jsonp',
type: 'GET',
cache: false,
data: {session_id: session_id, id: data.id}
}).always(function() {
cleanUp();
}).done(function() {
deferred.resolve.apply(deferred, arguments);
}).fail(function() {
deferred.reject.apply(deferred, arguments);
});
});
// now that the iframe can receive data, we fill and submit the form
$form.submit();
});
// append the iframe to the DOM (will trigger the first load)
$form.after($iframe);
if (settings.timeout) {
realSetTimeout(function() {
deferred.reject({});
}, settings.timeout);
}
return deferred;
}
});
}
// helper function to make a rpc with a function name hardcoded to 'call'
function rpc(url, params, settings) {
return jsonRpc(url, 'call', params, settings);
@@ -312,7 +237,7 @@ function get_file(options) {
xhr.open('POST', options.url);
data = new FormData();
_.each(options.data || {}, function (v, k) {
data.append(k, v)
data.append(k, v);
});
}
data.append('token', 'dummy-because-api-expects-one');
@@ -360,7 +285,7 @@ function get_file(options) {
name: String(xhr.status),
title: nodes.length > 0 ? nodes[0].textContent : '',
}
}
};
}
options.error(err);
};
@@ -375,7 +300,7 @@ function get_file(options) {
}
};
if (options.complete) {
xhr.onloadend = function () { options.complete(); }
xhr.onloadend = function () { options.complete(); };
}
xhr.send(data);
@@ -596,7 +521,6 @@ function loadLibs (libs) {
var ajax = {
jsonRpc: jsonRpc,
jsonpRpc: jsonpRpc,
rpc: rpc,
loadCSS: loadCSS,
loadJS: loadJS,
+11 -79
View File
@@ -22,11 +22,8 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
or `null` if the server to contact is the origin server.
@param {Dict} options A dictionary that can contain the following options:
* "override_session": Default to false. If true, the current session object will
not try to re-use a previously created session id stored in a cookie.
* "session_id": Default to null. If specified, the specified session_id will be used
by this session object. Specifying this option automatically implies that the option
"override_session" is set to true.
* "modules"
* "use_cors"
*/
init: function (parent, origin, options) {
mixins.EventDispatcherMixin.init.call(this);
@@ -34,8 +31,6 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
options = options || {};
this.module_list = (options.modules && options.modules.slice()) || (window.odoo._modules && window.odoo._modules.slice()) || [];
this.server = null;
this.session_id = options.session_id || null;
this.override_session = options.override_session || !!options.session_id || false;
this.avoid_recursion = false;
this.use_cors = options.use_cors || false;
this.setup(origin);
@@ -61,7 +56,6 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
this.origin = origin;
this.prefix = this.origin;
this.server = this.origin; // keep chs happy
this.origin_server = this.origin === window_origin;
options = options || {};
if ('use_cors' in options) {
this.use_cors = options.use_cors;
@@ -139,7 +133,6 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
if (!result.uid) {
return $.Deferred().reject();
}
delete result.session_id;
_.extend(self, result);
});
},
@@ -276,9 +269,6 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
return this.currencies[currency_id];
},
get_file: function (options) {
if (this.override_session){
options.data.session_id = this.session_id;
}
options.session = this;
return ajax.get_file(options);
},
@@ -290,29 +280,9 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
*/
session_reload: function () {
var result = _.extend({}, window.odoo.session_info);
delete result.session_id;
_.extend(this, result);
return $.when();
},
check_session_id: function () {
var self = this;
if (this.avoid_recursion)
return $.when();
if (this.session_id)
return $.when(); // we already have the session id
if (!this.use_cors && (this.override_session || ! this.origin_server)) {
// If we don't use the origin server we consider we should always create a new session.
// Even if some browsers could support cookies when using jsonp that behavior is
// not consistent and the browser creators are tending to removing that feature.
this.avoid_recursion = true;
return this.rpc("/gen_session_id", {}).then(function (result) {
self.session_id = result;
}).always(function () {
self.avoid_recursion = false;
});
}
return $.when();
},
/**
* Executes an RPC call, registering the provided callbacks.
*
@@ -333,57 +303,19 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
options.headers["X-Debug-Mode"] = $.deparam($.param.querystring()).debug;
}
var deferred = self.check_session_id();
var aborted = false;
var xhrDef;
deferred.abort = function () {
if (xhrDef) {
xhrDef.abort();
} else {
aborted = true;
}
};
// TODO: remove
if (! _.isString(url)) {
_.extend(options, url);
url = url.url;
}
if (self.use_cors) {
url = self.url(url, null);
}
var promise = deferred.then(function () {
if (aborted) {
var def = $.Deferred().reject({message: "XmlHttpRequestError abort"}, $.Event('abort'));
def.abort = function () {};
return def;
}
// TODO: remove
if (! _.isString(url)) {
_.extend(options, url);
url = url.url;
}
var fct;
if (self.origin_server) {
fct = ajax.jsonRpc;
if (self.override_session) {
options.headers["X-Openerp-Session-Id"] = self.session_id || '';
}
} else if (self.use_cors) {
fct = ajax.jsonRpc;
url = self.url(url, null);
options.session_id = self.session_id || '';
if (self.override_session) {
options.headers["X-Openerp-Session-Id"] = self.session_id || '';
}
} else {
fct = ajax.jsonpRpc;
url = self.url(url, null);
options.session_id = self.session_id || '';
}
xhrDef = fct(url, "call", params, options);
return xhrDef;
});
promise.abort = deferred.abort;
return promise;
return ajax.jsonRpc(url, "call", params, options);
},
url: function (path, params) {
params = _.extend(params || {});
if (this.override_session || (! this.origin_server))
params.session_id = this.session_id;
var qs = $.param(params);
if (qs.length > 0)
qs = "?" + qs;
@@ -1446,7 +1446,6 @@ var AbstractFieldBinary = AbstractField.extend({
self.on_file_uploaded(file.size, file.name, file.type, data);
};
} else {
this.$('form.o_form_binary_form input[name=session_id]').val(this.getSession().session_id);
this.$('form.o_form_binary_form').submit();
}
this.$('.o_form_binary_progress').show();
-4
View File
@@ -378,7 +378,6 @@
<t t-set="multi_upload" t-value="true"/>
<input type="hidden" name="model" t-att-value="widget.env and widget.env.model"/>
<input type="hidden" name="id" t-att-value="widget.env.activeIds[0]"/>
<input type="hidden" name="session_id" t-att-value="widget.getSession().session_id" t-if="widget.getSession().override_session"/>
<span>Add...</span>
</t>
</div>
@@ -998,7 +997,6 @@
<form class="o_form_binary_form" t-att-target="fileupload_id"
method="post" enctype="multipart/form-data" t-att-action="fileupload_action || '/web/binary/upload'">
<input type="hidden" name="csrf_token" t-att-value="csrf_token"/>
<input type="hidden" name="session_id" value="" t-if="widget.getSession().override_session"/>
<input type="hidden" name="callback" t-att-value="fileupload_id"/>
<input t-if="widget.image_only" type="file" class="o_input_file" name="ufile" accept="image/*"/>
<input t-if="!widget.image_only" type="file" class="o_input_file" name="ufile" t-att="{'multiple': multi_upload ? 'multiple' : null}"/>
@@ -1042,7 +1040,6 @@
<t t-set="multi_upload" t-value="true"/>
<input type="hidden" name="model" t-att-value="widget.model"/>
<input type="hidden" name="id" value="0"/>
<input t-if="widget.getSession().override_session" type="hidden" name="session_id" t-att-value="widget.getSession().session_id"/>
</t>
</div>
</div>
@@ -1567,7 +1564,6 @@
<t t-set="multi_upload" t-value="true"/>
<input type="hidden" name="model" t-att-value="widget.res_model"/>
<input type="hidden" name="id" t-att-value="widget.res_id"/>
<input type="hidden" name="session_id" t-att-value="widget.getSession().session_id"/>
</t>
</span>
</button>
@@ -47,8 +47,8 @@ odoo.define('website_sale.tour_shop_customize', function (require) {
args: ['product.group_product_variant', false],
kwargs: {}
};
ajax.jsonpRpc('/web/dataset/call_kw', 'call', get_group_payload).then(function(group_id) {
ajax.jsonpRpc('/web/dataset/call_kw', 'call', {
ajax.jsonRpc('/web/dataset/call_kw', 'call', get_group_payload).then(function(group_id) {
ajax.jsonRpc('/web/dataset/call_kw', 'call', {
model: 'res.groups',
method: 'write',
args: [group_id, {'users': [[4, 1]]}],
+6 -48
View File
@@ -302,7 +302,7 @@ class WebRequest(object):
def _handle_exception(self, exception):
"""Called within an except block to allow converting exceptions
to abitrary responses. Anything returned (except None) will
be used as response."""
be used as response."""
self._failed = exception # prevent tx commit
if not isinstance(exception, NO_POSTMORTEM) \
and not isinstance(exception, werkzeug.exceptions.HTTPException):
@@ -579,34 +579,14 @@ class JsonRequest(WebRequest):
def __init__(self, *args):
super(JsonRequest, self).__init__(*args)
self.jsonp_handler = None
self.params = {}
args = self.httprequest.args
jsonp = args.get('jsonp')
self.jsonp = jsonp
request = None
request_id = args.get('id')
if jsonp and self.httprequest.method == 'POST':
# jsonp 2 steps step1 POST: save call
def handler():
self.session['jsonp_request_%s' % (request_id,)] = self.httprequest.form['r']
self.session.modified = True
headers=[('Content-Type', 'text/plain; charset=utf-8')]
r = werkzeug.wrappers.Response(request_id, headers=headers)
return r
self.jsonp_handler = handler
return
elif jsonp and args.get('r'):
# jsonp method GET
request = args.get('r')
elif jsonp and request_id:
# jsonp 2 steps step2 GET: run and return result
request = self.session.pop('jsonp_request_%s' % (request_id,), '{}')
else:
# regular jsonrpc2
request = self.httprequest.get_data().decode(self.httprequest.charset)
# regular jsonrpc2
request = self.httprequest.get_data().decode(self.httprequest.charset)
# Read POST content or POST Form Data named "request"
try:
@@ -630,16 +610,8 @@ class JsonRequest(WebRequest):
if result is not None:
response['result'] = result
if self.jsonp:
# If we use jsonp, that's mean we are called from another host
# Some browser (IE and Safari) do no allow third party cookies
# We need then to manage http sessions manually.
response['session_id'] = self.session.sid
mime = 'application/javascript'
body = "%s(%s);" % (self.jsonp, json.dumps(response, default=date_utils.json_default))
else:
mime = 'application/json'
body = json.dumps(response, default=date_utils.json_default)
mime = 'application/json'
body = json.dumps(response, default=date_utils.json_default)
return Response(
body, status=error and error.pop('http_status', 200) or 200,
@@ -674,8 +646,6 @@ class JsonRequest(WebRequest):
return self._json_response(error=error)
def dispatch(self):
if self.jsonp_handler:
return self.jsonp_handler()
try:
rpc_request_flag = rpc_request.isEnabledFor(logging.DEBUG)
rpc_response_flag = rpc_response.isEnabledFor(logging.DEBUG)
@@ -1388,8 +1358,6 @@ class Root(object):
def get_request(self, httprequest):
# deduce type of request
if httprequest.args.get('jsonp'):
return JsonRequest(httprequest)
if httprequest.mimetype in ("application/json", "application/json-rpc"):
return JsonRequest(httprequest)
else:
@@ -1647,15 +1615,5 @@ def content_disposition(filename):
return "attachment; filename*=UTF-8''%s" % escaped
#----------------------------------------------------------
# RPC controller
#----------------------------------------------------------
class CommonController(Controller):
@route('/gen_session_id', type='json', auth="none")
def gen_session_id(self):
nsession = root.session_store.new()
return nsession.sid
# main wsgi handler
root = Root()