Files
Odoo-Modules/fusion_helpdesk/utils.py
gsinghpal fb345be375 fix(fusion_helpdesk): close IDOR + reliability gaps from code review
- P0 IDOR: escape LIKE wildcards in build_scope_domain + normalise emails (email_normalize) on both write and scope sides, so a self-set email of '%' can no longer match every ticket in a deployment (+2 regression tests).
- Convert XML-RPC ProtocolError (central 502/503/429) to _RemoteError instead of a raw 500.
- _resolve_author: log + post as service account on failure instead of silently impersonating the ticket customer.
- _mark_seen now best-effort (runs after the remote post) so a bookkeeping failure can't report a successful reply as failed (no duplicate replies).
- Attachment loop catches network errors + reports failed count (no duplicate-ticket trap); dialog surfaces failures.
- console.error in JS catches for diagnosability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:53:37 -04:00

99 lines
3.9 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Pure helpers for fusion_helpdesk.
No Odoo environment, no `request` — just data in, data out. Everything
here is unit-testable in isolation, which is what lets us validate the
identity keystone, the server-side scoping boundary, the public-message
filter and the unread maths without a live central Odoo to talk to.
"""
# Sentinel used so a missing label/email can never widen a domain to
# "match everything". An empty string in `=`/`=ilike` would match rows
# whose field is also empty; '__none__' will simply match nothing.
_NO_MATCH = '__none__'
def escape_like(value):
"""Escape SQL LIKE/ILIKE wildcards so a user-supplied value can never
widen an `=ilike` match to other rows.
`res.users.email` is self-writeable and unvalidated, so without this a
user could set their email to ``%`` and have ``partner_email =ilike '%'``
match EVERY ticket in their deployment (a cross-user IDOR). Escaping the
backslash first, then ``%`` and ``_``, makes those characters match
literally. Real emails containing ``_`` (e.g. ``john_doe@x.com``) keep
working — the underscore is matched as a literal, which is what we want.
"""
return (value or '').replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
def build_ticket_vals(kind, subject, body_html, team_id, client_label,
reporter_name, reporter_email, company_name):
"""Construct the `helpdesk.ticket` create vals for a forwarded report.
The identity fields (`partner_email`, `partner_name`,
`partner_company_name`) drive native helpdesk find-or-create of the
customer partner + follower subscription on the central Odoo, and
`x_fc_client_label` tags the deployment for the scoped inbox.
"""
kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request'
prefix = ('[%s] ' % client_label) if client_label else ''
vals = {
'name': '%s%s: %s' % (prefix, kind_label, subject or '(untitled)'),
'description': body_html,
'partner_name': reporter_name or '',
}
if team_id:
vals['team_id'] = team_id
if reporter_email:
vals['partner_email'] = reporter_email
if company_name:
vals['partner_company_name'] = company_name
if client_label:
vals['x_fc_client_label'] = client_label
return vals
def build_scope_domain(label, email, is_admin):
"""Server-side ticket scope for the embedded inbox.
`x_fc_client_label` is ALWAYS bound (defense in depth) so neither a
regular user nor a deployment admin can ever read another
deployment's tickets — even though the shared bot can technically see
every ticket on the central Odoo. Regular users are additionally
bound to their own `partner_email`.
"""
domain = [('x_fc_client_label', '=', label or _NO_MATCH)]
if not is_admin:
safe_email = escape_like(email)
domain.append(('partner_email', '=ilike', safe_email or _NO_MATCH))
return domain
def is_public_message(msg):
"""True when a message is customer-visible (not an internal note).
`msg` is a plain dict carrying a `subtype_is_internal` flag resolved
from the central `mail.message.subtype`. Internal notes must never be
shown to a client in the embedded inbox.
"""
return not msg.get('subtype_is_internal', False)
def compute_unread_count(tickets, seen_by_id):
"""Number of tickets with a support reply the user hasn't seen.
`tickets` is a list of dicts each carrying `id` and
`last_support_msg_id` (id of the latest customer-visible support
message, 0 if none). `seen_by_id` maps central ticket id -> last
message id the user has seen (absent => 0 baseline).
"""
count = 0
for ticket in tickets:
last = ticket.get('last_support_msg_id') or 0
if last and last > (seen_by_id.get(ticket['id']) or 0):
count += 1
return count