Odoo snippet filters: a pre-auth ORM domain you control, evaluated as superuser

the public /website/snippet/filters route merged an attacker-supplied search_domain into a SUPERUSER ORM search, turning a product widget into an unauthenticated boolean oracle over OAuth tokens, bank IBANs, employee SSNs and kiosk keys

POST /website/snippet/filters is auth=’public’. Its action_server_id branch runs .sudo().run(), and the website_sale product handler merges your search_domain into the ORM search with Domain.AND, restricting operators but never field paths. Odoo domains traverse relations with dotted paths, so as SUPERUSER you ask ('create_uid.company_id.attendance_kiosk_key','=like','a48f%') and read whether the widget renders results: a true/false oracle over any stored field, no login. It chains: the kiosk key opens a second oracle on hr.employee (SSN, passport, PINs) and then pre-auth attendance writes. Odoo fixed the main path with sudo(False) and committed to a CVE; a narrower single-record residual still leaks names and ids on current 19.0. Found with Ilyase Dehy.

a domain is a query language, and this route took one from the internet

/website/snippet/filters renders a marketing block: recently sold products, accessories, alternatives. It is auth='public' because those blocks appear on pages anonymous visitors load. Its request body carries the snippet’s parameters, and one of them is search_domain.

An Odoo domain is the ORM’s query DSL: leaves like [('list_price', '>', 100)], joined with & / |, and able to walk relations through dotted paths (create_uid.partner_id.email). The ORM compiles those into SQL JOINs. A public endpoint that accepts a domain and acts on it is, structurally, a public endpoint that accepts queries. Whether that is a data leak comes down to one thing: whose permissions the query runs under.

This route ran it under two different identities depending on which branch you hit, and one of them was root.

two branches, one of them runs as SUPERUSER

The controller, addons/website/controllers/main.py:426-435:

@http.route('/website/snippet/filters', type='jsonrpc', auth='public', website=True, readonly=True)
def get_dynamic_filter(self, filter_id, **kwargs):
    dynamic_filter_sudo = request.env['website.snippet.filter'].sudo()
    if filter_id:
        dynamic_filter_sudo = dynamic_filter_sudo.search(
            Domain('id', '=', filter_id) & request.website.website_domain()
        )
    single_record_filter = kwargs.get('limit') == 1 and kwargs.get('res_model') and kwargs.get('res_id')
    dynamic_filter_found = single_record_filter or dynamic_filter_sudo
    return dynamic_filter_sudo._render(**kwargs) if dynamic_filter_found else []

search_domain arrives in **kwargs and is never validated. _render hands it to _prepare_values, which splits into two paths in addons/website/models/website_snippet_filter.py:

# filter_id path: NOT vulnerable, runs as the public user
records = self.env[model_name].sudo(False).with_context(...).search(domain, ...)

# action_server_id path: VULNERABLE, runs as SUPERUSER
return self.action_server_id.with_context(
    dynamic_filter=self,
    limit=limit,
    search_domain=search_domain,     # untrusted input, carried in context
).sudo().run() or []                 # <-- SUPERUSER_ID

The action_server_id snippet filters are not something an admin has to configure. They are auto-created as data records the moment website_sale is installed (addons/website_sale/data/data.xml): “Recently Sold Products”, “Recently Viewed”, “Accessories”, “Alternatives”. Install the module, publish one product, and the SUPERUSER branch is live and reachable unauthenticated.

where the domain gets merged, and what the merge does not protect

addons/website_sale/models/website_snippet_filter.py:182-190:

@api.model
def _get_products(self, mode, **kwargs):
    ...
    search_domain = self.env.context.get('search_domain')   # untrusted
    domain = Domain.AND([
        [('website_published', '=', True)] if self.env.user._is_public() or self.env.user._is_portal() else [],
        website.website_domain(),
        [('company_id', 'in', [False, website.company_id.id])],
        search_domain or [],                                 # <-- injected here
    ])

Odoo 19 replaced expression.AND() with the new Domain class, and Domain.AND does one useful thing: it AND-combines, so you cannot inject a | to OR away the forced website_published = True filter. That is the protection Odoo’s own “Building the domains” guidance recommends.

It is also the entire protection. Domain.AND says nothing about which field paths a leaf may reference. You cannot cancel the existing filters, but you can append a condition on any field reachable by relational traversal from product.product, and because the search runs as SUPERUSER, field-level access control never fires:

# attacker sends:
search_domain = [("create_uid.company_id.attendance_kiosk_key", "=like", "a48f%")]

