Dangerously permissive Content-Type header value parsing
Categories
(Core :: Networking: HTTP, defect)
Tracking
()
People
(Reporter: mateusz.krzeszowiec, Unassigned)
References
(Depends on 1 open bug)
Details
(Keywords: reporter-external, Whiteboard: [client-bounty-form])
Attachments
(1 file)
|
1.23 KB,
application/x-javascript
|
Details |
VULNERABILITY DETAILS
Firefox accepts malformed Content-Type "executable" mime types (ones that result in an HTML document being rendered with JavaScript that will be executed), which may facilitate Content-Type validation bypasses.
In most severe cases, this leads to easier exploitation of cross-site scripting vulnerabilities in web applications. I've tested and verified unexpected behaviour for the "text/html" content type only, but it is very likely that it's a more generic bug in content-type parsing that needs to be addressed.
VERSION
Current Firefox version on macos
REPRODUCTION CASE
It's easiest to reproduce when running one's handwritten web server or using an intercepting proxy.
Using a trivial HTTP server like the one below, written in Node.js (attached):
const { createServer } = require('node:http');
const hostname = '127.0.0.1';
const port = 3000;
const server = createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html'); // (1)
res.end(<html><head><title>OMG?</title></head><body><a href="javascript: alert('omg');">test</a></body></html>);
});
server.listen(port, hostname, () => {
console.log(Server running at http://${hostname}:${port}/);
});
Add a non-permitted character (like ";" delimiter, as defined here: https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6) at the beggining of a content-type value on line (1), modifying it into, for example:
res.setHeader('Content-Type', ';text/html');
another variant of this issue can look as follows:
res.setHeader('Content-Type', 'text / <html');
or the delimiter can be appended to type as follows:
res.setHeader('Content-Type', 'text;/html');
Run the server by executing, for example: npx nodemon index.js
Now visit the page by pointing Firefox to, for example: http://localhost:3000
The document will be rendered as HTML which is unexpected and invalid behavior.
The key is that the webpage will be rendered as HTML document whether the content-type is set to an "executable" content-type like "text/html" but will also be rendered as a regular HTML document if the content-type is set to different
CREDIT INFORMATION
Reporter credit: Mateusz Krzeszowiec, inspired by @a_zara_n @ei01241 from Flatt Security
Comment 1•1 year ago
|
||
I'm not familiar with this code, but it looks like maybe this parsing is happening in nsMultiMixedConv::OnStartRequest, so I'll move this to Networking.
| Reporter | ||
Comment 2•1 year ago
|
||
Found another odd variant again accepted as a "proper" text/html content type by FF:
res.setHeader('Content-Type', 't;ext/html');
Comment 3•1 year ago
|
||
The severity field is not set for this bug.
:jesup, could you have a look please?
For more information, please visit BugBot documentation.
Comment 4•1 year ago
|
||
Unfortunately, browsers have been "dangerously permission" out of historical necessity since the 90s. More relevant to browsers than the RFC is the WHATWG Mime-sniffing spec: https://mimesniff.spec.whatwg.org/ It's possible we're not following that correctly, too, but let's start there.
Simon: are we getting this wrong?
Most important (and the reason for the WHATWG specs) is that we behave the same as other browsers so that Firefox users are not uniquely vulnerable in the face of an insecure/abused server.
Comment 5•1 year ago
|
||
Tests at https://wpt.fyi/results/mimesniff/mime-types?label=master&label=experimental&aligned
It looks like roughly the same tests pass and fail in Firefox as in Chrome and Safari.
| Reporter | ||
Comment 6•1 year ago
|
||
I understand that it is how it's working at the moment but I'm seeing a contrast between RFC/whatwg and actual implementation in browsers.
What also struck me is that if X-Content-Type-Options: nosniff is present, Firefox will fall back to text/plain content type in some cases or bring up a download dialogue in other cases.
This does not work as described here: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options - current behaviour goes as follows:
If the nosniff is set, then
- the document will be rendered with the provided content-type, if the content type is NOT malformed
- firefox will fallback to text/plain in some cases (for example: "text/sd", "firefoxisthebest")
- but not the others (for example: "boom!boom", "textz/sd")
And to be specific where FF deviates from the algorithm described in whatwg:
- https://mimesniff.spec.whatwg.org/#parsing-a-mime-type
- point 7 says: "Let subtype be the result of collecting a sequence of code points that are not U+003B (;) from input, given position."
- the actual behaviour is that FF seem to ignore white spaces, so the "text/html <" is still considered valid "text/html" content-type (that's with nosniff set)
I guess that if this is "works-as-expected" then please close this ticket. I'll try to document it somewhere if this is the case.
Comment 7•1 year ago
|
||
The problem is not what the spec says; the problem is what is required to actually make websites work. We'd probably first have to roll out some kind of data gathering to figure out how frequently we rely on non-compliant content types before we could be stricter. Right now I don't think we know one way or the other.
| Reporter | ||
Comment 8•1 year ago
|
||
It's getting weirder; you can force FF (and Chrome) to pick the SECOND mime-type in a Content-Type header value - if you add a comma.
This weirdly-looking binary string is a GIF with a comment that contains HTML.
TLDR:
- Given "Content-Type: image/gif text/html" FF will render document as image/gif, (again with X-Content-Type-Options: nosniff)
- Given "Content-Type: image/gif, text/html" FF will render document as text/html, (that's with X-Content-Type-Options: nosniff)
const url = require('node:url');
const hostname = 'localhost';
const port = 3000;
const server = createServer((req, res) => {
const parsedUrl = url.parse(req.url, true);
const gif = Buffer.from('47494638396130003000a10100000000ffffffffffffffffff21fe2f3c7374726f6e673e436f6e74656e742d547970652070617273696e6720697320626f726b65643c2f7374726f6e673e0021f904010a0002002c00000000300030000002c98c8f99c01da0a29cb1d9eba89eb8e30d1adef8851c8966e692b6d07ab832fcc82e6dcf66ae877cbff9dd40c2215118f3f87895cece26512e5b1ae714320a968eafd863b5d57669490b787c427b6bea6ad5fc7e77dba2cb514e2f33f4d13cbfa9125626a6562288a042015717b8b887989836076798b888876567c738b6c945c5f6f87745323ae9079833c806c3c4b97a36e4c9da2a1b79490ac542f603c8abea8ba3ab809b8bd2872a99929a8cbccc526c9ca51719fb1c5a5d7b7df87bcc294a0639cd185e6e2e5100003b', 'hex');
switch (parsedUrl.query.case) {
case 'gif':
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Type', 'image/gif text/html');
res.end(gif);
break;
case 'look-ma-no-hands':
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Type', 'image/gif, text/html');
res.end(gif);
break;
}
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});```
Comment 10•1 year ago
|
||
It's getting weirder; you can force FF (and Chrome) to pick the SECOND mime-type in a Content-Type header value - if you add a comma.
If you add a comma then it is interpreted as two Content-Type: headers that were coalesced into one (a feature of HTTP). At that point browsers have to decide if they use the first, the last, ignore it completely, or in some cases (like Content-Security-Policy:) use all of them. It causes compatibility problems when browsers make different choices, so the behavior has been specified (probably in one of the browser-centric WHATWG specs).
Updated•1 year ago
|
Comment 11•1 year ago
|
||
See the pref network.standard_content_type_parsing.response_headers -- # If true, HTTP response content-type headers will be parsed using the standards-compliant MimeType parser
Note that RFC 2616 3.7 (which our non-compliant parser implements) has this: media-type = type "/" subtype *( ";" parameter ) -- that may be where the issue with ';' comes from.
This is the parsing our non-compliant parser uses (from the source https://searchfox.org/mozilla-central/source/netwerk/base/nsURLHelper.cpp#827):
// Augmented BNF (from RFC 2616 section 3.7):
//
// header-value = media-type *( LWS "," LWS media-type )
// media-type = type "/" subtype *( LWS ";" LWS parameter )
// type = token
// subtype = token
// parameter = attribute "=" value
// attribute = token
// value = token | quoted-string
Note that this BNF doesn't allow for "type1 type2", so "Content-Type: image/gif text/html" might take the entire string as a media type, or more likely fail to find a valid media-type. See https://searchfox.org/mozilla-central/source/netwerk/base/nsURLHelper.cpp#669 I haven't worked through all the details of parsing a Content-Type with spaces separating. (It might ignore the entire thing, and fall back to sniffing)
When parsing with a ";" in a media type (see comment 0), that will be found as the start of parameters by https://searchfox.org/mozilla-central/source/netwerk/base/nsURLHelper.cpp#723
Note: "We also want to reject a mime-type if it does not include a slash."
I presume we don't want to switch to spec-compliant parsing because it would break a bunch of websites (valentin?)
Updated•1 year ago
|
// changed, then don't wipe-out an existing aContentCharset. We
// also want to reject a mime-type if it does not include a slash.
// some servers give junk after the charset parameter, which may
// include a comma, so this check makes us a bit more tolerant.
This is coming from 2005 - before the initial mercurial import.
https://github.com/mozilla/gecko-dev/commit/6c59f965fd51f61711bedcd637691988984fc49d
I assume this was just to avoid breaking non-compliant webservers a long time ago.
We were moving in the direction of a more standard-compliant content type parser, but we didn't yet land bug 1895075 due to concerns regarding webcompat.
Comment 13•1 year ago
|
||
Is this effectively a duplicate of bug 1895075? If not, what would we do in this bug that isn't in that one?
Let's dupe. Thanks!
Updated•1 year ago
|
Description
•