-
-
Notifications
You must be signed in to change notification settings - Fork 712
[15.0][ADD] attachment_mimetype_restriction #1857
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
AungKoKoLin1997
wants to merge
1
commit into
OCA:15.0
Choose a base branch
from
qrtl:15.0-add-attachment_mimetype_restriction
base: 15.0
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| from . import controllers | ||
| from . import models |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"], | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| from . import main |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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")], | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
|
|
||
| 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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.", | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I handled that.