# becomes, after Domain.AND:
[('website_published','=',True), ('company_id','in',[False,1]),
 ("create_uid.company_id.attendance_kiosk_key","=like","a48f%")]

# ORM compiles to JOINs:
#   product_product -> product_template -> res_users -> res_company
#   WHERE res_company.attendance_kiosk_key LIKE 'a48f%'

The widget renders a product (true) or renders nothing (false). Lengthen the prefix one character at a time and each character falls out. That is the oracle.

The reason group-restricted and private fields are reachable at all: Odoo enforces groups=, USER_PRIVATE_FIELDS, and check_field_access_rights during read (_read_from_database), not during domain evaluation in .search(). A field you can never read can still be filtered on, and filtering is enough to infer it bit by bit.

18 and 19 are the same bug with a different surface

I confirmed this on both Odoo 18.0 and 19.0. The vulnerable shape survived the rewrite between them; only the plumbing changed:

  18.0 19.0
route type type='json' type='jsonrpc'
handler signature named params (search_domain=None) **kwargs
domain merge expression.AND([domain, search_domain]) Domain.AND([...])
controller main.py:415-420 main.py:426-435
employee endpoint merge expression.AND([domain, ...]) Domain(domain) & Domain(...)
single-record branch not present present (the residual below)

The merge moved from the old expression.AND helper to the new Domain class, which is the API the post above shows. Neither form restricts field paths, so the oracle payload is byte-identical across versions; only the route’s type changes how you frame the JSON-RPC body. The injection, the SUPERUSER path, and the traversal all carry over unchanged.

the negative control, because an oracle you cannot falsify is noise

Every extracted path was confirmed against a control pattern that must not match. Probe a real prefix and a guaranteed-miss prefix on the same field:

('create_uid.company_id.attendance_kiosk_key','=like','a48f%')  -> renders  (true)
('create_uid.company_id.attendance_kiosk_key','=like','ZZZNOMATCH_XYZ99%') -> empty (false)

If the nonsense prefix had also matched, the “signal” would be a computed/non-stored field giving a constant answer, not a real read. It does not match. The bit is real and you can turn it off on demand.

That control is not theoretical, it caught three fields that look extractable and are not. res.users.password is a computed, non-stored field, so a domain leaf on it never reaches SQL and =like 'anything%' returns every row. Same for res.users.totp_secret. Both fail the control: the nonsense prefix matches, so there is no bit. res.partner.bank.acc_number is stored but ships a custom _search override that breaks =like/=ilike, returning zero for every pattern. The IBAN still leaks, just through sanitized_acc_number, a stored computed field with no override. Without the control I would have “extracted” password hashes that were never there.

the walk, and why each character is cheap

The oracle leaf is one line, [(field, "=like", prefix + "%")], and the only thing that makes it exact is escaping the LIKE wildcards in the prefix before sending it:

def esc(s):
    return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")

Without that, an underscore in a real value matches any character and the prefix drifts. With it, =like is an exact prefix test.

The naive walk tries every character in the charset at each position, so a 70-character alphabet costs up to 70 requests per character recovered. My Odoo 19 PoC replaces that with a binary search over the sorted charset using the >= operator, which orders lexically the same way Python sorts:

# narrow the next character to one of N by log2(N) range probes
if q(base + [(field, "=like", prefix + "%"), (field, ">=", prefix + sorted_chars[mid])]):
    lo = mid
else:
    hi = mid - 1

That drops each character from O(charset) to about 6 probes, and a parallel per-character probe is the fallback when ordering does not behave. One more guard earned its place: a runaway detector that trims the tail when the same character repeats four times, because a misconfigured leaf can loop forever appending the same byte.

Enumerating every value of a field, not just one, hit a subtlety in the ORM. The obvious move is to extract one value, then exclude it with [(field, '!=', extracted)] and extract the next. That works on direct columns and breaks on relational traversals. A one2many or many2many leaf compiles to EXISTS, so ('user_ids.login', '!=', 'admin') reads as “exists a related user whose login is not admin”, which is true the moment two users exist. The exclusion never narrows. The fix is to bucket by first character: scan which leading characters have data, then extract per bucket and only exclude inside a shared prefix where the set is small. A domain is a query language, and != on a join is NOT (column = X) only when you forget the join is an EXISTS.

what comes out, ranked by how much it should never be readable unauthenticated

