Odoo Code Hardening
- Security -
Software Security
is hard.
Intent changes the
odds.
Knowledge and
mindset are key.
Security Model
Business
Data
RBAC = Groups + ACL + Rules
Apps / Business Logic
Access
Control
RISKS?
TOP 10
A1. Injection A6. Security Misconfiguration
A2. Broken Authentication A7. Cross-Site Scripting (XSS)
A3. Sensitive Data Exposure A8. Insecure Deserialization
A4. XML External Entities (XXE) A9. Vulnerable Components
A5. Broken Access Control A10. Insufficient Logging
(2017 ed.)
HELP?
The Odoo framework includes a lot of
mechanisms to avoid mistakes.
But knowledge and mindset are key!
A1. Injection
Untrusted data interpreted as code
Hello, my name is
Robert’); DROP TABLE students;--
👱
https://xkcd.com/327
# SQL Injection (simplified)
def _get_partner_match(self, name, partner_type='is_customer'):
query = f"""SELECT id FROM res_partner
WHERE name ILIKE '%{name}%'
AND {partner_type} IS TRUE"""
self._cr.execute(query)
return self._cr.fetchall()
# SQL Injection (simplified) - 2
def _get_partner_match(self, name, partner_type='is_customer'):
query = f"""SELECT id FROM res_partner
WHERE name ILIKE '%%%(name)s%%'
AND {partner_type} IS TRUE """
self._cr.execute(query, (name,))
return self._cr.fetchall()
# SQL Injection (simplified) - 3 - Safe!
def _get_partner_match(self, name, partner_type='is_customer'):
if partner_type not in ('is_customer', 'is_supplier'):
raise ValueError()
query = f"""SELECT id FROM res_partner
WHERE name ILIKE '%%%(name)s%%'
AND {partner_type} IS TRUE """
self._cr.execute(query, (name,))
return self._cr.fetchall()
# Best option when possible: use the ORM
def _get_partner_match(self, name, partner_type='is_customer'):
return self.search([('name', 'ilike', name),
(partner_type, '=', True)])
A2. Broken Auth
Authentication / session management
logic error
A2. Broken Auth
Bad news: there are lots of moving
parts, it’s hard to get this right.
Good news: the system does all of
that for you.
Be very careful if you try to modify or
extend the authentication and session
mechanisms.
➔ Securing session cookie
➔ Preventing session injection
➔ Rotating session after login/logout
➔ Storing passwords securely
➔ Verifying passwords securely
➔ Preventing brute-force attacks
➔ Security of password reset flow
➔ Rejecting deactivated users
➔ Instant lockout after account change
➔ Integration with third-party auths
➔ Two-factor flow, token security
➔ ...
and much more...
What’s so hard?
A3. Sensitive Data
Exposure
Insufficient protection for sensitive data
TODO
# Sensitive data exposed
class Employee(models.Model):
_inherit = "hr.employee"
ssnid = fields.Char("Social Security Number")
passport = fields.Char("Passport No")
# Sensitive data protected
class Employee(models.Model):
_inherit = "hr.employee"
ssnid = fields.Char("Social Security Number", groups="hr.group_officer")
passport = fields.Char("Passport No", groups="hr.group_officer")
A4. XML External
Entities
Unsafe parsing of untrusted XML data
XXE: use safe XML parsers
Recursion bombs
<!DOCTYPE xmlbomb [
<!ENTITY a "1234567890" >
<!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;">
<!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;">
<!ENTITY d "&c;&c;&c;&c;&c;&c;&c;&c;">
]>
<bomb>&d;</bomb>
Local File Inclusion
<!DOCTYPE external [
<!ENTITY ee SYSTEM "file:///etc/password">
]>
<root>&ee;</root>
The framework protects you.
Use lxml.etree:
etree.fromstring(xml_data)
lxml.etree is configured to reject:
+ recursive entities
+ network resolution
+ local entity resolution
Or have a look at defusedxml.
A5. Broken Access
Control
Incorrect validation of user permissions
# Missing/Incorrect ACLs
➔ The most common mistake!
➔ New models require:
+ ACLs (CRUD)
+ Record rules (CRUD filter)
+ Field-level permission
Bad ACLs examples
# Full Access to everyone - incorrect
id,model_id,group_id,p_read,p_write,p_create,p_unlink
access_my_model,model_my_model, ,1,1,1,1
# Full Access to employees - probably incorrect
id,model_id,group_id,p_read,p_write,p_create,p_unlink
access_my_model,model_my_model,base.group_user,1,1,1,1
Normal ACLs examples
# Employee = Read | Manager = Full
id,model_id,group_id,p_read,p_write,p_create,p_unlink
access_my_model,model_my_model,base.group_user,1,0,0,0
access_my_model,model_my_model,base.group_manager,1,1,1,1
As of V14, also for TransientModel.
# Incorrect sudo() or permission test
# Portal controller route
@route(['/sale/<int:order_id>/approve'], type='json', methods=['POST'], auth='public')
def order_approve(self, order_id, **post):
order = self.env['sale.order'].sudo().browse(order_id)
order.action_approve()
user
public
none
converters type methods
auth
# Incorrect sudo() or permission test
# Portal controller route - bad!
@route(['/sale/<int:order_id>/approve'], type='json', methods=['POST'], auth='public')
def order_approve(self, order_id, **post):
order = self.env['sale.order'].sudo().browse(order_id)
order.action_approve()
# Incorrect sudo() or permission test
# Portal controller route - corrected
@route(['/sale/<int:order_id>/approve'], type='http', methods=['POST'], auth='public')
def order_approve(self, order_id, token, **post):
order = self.env['sale.order'].sudo().browse(order_id)
if not tools.consteq(order.access_token, token):
raise AccessDenied()
order.action_approve()
# Incorrect sudo() or permission test
def get_notifications(self, partner_id):
# direct SQL for complex query performance
query = """
SELECT DISTINCT m.id, m.author_id, m.message_type
FROM mail_message
LEFT JOIN mail_message_res_partner_rel
LEFT JOIN mail_message_res_partner_needaction_rel needaction
(...)
WHERE partner_id = %s
"""
self._cr.execute(query, (partner_id,))
return self._cr.fetchall()
# Incorrect sudo() or permission test
def _get_notifications(self, partner_id):
self.check_access_rights('read')
# direct SQL for complex query performance
query = """
SELECT DISTINCT m.id, m.author_id, m.message_type
FROM mail_message
LEFT JOIN mail_message_res_partner_rel
LEFT JOIN mail_message_res_partner_needaction_rel needaction
(...)
WHERE partner_id = %s
"""
self._cr.execute(query, (partner_id,))
return self._cr.fetchall()
A6. Security
Misconfiguration
odoo.com/documentatio
n ● PostgreSQL security (no super user)
● Web server + TLS
● Database manager security
● Separate Production / Staging / Dev
● No demo data on Production
● SSH security
● Rate-limiting and brute-force
protections
And more...
Deployment Checklist
A7. XSS
Untrusted data interpreted as code… again!
Stored / Reflected XSS
➔ Untrusted HTML content in the database,
in some text/char field
➔ Victim is tricked into viewing it
➔ When displayed in the browser, it
becomes executable
@route('/index', type='http', auth="none")
def index(self)
session_info = {
'user_name': request.env.user.name,
}
response = request.render(
'web.webclient_bootstrap',
{session_info: session_info}
)
return response
<template id="web.webclient_bootstrap">
<t t-call="web.layout">
<t t-set="head_web">
<script type="text/javascript">
odoo.session_info =
<t t-raw="str(session_info)"/>;
</script>
</t>
</t>
</template>
Name:
</script><script>alert(document.cookie);//
<script type="text/javascript">
odoo.session_info = {'user_name':
"</script><script>alert(document.cookie);//"};
</script>
Stored / Reflected XSS
➔ Untrusted HTML content in the database,
in some text/char field
➔ Victim is tricked into viewing it
➔ When displayed in the browser, it
does not become executable
@route('/index', type='http', auth="none")
def index(self)
session_info = {
'user_name': request.env.user.name,
}
response = request.render(
'web.webclient_bootstrap',
{session_info: session_info}
)
return response
<template id="web.webclient_bootstrap">
<t t-call="web.layout">
<t t-set="head_web">
<script type="text/javascript">
odoo.session_info =
<t t-esc="str(session_info)"/>;
</script>
</t>
</t>
</template>
Name:
</script><script>alert(document.cookie);//
<script type="text/javascript">
odoo.session_info = {'user_name':
'&lt;/script&gt;&lt;script&gt;alert(document.cookie);//'};
</script>
DOM-based XSS
Barrier broken between text and markup
// Text manipulation
elem.textContent = "...";
$elem.text(“..”);
// Markup manipulation
elem.innerHTML = ""...";
$elem.html(...);
Do not mix them.
/**
* Adds the product description based on attribute values
*
* @private
*/
_postProcessContent: function ($modalContent) {
var $productDescription = $modalContent
.find('.main_product');
var desc = $productDescription.html();
$.each(this.rootProduct.attribute_values, function () {
desc += ('<br/>' + this.attribute_value_name
+ ':' + this.custom_value);
});
$productDescription.html(desc);
return $modalContent;
},
DOM XSS
Barrier broken between text and markup
// Text manipulation
elem.textContent = "...";
$elem.text(“..”);
// Markup manipulation
elem.innerHTML = ""...";
$elem.html(...);
Do not mix.
Maybe convert.
markup = _.escape(text);
/**
* Adds the product description based on attribute values
*
* @private
*/
_postProcessContent: function ($modalContent) {
var $productDescription = $modalContent
.find('.main_product');
var desc = $productDescription.html();
$.each(this.rootProduct.attribute_values, function () {
desc += ('<br/>' + _.escape(this.attribute_value_name)
+ ':' + _.escape(this.custom_value));
});
$productDescription.html(desc);
return $modalContent;
},
DOM XSS
Barrier broken between text and markup
// Text manipulation
elem.textContent = "...";
$elem.text(“..”);
// Markup manipulation
elem.innerHTML = ""...";
$elem.html(...);
Do not mix.
Maybe convert.
But, really: do not mix.
/**
* Adds the product description based on attribute values
*
* @private
*/
_postProcessContent: function ($modalContent) {
var $productDescription = $modalContent
.find('.main_product');
var $customValuesDescription = $('<div>');
$.each(this.rootProduct.attribute_values, function () {
$customValuesDescription.append($('<div>', {
text: (this.attribute_value_name + ': ' +
this.custom_value);
}));
});
$productDescription.append($customValuesDescription);
return $modalContent;
},
STRING
FORMATTING
“<b>” + name
`<b>${name}</b>`
UNSAFE
DESERIALIZATION
eval()
VULNERABLE
TEMPLATES
t-raw
VULNERABLE LIB
$.popover()
FILES
.svg,.html
LINKS
javascript:
XSS VECTORS ARE EVERYWHERE
A8. Insecure
Deserialization
The framework uses safe serialization:
● RPC protocols (JSON-RPC, XML-RPC)
● Cache
● Cookies, sessions
● Signed structured data
So watch out:
● Don’t use pickle, use JSON!
● Don’t parse data with eval()
● Sign structured data
A9. Vulnerable
Components
A10. Insufficient
Logging
@classmethod
def _login(cls, db, login, password, user_agent_env):
ip = request.httprequest.environ['REMOTE_ADDR'] if request else 'n/a'
try:
# do login ...
except AccessDenied:
_logger.info("Login failed for db:%s login:%s from %s", db, login, ip)
raise
_logger.info("Login successful for db:%s login:%s from %s", db, login, ip)
_logger.info(
"Password reset attempt for <%s> by user <%s> from %s",
login, request.env.user.login, request.httprequest.remote_addr)
odoo.addons.base.models.res_users: Login failed for db:production login:mike@ex.com from 16.12.19.27
odoo.addons.base.models.res_users: Login successful for db:production login:mike@ex.com from 14.16.23.56
odoo.addons.auth_signup...main: Password reset attempt for <lenny@ex.com> by user <public> from 85.133.187.167
def assert_log_admin_access(method):
"""Decorator checking that the calling user is an administrator, and logging the call.
Raise an AccessDenied error if the user does not have administrator privileges
"""
def check_and_log(method, self, *args, **kwargs):
user = self.env.user
origin = request.httprequest.remote_addr if request else 'n/a'
log_data = (method.__name__, self.sudo().mapped('name'), user.login, user.id, origin)
if not self.env.is_admin():
_logger.warning('DENY access to module.%s on %s to user %s ID #%s via %s', *log_data)
raise AccessDenied()
_logger.info('ALLOW access to module.%s on %s to user %s #%s via %s', *log_data)
return method(self, *args, **kwargs)
return decorator(check_and_log, method)
odoo...ir_module: ALLOW access to module.button_immediate_uninstall on ['crm'] to user bart@ex.com #2 via 2.5.15.214
odoo...ir_module: ALLOW access to module.button_uninstall on ['crm'] to user bart@ex.com #2 via 2.5.15.214
odoo...ir_module: ALLOW access to module.module_uninstall on ['sale_crm', 'crm'] to user __system__ #1 via 2.5.15.214
CODE
REVIEW
CHECKLIST
01
CONTROL
ACCESS
Groups, ACLs, Rules
and Fields
02
VERIFY
PERMISSIONS
sudo(), controllers,
private methods
03
CHECK TEMPLATES
t-raw, untrusted
input escaped
04
SAFE EVAL
Eval only trusted input,
iff impossible to avoid
BLOCK INJECTIONS
05 Double-check raw SQL,
shell commands, etc.
XSS PREVENTION
06 DOM, Stored,
Reflected, look for the
signs!
Choose someone who
improves the knowledge
and mindset of the team.
Thank You

