From 6ae8df828c582b8eb40d03342ec8faf26ef8debf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?C=C4=83t=C4=83lin=20Mari=C8=99?= <alrraa@gmail.com>
Date: Mon, 22 Jan 2018 22:26:42 -0800
Subject: [PATCH] Breaking: Limit `X-Content-Type-Options` usage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Change `x-content-type-options` rule so that it limits the usage
of the `X-Content-Type-Options` header to scripts and stylesheets
as modern browsers actually only respect the header for those
types of resources¹.

Also, sending the header for resources such as images, creates
problems² in some older browsers.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

¹ https://fetch.spec.whatwg.org/#x-content-type-options-header
² https://github.com/whatwg/fetch/issues/395

Fix #767
---
 .../rules/x-content-type-options.md           | 41 +++++++++++----
 .../x-content-type-options.ts                 | 33 ++++++++++--
 tests/helpers/test-server.ts                  |  1 -
 .../lib/rules/x-content-type-options/tests.ts | 51 ++++++++++---------
 4 files changed, 85 insertions(+), 41 deletions(-)

diff --git a/docs/user-guide/rules/x-content-type-options.md b/docs/user-guide/rules/x-content-type-options.md
index e0fcd8bfb8d..3031fa7b548 100644
--- a/docs/user-guide/rules/x-content-type-options.md
+++ b/docs/user-guide/rules/x-content-type-options.md
@@ -1,7 +1,8 @@
 # Require `X-Content-Type-Options` HTTP response header (`x-content-type-options`)
 
-`x-content-type-options` warns against not serving resources with the
-`X-Content-Type-Options: nosniff` HTTP response header.
+`x-content-type-options` warns against not serving scripts and
+stylesheets with the `X-Content-Type-Options: nosniff` HTTP response
+header.
 
 ## Why is this important?
 
@@ -25,41 +26,56 @@ hosting untrusted content.
 Fortunately, browsers provide a way to opt-out of MIME sniffing by
 using the `X-Content-Type-Options: nosniff` HTTP response header.
 
-Note: [Most modern browsers only respect the header for `script`s and
-`style`s][fetch spec blocking] (see also [whatwg/fetch#395][fetch spec
-issue].
-
 Going back to the previous example, if the `X-Content-Type-Options: nosniff`
 header is sent for the script, if the browser detects that it’s a script
 and it wasn’t served with one of the [JavaScript media type][javascript
 media types], it will block it.
 
+Note: [Modern browsers only respect the header for scripts and
+stylesheets][fetch spec blocking], and sending the header for other
+resources such as images may [create problems in older browsers][fetch
+spec issue].
+
 ## What does the rule check?
 
-The rule checks if responses include the `X-Content-Type-Options`
-HTTP headers with the value of `nosniff`.
+The rule checks if only scripts and stylesheets are served with the
+`X-Content-Type-Options` HTTP headers with the value of `nosniff`.
 
 ### Examples that **trigger** the rule
 
+Resource that is not script or stylesheet is served with the
+`X-Content-Type-Options` HTTP header.
+
 ```text
 HTTP/... 200 OK
 
 ...
+
+Content-Type: image/png
+X-Content-Type-Options: nosniff
 ```
 
+Script is served with the `X-Content-Type-Options` HTTP header
+with the invalid value of `no-sniff`.
+
 ```text
 HTTP/... 200 OK
 
 ...
+Content-Type: text/javascript; charset=utf-8
 X-Content-Type-Options: no-sniff
 ```
 
 ### Examples that **pass** the rule
 
+Script is served with the `X-Content-Type-Options` HTTP header
+with the valid value of `nosniff`.
+
 ```text
 HTTP/... 200 OK
 
 ...
+Content-Type: text/javascript; charset=utf-8
 X-Content-Type-Options: nosniff
 ```
 
@@ -70,13 +86,16 @@ X-Content-Type-Options: nosniff
 <details>
 <summary>How to configure Apache</summary>
 
-Apache can be configured to serve resources with the
-`X-Content-Type-Options` header with the value of `nosniff`
+Presuming the script files use the `.js` or `.mjs` extension, and
+the stylesheets `.css`, Apache can be configured to serve the with
+the `X-Content-Type-Options` header with the value of `nosniff`
 using the [`Header` directive][header directive]:
 
 ```apache
 <IfModule mod_headers.c>
-    Header set X-Content-Type-Options "nosniff"
+     <FilesMatch "\.(css|m?js)$">
+        Header set X-Content-Type-Options "nosniff"
+    </FilesMatch>
 </IfModule>
 ```
 
diff --git a/src/lib/rules/x-content-type-options/x-content-type-options.ts b/src/lib/rules/x-content-type-options/x-content-type-options.ts
index 1f635f278fa..9c06355acc7 100644
--- a/src/lib/rules/x-content-type-options/x-content-type-options.ts
+++ b/src/lib/rules/x-content-type-options/x-content-type-options.ts
@@ -37,16 +37,41 @@ const rule: IRuleBuilder = {
                 return;
             }
 
+            let headerIsRequired = false;
+
             const headerValue: string = normalizeString(response.headers && response.headers['x-content-type-options']);
+            const nodeName = element && normalizeString(element.nodeName);
+
+            /*
+             * See:
+             *
+             *  * https://github.com/whatwg/fetch/issues/395
+             *  * https://fetch.spec.whatwg.org/#x-content-type-options-header
+             */
+
+            if (nodeName === 'script' ||
+                (nodeName === 'link' && normalizeString(element.getAttribute('rel')) === 'stylesheet')) {
+                headerIsRequired = true;
+            }
+
+            if (headerIsRequired) {
+                if (headerValue === null) {
+                    await context.report(resource, element, `'x-content-type-options' header is not specified`);
+
+                    return;
+                }
+
+                if (headerValue !== 'nosniff') {
+                    await context.report(resource, element, `'x-content-type-options' header value (${headerValue}) is invalid`);
 
-            if (headerValue === null) {
-                await context.report(resource, element, `'x-content-type-options' header was not specified`);
+                    return;
+                }
 
                 return;
             }
 
-            if (headerValue !== 'nosniff') {
-                await context.report(resource, element, `'x-content-type-options' header value (${headerValue}) is invalid`);
+            if (headerValue) {
+                await context.report(resource, element, `'x-content-type-options' header is not needed`);
             }
         };
 
diff --git a/tests/helpers/test-server.ts b/tests/helpers/test-server.ts
index d57ffad6563..0a570293629 100644
--- a/tests/helpers/test-server.ts
+++ b/tests/helpers/test-server.ts
@@ -281,7 +281,6 @@ export class Server {
                 res.status(200);
                 res.setHeader('Content-Length', '0');
                 res.setHeader('Content-Type', 'image/x-icon');
-                res.setHeader('X-Content-Type-Options', 'nosniff');
                 res.end();
             });
         }
