Skip to content

Commit

Permalink
Add rule to check for HTML documents only headers
Browse files Browse the repository at this point in the history
Partially fixes: #20, #21, and #25.
Close #91
  • Loading branch information
alrra committed Apr 12, 2017
1 parent 4818c40 commit c55bdfb
Show file tree
Hide file tree
Showing 8 changed files with 472 additions and 55 deletions.
3 changes: 2 additions & 1 deletion .sonarrc
Expand Up @@ -13,6 +13,7 @@
"manifest-file-extension": "warning",
"manifest-is-valid": "warning",
"no-double-slash": "warning",
"no-friendly-error-pages": "warning"
"no-friendly-error-pages": "warning",
"no-html-only-headers": "warning"
}
}
2 changes: 1 addition & 1 deletion src/lib/rules/disallowed-headers/disallowed-headers.md
Expand Up @@ -64,7 +64,7 @@ Yes, you can use:
should be ignored

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

```json
"disallowed-headers": [ "warning", {
Expand Down
45 changes: 7 additions & 38 deletions src/lib/rules/disallowed-headers/disallowed-headers.ts
Expand Up @@ -8,6 +8,7 @@

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

// ------------------------------------------------------------------------------
// Public
Expand All @@ -25,55 +26,23 @@ const rule: IRuleBuilder = {
'x-version'
];

const init = () => {
const loadRuleConfigs = () => {
const includeHeaders = (context.ruleOptions && context.ruleOptions.include) || [];
const ignoreHeaders = (context.ruleOptions && context.ruleOptions.ignore) || [];

let includeHeaders = (context.ruleOptions && context.ruleOptions.include) || [];
let ignoreHeaders = (context.ruleOptions && context.ruleOptions.ignore) || [];

includeHeaders = includeHeaders.map((e) => {
return e.toLowerCase();
});

ignoreHeaders = ignoreHeaders.map((e) => {
return e.toLowerCase();
});

// Add headers specified under 'include'.
includeHeaders.forEach((e) => {
if (!disallowedHeaders.includes(e)) {
disallowedHeaders.push(e);
}
});

// Remove headers specified under 'ignore'.
disallowedHeaders = disallowedHeaders.filter((e) => {
return !ignoreHeaders.includes(e);
});

};

const findDisallowedHeaders = (headers: object) => {
const headersFound = [];

for (const [key] of Object.entries(headers)) {
if (disallowedHeaders.includes(key.toLowerCase())) {
headersFound.push(key);
}
}

return headersFound;
disallowedHeaders = mergeIgnoreIncludeArrays(disallowedHeaders, ignoreHeaders, includeHeaders);
};

const validate = (fetchEnd: IFetchEndEvent) => {
const { element, resource } = fetchEnd;
const headers = findDisallowedHeaders(fetchEnd.response.headers);
const headers = getIncludedHeaders(fetchEnd.response.headers, disallowedHeaders);

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

init();
loadRuleConfigs();

return {
'fetch::end': validate,
Expand Down
110 changes: 110 additions & 0 deletions src/lib/rules/no-html-only-headers/no-html-only-headers.md
@@ -0,0 +1,110 @@
# Disallow unneeded HTTP headers for non-HTML resources (`no-html-only-headers`)

`no-html-only-headers` warns against responding with HTTP headers that
are not needed for non-HTML resources.


## Why is this important?

Some HTTP headers do not make sense to be send for non-HTML
resources, as sending them does not provide any value to users,
and just contributes to header bloat.


## What does the rule check?

The rule checks if non-HTML responses include any of the following
HTTP headers:

* `Content-Security-Policy`
* `X-Content-Security-Policy`
* `X-Frame-Options`
* `X-UA-Compatible`
* `X-WebKit-CSP`
* `X-XSS-Protection`

Examples that **trigger** the rule:

Response for `/test.js`:

```text
HTTP/1.1 200 OK
Content-Type: application/javascript
...
Content-Security-Policy: default-src 'none'
Content-Type: application/javascript; charset=utf-8
X-Content-Security-Policy: default-src 'none'
X-Frame-Options: DENY
X-UA-Compatible: IE=Edge,
X-WebKit-CSP: default-src 'none'
X-XSS-Protection: 1; mode=block
...
```

Response for `/test.html`:

```text
HTTP/1.1 200 OK
Content-Type: x/y
...
Content-Security-Policy: default-src 'none'
Content-Type: application/javascript; charset=utf-8
X-Content-Security-Policy: default-src 'none'
X-Frame-Options: DENY
X-UA-Compatible: IE=Edge,
X-WebKit-CSP: default-src 'none'
X-XSS-Protection: 1; mode=block
...
```

Examples that **pass** the rule:

Response for `/test.js`:

```text
HTTP/1.1 200 OK
Content-Type: application/javascript
...
```

Response for `/test.html`:

```text
HTTP/1.1 200 OK
Content-Type: text/html
...
Content-Security-Policy: default-src 'none'
Content-Type: application/javascript; charset=utf-8
X-Content-Security-Policy: default-src 'none'
X-Frame-Options: DENY
X-UA-Compatible: IE=Edge,
X-WebKit-CSP: default-src 'none'
X-XSS-Protection: 1; mode=block
...
```


## Can the rule be configured?

Yes, you can use:

* `include` to specify additional HTTP headers that should
be disallowed for non-HTML resources
* `ignore` to specify which of the disallowed HTTP headers
should be ignored

E.g. The following configuration will make the rule allow non-HTML
resources to be served with the `Content-Security-Policy` HTTP header,
but not with `Custom-Header`.

```json
"no-html-only-headers": [ "warning", {
"ignore": ["Content-Security-Policy"],
"include": ["Custom-Header"]
}]
```
113 changes: 113 additions & 0 deletions src/lib/rules/no-html-only-headers/no-html-only-headers.ts
@@ -0,0 +1,113 @@
/**
* @fileoverview Check if non HTML resources responses contain certain
* unneeded HTTP headers.
*/

// ------------------------------------------------------------------------------
// Requirements
// ------------------------------------------------------------------------------

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

// ------------------------------------------------------------------------------
// Public
// ------------------------------------------------------------------------------

const rule: IRuleBuilder = {
create(context: RuleContext): IRule {

let unneededHeaders = [
'content-security-policy',
'x-content-security-policy',
'x-frame-options',
'x-ua-compatible',
'x-webkit-csp',
'x-xss-protection'
];

const loadRuleConfigs = () => {
const includeHeaders = (context.ruleOptions && context.ruleOptions.include) || [];
const ignoreHeaders = (context.ruleOptions && context.ruleOptions.ignore) || [];

unneededHeaders = mergeIgnoreIncludeArrays(unneededHeaders, ignoreHeaders, includeHeaders);
};

const willBeTreatedAsHTML = (response: IResponse) => {
const mediaType = response.headers['content-type'].split(';')[0].trim();

// By default, browsers will treat resource sent with the
// following media types as HTML documents.

if (['text/html', 'application/xhtml+xml'].includes(mediaType)) {
return true;
}

// That is not the situation for other cases where the media
// type is in the form of `<type>/<subtype>`.

if (mediaType.indexOf('/') > 0) {
return false;
}

// If the media type is not specified or invalid, browser
// will try to sniff the content.
//
// https://mimesniff.spec.whatwg.org/
//
// At this point, even if browsers may decide to treat
// the content as a HTML document, things are obviously
// not done correctly, so the decision was to not try to
// also sniff the content, and instead, just signal this
// as a problem.

return false;
};

const checkHeaders = (fetchEnd: IFetchEndEvent) => {
const { element, resource, response } = fetchEnd;

if (!willBeTreatedAsHTML(response)) {
const headers = getIncludedHeaders(response.headers, unneededHeaders);

if (headers.length > 0) {
context.report(resource, element, `Unneeded HTTP header${headers.length > 1 ? 's' : ''} found: ${headers.join(', ')}`);
}
}
};

loadRuleConfigs();

return {
'fetch::end': checkHeaders,
'targetfetch::end': checkHeaders
};
},
meta: {
docs: {
category: 'performance',
description: 'Disallow unneeded HTTP headers for non-HTML resources',
recommended: true
},
fixable: 'code',
schema: {
additionalProperties: false,
definitions: {
'string-array': {
items: { type: 'string' },
minItems: 1,
type: 'array',
uniqueItems: true
}
},
properties: {
ignore: { $ref: '#/definitions/string-array' },
include: { $ref: '#/definitions/string-array' }
},
type: ['object', null]
}
}
};

module.exports = rule;
42 changes: 42 additions & 0 deletions src/lib/util/rule-helpers.ts
@@ -1,10 +1,52 @@
import * as path from 'path';
import * as d from 'debug';

export const getIncludedHeaders = (headers: object = {}, headerList: Array<string> = []) => {
const result = [];

for (const [key] of Object.entries(headers)) {
if (headerList.includes(key.toLowerCase())) {
result.push(key);
}
}

return result;
};

export const getRuleName = (dirname: string) => {
return path.basename(dirname);
};

export const mergeIgnoreIncludeArrays = (originalArray: Array<string> = [], ignoreArray: Array<string> = [], includeArray: Array<string> = []) => {

let result = originalArray.map((e) => {
return e.toLowerCase();
});

const include = includeArray.map((e) => {
return e.toLowerCase();
});

const ignore = ignoreArray.map((e) => {
return e.toLowerCase();
});

// Add elements specified under 'include'.
include.forEach((e) => {
if (!result.includes(e)) {
result.push(e);
}
});

// Remove elements specified under 'ignore'.
result = result.filter((e) => {
return !ignore.includes(e);
});

return result;

};

export const ruleDebug = (dirname: string) => {
return d(`sonar:rules:${getRuleName(dirname)}`);
};

0 comments on commit c55bdfb

Please sign in to comment.