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
21 changes: 12 additions & 9 deletions attachment_mimetype_restriction/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,18 @@ Attachment MIME Type Restriction

|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.
This module restricts the files users can upload as attachments to an allowlist
of MIME types, configured company-wide and optionally per model. Leaving the
allowlist empty disables the restriction. As the MIME type Odoo derives from a
file name cannot be trusted on its own, the content of the file is checked
against it as well, so that a renamed file is rejected.

Only what users upload is restricted. The documents the server writes on its
own, such as the pdf of a report or the xml of an electronic invoice, are not.

For incoming emails, the email itself is always accepted, but attachments whose
MIME type is not allowed are stripped out and listed in a notice posted on the
related record.

**Table of contents**

Expand Down
58 changes: 44 additions & 14 deletions attachment_mimetype_restriction/controllers/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,37 +10,67 @@
from odoo.addons.mail.controllers.discuss import DiscussController
from odoo.addons.web.controllers.main import Binary

UPLOAD_CALLBACK_TEMPLATE = """<script language="javascript" type="text/javascript">
var win = window.top.window;
win.jQuery(win).trigger(%s, %s);
</script>"""


def _to_res_id(value):
return int(value) if str(value).isdigit() else False


class BinaryExtended(Binary):
@http.route()
def upload_attachment(self, model, id, ufile, callback=None):
response = super().upload_attachment(model, id, ufile, callback)
mimetype_error = getattr(request, "mimetype_error", None)
if mimetype_error:
data = response.get_data(as_text=True)
response.set_data(
data.replace(
json.dumps(_("Something horrible happened")),
json.dumps(mimetype_error),
1,
# The whole upload is refused as soon as one of its files is, so that
# the user is told about it instead of silently losing the file.
errors = {}
files = request.httprequest.files.getlist("ufile")
for upload in files:
content = upload.read()
upload.seek(0)
try:
request.env["ir.attachment"]._check_upload_mimetype(
upload.filename, content, model, _to_res_id(id)
)
)
return response
except ValidationError as e:
errors[upload.filename] = str(e.args[0]) if e.args else str(e)
if not errors:
return super().upload_attachment(model, id, ufile, callback)
args = [
{
"error": errors.get(
upload.filename,
_("Not uploaded: another file of this upload was refused."),
)
}
for upload in files
]
if callback:
return UPLOAD_CALLBACK_TEMPLATE % (json.dumps(callback), json.dumps(args))
return json.dumps(args)


class DiscussControllerExtended(DiscussController):
@http.route("/mail/attachment/upload", methods=["POST"], type="http", auth="public")
@http.route()
def mail_attachment_upload(
self, ufile, thread_id, thread_model, is_pending=False, **kwargs
):
try:
# Pending uploads are created on mail.compose.message, so the
# allowlist of the actual thread model has to be checked here.
content = ufile.read()
ufile.seek(0)
request.env["ir.attachment"]._check_upload_mimetype(
ufile.filename, content, thread_model, _to_res_id(thread_id)
)
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),
data=json.dumps({"error": error_msg}),
headers=[("Content-Type", "application/json")],
)
237 changes: 203 additions & 34 deletions attachment_mimetype_restriction/models/ir_attachment.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,43 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).

import base64
import binascii

from odoo import _, api, models
from odoo.exceptions import ValidationError
from odoo.http import request
from odoo.tools.mimetypes import guess_mimetype

