12. Hello, my name is
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 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)])
18. 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?
23. 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>ⅇ</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.
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()
32. 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
35. 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>
36. 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':
'</script><script>alert(document.cookie);//'};
</script>
37. 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;
},
38. 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;
},
39. 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;
},
42. 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
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 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
47. 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!
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.
BUT WHY is that?
Is it a difficult engineering challenge,
compared to BUILDING a BRIDGE?
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.
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
-> 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
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
WHERE TO START: THE FAMOUS OWASP project
Comprehensive Directory, Statistics and Tools
Community-driven: vendors, users, researchers
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!)
ANIM: BUT REMEMBER! KNOWLEDGE + MINDSET
No silver bullet can block all problems.
// let’s go.. -> A1. Injection
# 00:10
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
The infamous comics from Randall Munroe / XKCD
Best known illustration for injection problems!
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..)
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”!
What changed?
test for partner_type is useless in general - only prevents attacks!
=> MINDSET!
// and of course the best option… (ORM)
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
There are many moving parts in managing this!
Good news: normally it’s all done for you by Odoo.
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)
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
A very common problem:
Bad or no encryption (e.g. for password, or personal data)
Unnecessary storage (Credit Cards) use PCI third-party
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
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)
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
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!
Remember the SECURITY MODEL?
This is the CRUX of Odoo SECURITY.
For v14: also wizards and transient models!
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)
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?
One option = validate it with a token!
Other options: auth=user
// This is important! Another example?
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?
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
Examples:
Unnecessary features enabled
Default parameters left or default credentials
Bad deployment security (AWS S3 buckets!)
When you are deploying: be sure to check the Deployment Security guide!
// AND BY THE WAY in v14… (Random admin password)
By the way:
New in v14: random password generated this first time!
# 00:32
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!
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)
Fix => use t-esc
May not always work, in that case find alternative escaping.
// Let’s see another kind of CSS (DOM-based)
TEXT vs MARKUP
ESCAPING text -> markup is an option.
Ideally: do not escape, just don’t mix at all
// So we’ve seen 2 examples, but there are so many more… (XSS table)
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
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)
Odoo includes safe unpickler, and dropped pickle for protocol and cache.
# 00: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
Very common too, and you find out only when you’re sorry!
Also MONITORING
Authentication layer already logs some sensitive operations
You should log your own too!
// Some built-in tools (assert_log_admin_access decorator)
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
List of most important things to check.
Keywords / red flags / ...
BUT this only the beginning, and it will not be enough...
MOST IMPORTANT!
Designate security responsible, ideally 1 per team.
Someone who is into security!
Who reads and learns!
Who does CTF games!
# 00:53