Skip to content

Commit c55bdfb

Browse files
committed
Add rule to check for HTML documents only headers
Partially fixes: #20, #21, and #25. Close #91
1 parent 4818c40 commit c55bdfb

File tree

8 files changed

+472
-55
lines changed

8 files changed

+472
-55
lines changed

.sonarrc

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"manifest-file-extension": "warning",
1414
"manifest-is-valid": "warning",
1515
"no-double-slash": "warning",
16-
"no-friendly-error-pages": "warning"
16+
"no-friendly-error-pages": "warning",
17+
"no-html-only-headers": "warning"
1718
}
1819
}

src/lib/rules/disallowed-headers/disallowed-headers.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Yes, you can use:
6464
should be ignored
6565

6666
E.g. The following configuration will make the rule allow responses
67-
to be served with the `Server` HTTP headers, but not with `Custom-Header`.
67+
to be served with the `Server` HTTP header, but not with `Custom-Header`.
6868

6969
```json
7070
"disallowed-headers": [ "warning", {

src/lib/rules/disallowed-headers/disallowed-headers.ts

+7-38
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { IFetchEndEvent, IRule, IRuleBuilder } from '../../interfaces'; // eslint-disable-line no-unused-vars
1010
import { RuleContext } from '../../rule-context'; // eslint-disable-line no-unused-vars
11+
import { getIncludedHeaders, mergeIgnoreIncludeArrays } from '../../util/rule-helpers';
1112

1213
// ------------------------------------------------------------------------------
1314
// Public
@@ -25,55 +26,23 @@ const rule: IRuleBuilder = {
2526
'x-version'
2627
];
2728

28-
const init = () => {
29+
const loadRuleConfigs = () => {
30+
const includeHeaders = (context.ruleOptions && context.ruleOptions.include) || [];
31+
const ignoreHeaders = (context.ruleOptions && context.ruleOptions.ignore) || [];
2932

30-
let includeHeaders = (context.ruleOptions && context.ruleOptions.include) || [];
31-
let ignoreHeaders = (context.ruleOptions && context.ruleOptions.ignore) || [];
32-
33-
includeHeaders = includeHeaders.map((e) => {
34-
return e.toLowerCase();
35-
});
36-
37-
ignoreHeaders = ignoreHeaders.map((e) => {
38-
return e.toLowerCase();
39-
});
40-
41-
// Add headers specified under 'include'.
42-
includeHeaders.forEach((e) => {
43-
if (!disallowedHeaders.includes(e)) {
44-
disallowedHeaders.push(e);
45-
}
46-
});
47-
48-
// Remove headers specified under 'ignore'.
49-
disallowedHeaders = disallowedHeaders.filter((e) => {
50-
return !ignoreHeaders.includes(e);
51-
});
52-
53-
};
54-
55-
const findDisallowedHeaders = (headers: object) => {
56-
const headersFound = [];
57-
58-
for (const [key] of Object.entries(headers)) {
59-
if (disallowedHeaders.includes(key.toLowerCase())) {
60-
headersFound.push(key);
61-
}
62-
}
63-
64-
return headersFound;
33+
disallowedHeaders = mergeIgnoreIncludeArrays(disallowedHeaders, ignoreHeaders, includeHeaders);
6534
};
6635

6736
const validate = (fetchEnd: IFetchEndEvent) => {
6837
const { element, resource } = fetchEnd;
69-
const headers = findDisallowedHeaders(fetchEnd.response.headers);
38+
const headers = getIncludedHeaders(fetchEnd.response.headers, disallowedHeaders);
7039

7140
if (headers.length > 0) {
7241
context.report(resource, element, `Disallowed HTTP header${headers.length > 1 ? 's' : ''} found: ${headers.join(', ')}`);
7342
}
7443
};
7544

76-
init();
45+
loadRuleConfigs();
7746

7847
return {
7948
'fetch::end': validate,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Disallow unneeded HTTP headers for non-HTML resources (`no-html-only-headers`)
2+
3+
`no-html-only-headers` warns against responding with HTTP headers that
4+
are not needed for non-HTML resources.
5+
6+
7+
## Why is this important?
8+
9+
Some HTTP headers do not make sense to be send for non-HTML
10+
resources, as sending them does not provide any value to users,
11+
and just contributes to header bloat.
12+
13+
14+
## What does the rule check?
15+
16+
The rule checks if non-HTML responses include any of the following
17+
HTTP headers:
18+
19+
* `Content-Security-Policy`
20+
* `X-Content-Security-Policy`
21+
* `X-Frame-Options`
22+
* `X-UA-Compatible`
23+
* `X-WebKit-CSP`
24+
* `X-XSS-Protection`
25+
26+
Examples that **trigger** the rule:
27+
28+
Response for `/test.js`:
29+
30+
```text
31+
32+
HTTP/1.1 200 OK
33+
34+
Content-Type: application/javascript
35+
...
36+
Content-Security-Policy: default-src 'none'
37+
Content-Type: application/javascript; charset=utf-8
38+
X-Content-Security-Policy: default-src 'none'
39+
X-Frame-Options: DENY
40+
X-UA-Compatible: IE=Edge,
41+
X-WebKit-CSP: default-src 'none'
42+
X-XSS-Protection: 1; mode=block
43+
...
44+
```
45+
46+
Response for `/test.html`:
47+
48+
```text
49+
HTTP/1.1 200 OK
50+
51+
Content-Type: x/y
52+
...
53+
Content-Security-Policy: default-src 'none'
54+
Content-Type: application/javascript; charset=utf-8
55+
X-Content-Security-Policy: default-src 'none'
56+
X-Frame-Options: DENY
57+
X-UA-Compatible: IE=Edge,
58+
X-WebKit-CSP: default-src 'none'
59+
X-XSS-Protection: 1; mode=block
60+
...
61+
```
62+
63+
Examples that **pass** the rule:
64+
65+
Response for `/test.js`:
66+
67+
```text
68+
HTTP/1.1 200 OK
69+
70+
Content-Type: application/javascript
71+
...
72+
```
73+
74+
Response for `/test.html`:
75+
76+
```text
77+
HTTP/1.1 200 OK
78+
79+
Content-Type: text/html
80+
...
81+
Content-Security-Policy: default-src 'none'
82+
Content-Type: application/javascript; charset=utf-8
83+
X-Content-Security-Policy: default-src 'none'
84+
X-Frame-Options: DENY
85+
X-UA-Compatible: IE=Edge,
86+
X-WebKit-CSP: default-src 'none'
87+
X-XSS-Protection: 1; mode=block
88+
...
89+
```
90+
91+
92+
## Can the rule be configured?
93+
94+
Yes, you can use:
95+
96+
* `include` to specify additional HTTP headers that should
97+
be disallowed for non-HTML resources
98+
* `ignore` to specify which of the disallowed HTTP headers
99+
should be ignored
100+
101+
E.g. The following configuration will make the rule allow non-HTML
102+
resources to be served with the `Content-Security-Policy` HTTP header,
103+
but not with `Custom-Header`.
104+
105+
```json
106+
"no-html-only-headers": [ "warning", {
107+
"ignore": ["Content-Security-Policy"],
108+
"include": ["Custom-Header"]
109+
}]
110+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* @fileoverview Check if non HTML resources responses contain certain
3+
* unneeded HTTP headers.
4+
*/
5+
6+
// ------------------------------------------------------------------------------
7+
// Requirements
8+
// ------------------------------------------------------------------------------
9+
10+
import { IFetchEndEvent, IResponse, IRule, IRuleBuilder } from '../../interfaces'; // eslint-disable-line no-unused-vars
11+
import { RuleContext } from '../../rule-context'; // eslint-disable-line no-unused-vars
12+
import { getIncludedHeaders, mergeIgnoreIncludeArrays } from '../../util/rule-helpers';
13+
14+
// ------------------------------------------------------------------------------
15+
// Public
16+
// ------------------------------------------------------------------------------
17+
18+
const rule: IRuleBuilder = {
19+
create(context: RuleContext): IRule {
20+
21+
let unneededHeaders = [
22+
'content-security-policy',
23+
'x-content-security-policy',
24+
'x-frame-options',
25+
'x-ua-compatible',
26+
'x-webkit-csp',
27+
'x-xss-protection'
28+
];
29+
30+
const loadRuleConfigs = () => {
31+
const includeHeaders = (context.ruleOptions && context.ruleOptions.include) || [];
32+
const ignoreHeaders = (context.ruleOptions && context.ruleOptions.ignore) || [];
33+
34+
unneededHeaders = mergeIgnoreIncludeArrays(unneededHeaders, ignoreHeaders, includeHeaders);
35+
};
36+
37+
const willBeTreatedAsHTML = (response: IResponse) => {
38+
const mediaType = response.headers['content-type'].split(';')[0].trim();
39+
40+
// By default, browsers will treat resource sent with the
41+
// following media types as HTML documents.
42+
43+
if (['text/html', 'application/xhtml+xml'].includes(mediaType)) {
44+
return true;
45+
}
46+
47+
// That is not the situation for other cases where the media
48+
// type is in the form of `<type>/<subtype>`.
49+
50+
if (mediaType.indexOf('/') > 0) {
51+
return false;
52+
}
53+
54+
// If the media type is not specified or invalid, browser
55+
// will try to sniff the content.
56+
//
57+
// https://mimesniff.spec.whatwg.org/
58+
//
59+
// At this point, even if browsers may decide to treat
60+
// the content as a HTML document, things are obviously
61+
// not done correctly, so the decision was to not try to
62+
// also sniff the content, and instead, just signal this
63+
// as a problem.
64+
65+
return false;
66+
};
67+
68+
const checkHeaders = (fetchEnd: IFetchEndEvent) => {
69+
const { element, resource, response } = fetchEnd;
70+
71+
if (!willBeTreatedAsHTML(response)) {
72+
const headers = getIncludedHeaders(response.headers, unneededHeaders);
73+
74+
if (headers.length > 0) {
75+
context.report(resource, element, `Unneeded HTTP header${headers.length > 1 ? 's' : ''} found: ${headers.join(', ')}`);
76+
}
77+
}
78+
};
79+
80+
loadRuleConfigs();
81+
82+
return {
83+
'fetch::end': checkHeaders,
84+
'targetfetch::end': checkHeaders
85+
};
86+
},
87+
meta: {
88+
docs: {
89+
category: 'performance',
90+
description: 'Disallow unneeded HTTP headers for non-HTML resources',
91+
recommended: true
92+
},
93+
fixable: 'code',
94+
schema: {
95+
additionalProperties: false,
96+
definitions: {
97+
'string-array': {
98+
items: { type: 'string' },
99+
minItems: 1,
100+
type: 'array',
101+
uniqueItems: true
102+
}
103+
},
104+
properties: {
105+
ignore: { $ref: '#/definitions/string-array' },
106+
include: { $ref: '#/definitions/string-array' }
107+
},
108+
type: ['object', null]
109+
}
110+
}
111+
};
112+
113+
module.exports = rule;

