Odoo 中的安全机制

除了通过自定义代码手动管理访问之外,Odoo 还提供了两种主要的数据驱动机制来管理或限制对数据的访问。

这两种机制都通过 群组 与特定用户关联:一个用户可以属于任意多个群组,而安全机制与群组关联,从而将安全机制应用到用户身上。

class res.groups
name

作为用户可读的群组标识(说明群组的角色/用途)

category_id

模块类别 用于将群组与一个 Odoo 应用(约等于一组相关的业务模型)关联起来,并将其转换为用户表单中的单选项。

implied_ids

要与该群组一起设置给用户的其它群组。这是一种便利的伪继承关系:可以在不移除施加者的情况下,显式地从用户移除被隐含的群组。

comment

关于该群组的补充说明,例如

访问权限

授予 对整个模型的一组给定操作的访问权限。如果(通过用户所属群组)没有任何访问权限匹配用户对某模型的操作,则该用户没有访问权限。

访问权限是可叠加的:用户的访问权限是其所有群组所授访问权限的并集。例如,某用户属于授予读取和创建权限的群组 A,以及授予更新权限的群组 B,则该用户将同时拥有创建、读取和更新这三种权限。

class ir.model.access
name

群组的用途或角色。

model_id

ACL 所控制访问的模型。

group_id

授予访问权限的 res.groups,空的 group_id 表示 ACL 授予 所有用户 (非员工,如门户或公共用户)。

perm_method 属性在设置时授予对应的 CRUD 访问权限,它们默认均未设置。

perm_create
perm_read
perm_write

记录规则

记录规则是必须满足的 条件,满足后操作才被允许。记录规则在访问权限之后逐条记录地求值。

记录规则默认是允许的:如果访问权限已授予访问,且没有任何规则适用于该用户对此模型的操作,则访问被授予。

class ir.rule
name

规则的描述。

model_id

规则所适用的模型。

groups

授予(或不授予)访问权限的 res.groups。可以指定多个群组。如果未指定群组,该规则为 全局 规则,其处理方式与”群组”规则不同(见下文)。

global

基于 groups 计算,便于访问该规则是否为全局的状态。

domain_force

以 domain 形式指定的谓词:如果域与记录匹配,规则允许所选操作,否则禁止。

该域是一个 Python 表达式,可以使用以下变量:

time

Python 的 python:time 模块。

user

当前用户,以单例记录集表示。

company_id

当前用户当前所选公司,以单个公司 id 表示(不是记录集)。

company_ids

当前用户有权访问的所有公司,以公司 id 列表表示(不是记录集),详情参见 安全规则。

perm_method 与 ir.model.access 中的语义完全不同:对于记录规则,它们指定规则 适用于 哪些操作。如果某操作未被选中,则该规则不会针对它进行检查,如同规则不存在一样。

默认选中所有操作。

perm_create
perm_read
perm_write

全局规则与群组规则

全局规则与群组规则在组合与合并方式上有很大区别:

  • 全局规则 取交集:如果两条全局规则都适用,则 必须同时满足 才能授予访问权限,这意味着添加全局规则总是进一步限制访问。

  • 群组规则 取并集:如果两条群组规则都适用,只需满足其中之一 即可授予访问权限。这意味着添加群组规则可以扩大访问范围,但不能超出全局规则定义的界限。

  • 全局规则集与群组规则集 取交集,这意味着向某个全局规则集添加第一条群组规则就会限制访问。

Danger

创建多条全局规则有风险,因为可能创建出互不重叠的规则集,从而移除所有访问权限。

字段访问

ORM 的 Field 可以带有 groups 属性,提供一组群组(以逗号分隔的 external identifiers 字符串)。

如果当前用户不在所列群组之一,他将无法访问该字段:

  • 受限字段会自动从请求的视图中移除

  • 受限字段会从 fields_get() 响应中移除

  • 尝试(显式)读取或写入受限字段会导致访问错误

安全陷阱

作为开发者,理解安全机制并避免导致不安全代码的常见错误非常重要。

不安全的公共方法

