Odoo 中的安全性

除了使用自定义代码手动管理访问权限外,Odoo 还提供了一种主要的、由数据驱动的机制来管理或限制对数据的访问。

这种机制通过 用户组 与特定用户关联:一个用户可以属于任意数量的用户组,而安全规则与用户组关联,从而将安全规则应用于用户。

class res.groups
name

用作该用户组便于用户识别的标识(明确说明该用户组的角色或用途)

privilege_id

该 作用域 用于将用户组与某个 Odoo 应用(~一组相关的业务模型)关联,并将其转换为用户表单中的独占选择。

implied_ids

用户应被视为属于的其他用户组。用户 显式 属于其分配的用户组,并 隐式 属于其分配的用户组直接或间接隐含的所有用户组。

comment

关于该用户组的附加说明,主要用于文档目的。

访问权限

授予 用户在给定的一组操作上对模型中记录的访问权限。如果没有任何访问权限能匹配某用户(通过其用户组)对某个模型的某项操作,则该用户没有访问权限。

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

class ir.access
name

该用户组的用途或角色。

model_id

该访问权限所适用的模型。

group_id

获得这些访问权限的 res.groups。空的 group_id 表示该访问权限实际上是应用于 每个用户 的 限制。

operation

该访问权限应用于哪些操作,是 create、read、update、delete 的子集,以字符串 "crud" 的子序列表示。例如,"r" 表示“读取”,"cd" 表示“创建、删除”。

domain

以 域 指定的谓词。

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

time

Python 的 time 模块。

user

当前用户,作为单条记录集。

company_id

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

company_ids

当前用户可以访问的所有公司,表示为公司 ID 的列表(不是记录集),更多详情请参阅 安全规则。

域

访问权限的 domain 是必须满足的 条件,满足后才能允许某项操作。当未设置 domain 时,该访问权限实际上应用于模型的 所有记录,就如同域为 [] (始终为真)一样。

如果用户在其(显式和隐式)用户组之一中拥有针对该记录所属模型和操作的访问权限,并且该记录满足该访问权限的域,则该用户可以对给定记录执行某项操作。

全局规则与组规则

未设置 group_id 的访问权限表现为 限制,并应用于 所有用户。这就是它们被称为 全局规则 的原因。

要对给定记录执行某项操作,该记录必须满足该模型和该操作的所有 全局规则 的域。

因此,全局访问权限与用户组访问权限在构成和组合方式上存在很大差异:

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

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

  • 全局规则集与用户组规则集 取交集,这意味着全局规则可以阻止某些原本会由用户组规则授予的访问。

危险

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

字段访问权限

ORM 的 Field 可以有一个 groups 属性,用于提供用户组列表(以逗号分隔的 外部标识符 字符串)。

