Blog post

Odoo: Get your Content Type right, or else!

Dennis Brinkrolf, Thomas Chauchefoin photo

Dennis Brinkrolf, Thomas Chauchefoin

Vulnerability Researchers

7 min read

  • Security

As a web developer, do you really know what content types are? Sure, something like text/html should ring a bell, but are you also aware that getting them wrong can lead to security vulnerabilities in your application? 

In this blog post, we will first give you a recap of what content types are and what they are used for. We will then show how important it is to get them right in your code by explaining how a small mistake led to a Cross-Site Scripting vulnerability in Odoo, a popular open-source business suite written in Python. Odoo has features for many business-critical areas, such as e-commerce, billing, or CRM, making it an interesting target for threat actors.

The vulnerability is tracked as CVE-2023-1434 and is caused by an incorrect content type being set on an API endpoint. Attackers could abuse it by crafting a malicious link that allows them to impersonate any victim on a vulnerable Odoo instance that clicks that link. If the victim has high privileges, attackers may be able to exfiltrate important business data. This bug is exploitable in the default configuration of Odoo; no addon is required. 

Odoo maintainers addressed this vulnerability on December 23, 2022, and the fix is already part of the 16.0 release. 

(If you are already up-to-speed on content types, feel free to jump to Diving into CVE-2023-1434!)

Content types?

The content type, also known as MIME type, is a crucial piece of information for web browsers. They need this information to display the server's response the right way. 