任何公共方法都可以通过 RPC 调用 使用任意参数执行。以 _ 开头的方法不能从动作按钮或外部 API 调用。

对于公共方法,执行方法所在的记录及参数都不可信任,因为 ACL 只在 CRUD 操作期间校验。

# this method is public and its arguments can not be trusted
def action_done(self):
    if self.state == "draft" and self.user_has_groups('base.manager'):
        self._set_state("done")

# this method is private and can only be called from other python methods
def _set_state(self, new_state):
    self.sudo().write({"state": new_state})

将方法设为私有显然还不够,还必须注意正确使用它。

绕过 ORM

当 ORM 能完成同样的事情时,绝不应直接使用数据库游标!这样做会绕过 ORM 的所有特性,可能包括翻译、字段失效、active、访问权限等自动化行为。

而且这很可能还会让代码更难读,安全性也可能更差。

# very very wrong
self.env.cr.execute('SELECT id FROM auction_lots WHERE auction_id in (' + ','.join(map(str, ids))+') AND state=%s AND obj_price > 0', ('draft',))
auction_lots_ids = [x[0] for x in self.env.cr.fetchall()]

# no injection, but still wrong
self.env.cr.execute('SELECT id FROM auction_lots WHERE auction_id in %s '\
           'AND state=%s AND obj_price > 0', (tuple(ids), 'draft',))
auction_lots_ids = [x[0] for x in self.env.cr.fetchall()]

# better
auction_lots_ids = self.search([('auction_id','in',ids), ('state','=','draft'), ('obj_price','>',0)])

SQL 注入

使用手写 SQL 查询时必须小心,避免引入 SQL 注入漏洞。当用户输入未被正确过滤或引号处理不当时,就会出现这种漏洞,使攻击者可以向 SQL 查询注入不良子句(例如绕过过滤条件,或执行 UPDATE 或 DELETE 命令)。

确保安全的最佳方法是绝不、绝不使用 Python 字符串拼接(+)或字符串参数插值(%)将变量传入 SQL 查询字符串。

第二个原因几乎同样重要:决定如何格式化查询参数是数据库抽象层(psycopg2)的职责,而不是你的!例如,psycopg2 知道当你传入一个值列表时,需要将其格式化为括号括起的逗号分隔列表!

# the following is very bad:
#   - it's a SQL injection vulnerability
#   - it's unreadable
#   - it's not your job to format the list of ids
self.env.cr.execute('SELECT distinct child_id FROM account_account_consol_rel ' +
           'WHERE parent_id IN ('+','.join(map(str, ids))+')')

# better
self.env.cr.execute('SELECT DISTINCT child_id '\
           'FROM account_account_consol_rel '\
           'WHERE parent_id IN %s',
           (tuple(ids),))

这一点非常重要,因此在重构时也请务必小心,最重要的是不要复制这些模式!

下面是一个便于记忆的示例,帮助你记住这个问题的要点(但请不要复制其中的代码)。在继续之前,请务必阅读 pyscopg2 的在线文档,以了解如何正确使用它:

未转义的字段内容

使用 JavaScript 和 XML 渲染内容时,人们可能想用 t-raw 来显示富文本内容。这是常见的 XSS 攻击载体,应当避免。

从计算到最终集成到浏览器 DOM,很难控制数据的完整性。引入时正确转义的 t-raw 在下一次 bug 修复或重构后可能不再安全。

QWeb.render('insecure_template', {
    info_message: "You have an <strong>important</strong> notification",
})
<div t-name="insecure_template">
    <div id="information-bar"><t t-raw="info_message" /></div>
</div>

上面的代码可能看起来是安全的,因为消息内容是受控的,但这是一种不良实践,一旦这段代码将来演化,可能导致意外的安全漏洞。

// XSS possible with unescaped user provided content !
QWeb.render('insecure_template', {
    info_message: "You have an <strong>important</strong> notification on " \
        + "the product <strong>" + product.name + "</strong>",
})

虽然以不同方式格式化模板可以避免此类漏洞。

QWeb.render('secure_template', {
    message: "You have an important notification on the product:",
    subject: product.name
})
<div t-name="secure_template">
    <div id="information-bar">
        <div class="info"><t t-esc="message" /></div>
        <div class="subject"><t t-esc="subject" /></div>
    </div>
