My understanding was that "preventing otherwise disallowed HTTP requests" was the entire point of the preflight OPTIONS request, and that CORS will do nothing if the request would otherwise be allowed.

For example, a POST request with a Content-Type of "text/json" would not be allowed to be sent to third-party hosts without an OPTIONS preflight, but one with a Content-Type of "multipart/form-data" would be allowed and wouldn't be stopped by CORS at all, even to third-party hosts.

(And, of course, if your endpoint just assumes JSON without strictly checking the Content-Type, then congratulations, you've just allowed any website to POST to you, with no user action required.)

> (And, of course, if your endpoint just assumes JSON without strictly checking the Content-Type, then congratulations, you've just allowed any website to POST to you, with no user action required.)

Is that so? Neither urlencoded forms nor multipart/form-data are valid JSON on the wire, so while other websites could send requests, wouldn't they just hit a parse error?

You can massage a text/plain form into valid JSON. text/plain is also one of the allowed default types. It works if the server doesn't check the content-type.

Source: I've done that successfully in multiple pentests.

Edit: lazy LLM generated example:

  <form action="https://example.com/api" method="POST" enctype="text/plain">
    <input name='{"key":"value", "ignore":"' value='"}'>
  </form>
That gives you

  {"key":"value", "ignore":"="}
The trick is to stuff the = character you cannot control into an irrelevant value.

Ouch! Ok, I wasn't aware text/plain was also one of the whitelisted values and lets you do that. That looks pretty bad indeed.

Or good, depending on who you are. I find CORS to be pain in my butt by effectively preventing ad-hoc client-side browser tools from being able to use most APIs, and forcing me to do bullshit make-work involving a server side component and certificates just to work around this + HTTPS, at which point I may as well just have my "backend" shell out to `curl`, which happily doesn't care about any of that nonsense.

I mean, it still relies on a vulnerable configuration (not checking the content type)

For adhoc dev tools, you could also just disable CORS: https://berkkaraal.com/notes/other/chrome-disable-cors/

> I mean, it still relies on a vulnerable configuration (not checking the content type)

Unless I'm misunderstanding the claim, that's not "vulnerable configuration", but extreme lunacy - basically treating parsing outcome as access control. "Did the payload parse correctly as JSON? No -> go away; Yes -> oh that must mean you're supposed to be here". I'm at a loss of words that this is even a problem in practice.

> For adhoc dev tools

There's a whole space between "adhoc" and "dev" tools, though, and this is what interests me the most. Yes, when I'm in full dev mode, I can make my computer do anything I need to. But more often than that, I'm just a user that wants to exercise some basic freedom of computing - to remove some toil or frustration from daily computing experience - without switching my hat from "user" to "developer". That's what CORS has been successfully defeating, by forcing any ad-hoc non-dev tool I could make for myself to require being in "developer mode" to use it.

This is fundamentally a CSRF issue and framing CSRF as an access control issue often yields to wrong conclusions. With CSRF you might face the situation that the request has a valid session cookie, but is actually created by an attacker coercing the victim's browser into sending a request unbeknownst to the victim.

This case gets more and more complicated with browser defenses such as SameSite cookies or fetch headers you can use to mitigate this case, but let's ignore that for now.

To drive my point home, similar to how ensuring the content-type is set correctly on your JSON endpoint prevents CSRF, it's actually also a very real defense to require a custom header to be set, e.g.

  I-Promise-To-Not-Be-Malicious: true
Requiring this header will prevent CSRF because browsers won't allow you to set that cross-origin (unless of course you allow anyone to set it via CORS)

Regarding the first part, it's easier than you might think to have a false sense of security.

I've seen a web application that did, in fact, check the Content-Type header to make sure that "application/json" was there - but it didn't check that the header value started with that. That meant that setting the header to "multipart/form-data; boundary=application/json" was enough to bypass a CORS preflight!

[deleted]

If your web application specifically parses data based on the Content-Type that it advertises itself to be, then yes, the webapp would hit a parse error. But there are many applications that don't do that.

An attacker might use JavaScript to set a "multipart/form-data" Content-Type (thereby bypassing the otherwise required OPTIONS preflight), but send JSON in the request body. Unless your web application specifically parses the body based on the Content-Type (web servers don't do this for you), then you wouldn't detect that.

Well, if your endpoint expects JSON, then at some point it will have to parse it. Even if it completely ignores the content-type header and simply always passes the request body to the JSON parser, the parser would throw.

(But I was wrong, there are ways to produce request bodies that are valid JSON even if the browser forces you into a different format, as the sibling comment demonstrated)

> ...there are ways to produce request bodies that are valid JSON even if the browser forces you into a different format...

The browser basically never forces you into a particular format. You don't even need to do the trick with the form stuff that the sibling was talking about. Consider the following JavaScript:

    var xhr = new XMLHttpRequest();
    var url = "http://localhost:12345/endpoint";
    xhr.open("POST", url, true);
    xhr.setRequestHeader('Content-Type', 'multipart/form-data');
    xhr.send('{"hello":"world"}');
No trickery required, it just does it.

[Edited to illustrate my point better.]

You can do that, but my understanding is you can't get the browser to attach cookies to your request in this way, while you can with forms. Do you agree?

I haven't actually investigated that (and I'm not able to do so right now), so I couldn't tell you for sure.

If that's the case, then yes, the forms method would be 'better'.

[deleted]

Interesting. Is this still sent as a "safe" request though or does it trigger a preflight request etc?

If it was one of the requests that would trigger a preflight normally, then yes, it would trigger a preflight. But the code as shown doesn't do that because "multipart/form-data" is one of the allowed MIME types that can bypass these preflights.

Obviously JSON is a subset of text/plain, so I don't know what people were expecting? For text/plain to mean "plaintext, excluding any string that could possibly parse as any of the other named formats that have a plaintext representation"?

Are people using JSON parser as proxy for access control? "Payload successfully parsed as JSON, therefore you are allowed to use this endpoint"?

If the JSON backend parses the payload as JSON without verifying the content type, then yes, it's a way through. There's no affirmative logic of "payload looks like json, so let it through" going on, it's just "parse this json without looking at content-type, and assume it was already subject to cross-origin restriction because it's json, right?" (same thing, just different intentions). And as we can see, that assumption fails to hold if you don't actually check the content-type.

My apps speak only JSON, so one of the first things I do is create a middleware that requires any POST/PUT/PATCH request to be application/json and reject everything else with a 415 error. That's so I can turn off the CSRF protection mechanics in the framework completely, but the two concerns are related.

I wasn't aware that plaintext was one of the whitelisted types that are allowed without a preflight request.

I guess the same trick might work with urlencoded forms, but it wouldn't work with multipart/form-data

> Are people using JSON parser as proxy for access control? "Payload successfully parsed as JSON, therefore you are allowed to use this endpoint"?

For better or worse, yes, or at least as one layer. That's one of the rationales behind the "safe" requests AFAIK.

And this wouldn't be the first time, protocols are made intentionally incompatible on the wire, so an attacker can't smuggle one inside the other. That's the entire reason for WebSocket's weird handshake dance and the "xor encoding" it applies to messages from the client.

> I wasn't aware that plaintext was one of the whitelisted types that are allowed without a preflight request.

Until now, I wasn't aware of that either. My response is about the fact you can massage the plaintext part to contain valid JSON somehow being a problem, one that apparently is a security issue in practice.

We're not talking about some clever polyglot quine like those COM executables that are somehow also valid Bash and C code and PDF files or something. text/plain is a superset of everything that can be represented by plain text, which includes approximately all code and data formats, JSON and XML included.

> And this wouldn't be the first time, protocols are made intentionally incompatible on the wire, so an attacker can't smuggle one inside the other.

I need to learn more about it, thanks for pointing it out.

Though at the surface, it reads to me like removing a feature. "Smuggling a protocol inside the other" sounds to me like an important feature, or perhaps more accurately, I find myself being part of the "attacker" population much more often than not. "Tunnel $whatever through HTTPS because corporate/ISP firewalls" is both a meme and success story for plenty a SaaS at this point.

> text/plain is a superset of everything that can be represented by plain text

Not in the context of web forms.

Just checked the spec and "text/plain" just seems to be an alias for "application/x-www-form-urlencoded" [1] - i.e. stuff that looks like

  key=value&anotherkey=anothervalue
on the wire.

Apparently though, keys and values can contain arbitrary characters and arent percent-encoded, so you can do a "quine" where the "key" is

  {"foo": "bar", "ignore": "
and the "value" is

  "}
And then the browser will happily send

  {"foo": "bar", "ignore": "="}
over the wire, which is valid json.

[1] https://html.spec.whatwg.org/multipage/form-control-infrastr...

> I guess the same trick might work with urlencoded forms, but it wouldn't work with multipart/form-data

It does, though. See my reply at https://news.ycombinator.com/item?id=48618539 .