TEXT_LIKE_MIMETYPES = (
"application/ecmascript",
"application/javascript",
"application/json",
"application/xml",
"image/svg",
"image/svg+xml",
)
MIMETYPE_SIGNATURES = {
"application/msword": (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",),
"application/pdf": (b"%PDF",),
"application/vnd.ms-excel": (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",),
"application/vnd.ms-powerpoint": (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",),
"image/bmp": (b"BM",),
"image/gif": (b"GIF87a", b"GIF89a"),
"image/jpeg": (b"\xff\xd8\xff",),
"image/png": (b"\x89PNG\r\n\x1a\n",),
"image/tiff": (b"II*\x00", b"MM\x00*"),
"image/x-icon": (b"\x00\x00\x01\x00",),
}
ZIP_SIGNATURES = (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
# Upload endpoints carrying the file in the json payload instead of a
# multipart form, and therefore not detected as a file upload otherwise.
JSON_UPLOAD_ENDPOINTS = ("/web_editor/attachment/add_data",)
ZIP_BASED_MIMETYPES = (
"application/vnd.oasis.opendocument.",
"application/vnd.openxmlformats-officedocument.",
"application/zip",
)


class IrAttachment(models.Model):
Expand All @@ -12,9 +46,7 @@ class IrAttachment(models.Model):
@api.model
def _get_allowed_mimetypes(self, company_id, res_model=None):
if res_model:
model = (
self.env["ir.model"].sudo().search([("model", "=", res_model)], limit=1)
)
model = self.env["ir.model"]._get(res_model)
if model and model.attachment_allowed_mimetypes:
return [
mt.strip().lower()
Expand All @@ -28,62 +60,199 @@ def _get_allowed_mimetypes(self, company_id, res_model=None):
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")
def _resolve_attachment_company_id(self, res_model=None, res_id=None):
if res_model and res_id and res_model in self.env:
record = self.env[res_model].sudo().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 framework-generated assets: compiled bundles (detected at create
# by res_model='ir.ui.view' + public=True, since their /web/assets/ url
# is only set in a later write) and customized scss/js overrides (which
# set url at create time).
if (vals.get("res_model") == "ir.ui.view" and vals.get("public")) or vals.get(
"url"
@api.model
def _is_text_like_mimetype(self, mimetype):
return mimetype.startswith("text/") or mimetype in TEXT_LIKE_MIMETYPES

@api.model
def _get_stored_mimetype(self, vals):
"""Return the mimetype that ir.attachment will actually store, which is
the one _check_contents computes, including its html/xml neutralization.
"""
values = self.with_context(image_no_postprocess=True)._check_contents(
dict(vals)
)
return values["mimetype"]

@api.model
def _get_raw_content(self, vals):
raw = vals.get("raw")
if raw:
return raw
if not vals.get("datas"):
return False
try:
return base64.b64decode(vals["datas"])
except (binascii.Error, TypeError, ValueError):
return False

@api.model
def _is_content_consistent(self, mimetype, raw):
"""Check the content against the magic bytes of its own mimetype, as the
latter is derived from the file name whenever it provides one.
"""
if mimetype.startswith(ZIP_BASED_MIMETYPES):
return raw.startswith(ZIP_SIGNATURES)
if mimetype == "image/svg+xml":
return b"<svg" in raw[:4096]
if mimetype == "image/webp":
return raw.startswith(b"RIFF") and raw[8:12] == b"WEBP"
signatures = MIMETYPE_SIGNATURES.get(mimetype)
if not signatures:
return True
return raw.startswith(signatures)

@api.model
def _is_equivalent_mimetype(self, declared_mimetype, content_mimetype):
if content_mimetype in (declared_mimetype, "application/octet-stream"):
return True
if content_mimetype == "application/zip" and declared_mimetype.startswith(
ZIP_BASED_MIMETYPES
):
return True
return self._is_text_like_mimetype(
content_mimetype
) and self._is_text_like_mimetype(declared_mimetype)

@api.model
def _get_not_allowed_message(self, mimetype, allowed_mimetypes):
return _(
"File type '%(mimetype)s' is not allowed. Allowed file types: "
"%(allowed)s."
) % {"mimetype": mimetype, "allowed": ", ".join(allowed_mimetypes)}

@api.model
def _get_mimetype_error(self, vals, allowed_mimetypes):
"""Return the reason why these values are refused, if any.

The policy is made of three rules:

1. either the mimetype the file declares through its name or the one
ir.attachment will store has to be allowed. _check_contents
neutralizes xml-like content to text/plain for users without
technical rights, and accepting both keeps the very same file from
being allowed for a system user and refused for a regular one;
2. the content has to match the magic bytes of the declared mimetype,
which is what makes a renamed file detectable;
3. a mimetype guessed from the content that is not equivalent to the
declared one has to be allowed as well.
"""
declared_mimetype = self._compute_mimetype(vals)
if not {declared_mimetype, self._get_stored_mimetype(vals)} & set(
allowed_mimetypes
):
return self._get_not_allowed_message(declared_mimetype, allowed_mimetypes)
raw = self._get_raw_content(vals)
if not raw:
return False
if not self._is_content_consistent(declared_mimetype, raw):
return (
_("The content of this file does not match its file type '%s'.")
% declared_mimetype
)
content_mimetype = (guess_mimetype(raw) or "").lower()
if not content_mimetype or content_mimetype in allowed_mimetypes:
return False
if self._is_equivalent_mimetype(declared_mimetype, content_mimetype):
return False
return self._get_not_allowed_message(content_mimetype, allowed_mimetypes)

@api.model
def _check_upload_mimetype(self, name, content, res_model=None, res_id=None):
"""Refuse an uploaded file whose mimetype is not allowed.

Meant to be called by the controllers handling file uploads, before the
attachment is created: attachments written by the server itself, such
as the pdf of a report or the xml of an electronic invoice, are trusted
and must not go through this check.
"""
allowed_mimetypes = self._get_allowed_mimetypes(
self._resolve_attachment_company_id(res_model, res_id), res_model
)
if not allowed_mimetypes:
return
error = self._get_mimetype_error(
{"name": name, "raw": content}, allowed_mimetypes
)
if error:
raise ValidationError(error)

@api.model
def _is_user_upload(self):
"""Tell whether the running request is a user uploading a file.

The allowlist applies to what users upload, not to the documents the
server writes on its own - the pdf of a report, the xml of an
electronic invoice, an asset bundle - which would otherwise break
unrelated features as soon as their mimetype is missing from the
allowlist. A file being uploaded always reaches the server either as
multipart form data or through one of the json endpoints listed above,
while documents written by the server never do.
"""
if not request:
return False
try:
httprequest = request.httprequest
except RuntimeError:
return False
if httprequest.files:
return True
return httprequest.path in JSON_UPLOAD_ENDPOINTS

def _validate_upload_vals(self, vals, content_vals=None):
if vals.get("res_field"):
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)
allowed_mimetypes = self._get_allowed_mimetypes(
self._resolve_attachment_company_id(res_model, vals.get("res_id"))
if not vals.get("company_id")
else vals["company_id"],
res_model,
)
if not allowed_mimetypes:
return
if mimetype.lower() not in allowed_mimetypes:
message = _("File type '%s' is not allowed.") % mimetype
if request:
request.mimetype_error = message
raise ValidationError(message)
error = self._get_mimetype_error(
vals if content_vals is None else content_vals, allowed_mimetypes
)
if error:
raise ValidationError(error)

@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
self._validate_mimetype_from_vals(vals)
if self._is_user_upload():
for vals in vals_list:
self._validate_upload_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):
if self._is_user_upload() and (
"datas" in vals or "raw" in vals or "mimetype" in vals
):
has_new_content = "datas" in vals or "raw" in vals
for record in self:
check_vals = {
"datas": vals.get("datas"),
"raw": vals.get("raw"),
"name": vals.get("name", record.name),
"mimetype": vals.get("mimetype", record.mimetype),
"res_model": vals.get("res_model", record.res_model),
"res_id": vals.get("res_id", record.res_id),
"url": vals.get("url", record.url),
"res_field": vals.get("res_field", record.res_field),
"company_id": vals.get(
"company_id",
record.company_id.id if record.company_id else False,
),
}
self._validate_mimetype_from_vals(check_vals)
# ir.attachment.write recomputes the mimetype from the written
# values only, so the record name must not be taken into
# account here.
content_vals = dict(vals)
if not has_new_content and "mimetype" not in vals:
content_vals["mimetype"] = record.mimetype
self._validate_upload_vals(check_vals, content_vals)
return super().write(vals)
Loading
Loading