如果当前用户不在所列的任何用户组中,则该用户将无法访问该字段:

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

  • 受限字段会从 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.env.user.has_group('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()]

# nearly there
auction_lots_ids = [x[x] for x in self.env.execute_query(SQL("""
    SELECT id FROM auction_lots
    WHERE auction_id IN %s AND state = %s AND obj_price > 0
""", tuple(ids), 'draft'))

# 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 知道当你传入一个值列表时,需要将其格式化为用括号括起来的逗号分隔列表!

更好的做法是使用 SQL 封装器,通过模板构建查询并处理输入的格式化。详细用法请参阅 SQL 执行。

# 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),))

# more readable
self.env.cr.execute(SQL("""
    SELECT DISTINCT child_id
    FROM account_account_consol_rel
    WHERE parent_id IN %s
""", tuple(ids)))

这一点非常重要,因此在重构时也请务必小心,最重要的是不要照搬这些写法!

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

构建域

域表示为列表,并且可序列化。你可能会倾向于直接操作这些列表,但如果输入未规范化,这可能引入用户注入域的细微问题。请使用 Domain 来安全地处理域的操作。

# bad
# the user can just pass ['|', ('id', '>', 0)] to access all
domain = ...  # passed by the user
security_domain = [('user_id', '=', self.env.uid)]
domain += security_domain  # can have a side-effect if this is a function argument
self.search(domain)

# better
domain = Domain(...)
domain &= Domain('user_id', '=', self.env.uid)
self.search(domain)

未转义的字段内容

使用 JavaScript 和 XML 渲染内容时,可能会想用 t-raw 来显示富文本内容。应避免这样做,因为它是常见的 XSS 攻击途径。

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

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-out="message" /></div>
        <div class="subject"><t t-out="subject" /></div>
    </div>
</div>
.subject {
    font-weight: bold;
}

使用 Markup 创建安全内容

有关解释请参阅 官方文档,但 Markup 的最大优势在于它是一个功能丰富的类型,重写了 str 的操作,以 自动转义参数。

这意味着,只需对字符串字面量使用 Markup,并“格式化填入”用户提供(因而可能不安全)的内容,就能轻松创建 安全 的 HTML 片段:

>>> Markup('<em>Hello</em> ') + '<foo>'
Markup('<em>Hello</em> &lt;foo&gt;')
>>> Markup('<em>Hello</em> %s') % '<foo>'
Markup('<em>Hello</em> &lt;foo&gt;')

尽管这是很好的特性,但请注意其效果有时可能会很奇怪:

>>> Markup('<a>').replace('>', 'x')
Markup('<a>')
>>> Markup('<a>').replace(Markup('>'), 'x')
Markup('<ax')
>>> Markup('<a&gt;').replace('>', 'x')
Markup('<ax')
>>> Markup('<a&gt;').replace('>', '&')
Markup('<a&amp;')

小技巧

大多数内容安全的 API 实际上返回的是 Markup,这意味着它具备上述的所有特性。

escape 方法(及其别名 html_escape)将 str 转换为 Markup 并转义其内容。它不会转义 Markup 对象的内容。

def get_name(self, to_html=False):
    if to_html:
        return Markup("<strong>%s</strong>") % self.name  # escape the name
    else:
        return self.name

>>> record.name = "<R&D>"
>>> escape(record.get_name())
Markup("&lt;R&amp;D&gt;")
>>> escape(record.get_name(True))
Markup("<strong>&lt;R&amp;D&gt;</strong>")  # HTML is kept

生成 HTML 代码时,务必将结构(标签)与内容(文本)分离。

>>> Markup("<p>") + "Hello <R&D>" + Markup("</p>")
Markup('<p>Hello &lt;R&amp;D&gt;</p>')
>>> Markup("%s <br/> %s") % ("<R&D>", Markup("<p>Hello</p>"))
Markup('&lt;R&amp;D&gt; <br/> <p>Hello</p>')
>>> escape("<R&D>")
Markup('&lt;R&amp;D&gt;')
>>> _("List of Tasks on project %s: %s",
...     project.name,
...     Markup("<ul>%s</ul>") % Markup().join(Markup("<li>%s</li>") % t.name for t in project.task_ids)
... )
Markup('Liste de tâches pour le projet &lt;R&amp;D&gt;: <ul><li>First &lt;R&amp;D&gt; task</li></ul>')

>>> Markup("<p>Foo %</p>" % bar)  # bad, bar is not escaped
>>> Markup("<p>Foo %</p>") % bar  # good, bar is escaped if text and kept if markup

>>> link = Markup("<a>%s</a>") % self.name
>>> message = "Click %s" % link  # bad, message is text and Markup did nothing
>>> message = escape("Click %s") % link  # good, format two markup objects together

>>> Markup(f"<p>Foo {self.bar}</p>")  # bad, bar is inserted before escaping
>>> Markup("<p>Foo {bar}</p>").format(bar=self.bar)  # good, sorry no fstring

处理翻译时,将 HTML 与文本分离尤为重要。翻译方法接受 Markup 参数,如果收到至少一个此类参数,就会对译文进行转义。

>>> Markup("<p>%s</p>") % _("Hello <R&D>")
Markup('<p>Bonjour &lt;R&amp;D&gt;</p>')
>>> _("Order %s has been confirmed", Markup("<a>%s</a>") % order.name)
Markup('Order <a>SO42</a> has been confirmed')
>>> _("Message received from %(name)s <%(email)s>",
...   name=self.name,
...   email=Markup("<a href='mailto:%s'>%s</a>") % (self.email, self.email)
Markup('Message received from Georges &lt;<a href=mailto:george@abitbol.example>george@abitbol.example</a>&gt;')

转义与净化

重要

混合数据与代码时,无论数据多么安全,转义始终是 100% 必须的

转义 将 文本 转换为 代码。每当你将 数据/文本 与 代码 混合时(例如生成 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
Markup('&lt;R&amp;D&gt;')

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

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

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

# Sanitizing *after* escaping is OK!
>>> html_sanitize(code)
Markup('<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)
Markup('<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

对象、列表等

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 和字段值进行额外的校验。