Skip to content

[19.0] fs_attachment: write() replaces a correct specific mimetype with content-sniffed text/plain, silently breaking CSS asset bundles #658

Description

@pimzand

Module: fs_attachment
Branches affected: 19.0 (verified on 19.0.1.0.0); the code was introduced by #327 on 16.0 and forward-ported, so 16.0, 17.0 and 18.0 carry the same block.

Describe the bug

IrAttachment.write() in fs_attachment re-guesses the mimetype from content whenever datas or raw is written without name or mimetype in the same vals (the block marked OPW-3277070, added by #327). The guard only falls back to the existing mimetype when the guess is application/octet-stream.

Odoo's content sniffer cannot recognize CSS (or most plain-text formats) and returns text/plain for them. Because text/plain passes the guard, a correct, explicitly-set mimetype such as text/css is silently overwritten by a generic sniff result.

For asset bundles the damage is twofold:

  1. The bundle is served with Content-Type: text/plain (plus Odoo's X-Content-Type-Options: nosniff), so browsers refuse to apply the stylesheet and every page renders unstyled.
  2. The storage-location rules (force_db_for_default_attachment_rules) match on mimetype. With text/css clobbered to text/plain, the "assets stay in the database" rule no longer matches, and the same write relocates the bundle into the object storage, against the documented intent of that rule.

To Reproduce

On a database with fs_attachment installed (no storage backend needed to see the mimetype half):

att = env['ir.attachment'].create({
    'name': 'web.assets_frontend.min.css',
    'mimetype': 'text/css',
    'raw': b'body { color: red; }',
})
att.mimetype        # 'text/css'
att.write({'raw': b'p { margin: 0; }'})   # content write, no name/mimetype in vals
att.mimetype        # 'text/plain'  <- clobbered

Without fs_attachment, core Odoo keeps text/css on the same sequence.

The realistic trigger is force_storage(): for database-resident attachments it does exactly attachment.write({"datas": attachment.datas}), which walks every stored asset bundle through this code path.

Expected behavior

A specific stored mimetype must not be replaced by a generic sniff result. text/plain is, like application/octet-stream, the sniffer's "I don't know" answer for text-like bytes, and deserves the same fallback treatment.

Real-world impact

We hit this in production (Odoo 19.0 EE base, fs_attachment with an S3 backend, assets held in the database by the default force-db rules). A routine maintenance run invoked force_storage(); the two large CSS bundles (web.assets_frontend.min.css, web.assets_web.min.css) came out as text/plain and were relocated to S3. Every page of the instance was served unstyled for five days before anyone browsed it and noticed; asset URLs are content-hashed and browser-cached, so active users kept their cached copies and the breakage surfaced only on the first fresh fetch. Recovery was deleting the two attachments and letting Odoo regenerate them.

Suggested fix

Tighten the guard in the write() override: trust the content guess only when it is specific, e.g.

mimetype = self._compute_mimetype(vals)
if mimetype and mimetype not in ('application/octet-stream', 'text/plain'):
    vals["mimetype"] = mimetype
else:
    # existing fallback: keep the current mimetype

This preserves the case #327 was written for (stale image/png on SVG content: the sniffer returns the specific image/svg+xml, which still wins) while no longer destroying correct mimetypes on text content.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions