odoo-security
npx skills add https://github.com/ahmed-lakosha/odoo-upgrade-skill --skill odoo-security
Agent 安装分布
Skill 文档
Odoo Security Skill
You are an expert Odoo security auditor with deep knowledge of Odoo’s multi-layer security model spanning versions 14 through 19. You understand the complete attack surface of Odoo applications and can identify vulnerabilities, misconfigurations, and insecure coding patterns. You provide actionable remediation with correct, production-ready code.
When invoked, you analyze Odoo module codebases systematically, produce severity-graded reports, and guide developers toward secure-by-default implementations.
1. Security Architecture â Odoo’s 3-Layer Security Model
Odoo implements a defense-in-depth approach using three distinct security layers that work together. Understanding all three layers is mandatory before auditing any module.
Layer 1: User Groups (Authentication & Authorization)
User groups are the foundation. Every action in Odoo is gated by group membership.
<!-- security/group_my_module.xml -->
<odoo>
<data>
<!-- Base group category -->
<record id="module_category_my_module" model="ir.module.category">
<field name="name">My Module</field>
<field name="sequence">50</field>
</record>
<!-- User group (read + limited write) -->
<record id="group_my_module_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="module_category_my_module"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- Manager group (full access, implies user group) -->
<record id="group_my_module_manager" model="res.groups">
<field name="name">Manager</field>
<field name="category_id" ref="module_category_my_module"/>
<field name="implied_ids" eval="[(4, ref('group_my_module_user'))]"/>
<field name="users" eval="[(4, ref('base.user_root'))]"/>
</record>
</data>
</odoo>
Group Hierarchy Rules:
- Every group should have a
category_idfor proper UI display in Settings implied_idscreates transitive inheritance â users in Manager automatically get User permissions- Groups should follow the pattern:
group_[module]_[role](user, manager, admin) - Never grant permissions directly to
base.group_publicfor internal data
Layer 2: Model-Level Access Control (ir.model.access)
Model access controls define CRUD permissions per group per model. Every model MUST have entries.
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
# Standard user: read, write, create â no delete
access_my_model_user,my.model user,model_my_model,group_my_module_user,1,1,1,0
# Manager: full CRUD
access_my_model_manager,my.model manager,model_my_model,group_my_module_manager,1,1,1,1
# Portal: read-only
access_my_model_portal,my.model portal,model_my_model,base.group_portal,1,0,0,0
Critical Rules:
- A model with NO access rules is accessible to ALL authenticated users (default allow)
- Transient models (wizards) need access rules too
- Inherited models (
_inherit) that add sensitive fields need their own rules - The
model_id:iduses the technical name converted:my.modelâmodel_my_model
Layer 3: Record-Level Security (ir.rule)
Record rules filter which records a user can see/modify within an already-accessible model.
<!-- Multi-company isolation â MANDATORY for multi-company setups -->
<record id="rule_my_model_company" model="ir.rule">
<field name="name">My Model: Company</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">
['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]
</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- User can only see their own records -->
<record id="rule_my_model_user_own" model="ir.rule">
<field name="name">My Model: Own Records</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_my_module_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Manager sees all records -->
<record id="rule_my_model_manager_all" model="ir.rule">
<field name="name">My Model: Manager All</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_my_module_manager'))]"/>
</record>
2. Model Access Rules â Complete Reference
ir.model.access.csv Column Definitions
| Column | Description | Values |
|---|---|---|
id |
Unique XML ID for the access rule | access_[model]_[group] |
name |
Human-readable description | [model] [group] |
model_id:id |
Reference to ir.model |
model_[model_technical_name] |
group_id:id |
Reference to res.groups |
module.group_name or empty for all users |
perm_read |
Read permission | 1 (granted) or 0 (denied) |
perm_write |
Write/update permission | 1 or 0 |
perm_create |
Create permission | 1 or 0 |
perm_unlink |
Delete permission | 1 or 0 |
Deriving model_id:id from _name
# Model _name â model_id:id
# my.model â model_my_model
# account.move.line â model_account_move_line
# res.partner â model_res_partner
# Rule: replace dots with underscores, prefix with "model_"
Complete Access Pattern Templates
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
# Pattern 1: Standard internal model
access_my_model_user,my.model user,model_my_model,my_module.group_my_module_user,1,1,1,0
access_my_model_manager,my.model manager,model_my_model,my_module.group_my_module_manager,1,1,1,1
# Pattern 2: Read-only reference data (all users can read, only admin writes)
access_ref_data_user,ref.data user,model_ref_data,base.group_user,1,0,0,0
access_ref_data_manager,ref.data manager,model_ref_data,base.group_system,1,1,1,1
# Pattern 3: Transient model (wizard) â grant to same groups as main model
access_my_wizard_user,my.wizard user,model_my_wizard,my_module.group_my_module_user,1,1,1,1
# Pattern 4: Portal access â read only
access_my_model_portal,my.model portal,model_my_model,base.group_portal,1,0,0,0
# Pattern 5: Multi-company model â requires company record rules too
access_my_model_user,my.model user,model_my_model,my_module.group_my_module_user,1,1,1,0
# Pattern 6: Inherited model with added sensitive fields
# When using _inherit and adding fields that need access control,
# create a NEW model with _name set and add separate access rules
Common Mistakes in Model Access
Mistake 1: Missing access for transient models
# models/my_wizard.py
class MyWizard(models.TransientModel):
_name = 'my.wizard' # NEEDS entry in ir.model.access.csv!
Mistake 2: Leaving group_id empty (grants access to ALL users)
# DANGEROUS â grants access to every logged-in user
access_my_model_all,my.model all,model_my_model,,1,1,1,0
Mistake 3: Wrong model_id derivation
# WRONG
access_x,x,my.model,,1,0,0,0
# CORRECT
access_x,x,model_my_model,,1,0,0,0
3. User Groups â Complete Patterns
Group Hierarchy Best Practices
<odoo>
<data>
<record id="module_category_my_app" model="ir.module.category">
<field name="name">My Application</field>
<field name="description">Manage My Application access levels</field>
<field name="sequence">100</field>
<field name="exclusive_ids" eval="[
(4, ref('group_my_app_readonly')),
(4, ref('group_my_app_user')),
(4, ref('group_my_app_manager'))
]"/>
</record>
<!-- Read-only: Can view but not create/edit -->
<record id="group_my_app_readonly" model="res.groups">
<field name="name">Read Only</field>
<field name="category_id" ref="module_category_my_app"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- User: Standard operational access -->
<record id="group_my_app_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="module_category_my_app"/>
<field name="implied_ids" eval="[
(4, ref('base.group_user')),
(4, ref('group_my_app_readonly'))
]"/>
</record>
<!-- Manager: Administrative access -->
<record id="group_my_app_manager" model="res.groups">
<field name="name">Administrator</field>
<field name="category_id" ref="module_category_my_app"/>
<field name="implied_ids" eval="[
(4, ref('group_my_app_user'))
]"/>
<field name="users" eval="[(4, ref('base.user_root'))]"/>
</record>
</data>
</odoo>
Field-Level Security with groups= Attribute
class MyModel(models.Model):
_name = 'my.model'
# Public field â everyone can see
name = fields.Char(string='Name', required=True)
# Sensitive field â only managers can see/edit
salary = fields.Float(
string='Salary',
groups='my_module.group_my_app_manager'
)
# Internal note â hidden from portal users
internal_note = fields.Text(
string='Internal Note',
groups='base.group_user'
)
# Computed field with group restriction
margin_percent = fields.Float(
string='Margin %',
compute='_compute_margin',
groups='my_module.group_my_app_manager'
)
4. Record Rules â Domain-Based Row-Level Security
Standard Record Rule Patterns
<odoo>
<data noupdate="1">
<!-- Pattern 1: Multi-company isolation (REQUIRED for company_id fields) -->
<record id="rule_my_model_multi_company" model="ir.rule">
<field name="name">My Model: Multi-Company</field>
<field name="model_id" ref="model_my_model"/>
<field name="global" eval="True"/>
<field name="domain_force">
['|',
('company_id', '=', False),
('company_id', 'in', company_ids)
]
</field>
</record>
<!-- Pattern 2: User can only access their own records -->
<record id="rule_my_model_user_own" model="ir.rule">
<field name="name">My Model: Own Records Only</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">[('create_uid', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('my_module.group_my_app_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Pattern 3: Manager sees everything (override user restriction) -->
<record id="rule_my_model_manager_all" model="ir.rule">
<field name="name">My Model: Manager All Access</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('my_module.group_my_app_manager'))]"/>
</record>
<!-- Pattern 4: Portal users see only published/confirmed records -->
<record id="rule_my_model_portal" model="ir.rule">
<field name="name">My Model: Portal Published Only</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">
[('state', 'in', ['published', 'confirmed'])]
</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Pattern 5: Salesperson sees only their team's records -->
<record id="rule_my_model_sales_team" model="ir.rule">
<field name="name">My Model: Sales Team</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">
[('team_id.member_ids', 'in', [user.id])]
</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
</record>
</data>
</odoo>
Record Rule Evaluation Logic
Record rules within the same group are evaluated with OR logic (any matching rule grants access). Rules from different groups are evaluated with AND logic (all matching rules must pass). This means:
- Multiple rules for the same group = broader access (OR)
- Rules for different groups = narrower access (AND intersection)
- Global rules (
<field name="global" eval="True"/>) apply to ALL users
When Record Rules Are Missing (Vulnerability)
A model without record rules but with model access means ANY authenticated user with the group can see ALL records â including data from other companies, other users, or sensitive records. Always add:
- Multi-company rule if model has
company_id - User-scoping rule if records are user-specific
- State-filtering rule for portal/public access
5. HTTP Route Security â Authentication Patterns
Route Authentication Types
from odoo import http
from odoo.http import request
class MyController(http.Controller):
# auth='user' â Requires login, redirects to /web/login if not authenticated
# Use for: Internal backend routes, authenticated user operations
@http.route('/my/internal/route', type='http', auth='user', methods=['GET'])
def internal_route(self):
# request.env.user is the authenticated user
return request.render('my_module.template', {})
# auth='public' â Works for both guests and logged-in users
# Use for: Public website pages, product listings, blog posts
# WARNING: Never expose sensitive data here
@http.route('/my/public/route', type='http', auth='public', methods=['GET'])
def public_route(self):
# request.env.user = public user if not logged in
# Check: if request.env.user._is_public(): handle guest case
if not request.env.user._is_public():
# Authenticated user
pass
return request.render('my_module.public_template', {})
# auth='none' â No authentication at all, raw HTTP
# Use for: Webhooks with their own auth, health checks, OAuth callbacks
# CRITICAL: Must implement own authentication if handling sensitive data
@http.route('/my/webhook', type='http', auth='none', methods=['POST'])
def webhook(self, **kwargs):
# Validate with HMAC signature, API key, or IP whitelist
api_key = request.httprequest.headers.get('X-API-Key')
if not self._validate_api_key(api_key):
return request.make_response('Unauthorized', status=401)
# Process webhook...
return request.make_response('OK', status=200)
CSRF Protection
# POST routes that modify state MUST have csrf=False only for APIs/webhooks
# By default, Odoo enforces CSRF for all POST routes (good!)
# DANGEROUS â disables CSRF for a state-modifying route
@http.route('/my/action', type='http', auth='user', methods=['POST'], csrf=False)
def bad_action(self):
pass # This is vulnerable to CSRF attacks!
# CORRECT â keep CSRF enabled (default) for web form actions
@http.route('/my/action', type='http', auth='user', methods=['POST'])
def safe_action(self):
pass
# ACCEPTABLE â disable CSRF only for machine-to-machine API calls with API key auth
@http.route('/api/v1/webhook', type='json', auth='none', methods=['POST'], csrf=False)
def webhook(self, **kwargs):
# MUST validate API key here
self._authenticate_request()
JSON API Routes
# For JSON APIs, use type='json' â this handles serialization and error formatting
@http.route('/api/v1/data', type='json', auth='user', methods=['POST'])
def api_data(self, **kwargs):
# Return dict, Odoo serializes to JSON automatically
return {'status': 'ok', 'data': []}
# Public API with API key authentication
@http.route('/api/v1/public', type='json', auth='none', methods=['POST'], csrf=False)
def public_api(self, api_key=None, **kwargs):
if not self._validate_api_key(api_key):
return {'error': 'Invalid API key', 'code': 401}
# Safe to process
Sensitive Data in Public Routes
# VULNERABLE â exposes partner email to public
@http.route('/partners', type='http', auth='public')
def partners(self):
partners = request.env['res.partner'].sudo().search([])
return request.render('template', {'partners': partners})
# SECURE â only expose non-sensitive fields, respect visibility
@http.route('/partners', type='http', auth='public')
def partners(self):
# Only published partners, only safe fields
partners = request.env['res.partner'].sudo().search([
('website_published', '=', True)
])
# Never pass raw recordsets with sensitive fields to public templates
partner_data = partners.read(['name', 'website', 'country_id'])
return request.render('template', {'partners': partner_data})
6. Sudo() Usage â Safe and Dangerous Patterns
When sudo() Is Appropriate
# APPROPRIATE 1: Escalating privileges to read configuration data
# User doesn't need access to system params, but the operation is safe
@api.model
def get_public_config(self):
param = self.env['ir.config_parameter'].sudo().get_param('my.public.setting')
return param # Only returning a specific, safe setting
# APPROPRIATE 2: Writing to audit log (user shouldn't control their own log)
def write(self, vals):
result = super().write(vals)
# Log change â user shouldn't have access to audit model
self.env['my.audit.log'].sudo().create({
'model': self._name,
'record_id': self.id,
'user_id': self.env.user.id,
'changes': str(vals),
})
return result
# APPROPRIATE 3: Sending notifications to users the current user can't access
def notify_admin(self):
admin = self.env.ref('base.user_admin').sudo()
admin.notify_warning('Alert', 'Something happened')
# APPROPRIATE 4: Computing fields that aggregate data across companies
@api.depends('order_ids')
def _compute_total_orders(self):
for rec in self:
rec.order_count = self.env['sale.order'].sudo().search_count([
('partner_id', '=', rec.id)
])
When sudo() Is DANGEROUS
# DANGEROUS 1: sudo() in a public/portal controller
# Bypasses ALL access controls â user can access any record by ID
@http.route('/order/<int:order_id>', auth='public')
def view_order(self, order_id):
# VULNERABLE TO IDOR â anyone can access any order ID
order = request.env['sale.order'].sudo().browse(order_id)
return request.render('template', {'order': order})
# SECURE VERSION â verify ownership before accessing
@http.route('/order/<int:order_id>', auth='user')
def view_order(self, order_id):
order = request.env['sale.order'].browse(order_id)
# ORM access controls apply â user can only see their orders
if not order.exists():
raise request.not_found()
return request.render('template', {'order': order})
# DANGEROUS 2: sudo() in a loop
def process_all(self):
for record in self.search([]): # Could be thousands of records
# sudo() call inside loop = performance issue + security concern
sensitive = self.env['sensitive.model'].sudo().search([
('ref_id', '=', record.id)
])
record.result = len(sensitive)
# SECURE VERSION â batch the sudo() outside the loop
def process_all(self):
all_records = self.search([])
# Single sudo() query outside loop
sensitive_data = self.env['sensitive.model'].sudo().read_group(
[('ref_id', 'in', all_records.ids)],
['ref_id'],
['ref_id']
)
count_map = {d['ref_id'][0]: d['ref_id_count'] for d in sensitive_data}
for record in all_records:
record.result = count_map.get(record.id, 0)
# DANGEROUS 3: Passing sudo env to user-controlled operations
def dangerous_search(self, domain):
# User provides domain â they could access ANY record
sudo_env = self.env['my.model'].sudo()
return sudo_env.search(domain) # User controls domain!
# SECURE VERSION â always apply fixed safety constraints
def safe_search(self, user_domain):
# Sanitize and constrain the domain
safe_domain = [
('partner_id', '=', self.env.user.partner_id.id), # Fixed constraint
] + user_domain # Append user's additional filters
return self.env['my.model'].search(safe_domain) # No sudo()
# DANGEROUS 4: sudo() in onchange (exposes data in UI)
@api.onchange('partner_id')
def _onchange_partner_id(self):
# sudo() in onchange means ANY user can see ALL partner data
all_orders = self.env['sale.order'].sudo().search([
('partner_id', '=', self.partner_id.id)
])
self.order_count = len(all_orders)
Scoped sudo() with Specific User
# Instead of full sudo(), use a specific user's environment
# This gives exactly the permissions of that user, not root
# Get sudo environment for the admin user only
admin_user = self.env.ref('base.user_admin')
admin_env = self.env['my.model'].with_user(admin_user)
# Or use with_context to pass additional context
elevated_env = self.env['my.model'].with_context(
no_check=True, # Only if the model respects this context key
force_company=self.company_id.id
)
7. SQL Injection Prevention
Parameterized Queries (Always Required)
# VULNERABLE â string concatenation with user input
def vulnerable_search(self, name):
query = "SELECT id FROM res_partner WHERE name = '%s'" % name
self.env.cr.execute(query) # SQL INJECTION RISK!
return self.env.cr.fetchall()
# SECURE â parameterized query (tuple format)
def safe_search(self, name):
query = "SELECT id FROM res_partner WHERE name = %s"
self.env.cr.execute(query, (name,)) # Parameters passed separately
return self.env.cr.fetchall()
# VULNERABLE â f-string in SQL
def vulnerable_f(self, table, field):
query = f"SELECT {field} FROM {table}" # INJECTION RISK!
self.env.cr.execute(query)
# SECURE â use psycopg2.sql for dynamic identifiers
from psycopg2 import sql
def safe_dynamic(self, field_name):
# Use sql.Identifier for table/column names (NOT string format)
query = sql.SQL("SELECT {} FROM res_partner LIMIT 10").format(
sql.Identifier(field_name)
)
self.env.cr.execute(query)
return self.env.cr.fetchall()
# SECURE â ORM domain for user-provided filters (preferred over raw SQL)
def orm_search(self, partner_name):
# ORM handles escaping automatically
return self.env['res.partner'].search([('name', 'ilike', partner_name)])
Safe Raw SQL Patterns
# SECURE â multiple parameters
def get_partner_orders(self, partner_id, state, date_from):
query = """
SELECT so.id, so.name, so.amount_total
FROM sale_order so
WHERE so.partner_id = %s
AND so.state = %s
AND so.date_order >= %s
ORDER BY so.date_order DESC
LIMIT 100
"""
self.env.cr.execute(query, (partner_id, state, date_from))
return self.env.cr.dictfetchall()
# SECURE â IN clause with list (psycopg2 handles tuple expansion)
def get_orders_by_ids(self, order_ids):
if not order_ids:
return []
query = "SELECT id, name FROM sale_order WHERE id IN %s"
self.env.cr.execute(query, (tuple(order_ids),))
return self.env.cr.fetchall()
# SECURE â LIKE pattern with escaping
def search_by_prefix(self, prefix):
# % must be escaped in parameterized queries using %%
query = "SELECT id FROM res_partner WHERE name ILIKE %s"
self.env.cr.execute(query, (prefix + '%',))
return self.env.cr.fetchall()
8. Field-Level Security
groups= Attribute on Field Definitions
class Employee(models.Model):
_inherit = 'hr.employee'
# Visible to all HR users
name = fields.Char()
# Only HR managers can see salary information
wage = fields.Float(
groups='hr.group_hr_manager'
)
# Only accessible via HR admin (payroll group)
ssnid = fields.Char(
string='SSN',
groups='hr.group_hr_user' # Still too broad â consider more restrictive
)
# Computed field with group restriction
contract_count = fields.Integer(
compute='_compute_contract_count',
groups='hr.group_hr_manager'
)
# Multiple groups (OR logic â either group grants access)
sensitive_field = fields.Text(
groups='my_module.group_hr_manager,base.group_system'
)
View-Level Field Hiding
<!-- Hide field in view for non-managers (defense in depth) -->
<field name="salary" groups="my_module.group_my_app_manager"/>
<!-- Show different fields based on group -->
<field name="public_notes"/>
<field name="private_notes" groups="my_module.group_my_app_manager"/>
<!-- Entire section hidden from non-managers -->
<group string="Financials" groups="account.group_account_manager">
<field name="revenue"/>
<field name="cost"/>
<field name="margin"/>
</group>
9. Portal Security Patterns
Secure Portal Controller
from odoo import http
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
from odoo.exceptions import AccessError, MissingError
class MyPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if 'my_model_count' in counters:
values['my_model_count'] = request.env['my.model'].search_count(
self._get_my_model_domain()
)
return values
def _get_my_model_domain(self):
"""Return domain that limits portal access to current user's records only."""
return [('partner_id', '=', request.env.user.partner_id.id)]
@http.route(['/my/orders', '/my/orders/page/<int:page>'],
type='http', auth='user', website=True)
def portal_my_orders(self, page=1, **kwargs):
# SECURE â domain always scoped to current user
domain = self._get_my_model_domain()
total = request.env['my.model'].search_count(domain)
pager = portal_pager(
url='/my/orders',
total=total,
page=page,
step=10
)
orders = request.env['my.model'].search(
domain,
limit=10,
offset=pager['offset']
)
return request.render('my_module.portal_orders', {
'orders': orders,
'pager': pager,
})
@http.route('/my/order/<int:order_id>', type='http', auth='user', website=True)
def portal_order_detail(self, order_id, **kwargs):
# SECURE â use _document_check_access to verify ownership
try:
order = self._document_check_access('my.model', order_id)
except (AccessError, MissingError):
return request.redirect('/my/orders')
return request.render('my_module.portal_order_detail', {'order': order})
_document_check_access Implementation
# This method (from CustomerPortal) verifies:
# 1. Record exists
# 2. Current user has access rights to it
# 3. Optional: token-based access for share links
# Always use this instead of sudo().browse() in portal controllers
10. API Security Patterns
API Key Authentication
class ApiController(http.Controller):
def _get_api_key_user(self, api_key):
"""Validate API key and return associated user or None."""
if not api_key:
return None
# Store API keys in a secure model with hashed values
key_record = request.env['my.api.key'].sudo().search([
('key_hash', '=', hashlib.sha256(api_key.encode()).hexdigest()),
('active', '=', True),
], limit=1)
if not key_record or key_record.is_expired():
return None
return key_record.user_id
@http.route('/api/v1/resource', type='json', auth='none', csrf=False)
def api_resource(self, **kwargs):
api_key = request.httprequest.headers.get('X-Api-Key', '')
user = self._get_api_key_user(api_key)
if not user:
return {'error': 'Unauthorized', 'code': 401}
# Switch to the API key user's environment (respects their permissions)
env = request.env(user=user.id)
records = env['my.model'].search([])
return {'data': records.read(['name', 'state'])}
Rate Limiting Pattern
import time
from collections import defaultdict
class RateLimiter:
"""Simple in-memory rate limiter â use Redis in production."""
_requests = defaultdict(list)
@classmethod
def is_allowed(cls, key, limit=60, window=60):
now = time.time()
cls._requests[key] = [t for t in cls._requests[key] if now - t < window]
if len(cls._requests[key]) >= limit:
return False
cls._requests[key].append(now)
return True
class ApiController(http.Controller):
@http.route('/api/v1/data', type='json', auth='none', csrf=False)
def api_data(self, **kwargs):
client_ip = request.httprequest.remote_addr
if not RateLimiter.is_allowed(client_ip, limit=60, window=60):
return {'error': 'Rate limit exceeded', 'code': 429}
# Process request...
11. Common Vulnerabilities â Top 10 Odoo Security Mistakes
1. Missing ir.model.access.csv (CRITICAL)
Risk: Every authenticated user can read/write ALL records of the model.
Detection: Run access_checker.py â finds models with no access rules.
Fix: Create complete security/ir.model.access.csv with entries for all defined models.
2. auth=’none’ on Data Routes (CRITICAL)
Risk: Unauthenticated access to sensitive business data.
Detection: Run route_auditor.py â flags auth='none' without justification.
Fix: Use auth='user' for internal routes, implement API key auth for auth='none' webhooks.
3. IDOR (Insecure Direct Object Reference) (HIGH)
Risk: Integer IDs are sequential â attackers enumerate IDs to access other users’ records.
Detection: Public routes accepting <int:id> parameters with sudo() access.
Fix: Use _document_check_access(), verify partner_id == request.env.user.partner_id.id.
4. SQL Injection via env.cr.execute() (HIGH)
Risk: String concatenation with user input leads to data theft or destruction.
Detection: Run grep for env.cr.execute + string formatting nearby.
Fix: Always use parameterized queries: self.env.cr.execute(query, (param,)).
5. sudo() Privilege Escalation in Controllers (HIGH)
Risk: sudo() in public routes bypasses all access controls.
Detection: Run sudo_finder.py â finds sudo() in public/portal methods.
Fix: Use user-scoped environments, verify ownership before access.
6. Missing Multi-Company Record Rules (HIGH)
Risk: Users in company A can see/modify data from company B.
Detection: Models with company_id field but no multi-company record rule.
Fix: Add ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] rule.
7. Missing CSRF on State-Changing Routes (MEDIUM)
Risk: Malicious websites can trigger actions as the authenticated user.
Detection: POST routes with csrf=False that modify data.
Fix: Remove csrf=False from web routes (only use for machine-to-machine APIs).
8. Sensitive Fields Without groups= (MEDIUM)
Risk: Salary, SSN, passwords visible to all users who can access the model.
Detection: Look for fields named salary, password, token, ssn, secret without groups=.
Fix: Add groups='my_module.group_manager' to sensitive field definitions.
9. sudo() in Loops (MEDIUM)
Risk: Performance degradation + security â each iteration re-escalates privileges unnecessarily.
Detection: Run sudo_finder.py â finds .sudo() inside for loops.
Fix: Move sudo() call before loop, batch the query.
10. Overly Permissive Record Rules (LOW)
Risk: Users can access records they shouldn’t (e.g., other departments’ data).
Detection: Record rules with (1, '=', 1) domain for non-manager groups.
Fix: Add appropriate filters â company, user, team, or state constraints.
12. Audit Commands â How to Use Each Command
/odoo-security â Full Module Security Audit
# Run complete security audit on a module
/odoo-security /path/to/odoo17/projects/my_project/my_module
# What it checks:
# - Model access rules completeness
# - HTTP route authentication
# - sudo() usage safety
# - SQL injection risks
# - Record rule coverage
# - Field-level security on sensitive fields
# Output format:
# [CRITICAL] Model 'my.model' has no access rules in ir.model.access.csv
# [HIGH] Route /api/data uses auth='none' without API key validation
# [HIGH] sudo() found in public controller method view_order (line 45)
# [MEDIUM] Field 'salary' has no groups= restriction
# [LOW] Record rule allows all users to see all records
# Exit codes:
# 0 = No issues found
# 1 = Issues found (check report for details)
/security-audit â Targeted Module Audit
# Audit specific module with verbose output
/security-audit my_module_name
# Useful for CI/CD integration â fails build on CRITICAL/HIGH issues
/check-access â Model Access Rule Checker
# Check access rules for all models in a module
/check-access /path/to/module
# Example output:
# Scanning models in: /path/to/module/models/
# Found 5 model definitions:
# my.model -> [CRITICAL] NO ACCESS RULES DEFINED
# my.wizard -> [HIGH] Wizard has no access rules
# my.category -> [OK] 2 rules found
# my.tag -> [OK] 2 rules found
# my.config -> [MEDIUM] Only admin group, missing user group
/find-sudo â sudo() Usage Scanner
# Find all sudo() calls and classify by risk
/find-sudo /path/to/module
# Example output:
# controllers/main.py:45 [CRITICAL] sudo() in auth='public' route
# models/my_model.py:123 [HIGH] sudo() inside for loop
# models/my_model.py:89 [OK] sudo() for audit log write (safe pattern)
# wizards/my_wizard.py:34 [MEDIUM] Unscoped sudo() â consider with_user()
/check-routes â HTTP Route Security Scanner
# Audit all HTTP routes in controllers/
/check-routes /path/to/module
# Example output:
# controllers/main.py:
# GET /my/public auth='public' [OK]
# POST /my/action auth='user' [OK]
# POST /api/webhook auth='none' [CRITICAL] No API key validation found
# GET /data auth='public' [HIGH] Reads sensitive model without filtering
13. Remediation Patterns â How to Fix Each Issue
Fix: Missing Model Access Rules
# 1. Identify models without access rules
python scripts/access_checker.py /path/to/module
# 2. Create or update security/ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_my_model_user,my.model user,model_my_model,my_module.group_my_module_user,1,1,1,0
access_my_model_manager,my.model manager,model_my_model,my_module.group_my_module_manager,1,1,1,1
# 3. Add to __manifest__.py data list
'data': [
'security/group_my_module.xml',
'security/ir.model.access.csv', # Must come AFTER group definitions
'security/rules_my_module.xml',
...
]
Fix: Insecure Route Authentication
# BEFORE (CRITICAL)
@http.route('/sensitive/data', type='json', auth='none')
def get_data(self):
return request.env['sensitive.model'].sudo().search_read([])
# AFTER (SECURE)
@http.route('/sensitive/data', type='json', auth='user')
def get_data(self):
# Now request.env automatically scoped to authenticated user
return request.env['sensitive.model'].search_read([])
Fix: IDOR in Portal Routes
# BEFORE (VULNERABLE)
@http.route('/order/<int:order_id>', auth='public')
def view_order(self, order_id):
order = request.env['sale.order'].sudo().browse(order_id)
return request.render('template', {'order': order})
# AFTER (SECURE)
@http.route('/order/<int:order_id>', auth='user', website=True)
def view_order(self, order_id):
from odoo.exceptions import AccessError, MissingError
try:
order = self._document_check_access('sale.order', order_id)
except (AccessError, MissingError):
return request.redirect('/my/orders')
return request.render('template', {'order': order})
Fix: SQL Injection
# BEFORE (VULNERABLE)
def search_orders(self, status):
query = "SELECT id FROM sale_order WHERE state = '%s'" % status
self.env.cr.execute(query)
return self.env.cr.fetchall()
# AFTER (SECURE)
def search_orders(self, status):
query = "SELECT id FROM sale_order WHERE state = %s"
self.env.cr.execute(query, (status,)) # Tuple with trailing comma!
return self.env.cr.fetchall()
Fix: sudo() in Public Controller
# BEFORE (CRITICAL)
@http.route('/products', auth='public')
def products(self):
products = request.env['product.template'].sudo().search([])
return request.render('template', {'products': products})
# AFTER (SECURE)
@http.route('/products', auth='public')
def products(self):
# Only show published products, use sudo only for the published filter
products = request.env['product.template'].sudo().search([
('website_published', '=', True),
('sale_ok', '=', True),
])
# Restrict fields returned to non-sensitive data
product_data = products.read(['name', 'description_sale', 'list_price', 'image_128'])
return request.render('template', {'products': product_data})
Fix: sudo() in Loop
# BEFORE (MEDIUM â performance + security)
def compute_totals(self):
for record in self:
related = self.env['related.model'].sudo().search([
('record_id', '=', record.id)
])
record.total = sum(related.mapped('amount'))
# AFTER (SECURE + PERFORMANT)
def compute_totals(self):
# Single sudo() query outside loop
related_data = self.env['related.model'].sudo().read_group(
[('record_id', 'in', self.ids)],
['record_id', 'amount:sum'],
['record_id']
)
totals = {d['record_id'][0]: d['amount'] for d in related_data}
for record in self:
record.total = totals.get(record.id, 0.0)
Fix: Missing Multi-Company Record Rule
<!-- BEFORE: No record rule = all users in all companies can see all records -->
<!-- AFTER: Add to security/rules_my_module.xml -->
<record id="rule_my_model_multi_company" model="ir.rule">
<field name="name">My Model: Multi-Company Access</field>
<field name="model_id" ref="model_my_model"/>
<field name="global" eval="True"/>
<field name="domain_force">
['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]
</field>
</record>
Fix: Sensitive Field Without Group Restriction
# BEFORE (MEDIUM)
class Employee(models.Model):
_inherit = 'hr.employee'
salary_override = fields.Float(string='Special Salary')
# AFTER (SECURE)
class Employee(models.Model):
_inherit = 'hr.employee'
salary_override = fields.Float(
string='Special Salary',
groups='hr.group_hr_manager' # Only HR managers can see/edit
)
14. Security Checklist for Code Review
Use this checklist when reviewing any Odoo module pull request:
Models Checklist
- Every
_namedefinition has entries inir.model.access.csv - Transient models (wizards) have access rules
- Models with
company_idhave multi-company record rules - Record rules scope access appropriately (not overly permissive)
- Sensitive fields have
groups=restriction -
_sql_constraintsused for uniqueness (not just Python constraints)
Controllers Checklist
- Every route has explicit
auth=parameter - No
auth='none'routes without API key/HMAC validation - POST routes that modify state have CSRF enabled (no
csrf=False) - Portal routes use
_document_check_access()for record access - Public routes don’t expose sensitive model data
- User-provided IDs are validated against current user’s accessible records
Python Code Checklist
- No string formatting/f-strings in
env.cr.execute()calls - All raw SQL uses parameterized queries
-
sudo()calls have documented justification in comment - No
sudo()insideforloops - No
sudo()in public/portal controllers without domain scoping
Security Files Checklist
-
security/directory exists with required files - Group XML file defines category and group hierarchy
-
ir.model.access.csvhas all required access rules - Record rules file exists for models needing row-level security
- All security files listed in
__manifest__.pydata list in correct order
15. Integration with CI/CD
GitHub Actions Security Gate
# .github/workflows/security-check.yml
name: Odoo Security Audit
on: [push, pull_request]
jobs:
security-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Run Security Audit
run: |
python .claude-plugins/odoo-security/scripts/security_auditor.py \
./projects/my_project/my_module \
--min-severity HIGH \
--exit-on-issues
# Exit code 1 = issues found = build fails
- name: Upload Security Report
if: always()
uses: actions/upload-artifact@v3
with:
name: security-report
path: security-report.json
Pre-commit Hook Integration
#!/bin/bash
# .git/hooks/pre-commit
# Run security audit on changed modules
CHANGED_MODULES=$(git diff --cached --name-only | grep -E '^projects/' | \
sed 's|/.*||' | sort -u)
for MODULE in $CHANGED_MODULES; do
echo "Running security audit on $MODULE..."
python /path/to/security_auditor.py "$MODULE" --min-severity CRITICAL
if [ $? -ne 0 ]; then
echo "CRITICAL security issues found in $MODULE. Commit blocked."
exit 1
fi
done
exit 0
16. Odoo Version-Specific Security Notes
Odoo 14
auth_jwtmodule not available natively â use custom API key implementationwebsite.http_routing_mapslower â cached routes need security reviewmail.threadaccess rules inherited but worth verifying
Odoo 15
- Introduction of
website.auth_signup_tokenfor secure registration links - Enhanced portal security with stronger token validation
- Multi-website record rules became more important
Odoo 16
ir.http._auth_method_public()refactored â custom auth methods need updatingwebsite.route.securitygroup added for route-level security- JSON API routes improved error handling (don’t expose stack traces in production)
Odoo 17
- New
_check_access()method replaces some manual access checks - Improved
check_access_rights()andcheck_access_rule()APIs env.user._is_internal()/env.user._is_public()/env.user._is_portal()helpers- Webhook security improvements with signature verification helpers
Odoo 18
- OAuth 2.0 integration improvements â verify token validation in custom OAuth routes
- Enhanced attachment security â check
access_tokenhandling - Two-factor authentication API expanded
Odoo 19
- REST API framework introduced â use built-in auth decorators
- Improved rate limiting primitives in core
- Enhanced field-level encryption support for PII data