src/lib/util/rule-helpers.ts

+42
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,52 @@
11
import * as path from 'path';
22
import * as d from 'debug';
33

4+
export const getIncludedHeaders = (headers: object = {}, headerList: Array<string> = []) => {
5+
const result = [];
6+
7+
for (const [key] of Object.entries(headers)) {
8+
if (headerList.includes(key.toLowerCase())) {
9+
result.push(key);
10+
}
11+
}
12+
13+
return result;
14+
};
15+
416
export const getRuleName = (dirname: string) => {
517
return path.basename(dirname);
618
};
719

20+
export const mergeIgnoreIncludeArrays = (originalArray: Array<string> = [], ignoreArray: Array<string> = [], includeArray: Array<string> = []) => {
21+
22+
let result = originalArray.map((e) => {
23+
return e.toLowerCase();
24+
});
25+
26+
const include = includeArray.map((e) => {
27+
return e.toLowerCase();
28+
});
29+
30+
const ignore = ignoreArray.map((e) => {
31+
return e.toLowerCase();
32+
});
33+
34+
// Add elements specified under 'include'.
35+
include.forEach((e) => {
36+
if (!result.includes(e)) {
37+
result.push(e);
38+
}
39+
});
40+
41+
// Remove elements specified under 'ignore'.
42+
result = result.filter((e) => {
43+
return !ignore.includes(e);
44+
});
45+
46+
return result;
47+
48+
};
49+
850
export const ruleDebug = (dirname: string) => {
951
return d(`sonar:rules:${getRuleName(dirname)}`);
1052
};

0 commit comments

Comments
 (0)