</div>
.subject {
    font-weight: bold;
}

转义与净化

Important

只要数据与代码混用,无论数据多么安全,转义都是 100% 必须执行的。

转义(Escaping) 是将 文本 转换为 代码。每当把 数据/文本 与 代码 混合时(例如生成 HTML 或将要放入 safe_eval 中求值的 Python 代码),都绝对必须执行转义,因为 代码 总是要求 文本 经过编码。这对安全至关重要,但也是一个正确性问题。即使不存在安全风险(因为文本 100% 保证安全或可信),转义仍然是必需的(例如避免破坏生成的 HTML 中的布局)。

只要开发者能识别哪个变量包含 文本、哪个包含 代码,转义就永远不会破坏任何功能。

>>> from odoo.tools import html_escape, html_sanitize
>>> data = "<R&D>" # `data` is some TEXT coming from somewhere

# Escaping turns it into CODE, good!
>>> code = html_escape(data)
>>> code
'&lt;R&amp;D&gt;'

# Now you can mix it with other code...
>>> self.message_post(body="<strong>%s</strong>" % code)

净化(Sanitizing) 是将 代码 转换为 更安全的代码 (但不一定是 安全 的代码)。它不作用于 文本。只有当 代码 不可信时,也就是它全部或部分来自某些用户提供的数据时,才需要进行净化。如果用户提供的数据以 文本 形式存在(例如用户填写的表单内容),并且在放入 代码 之前已正确转义,那么净化是无用的(但仍可执行)。然而,如果用户提供的数据 未经转义,那么净化将 无法 按预期工作。

# Sanitizing without escaping is BROKEN: data is corrupted!
>>> html_sanitize(data)
''

# Sanitizing *after* escaping is OK!
>>> html_sanitize(code)
'<p>&lt;R&amp;D&gt;</p>'

净化可能会破坏功能,这取决于 代码 是否预期包含不安全的模式。这就是为什么 fields.Html 和 tools.html_sanitize() 提供了选项来微调样式等方面的净化级别。这些选项需要根据数据的来源和所需的功能仔细权衡。净化安全性与净化破坏性是相互制衡的:净化越安全,破坏功能的可能性就越大。

>>code = "<p class='text-warning'>Important Information</p>"
# this will remove the style, which may break features
# but is necessary if the source is untrusted
>> html_sanitize(code, strip_classes=True)
'<p>Important Information</p>'

对内容求值

有人可能想用 eval 来解析用户提供的内容。应当不惜一切代价避免使用 eval。可以改用更安全、沙箱化的 safe_eval 方法,但它仍然赋予执行它的用户强大的能力,因此必须仅保留给受信任的特权用户使用,因为它打破了代码与数据之间的屏障。

# very bad
domain = eval(self.filter_domain)
return self.search(domain)

# better but still not recommended
from odoo.tools import safe_eval
domain = safe_eval(self.filter_domain)
return self.search(domain)

# good
from ast import literal_eval
domain = literal_eval(self.filter_domain)
return self.search(domain)

解析内容不需要 eval

语言

数据类型

合适的解析器

Python

int、float 等。

int(), float()

Javascript

int、float 等。

parseInt(), parseFloat()

Python

dict

json.loads()、ast.literal_eval() 函数

Javascript

object、list 等。

JSON.parse() 函数

访问对象属性

如果需要动态地获取或修改记录的值,可以使用 getattr 和 setattr 方法。

# unsafe retrieval of a field value
def _get_state_value(self, res_id, state_field):
    record = self.sudo().browse(res_id)
    return getattr(record, state_field, False)

然而这段代码并不安全,因为它允许访问记录的任意属性,包括私有属性或方法。

记录集的 __getitem__ 已被定义,因此可以安全地轻松访问动态字段值:

# better retrieval of a field value
def _get_state_value(self, res_id, state_field):
    record = self.sudo().browse(res_id)
    return record[state_field]

上述方法显然仍然过于乐观,必须对记录 ID 和字段值进行额外的校验。