Successfully reported this slideshow.
Your SlideShare is downloading. ×

Security: Odoo Code Hardening

Ad

Odoo Code Hardening
- Security -

Ad

Software Security
is hard.

Ad

Intent changes the
odds.

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Ad

Loading in …3
×

Check these out next

1 of 49 Ad
1 of 49 Ad

More Related Content

Security: Odoo Code Hardening

  1. 1. Odoo Code Hardening - Security -
  2. 2. Software Security is hard.
  3. 3. Intent changes the odds.
  4. 4. Knowledge and mindset are key.
  5. 5. Security Model Business Data RBAC = Groups + ACL + Rules Apps / Business Logic Access Control
  6. 6. RISKS? TOP 10
  7. 7. 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.)
  8. 8. HELP? The Odoo framework includes a lot of mechanisms to avoid mistakes. But knowledge and mindset are key!
  9. 9. A1. Injection Untrusted data interpreted as code
  10. 10. Hello, my name is Robert’); DROP TABLE students;-- 👱 https://xkcd.com/327
  11. 11. # 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()
  12. 12. # 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()
  13. 13. # 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()
  14. 14. # 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)])
  15. 15. A2. Broken Auth Authentication / session management logic error
  16. 16. 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?
  17. 17. A3. Sensitive Data Exposure Insufficient protection for sensitive data
  18. 18. 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")
  19. 19. A4. XML External Entities Unsafe parsing of untrusted XML data
  20. 20. 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.
  21. 21. A5. Broken Access Control Incorrect validation of user permissions
  22. 22. # 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.
  23. 23. # 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
  24. 24. # 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()
  25. 25. # 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()
  26. 26. # 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()
  27. 27. # 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()
  28. 28. A6. Security Misconfiguration
  29. 29. 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
  30. 30. A7. XSS Untrusted data interpreted as code… again!
  31. 31. 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>
  32. 32. 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>
  33. 33. 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; },
  34. 34. 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; },
  35. 35. 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; },
  36. 36. 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
  37. 37. A8. Insecure Deserialization
  38. 38. 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
  39. 39. A9. Vulnerable Components
  40. 40. A10. Insufficient Logging
  41. 41. @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
  42. 42. 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
  43. 43. 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!
  44. 44. Choose someone who improves the knowledge and mindset of the team.
  45. 45. Thank You

Editor's Notes

  • 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

×