diff --git a/tests/lib/rules/x-content-type-options/tests.ts b/tests/lib/rules/x-content-type-options/tests.ts
index cd97dd31beb..747bd7224a9 100644
--- a/tests/lib/rules/x-content-type-options/tests.ts
+++ b/tests/lib/rules/x-content-type-options/tests.ts
@@ -7,69 +7,70 @@ import * as ruleRunner from '../../../helpers/rule-runner';
 
 // Error messages.
 
-const noHeaderMessage = `'x-content-type-options' header was not specified`;
+const noHeaderMessage = `'x-content-type-options' header is not specified`;
+const unneededHeaderMessage = `'x-content-type-options' header is not needed`;
 const generateInvalidValueMessage = (value: string = '') => {
     return `'x-content-type-options' header value (${value}) is invalid`;
 };
 
 // Page data.
 
-const generateHTMLPageData = (content: string) => {
-    return {
-        content,
-        headers: { 'X-Content-Type-Options': 'nosniff' }
-    };
-};
-
-const htmlPageWithScriptData = generateHTMLPageData(generateHTMLPage(undefined, '<script src="test.js"></script>'));
-const htmlPageWithManifestData = generateHTMLPageData(generateHTMLPage('<link rel="manifest" href="test.webmanifest">'));
+const htmlPageWithScript = generateHTMLPage(undefined, '<script src="test.js"></script>');
+const htmlPageWithStylesheet = generateHTMLPage('<link rel="stylesheet" href="test.css">');
+const htmlPageWithManifest = generateHTMLPage('<link rel="manifest" href="test.webmanifest">');
 
 // Tests.
 
 const tests: Array<IRuleTest> = [
     {
         name: `HTML page is served without 'X-Content-Type-Options' header`,
-        reports: [{ message: noHeaderMessage }],
         serverConfig: { '/': '' }
     },
     {
         name: `Manifest is served without 'X-Content-Type-Options' header`,
-        reports: [{ message: noHeaderMessage }],
         serverConfig: {
-            '/': htmlPageWithManifestData,
+            '/': htmlPageWithManifest,
             '/test.webmanifest': ''
         }
     },
     {
-        name: `Resource is served without 'X-Content-Type-Options' header`,
+        name: `Script is served without 'X-Content-Type-Options' header`,
         reports: [{ message: noHeaderMessage }],
         serverConfig: {
-            '/': htmlPageWithScriptData,
+            '/': htmlPageWithScript,
             '/test.js': ''
         }
     },
+    {
+        name: `Stylesheet is served without 'X-Content-Type-Options' header`,
+        reports: [{ message: noHeaderMessage }],
+        serverConfig: {
+            '/': htmlPageWithStylesheet,
+            '/test.css': ''
+        }
+    },
     {
         name: `Resource is specified as a data URI`,
-        serverConfig: { '/': generateHTMLPageData(generateHTMLPage(undefined, '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==">')) }
+        serverConfig: { '/': generateHTMLPage(undefined, '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==">') }
     },
     {
-        name: `HTML page is served with 'X-Content-Type-Options' header with invalid value`,
-        reports: [{ message: generateInvalidValueMessage('no-sniff') }],
-        serverConfig: { '/': { headers: { 'X-Content-Type-Options': 'no-sniff' } } }
+        name: `HTML page is served with the 'X-Content-Type-Options' header`,
+        reports: [{ message: unneededHeaderMessage }],
+        serverConfig: { '/': { headers: { 'X-Content-Type-Options': 'nosniff' } } }
     },
     {
-        name: `Manifest is served with 'X-Content-Type-Options' header with invalid value`,
-        reports: [{ message: generateInvalidValueMessage() }],
+        name: `Manifest is served without 'X-Content-Type-Options' header`,
+        reports: [{ message: unneededHeaderMessage }],
         serverConfig: {
-            '/': htmlPageWithManifestData,
-            '/test.webmanifest': { headers: { 'X-Content-Type-Options': '' } }
+            '/': htmlPageWithManifest,
+            '/test.webmanifest': { headers: { 'X-Content-Type-Options': 'invalid' } }
         }
     },
     {
-        name: `Resource is served with 'X-Content-Type-Options' header with invalid value`,
+        name: `Script is served with 'X-Content-Type-Options' header with invalid value`,
         reports: [{ message: generateInvalidValueMessage('invalid') }],
         serverConfig: {
-            '/': htmlPageWithScriptData,
+            '/': htmlPageWithScript,
             '/test.js': { headers: { 'X-Content-Type-Options': 'invalid' } }
         }
     }