Skip to content

Commit 061ca2a

Browse files
[ADD] attachment_mimetype_restriction
1 parent 2163b42 commit 061ca2a

21 files changed

Lines changed: 1052 additions & 0 deletions
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
.. image:: https://odoo-community.org/readme-banner-image
2+
:target: https://odoo-community.org/get-involved?utm_source=readme
3+
:alt: Odoo Community Association
4+
5+
================================
6+
Attachment MIME Type Restriction
7+
================================
8+
9+
..
10+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
11+
!! This file is generated by oca-gen-addon-readme !!
12+
!! changes will be overwritten. !!
13+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
14+
!! source digest: sha256:97078c2e3e231052fbfc80c9c3181fd9223d0fa5d7e0c7b6b8b1b22ef6932c25
15+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
16+
17+
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
18+
:target: https://odoo-community.org/page/development-status
19+
:alt: Beta
20+
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
21+
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
22+
:alt: License: AGPL-3
23+
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github
24+
:target: https://github.com/OCA/social/tree/15.0/attachment_mimetype_restriction
25+
:alt: OCA/social
26+
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
27+
:target: https://translation.odoo-community.org/projects/social-15-0/social-15-0-attachment_mimetype_restriction
28+
:alt: Translate me on Weblate
29+
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
30+
:target: https://runboat.odoo-community.org/builds?repo=OCA/social&target_branch=15.0
31+
:alt: Try me on Runboat
32+
33+
|badge1| |badge2| |badge3| |badge4| |badge5|
34+
35+
This module restricts attachment uploads to an explicit allowlist of MIME types
36+
using content-based detection rather than filename extensions. Only configured
37+
MIME types are accepted; everything else is rejected. Leaving the allowlist
38+
empty disables the restriction and allows all file types.
39+
40+
For incoming emails, the email itself is always accepted, but any attachments
41+
whose MIME type is not in the allowlist are stripped out before the message is
42+
saved. A security notice is then posted on the related record listing the
43+
removed files, so users can see what was filtered.
44+
45+
**Table of contents**
46+
47+
.. contents::
48+
:local:
49+
50+
Configuration
51+
=============
52+
53+
**Global Configuration (Company-wide):**
54+
55+
#. Go to Settings → General Settings
56+
#. In the "Allowed Attachment Types" field, enter comma-separated MIME types
57+
#. Example: ``image/png,application/pdf``
58+
#. Leave empty to allow all file types
59+
60+
**Per-Model Configuration (Optional):**
61+
62+
#. Go to Settings → Technical → Database Structure → Models
63+
#. Select a model (e.g., "Contact" for res.partner)
64+
#. In the "Allowed Attachment Types" field, enter comma-separated MIME types
65+
#. Empty value = use global config; set value = override global config
66+
67+
**Configuration Hierarchy:**
68+
69+
Per-model settings override global settings when defined.
70+
71+
Bug Tracker
72+
===========
73+
74+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/social/issues>`_.
75+
In case of trouble, please check there if your issue has already been reported.
76+
If you spotted it first, help us to smash it by providing a detailed and welcomed
77+
`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**>`_.
78+
79+
Do not contact contributors directly about support or help with technical issues.
80+
81+
Credits
82+
=======
83+
84+
Authors
85+
~~~~~~~
86+
87+
* Quartile
88+
89+
Contributors
90+
~~~~~~~~~~~~
91+
92+
- Quartile \<<https://www.quartile.co>\>
93+
- Aung Ko Ko Lin
94+
95+
Maintainers
96+
~~~~~~~~~~~
97+
98+
This module is maintained by the OCA.
99+
100+
.. image:: https://odoo-community.org/logo.png
101+
:alt: Odoo Community Association
102+
:target: https://odoo-community.org
103+
104+
OCA, or the Odoo Community Association, is a nonprofit organization whose
105+
mission is to support the collaborative development of Odoo features and
106+
promote its widespread use.
107+
108+
.. |maintainer-yostashiro| image:: https://github.com/yostashiro.png?size=40px
109+
:target: https://github.com/yostashiro
110+
:alt: yostashiro
111+
.. |maintainer-aungkokolin1997| image:: https://github.com/aungkokolin1997.png?size=40px
112+
:target: https://github.com/aungkokolin1997
113+
:alt: aungkokolin1997
114+
115+
Current `maintainers <https://odoo-community.org/page/maintainer-role>`__:
116+
117+
|maintainer-yostashiro| |maintainer-aungkokolin1997|
118+
119+
This module is part of the `OCA/social <https://github.com/OCA/social/tree/15.0/attachment_mimetype_restriction>`_ project on GitHub.
120+
121+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import controllers
2+
from . import models
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Copyright 2026 Quartile (https://www.quartile.co)
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
3+
4+
{
5+
"name": "Attachment MIME Type Restriction",
6+
"summary": "Restrict attachment uploads to an allowlist of MIME types",
7+
"version": "15.0.1.0.0",
8+
"category": "Social",
9+
"website": "https://github.com/OCA/social",
10+
"author": "Quartile, Odoo Community Association (OCA)",
11+
"license": "AGPL-3",
12+
"depends": ["base", "mail"],
13+
"data": [
14+
"views/ir_model_views.xml",
15+
"views/res_config_settings_views.xml",
16+
],
17+
"maintainers": ["yostashiro", "aungkokolin1997"],
18+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import main
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copyright 2026 Quartile (https://www.quartile.co)
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
3+
4+
import json
5+
6+
from odoo import http
7+
from odoo.exceptions import ValidationError
8+
from odoo.http import request
9+
10+
from odoo.addons.mail.controllers.discuss import DiscussController
11+
12+
13+
class DiscussControllerExtended(DiscussController):
14+
@http.route("/mail/attachment/upload", methods=["POST"], type="http", auth="public")
15+
def mail_attachment_upload(
16+
self, ufile, thread_id, thread_model, is_pending=False, **kwargs
17+
):
18+
try:
19+
return super().mail_attachment_upload(
20+
ufile, thread_id, thread_model, is_pending, **kwargs
21+
)
22+
except ValidationError as e:
23+
error_msg = str(e.args[0]) if e.args else str(e)
24+
attachmentData = {"error": error_msg}
25+
return request.make_response(
26+
data=json.dumps(attachmentData),
27+
headers=[("Content-Type", "application/json")],
28+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from . import ir_attachment
2+
from . import ir_model
3+
from . import mail_thread
4+
from . import res_company
5+
from . import res_config_settings
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Copyright 2026 Quartile (https://www.quartile.co)
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
3+
4+
from odoo import _, api, models
5+
from odoo.exceptions import ValidationError
6+
7+
8+
class IrAttachment(models.Model):
9+
_inherit = "ir.attachment"
10+
11+
@api.model
12+
def _get_allowed_mimetypes(self, company_id, res_model=None):
13+
if res_model:
14+
model = self.env["ir.model"].search([("model", "=", res_model)], limit=1)
15+
if model and model.attachment_allowed_mimetypes:
16+
return [
17+
mt.strip().lower()
18+
for mt in model.attachment_allowed_mimetypes.split(",")
19+
if mt.strip()
20+
]
21+
company = self.env["res.company"].browse(company_id)
22+
global_mimetypes = company.attachment_allowed_mimetypes
23+
if not global_mimetypes:
24+
return []
25+
return [mt.strip().lower() for mt in global_mimetypes.split(",") if mt.strip()]
26+
27+
@api.model
28+
def _resolve_attachment_company_id(self, vals):
29+
company_id = vals.get("company_id")
30+
if company_id:
31+
return company_id
32+
res_model = vals.get("res_model")
33+
res_id = vals.get("res_id")
34+
if res_model and res_id and res_model in self.env:
35+
record = self.env[res_model].browse(res_id).exists()
36+
if record and "company_id" in record._fields and record.company_id:
37+
return record.company_id.id
38+
return self.env.company.id
39+
40+
def _validate_mimetype_from_vals(self, vals):
41+
if self.env.context.get("install_mode"):
42+
return
43+
# Skip runtime asset bundles (compiled JS/CSS). Their /web/assets/ url
44+
# is only set in a follow-up write() after create(), so detect them by
45+
# the create-time signature: res_model='ir.ui.view' + public=True.
46+
if vals.get("res_model") == "ir.ui.view" and vals.get("public"):
47+
return
48+
mimetype = self._compute_mimetype(vals)
49+
res_model = vals.get("res_model")
50+
company_id = self._resolve_attachment_company_id(vals)
51+
allowed_mimetypes = self._get_allowed_mimetypes(company_id, res_model)
52+
if not allowed_mimetypes:
53+
return
54+
if mimetype.lower() not in allowed_mimetypes:
55+
raise ValidationError(_("File type '%s' is not allowed.") % mimetype)
56+
57+
@api.model_create_multi
58+
def create(self, vals_list):
59+
for vals in vals_list:
60+
self._validate_mimetype_from_vals(vals)
61+
return super().create(vals_list)
62+
63+
def write(self, vals):
64+
fields_to_check = ["datas", "raw", "mimetype", "res_model", "company_id"]
65+
if any(key in vals for key in fields_to_check):
66+
for record in self:
67+
check_vals = {
68+
"datas": vals.get("datas"),
69+
"raw": vals.get("raw"),
70+
"mimetype": vals.get("mimetype", record.mimetype),
71+
"res_model": vals.get("res_model", record.res_model),
72+
"res_id": vals.get("res_id", record.res_id),
73+
"company_id": vals.get(
74+
"company_id",
75+
record.company_id.id if record.company_id else False,
76+
),
77+
}
78+
self._validate_mimetype_from_vals(check_vals)
79+
return super().write(vals)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Copyright 2026 Quartile (https://www.quartile.co)
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
3+
4+
from odoo import fields, models
5+
6+
7+
class IrModel(models.Model):
8+
_inherit = "ir.model"
9+
10+
attachment_allowed_mimetypes = fields.Char(
11+
string="Allowed Attachment Types",
12+
help="Comma-separated list of allowed MIME types for attachments on this "
13+
"model. Leave empty to use company's global configuration. "
14+
"Example: image/png,application/pdf. "
15+
"This configuration applies globally to all companies.",
16+
)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Copyright 2026 Quartile (https://www.quartile.co)
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
3+
4+
import base64
5+
import logging
6+
7+
from markupsafe import escape
8+
9+
from odoo import _, models
10+
11+
_logger = logging.getLogger(__name__)
12+
13+
14+
class MailThread(models.AbstractModel):
15+
_inherit = "mail.thread"
16+
17+
def _evaluate_attachment_against_allowlist(
18+
self, name, content, info, model, res_id, company_id
19+
):
20+
try:
21+
if isinstance(content, str):
22+
encoding = info and info.get("encoding")
23+
try:
24+
content_bytes = content.encode(encoding or "utf-8")
25+
except UnicodeEncodeError:
26+
_logger.debug(
27+
"Encoding '%s' failed for attachment '%s'; "
28+
"retrying as utf-8",
29+
encoding,
30+
name,
31+
)
32+
content_bytes = content.encode("utf-8")
33+
else:
34+
content_bytes = content
35+
temp_vals = {
36+
"name": name,
37+
"datas": base64.b64encode(content_bytes),
38+
"res_model": model,
39+
"res_id": res_id,
40+
"company_id": company_id,
41+
}
42+
mimetype = self.env["ir.attachment"]._compute_mimetype(temp_vals)
43+
allowed_mimetypes = self.env["ir.attachment"]._get_allowed_mimetypes(
44+
company_id, model
45+
)
46+
except Exception as e:
47+
_logger.warning(
48+
"Pre-validation failed for attachment '%s' (%s); blocking by default",
49+
name,
50+
e,
51+
)
52+
return {"name": name, "mimetype": _("could not be analyzed")}
53+
if mimetype and allowed_mimetypes and mimetype.lower() not in allowed_mimetypes:
54+
return {"name": name, "mimetype": mimetype}
55+
return None
56+
57+
def _message_post_process_attachments(
58+
self, attachments, attachment_ids, message_values
59+
):
60+
model = message_values.get("model")
61+
res_id = message_values.get("res_id")
62+
target_record = None
63+
if model and res_id and model in self.env:
64+
target_record = self.env[model].browse(res_id).exists() or None
65+
if (
66+
target_record
67+
and "company_id" in target_record._fields
68+
and target_record.company_id
69+
):
70+
company_id = target_record.company_id.id
71+
else:
72+
company_id = self.env.company.id
73+
blocked_attachments_info = []
74+
if attachments:
75+
filtered_attachments = []
76+
for attachment in attachments:
77+
if len(attachment) == 2:
78+
name, content = attachment
79+
info = {}
80+
elif len(attachment) == 3:
81+
name, content, info = attachment
82+
else:
83+
continue
84+
blocked_info = self._evaluate_attachment_against_allowlist(
85+
name, content, info, model, res_id, company_id
86+
)
87+
if blocked_info:
88+
blocked_attachments_info.append(blocked_info)
89+
continue
90+
filtered_attachments.append(attachment)
91+
attachments = filtered_attachments
92+
result = super()._message_post_process_attachments(
93+
attachments, attachment_ids, message_values
94+
)
95+
if blocked_attachments_info and target_record:
96+
blocked_list = []
97+
for blocked in blocked_attachments_info:
98+
blocked_list.append(
99+
f"• <strong>{escape(blocked['name'])}</strong> "
100+
f"({escape(blocked['mimetype'])})"
101+
)
102+
notification_body = (
103+
'<div class="o_mail_notification">'
104+
"<p><strong>⚠️ Security Notice: Blocked Attachments</strong></p>"
105+
"<p>The following attachment(s) from the email above were "
106+
"blocked:</p>"
107+
"<p>" + "<br/>".join(blocked_list) + "</p>"
108+
"<p><em>These file types are not allowed by your organization's "
109+
"security policy.</em></p>"
110+
"</div>"
111+
)
112+
try:
113+
target_record.sudo().message_post(
114+
body=notification_body,
115+
message_type="notification",
116+
subtype_xmlid="mail.mt_note",
117+
)
118+
except Exception as e:
119+
_logger.warning("Could not post blocked attachment notification: %s", e)
120+
return result

0 commit comments

Comments
 (0)