Odoo中的安全性

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

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

class res.groups
name

为用户提供可读的组标识(拼写出组的角色/目的)

category_id

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

implied_ids

在此用户旁边设置的其他组。这是一种方便的伪继承关系:可以明确地从用户中删除隐含的组,而不必删除隐含者。

comment

关于组的附加说明,例如:

访问权限

授予 对给定操作集的整个模型的访问权限。如果用户(通过其组)在模型上的操作没有匹配的访问权限,则该用户无权访问。

访问权限是可叠加的,用户的访问权限是他们通过所有组获得的访问权限的并集,例如,给定一个用户属于组A,授予读取和创建访问权限,以及一个组B授予更新访问权限,用户将拥有创建、读取和更新的所有三个权限。

class ir.model.access
name

组的目的或角色。

model_id

控制访问控制列表的模型。

group_id

授予访问权限的 res.groups,一个空的 group_id 表示 ACL 授予给 *每个用户*(非员工,例如门户或公共用户)。

The perm_method attributes grant the corresponding CRUD access when set, they are all unset by default.

perm_create
perm_read
perm_write

记录规则

记录规则是必须满足的 条件 ,才能允许操作。记录规则按记录逐个评估,遵循访问权限。

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

class ir.rule
name

规则的描述。

model_id

适用规则的模型。

groups

授予(或不授予)访问权限的 res.groups。可以指定多个组。如果未指定组,则规则是 全局 的,与“组”规则(见下文)不同对待。

global

基于 groups 计算,提供对规则的全局状态(或非状态)的便捷访问。

domain_force

一个作为 domain 指定的谓词,如果域与记录匹配,则允许所选操作,否则禁止。

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

time

Python 的 time 模块。

user

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

company_id

当前用户当前选择的公司作为单个公司ID(而不是记录集)。

company_ids

当前用户可以访问的所有公司,以公司ID的列表形式(而不是记录集)表示,有关详细信息,请参见: 安全规则

perm_method 的语义与 ir.model.access 完全不同:对于规则而言,它们指定了规则适用的操作。如果未选择某个操作,则该规则不会对其进行检查,就像该规则不存在一样。

默认情况下选择所有操作。

perm_create
perm_read
perm_write

全局规则与组规则

全局规则和组规则在组合和合并方面存在很大的差异:

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

  • 组规则 统一 ,如果两个组规则都适用,则可以满足 任一 规则以获得访问权限。这意味着添加组规则可以扩展访问权限,但不能超出全局规则所定义的范围。

  • 全局和组规则集 相交,这意味着将第一个组规则添加到给定的全局规则集将限制访问。

危险

创建多个全局规则是有风险的,因为可能会创建不重叠的规则集,这将删除所有访问权限。

字段访问

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()]

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

SQL注入攻击

使用手动 SQL 查询时,必须注意不要引入 SQL 注入漏洞。当用户输入被错误过滤或错误引用时,就会存在漏洞,允许攻击者向 SQL 查询中引入不必要的子句(例如绕过过滤器或执行 UPDATEDELETE 命令)。

保持安全的最佳方法是永远不要使用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 <https://en.wikipedia.org/wiki/Cross-site_scripting> _向量。

从计算到最终在浏览器 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-esc="message" /></div>
        <div class="subject"><t t-esc="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%必需的

转义文本 转换为 代码。每次将 数据/文本代码 混合时(例如生成要在 safe_eval 中评估的 HTML 或 Python 代码),绝对必须进行转义,因为 代码 始终要求 文本 被编码。这对于安全性至关重要,但也是一个正确性的问题。即使没有安全风险(因为文本 100% 保证是安全或可信的),仍然需要进行转义(例如,以避免在生成的 HTML 中破坏布局)。

转义永远不会破坏任何功能,只要开发人员确定哪个变量包含 TEXT,哪个变量包含 CODE

>>> 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

SanitizingCODE 转换为 SAFER CODE*(但不一定是 *safe 代码)。它不适用于 TEXT。只有当 CODE 不可信时,才需要进行 Sanitizing,因为它全部或部分来自用户提供的数据。如果用户提供的数据是 TEXT 形式(例如用户填写的表单内容),并且如果该数据在放入 CODE 之前已正确转义,那么 Sanitizing 是无用的(但仍然可以进行)。然而,如果用户提供的数据 未转义,那么 Sanitizing 将 不会 按预期工作。

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

消毒可能会破坏功能,这取决于 CODE 是否预期包含不安全的模式。这就是为什么 fields.Htmltools.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()

Javascript

整数,浮点数等。

parseInt(),parseFloat()

Python

字典

json.loads(), ast.literal_eval()

Javascript

对象,列表等。

JSON.parse()

访问对象属性

如果需要动态检索或修改记录的值,则可以使用 getattrsetattr 方法。

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