It starts in the request, where the browser sets the Accept header to tell the server what acceptable types are. For instance, when your browser requests a CSS stylesheet, it will likely attach Accept: text/css. Your browser could also feel adventurous and send */* (meaning, any type!), or send multiple values, each with a weight like q=0.1, to give the server a choice. 

The server can then use this value to decide on which Content-Type header to attach to the response. It can also use values from the request path (i.e., extensions) to take this decision or simply ignore it. 

Content Sniffing

In cases where the content type of a resource is not explicitly stated by one of the two sides, Content Sniffing usually kicks in. It means that an application has to decide on its own which type of content some unknown blob of data is, and yes, it is as likely to have the wrong result as it sounds.

Server-side Content Sniffing

It can happen server-side, by a reverse proxy or the application itself, when the developer specifies no content type. This process is error-prone and likely leads to unintended results. There are several documented examples of this going wrong. For instance, Simon Scannell exploited it in CVE-2021-39249 on Invision Power Board, where he could upload attachment files without extensions. However, by default, the Apache HTTP server will attach text/html to files without extensions, letting Simon upload files later distributed as HTML documents.

We also highly recommend reading Server-Side MIME Sniff Caused by Go Language Project Containerization by @RuiShang9. 

The Go standard library has a very limited set of file extensions and their associated MIME types. In minimalistic environments of containers, i.e. based on alpine, the system may not provide enough additional type definitions. 

In this context, it is then likely that attackers could upload static files whose extension is allowed by the application but unknown by the Go server-side MIME sniffing feature. The file may then be served as text/html and introduces a Stored Cross-Site Scripting vulnerability. 

Client-side Content Sniffing

It can also happen client-side, in the user's browser, when the response doesn't contain a Content-Type header or an invalid one. The MIME sniffing algorithm is documented in a WHATWG living document and lists byte patterns to look for and the computed MIME type to attach if they are found in the response. For instance, the presence of <!DOCTYPE HTML or <HTML along with a character closing the tags raises text/html, %PDF- raises application/pdf, and so on. 

Yaniv Nizry identified a quirk in Apache's mod_mime module, where files with extensions but an empty (.jpg) or dot name (…jpg) would be served without a content type. The browser would then "sniff" the content and could be tricked into rendering them as HTML documents. 

With these examples, it is clear that Content Sniffing is here to accommodate users and always tries to show them valid pages in their browsers–not for security. 

We even developed a rule as part of our Clean Code offering to remember telling browsers not to rely on it: Allowing browsers to sniff MIME types is security-sensitive. We suggest addressing it by setting the header X-Content-Type-Options to nosniff in all responses to tell browsers not to attempt content sniffing on the resources. It won't prevent cases where the content type is incorrectly stated. 

What could go wrong? 

Let's take the example of an image returned with the wrong content type information, for instance, text/html. The browser displays gibberish–the ASCII representation of the file's bytes:

But that also means that if there's any HTML tag in this file, they will be rendered by the browser. For instance, below, we have the result of the emoji in a <h1>:

Attackers could replace this tag with <script> to include arbitrary JavaScript code instead. Executing such code in the victim's browser allows impersonating them on the same origin (as in "Same-Origin Policy"). 

Now that we have a good understanding of content types and why they can be security-relevant, we can look into a vulnerability we found in Odoo.

Diving into CVE-2023-1434

As part of the advanced features for developers, Odoo users can enable profiling for their session to identify potential performance bottlenecks in their application. They can later visualize flame graphs of their traces with a speedscope instance:

One of the ways to interact with the profiler is through an API handler, like /web/set_profiling/. At [1], the decorator exposes it to /web/set_profiling without authentication, at [2] it creates the variable state with a call to set_profiling(), and then at [3] it returns a JSON-encoded output of this variable:

class Profiling(Controller):

    @route('/web/set_profiling', type='http', auth='public', sitemap=False) # [1]
    def profile(self, profile=None, collectors=None, **params):
        # [...]
            state = request.env['ir.profile'].set_profiling(profile, collectors=collectors, params=params) # [2]
            return Response(json.dumps(state)) # [3]
        except UserError as e:
            return Response(response='error: %s' % e, status=500)

Digging into Odoo's Response implementation, we can see that it directly inherits from werkzeug's Response, which is the underlying web framework:

class Response(werkzeug.wrappers.Response):
    Outgoing HTTP response with body, status, headers and qweb support.
    Also exposes all the attributes and methods of
    default_mimetype = 'text/html'

The attribute default_mimetype is set to text/html–very interesting! Indeed, werkzeug's default MIME type is originally set to text/plain if the developer didn't override it in the constructor:

class BaseResponse(object):
    # [...]
    #: the charset of the response.
    charset = "utf-8"

    #: the default status if none is provided.
    default_status = 200

    #: the default mimetype if none is provided.
    default_mimetype = "text/plain"

We are now in a situation where we are returning JSON data with a text/html content type. But do we control parts of that data? 

The method set_profiling() is defined in In the snippet below, at [1], [2], and [3], request.session is populated with the method parameters profile, collectors, and params. These values are then returned in a dict:

def set_profiling(self, profile=None, collectors=None, params=None):
    # [...]
    if profile:
        # [...]
    elif profile is not None:
        # [1]
        request.session.profile_session = None 

    if collectors is not None:
        # [2]
        request.session.profile_collectors = collectors

    if params is not None:
        # [3]
        request.session.profile_params = params

    return {
        'session': request.session.profile_session,
        'collectors': request.session.profile_collectors,
        'params': request.session.profile_params,

So yes, we have full control over them. URL parameters like profile=0, collectors=<script>alert(document.domain)</script> is enough to trigger the vulnerability. The resulting DOM, as seen by the client's browser, is as follows: 

Note that, while the server does not send them, the browser added the html, head, and body tags around the actual data because the server signaled that the response is an HTML page! Accessing the page is enough to trigger the JavaScript code:

Remediating Cross-Site Scripting Vulnerabilities

In the case of Cross-Site Scripting vulnerabilities, we believe that the best way of addressing these risks is at the very end of the chain: when displaying the data. Special characters must be made ineffective, whether by escaping or encoding them, but always depending on the context in which the data is injected. 

For instance, JavaScript string literals and HTML support different escaping methods, and using the wrong one will likely introduce a Cross-Site Scripting vulnerability. Always make sure to know the context and use the most appropriate function. 

The case of Odoo is a bit unusual. Common solutions would have been to implement a strict validation of the parameters or convert tags into HTML entities in the JSON string. Still, none of these should be considered satisfactory because the root cause boils down to this wrong content type: it must be addressed by setting the right content type on the API endpoint. 

We also recommend investing in a strong Content Security Policy, which will not prevent vulnerabilities but make them harder or impossible to exploit. It always takes time and a few iterations to get it right, so the sooner, the better! 

Patching CVE-2023-1434

Odoo maintainers addressed the vulnerability with ec8dd1a by adding an explicit content type, application/json, on this endpoint. 

If an UserError exception is raised, the exception message is prefixed with error:; this is not a valid JSON document. In that specific case, the maintainers set the content type to text/plain to tell browsers not to render it. 

diff --git a/addons/web/controllers/ b/addons/web/controllers/
index b320ee0cfba4e..640f8b4e210fc 100644
--- a/addons/web/controllers/
+++ b/addons/web/controllers/
@@ -16,9 +16,9 @@ def profile(self, profile=None, collectors=None, **params):
         profile = profile and profile != '0'
             state = request.env['ir.profile'].set_profiling(profile, collectors=collectors, params=params)
-            return json.dumps(state)
+            return Response(json.dumps(state), mimetype='application/json')
         except UserError as e:
-            return Response(response='error: %s' % e, status=500)
+            return Response(response='error: %s' % e, status=500, mimetype='text/plain')
     @route(['/web/speedscope', '/web/speedscope/<model("ir.profile"):profile>'], type='http', sitemap=False, auth='user')
     def speedscope(self, profile=None):

(We will update this publication with a link to the official advisory as soon it is published).


2022-12-22We report the vulnerability to the vendor. 
2022-12-23The vulnerability is fixed in ec8dd1a
2022-12-25Vendor informs us that the SaaS platform is not vulnerable and that a fix is under validation.


In short, getting the content type right is crucial for web developers to ensure the security of their applications. Client-side vulnerabilities can have a significant impact on the security of an application and should not be ignored. 

We would like to thank Olivier Dony of Odoo S.A. for promptly deploying a patch and for their very effective communication.

Enjoy all things Python, and want more? Register now for our upcoming webinar Clean Code for your Python projects, with Nafiul Islam - Wednesday, May 10th - 5 PM CEST / 10 AM CDT.

Related Blog Posts

Free Video! Learn about Clean Code for Python.
Watch Now