Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions attachment_mimetype_restriction/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association

================================
Attachment MIME Type Restriction
================================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:97078c2e3e231052fbfc80c9c3181fd9223d0fa5d7e0c7b6b8b1b22ef6932c25
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github
:target: https://github.com/OCA/social/tree/15.0/attachment_mimetype_restriction
:alt: OCA/social
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/social-15-0/social-15-0-attachment_mimetype_restriction
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/social&target_branch=15.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

This module restricts attachment uploads to an explicit allowlist of MIME types
using content-based detection rather than filename extensions. Only configured
MIME types are accepted; everything else is rejected. Leaving the allowlist
empty disables the restriction and allows all file types.

For incoming emails, the email itself is always accepted, but any attachments
whose MIME type is not in the allowlist are stripped out before the message is
saved. A security notice is then posted on the related record listing the
removed files, so users can see what was filtered.

**Table of contents**

.. contents::
:local:

Configuration
=============

**Global Configuration (Company-wide):**

#. Go to Settings → General Settings
#. In the "Allowed Attachment Types" field, enter comma-separated MIME types
#. Example: ``image/png,application/pdf``
#. Leave empty to allow all file types

**Per-Model Configuration (Optional):**

#. Go to Settings → Technical → Database Structure → Models
#. Select a model (e.g., "Contact" for res.partner)
#. In the "Allowed Attachment Types" field, enter comma-separated MIME types
#. Empty value = use global config; set value = override global config

**Configuration Hierarchy:**

Per-model settings override global settings when defined.

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/social/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/social/issues/new?body=module:%20attachment_mimetype_restriction%0Aversion:%2015.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
~~~~~~~

* Quartile

Contributors
~~~~~~~~~~~~

- Quartile \<<https://www.quartile.co>\>
- Aung Ko Ko Lin

Maintainers
~~~~~~~~~~~

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org

OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

.. |maintainer-yostashiro| image:: https://github.com/yostashiro.png?size=40px
:target: https://github.com/yostashiro
:alt: yostashiro
.. |maintainer-aungkokolin1997| image:: https://github.com/aungkokolin1997.png?size=40px
:target: https://github.com/aungkokolin1997
:alt: aungkokolin1997

Current `maintainers <https://odoo-community.org/page/maintainer-role>`__:

|maintainer-yostashiro| |maintainer-aungkokolin1997|

This module is part of the `OCA/social <https://github.com/OCA/social/tree/15.0/attachment_mimetype_restriction>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
2 changes: 2 additions & 0 deletions attachment_mimetype_restriction/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import controllers
from . import models
18 changes: 18 additions & 0 deletions attachment_mimetype_restriction/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).

{
"name": "Attachment MIME Type Restriction",
"summary": "Restrict attachment uploads to an allowlist of MIME types",
"version": "15.0.1.0.0",
"category": "Social",
"website": "https://github.com/OCA/social",
"author": "Quartile, Odoo Community Association (OCA)",
"license": "AGPL-3",
"depends": ["base", "mail"],
"data": [
"views/ir_model_views.xml",
"views/res_config_settings_views.xml",
],
"maintainers": ["yostashiro", "aungkokolin1997"],
}
1 change: 1 addition & 0 deletions attachment_mimetype_restriction/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import main
28 changes: 28 additions & 0 deletions attachment_mimetype_restriction/controllers/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).

import json

from odoo import http
from odoo.exceptions import ValidationError
from odoo.http import request

from odoo.addons.mail.controllers.discuss import DiscussController


class DiscussControllerExtended(DiscussController):
@http.route("/mail/attachment/upload", methods=["POST"], type="http", auth="public")
def mail_attachment_upload(
self, ufile, thread_id, thread_model, is_pending=False, **kwargs
):
try:
return super().mail_attachment_upload(
ufile, thread_id, thread_model, is_pending, **kwargs
)
except ValidationError as e:
error_msg = str(e.args[0]) if e.args else str(e)
attachmentData = {"error": error_msg}
return request.make_response(
data=json.dumps(attachmentData),
headers=[("Content-Type", "application/json")],
)
5 changes: 5 additions & 0 deletions attachment_mimetype_restriction/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from . import ir_attachment
from . import ir_model
from . import mail_thread
from . import res_company
from . import res_config_settings
79 changes: 79 additions & 0 deletions attachment_mimetype_restriction/models/ir_attachment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).

from odoo import _, api, models
from odoo.exceptions import ValidationError


class IrAttachment(models.Model):
_inherit = "ir.attachment"

@api.model
def _get_allowed_mimetypes(self, company_id, res_model=None):
if res_model:
model = self.env["ir.model"].search([("model", "=", res_model)], limit=1)
if model and model.attachment_allowed_mimetypes:
return [
mt.strip().lower()
for mt in model.attachment_allowed_mimetypes.split(",")
if mt.strip()
]
company = self.env["res.company"].browse(company_id)
global_mimetypes = company.attachment_allowed_mimetypes
if not global_mimetypes:
return []
return [mt.strip().lower() for mt in global_mimetypes.split(",") if mt.strip()]

@api.model
def _resolve_attachment_company_id(self, vals):
company_id = vals.get("company_id")
if company_id:
return company_id
res_model = vals.get("res_model")
res_id = vals.get("res_id")
if res_model and res_id and res_model in self.env:
record = self.env[res_model].browse(res_id).exists()
if record and "company_id" in record._fields and record.company_id:
return record.company_id.id
return self.env.company.id

def _validate_mimetype_from_vals(self, vals):
if self.env.context.get("install_mode"):
return
# Skip runtime asset bundles (compiled JS/CSS). Their /web/assets/ url
# is only set in a follow-up write() after create(), so detect them by
# the create-time signature: res_model='ir.ui.view' + public=True.
if vals.get("res_model") == "ir.ui.view" and vals.get("public"):
return
mimetype = self._compute_mimetype(vals)
res_model = vals.get("res_model")
company_id = self._resolve_attachment_company_id(vals)
allowed_mimetypes = self._get_allowed_mimetypes(company_id, res_model)
if not allowed_mimetypes:
return
if mimetype.lower() not in allowed_mimetypes:
raise ValidationError(_("File type '%s' is not allowed.") % mimetype)

@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
self._validate_mimetype_from_vals(vals)
return super().create(vals_list)
Comment on lines +58 to +61
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the handling of JS and CSS files that come from module installation?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I handled that.


def write(self, vals):
fields_to_check = ["datas", "raw", "mimetype", "res_model", "company_id"]
if any(key in vals for key in fields_to_check):
for record in self:
check_vals = {
"datas": vals.get("datas"),
"raw": vals.get("raw"),
"mimetype": vals.get("mimetype", record.mimetype),
"res_model": vals.get("res_model", record.res_model),
"res_id": vals.get("res_id", record.res_id),
"company_id": vals.get(
"company_id",
record.company_id.id if record.company_id else False,
),
}
self._validate_mimetype_from_vals(check_vals)
return super().write(vals)
16 changes: 16 additions & 0 deletions attachment_mimetype_restriction/models/ir_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).

from odoo import fields, models


class IrModel(models.Model):
_inherit = "ir.model"

attachment_allowed_mimetypes = fields.Char(
string="Allowed Attachment Types",
help="Comma-separated list of allowed MIME types for attachments on this "
"model. Leave empty to use company's global configuration. "
"Example: image/png,application/pdf. "
"This configuration applies globally to all companies.",
)
120 changes: 120 additions & 0 deletions attachment_mimetype_restriction/models/mail_thread.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).

import base64
import logging

from markupsafe import escape

from odoo import _, models

_logger = logging.getLogger(__name__)


class MailThread(models.AbstractModel):
_inherit = "mail.thread"

def _evaluate_attachment_against_allowlist(
self, name, content, info, model, res_id, company_id
):
try:
if isinstance(content, str):
encoding = info and info.get("encoding")
try:
content_bytes = content.encode(encoding or "utf-8")
except UnicodeEncodeError:
_logger.debug(
"Encoding '%s' failed for attachment '%s'; "
"retrying as utf-8",
encoding,
name,
)
content_bytes = content.encode("utf-8")
else:
content_bytes = content
temp_vals = {
"name": name,
"datas": base64.b64encode(content_bytes),
"res_model": model,
"res_id": res_id,
"company_id": company_id,
}
mimetype = self.env["ir.attachment"]._compute_mimetype(temp_vals)
allowed_mimetypes = self.env["ir.attachment"]._get_allowed_mimetypes(
company_id, model
)
except Exception as e:
_logger.warning(
"Pre-validation failed for attachment '%s' (%s); blocking by default",
name,
e,
)
return {"name": name, "mimetype": _("could not be analyzed")}
if mimetype and allowed_mimetypes and mimetype.lower() not in allowed_mimetypes:
return {"name": name, "mimetype": mimetype}
return None

def _message_post_process_attachments(
self, attachments, attachment_ids, message_values
):
model = message_values.get("model")
res_id = message_values.get("res_id")
target_record = None
if model and res_id and model in self.env:
target_record = self.env[model].browse(res_id).exists() or None
if (
target_record
and "company_id" in target_record._fields
and target_record.company_id
):
company_id = target_record.company_id.id
else:
company_id = self.env.company.id
blocked_attachments_info = []
if attachments:
filtered_attachments = []
for attachment in attachments:
if len(attachment) == 2:
name, content = attachment
info = {}
elif len(attachment) == 3:
name, content, info = attachment
else:
continue
blocked_info = self._evaluate_attachment_against_allowlist(
name, content, info, model, res_id, company_id
)
if blocked_info:
blocked_attachments_info.append(blocked_info)
continue
filtered_attachments.append(attachment)
attachments = filtered_attachments
result = super()._message_post_process_attachments(
attachments, attachment_ids, message_values
)
if blocked_attachments_info and target_record:
blocked_list = []
for blocked in blocked_attachments_info:
blocked_list.append(
f"• <strong>{escape(blocked['name'])}</strong> "
f"({escape(blocked['mimetype'])})"
)
notification_body = (
'<div class="o_mail_notification">'
"<p><strong>⚠️ Security Notice: Blocked Attachments</strong></p>"
"<p>The following attachment(s) from the email above were "
"blocked:</p>"
"<p>" + "<br/>".join(blocked_list) + "</p>"
"<p><em>These file types are not allowed by your organization's "
"security policy.</em></p>"
"</div>"
)
try:
target_record.sudo().message_post(
body=notification_body,
message_type="notification",
subtype_xmlid="mail.mt_note",
)
except Exception as e:
_logger.warning("Could not post blocked attachment notification: %s", e)
return result
Loading
Loading