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 的 time 模块。

user

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

company_id

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

company_ids

当前用户有权访问的所有公司作为公司 ID 列表(不是记录集),请参阅 安全规则 了解更多详细信息。

perm_methodir.model.access 具有完全不同的语义:对于规则,它们指定规则适用于*哪个操作。如果未选择某个操作,则不会检查该操作的规则,就好像该规则不存在一样。

默认选择所有操作。

perm_create
perm_read
perm_write

全局规则与组规则

全局规则和组规则在如何组成和组合方面存在很大差异:

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

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

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

危险

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

现场访问

ORM Field 可以具有提供组列表的 groups 属性(作为逗号分隔的 external identifiers 字符串)。

如果当前用户不在列出的组之一中,他将无权访问该字段:

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

  • 受限字段已从 fields_get() 响应中删除

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

安全陷阱

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

不安全的公共方法

任何公共方法都可以通过 RPC call 使用所选参数执行。以“_”开头的方法不能从操作按钮或外部 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 or DELETE 命令)。

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

第二个原因几乎同样重要,是数据库抽象层 (psycopg2) 的工作是决定如何格式化查询参数,而不是您的工作!例如,psycopg2 知道当您传递值列表时,它需要将它们格式化为逗号分隔的列表,并用括号括起来!

更好的是,存在一个 SQL 包装器,可以使用处理输入格式的模板来构建查询。检查 SQL execution 了解详细用法。

# 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 to display rich-text content. This should be avoided as a frequent 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-esc="message" /></div>
        <div class="subject"><t t-esc="subject" /></div>
    </div>
</div>
.subject {
    font-weight: bold;
}

使用 Markup 创建安全内容

请参阅 official documentation 进行解释,但 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% 强制的

**转义**将*TEXT* 转换为*CODE*。每次将 DATA/TEXTCODE 混合时(例如生成要在 safe_eval 内评估的 HTML 或 python 代码),绝对必须执行此操作,因为 CODE 始终需要对 TEXT 进行编码。这对于安全性至关重要,但也是一个正确性问题。即使不存在安全风险(因为文本 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

清理**将*代码*转换为*安全代码*(但不是必需的*安全*代码)。它不适用于 *TEXT*。仅当 *CODE* 不受信任时才需要进行清理,因为它全部或部分来自某些用户提供的数据。如果用户提供的数据采用 *TEXT* 的形式(例如,用户填写的表单中的内容),并且如果该数据在放入 *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>')

清理可能会破坏功能,具体取决于 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` to parse user provided content. Using ``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

整型、浮点型等

整型()、浮点型()

JavaScript

整型、浮点型等

parseInt()、parseFloat()

Python

词典

json.loads(), ast.literal_eval()

JavaScript

对象、列表等

JSON.parse()

访问对象属性

如果需要动态检索或修改记录的值,则可能需要使用“getattr` and ``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和字段值进行额外的验证。