Security: Odoo Code Hardening

  • 2.
  • 3.
  • 5.
  • 6.
  • 7.
    Security Model Business Data RBAC =Groups + ACL + Rules Apps / Business Logic Access Control
  • 8.
  • 9.
    A1. Injection A6.Security Misconfiguration A2. Broken Authentication A7. Cross-Site Scripting (XSS) A3. Sensitive Data Exposure A8. Insecure Deserialization A4. XML External Entities (XXE) A9. Vulnerable Components A5. Broken Access Control A10. Insufficient Logging (2017 ed.)
  • 10.
    HELP? The Odoo frameworkincludes a lot of mechanisms to avoid mistakes. But knowledge and mindset are key!
  • 11.
    A1. Injection Untrusted datainterpreted as code
  • 12.
    Hello, my nameis Robert’); DROP TABLE students;-- 👱 https://xkcd.com/327
  • 13.
    # SQL Injection(simplified) def _get_partner_match(self, name, partner_type='is_customer'): query = f"""SELECT id FROM res_partner WHERE name ILIKE '%{name}%' AND {partner_type} IS TRUE""" self._cr.execute(query) return self._cr.fetchall()
  • 14.
    # SQL Injection(simplified) - 2 def _get_partner_match(self, name, partner_type='is_customer'): query = f"""SELECT id FROM res_partner WHERE name ILIKE '%%%(name)s%%' AND {partner_type} IS TRUE """ self._cr.execute(query, (name,)) return self._cr.fetchall()
  • 15.
    # SQL Injection(simplified) - 3 - Safe! def _get_partner_match(self, name, partner_type='is_customer'): if partner_type not in ('is_customer', 'is_supplier'): raise ValueError() query = f"""SELECT id FROM res_partner WHERE name ILIKE '%%%(name)s%%' AND {partner_type} IS TRUE """ self._cr.execute(query, (name,)) return self._cr.fetchall()
  • 16.
    # Best optionwhen possible: use the ORM def _get_partner_match(self, name, partner_type='is_customer'): return self.search([('name', 'ilike', name), (partner_type, '=', True)])
  • 17.
    A2. Broken Auth Authentication/ session management logic error
  • 18.
    A2. Broken Auth Badnews: there are lots of moving parts, it’s hard to get this right. Good news: the system does all of that for you. Be very careful if you try to modify or extend the authentication and session mechanisms. ➔ Securing session cookie ➔ Preventing session injection ➔ Rotating session after login/logout ➔ Storing passwords securely ➔ Verifying passwords securely ➔ Preventing brute-force attacks ➔ Security of password reset flow ➔ Rejecting deactivated users ➔ Instant lockout after account change ➔ Integration with third-party auths ➔ Two-factor flow, token security ➔ ... and much more... What’s so hard?
  • 20.
    A3. Sensitive Data Exposure Insufficientprotection for sensitive data
  • 21.
    TODO # Sensitive dataexposed class Employee(models.Model): _inherit = "hr.employee" ssnid = fields.Char("Social Security Number") passport = fields.Char("Passport No") # Sensitive data protected class Employee(models.Model): _inherit = "hr.employee" ssnid = fields.Char("Social Security Number", groups="hr.group_officer") passport = fields.Char("Passport No", groups="hr.group_officer")
  • 22.
    A4. XML External Entities Unsafeparsing of untrusted XML data
  • 23.
    XXE: use safeXML parsers Recursion bombs <!DOCTYPE xmlbomb [ <!ENTITY a "1234567890" > <!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;"> <!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;"> <!ENTITY d "&c;&c;&c;&c;&c;&c;&c;&c;"> ]> <bomb>&d;</bomb> Local File Inclusion <!DOCTYPE external [ <!ENTITY ee SYSTEM "file:///etc/password"> ]> <root>&ee;</root> The framework protects you. Use lxml.etree: etree.fromstring(xml_data) lxml.etree is configured to reject: + recursive entities + network resolution + local entity resolution Or have a look at defusedxml.
  • 24.
    A5. Broken Access Control Incorrectvalidation of user permissions
  • 25.
    # Missing/Incorrect ACLs ➔The most common mistake! ➔ New models require: + ACLs (CRUD) + Record rules (CRUD filter) + Field-level permission Bad ACLs examples # Full Access to everyone - incorrect id,model_id,group_id,p_read,p_write,p_create,p_unlink access_my_model,model_my_model, ,1,1,1,1 # Full Access to employees - probably incorrect id,model_id,group_id,p_read,p_write,p_create,p_unlink access_my_model,model_my_model,base.group_user,1,1,1,1 Normal ACLs examples # Employee = Read | Manager = Full id,model_id,group_id,p_read,p_write,p_create,p_unlink access_my_model,model_my_model,base.group_user,1,0,0,0 access_my_model,model_my_model,base.group_manager,1,1,1,1 As of V14, also for TransientModel.
  • 26.
    # Incorrect sudo()or permission test # Portal controller route @route(['/sale/<int:order_id>/approve'], type='json', methods=['POST'], auth='public') def order_approve(self, order_id, **post): order = self.env['sale.order'].sudo().browse(order_id) order.action_approve() user public none converters type methods auth
  • 27.
    # Incorrect sudo()or permission test # Portal controller route - bad! @route(['/sale/<int:order_id>/approve'], type='json', methods=['POST'], auth='public') def order_approve(self, order_id, **post): order = self.env['sale.order'].sudo().browse(order_id) order.action_approve()
  • 28.
    # Incorrect sudo()or permission test # Portal controller route - corrected @route(['/sale/<int:order_id>/approve'], type='http', methods=['POST'], auth='public') def order_approve(self, order_id, token, **post): order = self.env['sale.order'].sudo().browse(order_id) if not tools.consteq(order.access_token, token): raise AccessDenied() order.action_approve()
  • 29.
    # Incorrect sudo()or permission test def get_notifications(self, partner_id): # direct SQL for complex query performance query = """ SELECT DISTINCT m.id, m.author_id, m.message_type FROM mail_message LEFT JOIN mail_message_res_partner_rel LEFT JOIN mail_message_res_partner_needaction_rel needaction (...) WHERE partner_id = %s """ self._cr.execute(query, (partner_id,)) return self._cr.fetchall()
  • 30.
    # Incorrect sudo()or permission test def _get_notifications(self, partner_id): self.check_access_rights('read') # direct SQL for complex query performance query = """ SELECT DISTINCT m.id, m.author_id, m.message_type FROM mail_message LEFT JOIN mail_message_res_partner_rel LEFT JOIN mail_message_res_partner_needaction_rel needaction (...) WHERE partner_id = %s """ self._cr.execute(query, (partner_id,)) return self._cr.fetchall()
  • 31.
  • 32.
    odoo.com/documentatio n ● PostgreSQLsecurity (no super user) ● Web server + TLS ● Database manager security ● Separate Production / Staging / Dev ● No demo data on Production ● SSH security ● Rate-limiting and brute-force protections And more... Deployment Checklist
  • 34.
    A7. XSS Untrusted datainterpreted as code… again!
  • 35.
    Stored / ReflectedXSS ➔ Untrusted HTML content in the database, in some text/char field ➔ Victim is tricked into viewing it ➔ When displayed in the browser, it becomes executable @route('/index', type='http', auth="none") def index(self) session_info = { 'user_name': request.env.user.name, } response = request.render( 'web.webclient_bootstrap', {session_info: session_info} ) return response <template id="web.webclient_bootstrap"> <t t-call="web.layout"> <t t-set="head_web"> <script type="text/javascript"> odoo.session_info = <t t-raw="str(session_info)"/>; </script> </t> </t> </template> Name: </script><script>alert(document.cookie);// <script type="text/javascript"> odoo.session_info = {'user_name': "</script><script>alert(document.cookie);//"}; </script>
  • 36.
    Stored / ReflectedXSS ➔ Untrusted HTML content in the database, in some text/char field ➔ Victim is tricked into viewing it ➔ When displayed in the browser, it does not become executable @route('/index', type='http', auth="none") def index(self) session_info = { 'user_name': request.env.user.name, } response = request.render( 'web.webclient_bootstrap', {session_info: session_info} ) return response <template id="web.webclient_bootstrap"> <t t-call="web.layout"> <t t-set="head_web"> <script type="text/javascript"> odoo.session_info = <t t-esc="str(session_info)"/>; </script> </t> </t> </template> Name: </script><script>alert(document.cookie);// <script type="text/javascript"> odoo.session_info = {'user_name': '&lt;/script&gt;&lt;script&gt;alert(document.cookie);//'}; </script>
  • 37.
    DOM-based XSS Barrier brokenbetween text and markup // Text manipulation elem.textContent = "..."; $elem.text(“..”); // Markup manipulation elem.innerHTML = ""..."; $elem.html(...); Do not mix them. /** * Adds the product description based on attribute values * * @private */ _postProcessContent: function ($modalContent) { var $productDescription = $modalContent .find('.main_product'); var desc = $productDescription.html(); $.each(this.rootProduct.attribute_values, function () { desc += ('<br/>' + this.attribute_value_name + ':' + this.custom_value); }); $productDescription.html(desc); return $modalContent; },
  • 38.
    DOM XSS Barrier brokenbetween text and markup // Text manipulation elem.textContent = "..."; $elem.text(“..”); // Markup manipulation elem.innerHTML = ""..."; $elem.html(...); Do not mix. Maybe convert. markup = _.escape(text); /** * Adds the product description based on attribute values * * @private */ _postProcessContent: function ($modalContent) { var $productDescription = $modalContent .find('.main_product'); var desc = $productDescription.html(); $.each(this.rootProduct.attribute_values, function () { desc += ('<br/>' + _.escape(this.attribute_value_name) + ':' + _.escape(this.custom_value)); }); $productDescription.html(desc); return $modalContent; },
  • 39.
    DOM XSS Barrier brokenbetween text and markup // Text manipulation elem.textContent = "..."; $elem.text(“..”); // Markup manipulation elem.innerHTML = ""..."; $elem.html(...); Do not mix. Maybe convert. But, really: do not mix. /** * Adds the product description based on attribute values * * @private */ _postProcessContent: function ($modalContent) { var $productDescription = $modalContent .find('.main_product'); var $customValuesDescription = $('<div>'); $.each(this.rootProduct.attribute_values, function () { $customValuesDescription.append($('<div>', { text: (this.attribute_value_name + ': ' + this.custom_value); })); }); $productDescription.append($customValuesDescription); return $modalContent; },
  • 40.
  • 41.
  • 42.
    The framework usessafe serialization: ● RPC protocols (JSON-RPC, XML-RPC) ● Cache ● Cookies, sessions ● Signed structured data So watch out: ● Don’t use pickle, use JSON! ● Don’t parse data with eval() ● Sign structured data
  • 43.
  • 44.
  • 45.
    @classmethod def _login(cls, db,login, password, user_agent_env): ip = request.httprequest.environ['REMOTE_ADDR'] if request else 'n/a' try: # do login ... except AccessDenied: _logger.info("Login failed for db:%s login:%s from %s", db, login, ip) raise _logger.info("Login successful for db:%s login:%s from %s", db, login, ip) _logger.info( "Password reset attempt for <%s> by user <%s> from %s", login, request.env.user.login, request.httprequest.remote_addr) odoo.addons.base.models.res_users: Login failed for db:production login:mike@ex.com from 16.12.19.27 odoo.addons.base.models.res_users: Login successful for db:production login:mike@ex.com from 14.16.23.56 odoo.addons.auth_signup...main: Password reset attempt for <lenny@ex.com> by user <public> from 85.133.187.167
  • 46.
    def assert_log_admin_access(method): """Decorator checkingthat the calling user is an administrator, and logging the call. Raise an AccessDenied error if the user does not have administrator privileges """ def check_and_log(method, self, *args, **kwargs): user = self.env.user origin = request.httprequest.remote_addr if request else 'n/a' log_data = (method.__name__, self.sudo().mapped('name'), user.login, user.id, origin) if not self.env.is_admin(): _logger.warning('DENY access to module.%s on %s to user %s ID #%s via %s', *log_data) raise AccessDenied() _logger.info('ALLOW access to module.%s on %s to user %s #%s via %s', *log_data) return method(self, *args, **kwargs) return decorator(check_and_log, method) odoo...ir_module: ALLOW access to module.button_immediate_uninstall on ['crm'] to user bart@ex.com #2 via 2.5.15.214 odoo...ir_module: ALLOW access to module.button_uninstall on ['crm'] to user bart@ex.com #2 via 2.5.15.214 odoo...ir_module: ALLOW access to module.module_uninstall on ['sale_crm', 'crm'] to user __system__ #1 via 2.5.15.214
  • 47.
    CODE REVIEW CHECKLIST 01 CONTROL ACCESS Groups, ACLs, Rules andFields 02 VERIFY PERMISSIONS sudo(), controllers, private methods 03 CHECK TEMPLATES t-raw, untrusted input escaped 04 SAFE EVAL Eval only trusted input, iff impossible to avoid BLOCK INJECTIONS 05 Double-check raw SQL, shell commands, etc. XSS PREVENTION 06 DOM, Stored, Reflected, look for the signs!
  • 48.
    Choose someone who improvesthe knowledge and mindset of the team.
  • 49.