From product.product, with website_sale alone: company name/email/phone/VAT/registry, company bank IBAN (bank_ids.sanitized_acc_number), every user login, user emails/phones/street/city, supplier names and contacts, message author emails via mail.thread. Add modules and it gets worse:

  • auth_oauth -> create_uid.user_ids.oauth_access_token, the live Google/Microsoft SSO bearer token, a USER_PRIVATE_FIELDS entry.
  • hr_attendance -> create_uid.company_id.attendance_kiosk_key, a groups='hr_attendance...' field. That key is the pivot.

26 traversal paths verified on product.product. The point is not the count, it is that one unauthenticated GET-shaped JSON-RPC reaches three modules’ secrets through a marketing widget.

the chain: kiosk key -> employee oracle -> attendance writes

hr_attendance exposes its own public route with the same shape, addons/hr_attendance/controllers/main.py:202:

@http.route('/hr_attendance/employees_infos', type="jsonrpc", auth="public")
def employees_infos(self, token, limit, offset, domain):
    company = self._get_company(token)               # token = the kiosk key we just oracled
    if company:
        domain = Domain(domain) & Domain('company_id', '=', company.id)
        employees = request.env['hr.employee'].sudo().search_fetch(domain, ...)

With the kiosk key, domain=[] enumerates every employee, and [('id','=',N),('pin','=like','1%')] walks each PIN digit by digit (recovered four exact PINs on the lab). The same oracle reaches ssnid, passport_id, visa_no, permit_no, private_street, emergency_contact, bank_account_id.sanitized_acc_number, birthday, and traverses parent_id / coach_id / child_ids to pull the manager’s SSN and subordinates’ private emails. 22+ verified paths on hr.employee, all groups="hr.group_hr_user" fields the public user can never read, all reachable because the search is .sudo().

Then it stops being read-only. POST /hr_attendance/set_settings (auth='public') writes res.company.attendance_kiosk_mode with nothing but the kiosk key, and POST /hr_attendance/manual_selection clocks any employee in or out when PIN mode is off (the default). That is the I:Low in the score: a pre-auth write to a company record the public role only has Read on.

severity

AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N = 8.2 High. Unauthenticated, network, no interaction, confidentiality high (OAuth tokens, IBANs, SSNs), integrity low (the attendance writes). Without hr_attendance it is still C:H/I:N/A:N = 7.5: OAuth tokens, company IBANs, and every user login. It is the same class as CVE-2024-36259 (Odoo 17 oracle via elevated RPC search), except pre-auth and over a public website route.

This is not user enumeration. It does not check whether a username exists; it reconstructs full values of access-controlled fields (SSNs, tokens, IBANs) character by character, and chains into integrity loss. Those are different findings.

the fix, from the commit

Corrected 2026-10-13. This section originally cited commit c0c93e0110f9 and showed a two-hunk diff. Neither was right. That SHA does not exist in odoo/odoo, which I only established after pulling the full history: a shallow clone answers “not found” for every commit outside its window, so the wrong SHA had survived on a negative I had no right to trust. The second hunk never existed either. Both are replaced below with what git actually holds.

The real fix is b509b3607cb, [FIX] website_sale: dynamic filters as a visitor, internal ticket opw-6041547, authored 2026-01-29 and committed 2026-03-23. One file, one line changed:

-        handler = getattr(self, '_get_products_%s' % mode, self._get_products_latest_sold)
+        handler = getattr(self.sudo(False), '_get_products_%s' % mode, self.sudo(False)._get_products_latest_sold)

sudo(False) evaluates the attacker’s domain as the public website user. A leaf traversing into a field that user cannot read now raises an access error instead of silently resolving, and the oracle loses what it was reading. My original boolean-oracle PoC against the patched build fails with exactly that access error, which is the cleanest confirmation the fix is real.

What the fix does not do is worth stating, because it is the interesting part. The attacker still controls search_domain, and it is still ANDed straight into the query with no field allowlist, no depth limit and no rejection of relational traversal. Odoo removed the privilege the domain ran under, not the attacker’s control of the domain. That is the right call here: the oracle existed because arbitrary field paths were evaluated as superuser, so de-elevation kills the class rather than playing whack-a-mole with field names.

the residual, and its fix

The original version of this post ended with a live residual: the single-record branch, limit=1 plus res_model and res_id, still triggered an elevated render that read fields off arbitrary records such as res.users and res.company.

The payload that reached it, kept here because the fix below is only legible next to what it blocks:

$ curl -s http://localhost:8019/website/snippet/filters \
    -H "Content-Type: application/json" \
    -d '{"jsonrpc":"2.0","method":"call","id":1,"params":{
          "filter_id":7,
          "template_key":"website_sale.dynamic_filter_template_product_public_category_default",
          "limit":1,"res_model":"res.users","res_id":1}}'
