Безпека в 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

Предикат, визначений як домен, правило дозволяє вибрані операції, якщо домен відповідає запису, і забороняє в іншому випадку.

Домен - це вираз Python, який може використовувати такі змінні:

time

Модуль Python time.

user

Поточний користувач, як єдиний набір записів.

company_id

Вибрана наразі компанія поточного користувача як єдиний ідентифікатор компанії (а не набір записів).

company_ids

Усі компанії, до яких поточний користувач має доступ у вигляді списку ідентифікаторів компаній (а не набору записів), див. Правила безпеки для отримання додаткової інформації.

perm_method має зовсім іншу семантику, ніж для ir.model.access: для правил вони вказують, до якої операції правила застосовуються *. Якщо операція не вибрана, то правило для неї не перевіряється, ніби правила не існує.

Усі операції вибрано за замовчуванням.

perm_create
perm_read
perm_write

Глобальні правила проти групових правил

Між глобальними та груповими правилами існує велика різниця в тому, як вони складають і поєднують:

  • Глобальні правила перетинаються, якщо застосовуються два глобальні правила, то обидва мають бути виконані для надання доступу, це означає, що додавання глобальних правил завжди обмежує доступ далі.

  • Правила групи уніфікують, якщо застосовуються два правила групи, то кожне може бути задоволено для надання доступу. Це означає, що додавання групових правил може розширити доступ, але не за межі, визначені глобальними правилами.

  • Глобальний і груповий набори правил перетинаються, що означає, що перше групове правило, додане до даного глобального набору правил, обмежить доступ.

Небезпека

Створення кількох глобальних правил є ризикованим, оскільки можна створити набори правил, які не перекриваються, що призведе до втрати доступу.

Доступ до поля

ORM Field може мати атрибут groups, який надає список груп (у вигляді розділених комами рядків зовнішніх ідентифікаторів).

Якщо поточний користувач не входить до однієї з перелічених груп, він не матиме доступу до поля:

  • обмежені поля автоматично видаляються із запитаних представлень

  • заборонені поля видаляються з відповідей fields_get()

  • спроби (явно) прочитати або записати в обмежені поля призводять до помилки доступу

Security Pitfalls

Як розробнику, важливо розуміти механізми безпеки та уникати типових помилок, які призводять до небезпечного коду.

Небезпечні публічні методи

Будь-який публічний метод можна виконати через 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, можливо, автоматизовану поведінку, як-от переклади, визнання полів недійсними, активні, права доступу тощо.

І є ймовірність, що ви також робите код важчим для читання та, можливо, менш безпечним.

# 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;
}

Creating safe content using Markup

See the official documentation for explanations, but the big advantage of Markup is that it’s a very rich type overrinding str operations to automatically escape parameters.

This means that it’s easy to create safe html snippets by using Markup on a string literal and «formatting in» user-provided (and thus potentially unsafe) content:

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

though it is a very good thing, note that the effects can be odd at times:

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

Порада

Most of the content-safe APIs actually return a Markup with all that implies.

The escape method (and its alias html_escape) turns a str into a Markup and escapes its content. It will not escape the content of a Markup object.

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

When generating HTML code, it is important to separate the structure (tags) from the content (text).

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

When working with translations, it is especially important to separate the HTML from the text. The translation methods accepts a Markup parameters and will escape the translation if it gets receives at least one.

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

Escaping vs Sanitizing

Важливо

Екранування завжди на 100% обов’язкове, коли ви змішуєте дані та код, незалежно від того, наскільки безпечними є дані

Екранування перетворює TEXT на CODE. Абсолютно обов’язково робити це кожного разу, коли ви змішуєте DATA/TEXT з CODE (наприклад, генеруючи код HTML або python для оцінки всередині safe_eval), оскільки 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

Дезінфікація перетворює CODE на SAFER CODE (але необов’язковий safe код). Це не працює на 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.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

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]

Наведений вище метод, очевидно, все ще надто оптимістичний, тому необхідно виконати додаткові перевірки ідентифікатора запису та значення поля.