Editor's Notes

  • #3 Could be called “Odoo Security Introduction” ...but strong focus on developers. A talk every year because software security is incredibly important but also very hard.
  • #4 BUT WHY is that? Is it a difficult engineering challenge, compared to BUILDING a BRIDGE?
  • #5 STORY: when building a BRIDGE, engineers have a list of the possible PHYSICAL challenges: - earthquake - hurricane - truck spill SOFTWARE: the attacks are creatively invented by the attackers! if something is REMOTELY POSSIBLE - someone will try it - and probably combine different ATTACKS at once! Like having all physical challenges coordinating to attack the bridge --- it would collapse for sure.
  • #6 The attacker can make all the bad things happen at the same time! How can you counter that? The only way to prevent that is to think like an attacker. But our natural BIAS is different: make things work, consider likely cases // So you need a permanent mindset, and the same knowledge as the attacker
  • #7 -> MINDSET: THINK like an ATTACKER (Eng bias = make things work, consider likely cases) -> KNOWLEDGE: LEARN about the problems => This talk: best practices for odoo = knowledge => Will have REVIEW CHECKLIST too // But one more thing before we start.. (model) # 00:06
  • #8 Knowledge: understand the Security Model to be able to defend it. No time to describe it, but you can look at older slides // So where do we start? (risks / OWASP) # 00:09
  • #9 WHERE TO START: THE FAMOUS OWASP project Comprehensive Directory, Statistics and Tools Community-driven: vendors, users, researchers
  • #10 MORE TIME ON A5 and A7 because they are the most common problems in Odoo code! We will go over the top 10, see how that applies to Odoo Environments and Apps // … and what can help you fight? (framework!)
  • #11 ANIM: BUT REMEMBER! KNOWLEDGE + MINDSET No silver bullet can block all problems. // let’s go.. -> A1. Injection # 00:10
  • #12 Typically in a COMMAND or QUERY sent to an INTERPRETER Very common case, but easy to avoid if you understand data vs code and know the API. // an example: the famous SQLi
  • #13 The infamous comics from Randall Munroe / XKCD Best known illustration for injection problems!
  • #14 What do we have here? RAW SQL (perhaps performance, or ignorance) Python 3 f-string `name` = char, `is_customer` = bool Parameters are injected into query PROBLEM: query_type and name are UNTRUSTED DATA -> CODE In reality should use search(), but let’s pretend for a minute we can’t (JOINS, Perf..)
  • #15 What changed? Name is passed as a query param: good! Notice the double %% escape! But partner_type cannot be a parameter, it’s a column name! Still need to fix partner_type, even if it is a “private method”!
  • #16 What changed? test for partner_type is useless in general - only prevents attacks! => MINDSET! // and of course the best option… (ORM)
  • #17 Obvious solution if you can: use search() It works also if the left hand side term is variable. The ORM will take care of: - escaping query parameters - disallow invalid column names # 00:12
  • #18 There are many moving parts in managing this! Good news: normally it’s all done for you by Odoo.
  • #19 I can’t go in details over the list on the right, but there’s a lot of things that can go wrong: Managing the session_id is tricky, and it evolves over time (SameSite, Secure, HttpOnly, …) Password management etc. If you ever need to do this: please read the code carefully + OWASP documentation! Each Odoo version comes with auth improvements. // And by the way in v14.. (MFA)
  • #20 By the way: New in v14: TOTP built-in two-factor authentication Also API Keys No more need for 3rd-party apps or OAuth/LDAP # 00:14
  • #21 A very common problem: Bad or no encryption (e.g. for password, or personal data) Unnecessary storage (Credit Cards) use PCI third-party
  • #22 Here is an example with PII (employee personal info) that needs to be protected from other employees! Golden rules: Classify data and protect accordingly (e.g PII - also for GDPR!) What you don’t store cannot be stolen! (PCI Compliance, e.g. acquirers in FORM form - SAQ) # 00:17
  • #23 An old problem, that can be used for Denial of Service, file disclosure or even remote code execution. Bad or no encryption (e.g. for password, or personal data) Unnecessary storage (Credit Cards)
  • #24 Famous vulnerability called “Billion Laughs” Odoo implements default protections, but you should be careful with any custom parsing. If you have unusual parsing needs (e.g. SAX), consider defusedxml. # 00:20
  • #25 One of the 2 MOST COMMON issues we see in reviews! Let’s see some examples of those issues! // This brings us back to the Security Model!
  • #26 Remember the SECURITY MODEL? This is the CRUX of Odoo SECURITY. For v14: also wizards and transient models!
  • #27 Another FREQUENT case: Incorrect Direct Object Reference (IDOR) Anatomy of a case for anonymous portal: Converters are run with <auth> permission (so here cannot be sale.order) Auth determines environment permission // Here auth=public (portal) so what permissions? -> (sudo)
  • #28 Auth=public -> we need to use sudo() to access data! But here we don’t validate who can approve which order? // How can we fix this?
  • #29 One option = validate it with a token! Other options: auth=user // This is important! Another example?
  • #30 Now a python method, with RAW SQL. -> Good, no SQL injection right? But SQL bypasses all ACL checks! And it’s PUBLIC and can be called by anyone via RPC! // What should we do?
  • #31  At least include an explicit ACL check . And make it private! But you may still bypass record rules, so extra precautions may be needed… These are just a few examples.. There are so many more! # 00:30
  • #32 Examples: Unnecessary features enabled Default parameters left or default credentials Bad deployment security (AWS S3 buckets!)
  • #33 When you are deploying: be sure to check the Deployment Security guide! // AND BY THE WAY in v14… (Random admin password)
  • #34 By the way: New in v14: random password generated this first time! # 00:32
  • #35 One of the 2 MOST COMMON issues we see in reviews! It’s a special form of INJECTION where the execution happens in the browser. Let’s see several cases in details!
  • #36 Cause: uses `t-raw` with Input untrusted data Why a Problem?: browsers parse <script> tags firsts Solution: escape session_info content (can’t use t-esc) Rule of thumb: always consider data untrusted (don’t try to have “safe variables”) -> suggestion: className instead of adding <b> etc! Impact: Session hijack / steal / MFA bypass / Phishing / ... Here: example with STORED, but reflected is similar. // How can we fix this one? (t-esc)
  • #37 Fix => use t-esc May not always work, in that case find alternative escaping. // Let’s see another kind of CSS (DOM-based)
  • #38 TEXT vs MARKUP
  • #39 ESCAPING text -> markup is an option.
  • #40  Ideally: do not escape, just don’t mix at all // So we’ve seen 2 examples, but there are so many more… (XSS table)
  • #41 String formatting or mixing markup and data T-raw vs t-esc (only t-raw with e.g sanitized html field) eval() is not to be used for deserialization! Libraries like bootstrap/jquery contain similar bugs, don’t feed them untrusted data Files and Content-type headers (Odoo defuses them if non admin) JS links inserted in contents (Odoo widgets block it) and more... ADVICE: have someone in the team specialize in this! # 00:42
  • #42 eval() is not a data parser use JSON Can lead to arbitrary code exec … but not just that! Also security bypass… (think email with token in URL)
  • #43 Odoo includes safe unpickler, and dropped pickle for protocol and cache. # 00:44
  • #44 Odoo policy: can’t change lib version in stable, so we review the CVEs Libraries don’t maintain multiple series correctly. Can also be Operating System or even Hardware: SPECTRE/MELTDOWN # 00:46
  • #45 Very common too, and you find out only when you’re sorry! Also MONITORING
  • #46 Authentication layer already logs some sensitive operations You should log your own too! // Some built-in tools (assert_log_admin_access decorator)
  • #47  Log all sensitive actions: payment, login/logout, etc. Also be careful of WHAT you log. // OK, so now that we’ve toured TOP 10 - where do YOU start? (CHECKLIST) # 00:48
  • #48  List of most important things to check. Keywords / red flags / ... BUT this only the beginning, and it will not be enough...
  • #49 MOST IMPORTANT! Designate security responsible, ideally 1 per team. Someone who is into security! Who reads and learns! Who does CTF games! # 00:53