-> result: renders one res.users record, name and login identifiers readable

That is closed too, in 9bc3520d261, [FIX] website, *: restrict access to content in dynamic snippet config, authored 2026-04-01 and committed 2026-04-03, closing odoo/odoo#257208. Two changes, and both are needed:

+        website_filter_models = list({
+            website_filter.action_server_id.model_id.model if website_filter.action_server_id
+            else website_filter.filter_id.model_id
+            for website_filter in self.env['website.snippet.filter'].search([])
+        })
+        if model_name and model_name not in website_filter_models:
+            return []
-                ) if not single_record_filter else self.env[model_name].browse([res_id])
+                ) if not single_record_filter else self.env[model_name].sudo(False).search([('id', '=', res_id)], limit=1)

The allowlist is the obvious half: res_model must name a model that some website.snippet.filter actually points at, so res.users returns an empty list before anything renders.

The second half is the one worth learning from. browse([res_id]) was swapped for sudo(False).search([('id', '=', res_id)], limit=1). Those are not equivalent. browse constructs a recordset by id without consulting record rules, and the rules only bite later when a field is read, which in an elevated environment is never. search runs the id through the ORM’s rule layer up front, and sudo(False) makes sure there is a real user for those rules to apply to. Swapping browse for search is a security change disguised as a refactor, and it is the one that actually enforces the boundary.

I confirmed both are present at 0f577f1d3 on branch 19.0, at addons/website/models/website_snippet_filter.py:91-97 and the search on the single-record branch.

So the honest status today: the pre-auth oracle is closed, and the residual I flagged as still live is closed as well. Neither carries a CVE.

the lesson

A domain is code. The only boundary on a public endpoint that evaluates one is the identity it runs under. auth='public' plus .sudo().run() is the pair to grep for: open to everyone, executed as root. Domain.AND stopped operator injection and everyone assumed the input was safe; it never constrained the field paths, which is where the whole oracle lives. The fix is one word, sudo(False), applied to the branch that forgot it. The residual needed a second idea on top: browse() skips record rules and search() does not, so swapping one for the other is an access-control change even though it reads like tidying.

re-verified against current source, 2026-10-13

Read at 0f577f1d3. The de-elevation held, and it is applied in both branches rather than only the one I originally traced.

The filter_id branch in addons/website/models/website_snippet_filter.py now runs the search as the real caller:

records = self.env[model_name].sudo(False).with_context(**literal_eval(filter_sudo.context)).search(

The website_sale consumer does the same thing to its handler, in _get_products:

handler = getattr(self.sudo(False), '_get_products_%s' % mode, self.sudo(False)._get_products_latest_sold)

The structural point is worth drawing out, because it is the opposite of what most people would patch. The attacker still controls search_domain, and it is still ANDed straight into the query:

domain = Domain.AND([
    [('website_published', '=', True)] if self.env.user._is_public() or self.env.user._is_portal() else [],
    website.website_domain(),
    [('company_id', 'in', [False, website.company_id.id])],
    search_domain or [],
])

Nothing validates the field paths in that domain. No allowlist, no depth limit, no rejection of relational traversal. The fix removed the privilege the domain executed under, not the attacker’s control of the domain. That is the correct call: the oracle existed because arbitrary field paths were evaluated as superuser, and once the query runs as the public user the ORM’s own record rules and field access decide what a traversal can reach.

One asymmetry remains and I am flagging it as an observation, not a finding. The action_server_id branch still invokes the action itself elevated:

return self.action_server_id.with_context(
    dynamic_filter=self,
    limit=limit,
    search_domain=search_domain,
).sudo().run() or []

The elevation is on run(), while the product search inside the shipped handler is de-elevated by the sudo(False) above, so the known path is covered. Whether every server action a deployment can attach to a snippet filter de-elevates before it touches the domain is a per-action question, and website_sale/data/data.xml ships six filters bound to server actions. hoodie: assumes a custom or future server action could consume search_domain while still elevated; disproof is reading each action_server_id referenced by a website.snippet.filter record and confirming a sudo(False) before the domain reaches a search.

The sibling route is guarded differently and is worth contrasting. /website/snippet/options_filters is auth='user', requires website.group_website_restricted_editor, and validates the domain by field allowlist:

assert all(condition.field_expr in request.env['website.snippet.filter']._fields for condition in search_domain.iter_conditions())

So Odoo does validate domain fields, on the editor-facing route where the caller is known. The public route is protected by de-elevation instead. Two different answers to the same input, chosen by who is calling.

references