安全在 Odoo 中¶
除了使用自定义代码手动管理访问权限外,Odoo 提供了两种主要的数据驱动机制来管理和限制对数据的访问。
两种机制都通过*组*与特定用户相关联:用户可以属于任意数量的组,而安全机制则与组相关联,从而将安全机制应用于用户。
访问权限¶
权限 为用户组在特定操作集上提供对整个模型的访问权限。如果用户的组没有匹配的操作权限,则用户将无法访问该模型。
访问权限是累加的,用户的访问权限是他们所属所有用户组的访问权限的并集。例如,如果一个用户属于授予读取和创建权限的用户组 A,以及授予更新权限的用户组 B,则该用户将拥有创建、读取和更新三种权限。
记录规则¶
记录规则是*条件*,必须满足这些条件操作才能被允许。记录规则是按记录逐条评估的,遵循访问权限。
记录规则是默认允许的:如果访问权限授予了访问权限,且没有规则适用于用户的操作和模型,则允许访问。
- class ir.rule¶
- name¶
规则的描述。
- model_id¶
应用该规则的模型。
- groups¶
授予(或不授予)访问权限的
res.groups
。可以指定多个组。如果没有指定组,则该规则为 global,这与“group”规则不同(请参见下文)。
该
perm_method
的语义与ir.model.access
完全不同:对于规则而言,它们指明了规则 适用于 哪种操作。如果未选择某种操作,则对该操作不会检查该规则,就好像该规则不存在一样。默认情况下,所有操作均被选中。
- perm_create¶
- perm_read¶
- perm_write¶
- perm_unlink¶
全局规则与用户组规则¶
全局规则和用户组规则在组合和合并方式上存在很大差异:
全局规则是**相交**的,如果两个全局规则都适用,则**必须同时满足**这两个规则才能授予访问权限,这意味着添加全局规则总是会进一步限制访问。
用户组规则 统一,如果两个用户组规则都适用,则 只要满足其中一个 即可授予访问权限。这意味着添加用户组规则可以扩大访问范围,但不会超出全局规则所定义的界限。
全局规则集和用户组规则集是**相交**的,这意味着添加到某个全局规则集中的第一个用户组规则将限制访问。
危险
创建多个全局规则存在风险,因为可能会创建不重叠的规则集,这将导致所有访问权限被移除。
字段访问¶
一个 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 查询中插入不需要的子句(例如绕过过滤器或执行 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
可能在后续的错误修复或重构后不再安全。
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> <foo>')
>>> Markup('<em>Hello</em> %s') % '<foo>'
Markup('<em>Hello</em> <foo>')
虽然这是一件非常好的事情,但请注意,有时效果可能会显得有些奇怪:
>>> Markup('<a>').replace('>', 'x')
Markup('<a>')
>>> Markup('<a>').replace(Markup('>'), 'x')
Markup('<ax')
>>> Markup('<a>').replace('>', 'x')
Markup('<ax')
>>> Markup('<a>').replace('>', '&')
Markup('<a&')
小技巧
大多数内容安全的 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("<R&D>")
>>> escape(record.get_name(True))
Markup("<strong><R&D></strong>") # HTML is kept
在生成 HTML 代码时,将结构(标签)与内容(文本)分开非常重要。
>>> Markup("<p>") + "Hello <R&D>" + Markup("</p>")
Markup('<p>Hello <R&D></p>')
>>> Markup("%s <br/> %s") % ("<R&D>", Markup("<p>Hello</p>"))
Markup('<R&D> <br/> <p>Hello</p>')
>>> escape("<R&D>")
Markup('<R&D>')
>>> _("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 <R&D>: <ul><li>First <R&D> 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 <R&D></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 <<a href=mailto:george@abitbol.example>george@abitbol.example</a>>')
转义与清理¶
重要
当您将数据和代码混合时,转义始终是 100% 必须的,无论数据看起来多么安全
**转义**会将 文本 转换为 代码。每次将 数据/文本 与 代码 混合时(例如在 safe_eval
中生成 HTML 或 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('<R&D>')
# Now you can mix it with other code...
>>> self.website_description = Markup("<strong>%s</strong>") % code
清理**会将 *CODE* 转换为 *SAFER 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><R&D></p>')
清理(Sanitizing)可能会破坏功能,这取决于 CODE 是否包含不安全的模式。这就是为什么 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() |
JavaScript |
整数、浮点数等 |
parseInt(),parseFloat() |
Python |
字典 |
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 和字段值进行额外的验证。