Skip to content

Commit 66f1fd3

Browse files
authored
Detect missing header vulnerabilities (#3269)
* Detect X-Content-Type-Options missing header * HSTS Header missing analyzer and refactor of xcontenttype-header-missing-aanalyzer * Move function to class method * use startswith instead of index===0 * Do not send evidence if value is undefined * Fix comment in PR and add test * Changes to support telemetry * Rename method name * Rename analyzer object key
1 parent b3413ff commit 66f1fd3

File tree

13 files changed

+326
-17
lines changed

13 files changed

+326
-17
lines changed

packages/dd-trace/src/appsec/iast/analyzers/analyzers.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
module.exports = {
44
'COMMAND_INJECTION_ANALYZER': require('./command-injection-analyzer'),
5+
'HSTS_HEADER_MISSING_ANALYZER': require('./hsts-header-missing-analyzer'),
56
'INSECURE_COOKIE_ANALYZER': require('./insecure-cookie-analyzer'),
67
'LDAP_ANALYZER': require('./ldap-injection-analyzer'),
78
'NO_HTTPONLY_COOKIE_ANALYZER': require('./no-httponly-cookie-analyzer'),
@@ -11,5 +12,6 @@ module.exports = {
1112
'SSRF': require('./ssrf-analyzer'),
1213
'UNVALIDATED_REDIRECT_ANALYZER': require('./unvalidated-redirect-analyzer'),
1314
'WEAK_CIPHER_ANALYZER': require('./weak-cipher-analyzer'),
14-
'WEAK_HASH_ANALYZER': require('./weak-hash-analyzer')
15+
'WEAK_HASH_ANALYZER': require('./weak-hash-analyzer'),
16+
'XCONTENTTYPE_HEADER_MISSING_ANALYZER': require('./xcontenttype-header-missing-analyzer')
1517
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use strict'
2+
3+
const { HSTS_HEADER_MISSING } = require('../vulnerabilities')
4+
const { MissingHeaderAnalyzer } = require('./missing-header-analyzer')
5+
6+
const HSTS_HEADER_NAME = 'Strict-Transport-Security'
7+
const HEADER_VALID_PREFIX = 'max-age'
8+
class HstsHeaderMissingAnalyzer extends MissingHeaderAnalyzer {
9+
constructor () {
10+
super(HSTS_HEADER_MISSING, HSTS_HEADER_NAME)
11+
}
12+
_isVulnerableFromRequestAndResponse (req, res) {
13+
const headerToCheck = res.getHeader(HSTS_HEADER_NAME)
14+
return !this._isHeaderValid(headerToCheck) && this._isHttpsProtocol(req)
15+
}
16+
17+
_isHeaderValid (headerValue) {
18+
if (!headerValue) {
19+
return false
20+
}
21+
headerValue = headerValue.trim()
22+
23+
if (!headerValue.startsWith(HEADER_VALID_PREFIX)) {
24+
return false
25+
}
26+
27+
const semicolonIndex = headerValue.indexOf(';')
28+
let timestampString
29+
if (semicolonIndex > -1) {
30+
timestampString = headerValue.substring(HEADER_VALID_PREFIX.length + 1, semicolonIndex)
31+
} else {
32+
timestampString = headerValue.substring(HEADER_VALID_PREFIX.length + 1)
33+
}
34+
35+
const timestamp = parseInt(timestampString)
36+
// eslint-disable-next-line eqeqeq
37+
return timestamp == timestampString && timestamp > 0
38+
}
39+
40+
_isHttpsProtocol (req) {
41+
return req.protocol === 'https' || req.headers['x-forwarded-proto'] === 'https'
42+
}
43+
}
44+
45+
module.exports = new HstsHeaderMissingAnalyzer()

packages/dd-trace/src/appsec/iast/analyzers/index.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
const analyzers = require('./analyzers')
44
const setCookiesHeaderInterceptor = require('./set-cookies-header-interceptor')
55

6-
function enableAllAnalyzers () {
7-
setCookiesHeaderInterceptor.configure(true)
6+
function enableAllAnalyzers (tracerConfig) {
7+
setCookiesHeaderInterceptor.configure({ enabled: true, tracerConfig })
88
for (const analyzer in analyzers) {
9-
analyzers[analyzer].configure(true)
9+
analyzers[analyzer].configure({ enabled: true, tracerConfig })
1010
}
1111
}
1212

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
'use strict'
2+
3+
const Analyzer = require('./vulnerability-analyzer')
4+
5+
const SC_MOVED_PERMANENTLY = 301
6+
const SC_MOVED_TEMPORARILY = 302
7+
const SC_NOT_MODIFIED = 304
8+
const SC_TEMPORARY_REDIRECT = 307
9+
const SC_NOT_FOUND = 404
10+
const SC_GONE = 410
11+
const SC_INTERNAL_SERVER_ERROR = 500
12+
13+
const IGNORED_RESPONSE_STATUS_LIST = [SC_MOVED_PERMANENTLY, SC_MOVED_TEMPORARILY, SC_NOT_MODIFIED,
14+
SC_TEMPORARY_REDIRECT, SC_NOT_FOUND, SC_GONE, SC_INTERNAL_SERVER_ERROR]
15+
const HTML_CONTENT_TYPES = ['text/html', 'application/xhtml+xml']
16+
17+
class MissingHeaderAnalyzer extends Analyzer {
18+
constructor (type, headerName) {
19+
super(type)
20+
21+
this.headerName = headerName
22+
}
23+
24+
onConfigure () {
25+
this.addSub({
26+
channelName: 'datadog:iast:response-end',
27+
moduleName: 'http'
28+
}, (data) => this.analyze(data))
29+
}
30+
31+
_getLocation () {
32+
return undefined
33+
}
34+
35+
_checkOCE (context) {
36+
return true
37+
}
38+
39+
_createHashSource (type, evidence, location) {
40+
return `${type}:${this.config.tracerConfig.service}`
41+
}
42+
43+
_getEvidence ({ res }) {
44+
return { value: res.getHeader(this.headerName) }
45+
}
46+
47+
_isVulnerable ({ req, res }, context) {
48+
if (!IGNORED_RESPONSE_STATUS_LIST.includes(res.statusCode) && this._isResponseHtml(res)) {
49+
return this._isVulnerableFromRequestAndResponse(req, res)
50+
}
51+
return false
52+
}
53+
54+
_isVulnerableFromRequestAndResponse (req, res) {
55+
return false
56+
}
57+
58+
_isResponseHtml (res) {
59+
const contentType = res.getHeader('content-type')
60+
return contentType && HTML_CONTENT_TYPES.some(htmlContentType => {
61+
return htmlContentType === contentType || contentType.startsWith(htmlContentType + ';')
62+
})
63+
}
64+
}
65+
66+
module.exports = { MissingHeaderAnalyzer }
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use strict'
2+
3+
const { XCONTENTTYPE_HEADER_MISSING } = require('../vulnerabilities')
4+
const { MissingHeaderAnalyzer } = require('./missing-header-analyzer')
5+
6+
const XCONTENTTYPEOPTIONS_HEADER_NAME = 'X-Content-Type-Options'
7+
8+
class XcontenttypeHeaderMissingAnalyzer extends MissingHeaderAnalyzer {
9+
constructor () {
10+
super(XCONTENTTYPE_HEADER_MISSING, XCONTENTTYPEOPTIONS_HEADER_NAME)
11+
}
12+
13+
_isVulnerableFromRequestAndResponse (req, res) {
14+
const headerToCheck = res.getHeader(XCONTENTTYPEOPTIONS_HEADER_NAME)
15+
return !headerToCheck || headerToCheck.trim().toLowerCase() !== 'nosniff'
16+
}
17+
}
18+
19+
module.exports = new XcontenttypeHeaderMissingAnalyzer()

packages/dd-trace/src/appsec/iast/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ const iastTelemetry = require('./telemetry')
1919
// order of the callbacks can be enforce
2020
const requestStart = dc.channel('dd-trace:incomingHttpRequestStart')
2121
const requestClose = dc.channel('dd-trace:incomingHttpRequestEnd')
22+
const iastResponseEnd = dc.channel('datadog:iast:response-end')
2223

2324
function enable (config, _tracer) {
2425
iastTelemetry.configure(config, config.iast && config.iast.telemetryVerbosity)
25-
enableAllAnalyzers()
26+
enableAllAnalyzers(config)
2627
enableTaintTracking(config.iast, iastTelemetry.verbosity)
2728
requestStart.subscribe(onIncomingHttpRequestStart)
2829
requestClose.subscribe(onIncomingHttpRequestEnd)
@@ -72,6 +73,8 @@ function onIncomingHttpRequestEnd (data) {
7273
const topContext = web.getContext(data.req)
7374
const iastContext = iastContextFunctions.getIastContext(store, topContext)
7475
if (iastContext && iastContext.rootSpan) {
76+
iastResponseEnd.publish(data)
77+
7578
const vulnerabilities = iastContext.vulnerabilities
7679
const rootSpan = iastContext.rootSpan
7780
vulnerabilityReporter.sendVulnerabilities(vulnerabilities, rootSpan)

packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ class VulnerabilityFormatter {
5454

5555
formatEvidence (type, evidence, sourcesIndexes, sources) {
5656
if (!evidence.ranges) {
57-
return { value: evidence.value }
57+
if (typeof evidence.value === 'undefined') {
58+
return undefined
59+
} else {
60+
return { value: evidence.value }
61+
}
5862
}
5963

6064
return this._redactVulnearbilities

packages/dd-trace/src/appsec/iast/vulnerabilities.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
module.exports = {
22
COMMAND_INJECTION: 'COMMAND_INJECTION',
3+
HSTS_HEADER_MISSING: 'HSTS_HEADER_MISSING',
34
INSECURE_COOKIE: 'INSECURE_COOKIE',
45
LDAP_INJECTION: 'LDAP_INJECTION',
56
NO_HTTPONLY_COOKIE: 'NO_HTTPONLY_COOKIE',
@@ -9,5 +10,6 @@ module.exports = {
910
SSRF: 'SSRF',
1011
UNVALIDATED_REDIRECT: 'UNVALIDATED_REDIRECT',
1112
WEAK_CIPHER: 'WEAK_CIPHER',
12-
WEAK_HASH: 'WEAK_HASH'
13+
WEAK_HASH: 'WEAK_HASH',
14+
XCONTENTTYPE_HEADER_MISSING: 'XCONTENTTYPE_HEADER_MISSING'
1315
}

packages/dd-trace/src/appsec/iast/vulnerability-reporter.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ function addVulnerability (iastContext, vulnerability) {
2828

2929
function isValidVulnerability (vulnerability) {
3030
return vulnerability && vulnerability.type &&
31-
vulnerability.evidence && vulnerability.evidence.value &&
31+
vulnerability.evidence &&
3232
vulnerability.location && vulnerability.location.spanId
3333
}
3434

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
'use strict'
2+
3+
const { prepareTestServerForIast } = require('../utils')
4+
const Analyzer = require('../../../../src/appsec/iast/analyzers/vulnerability-analyzer')
5+
const { HSTS_HEADER_MISSING } = require('../../../../src/appsec/iast/vulnerabilities')
6+
const axios = require('axios')
7+
const analyzer = new Analyzer()
8+
9+
describe('hsts header missing analyzer', () => {
10+
it('Expected vulnerability identifier', () => {
11+
expect(HSTS_HEADER_MISSING).to.be.equals('HSTS_HEADER_MISSING')
12+
})
13+
14+
prepareTestServerForIast('hsts header missing analyzer',
15+
(testThatRequestHasVulnerability, testThatRequestHasNoVulnerability, config) => {
16+
function makeRequestWithXFordwardedProtoHeader (done) {
17+
axios.get(`http://localhost:${config.port}/`, {
18+
headers: {
19+
'X-Forwarded-Proto': 'https'
20+
}
21+
}).catch(done)
22+
}
23+
24+
testThatRequestHasVulnerability((req, res) => {
25+
res.setHeader('content-type', 'text/html')
26+
res.end('<html><body><h1>Test</h1></body></html>')
27+
}, HSTS_HEADER_MISSING, 1, function (vulnerabilities) {
28+
expect(vulnerabilities[0].evidence).to.be.undefined
29+
expect(vulnerabilities[0].hash).to.be.equals(analyzer._createHash('HSTS_HEADER_MISSING:mocha'))
30+
}, makeRequestWithXFordwardedProtoHeader)
31+
32+
testThatRequestHasVulnerability((req, res) => {
33+
res.setHeader('content-type', 'text/html;charset=utf-8')
34+
res.end('<html><body><h1>Test</h1></body></html>')
35+
}, HSTS_HEADER_MISSING, 1, function (vulnerabilities) {
36+
expect(vulnerabilities[0].evidence).to.be.undefined
37+
expect(vulnerabilities[0].hash).to.be.equals(analyzer._createHash('HSTS_HEADER_MISSING:mocha'))
38+
}, makeRequestWithXFordwardedProtoHeader)
39+
40+
testThatRequestHasVulnerability((req, res) => {
41+
res.setHeader('content-type', 'application/xhtml+xml')
42+
res.end('<html><body><h1>Test</h1></body></html>')
43+
}, HSTS_HEADER_MISSING, 1, function (vulnerabilities) {
44+
expect(vulnerabilities[0].evidence).to.be.undefined
45+
expect(vulnerabilities[0].hash).to.be.equals(analyzer._createHash('HSTS_HEADER_MISSING:mocha'))
46+
}, makeRequestWithXFordwardedProtoHeader)
47+
48+
testThatRequestHasVulnerability((req, res) => {
49+
res.setHeader('content-type', 'text/html')
50+
res.setHeader('Strict-Transport-Security', 'max-age=-100')
51+
res.end('<html><body><h1>Test</h1></body></html>')
52+
}, HSTS_HEADER_MISSING, 1, function (vulnerabilities) {
53+
expect(vulnerabilities[0].evidence.value).to.be.equal('max-age=-100')
54+
expect(vulnerabilities[0].hash).to.be.equals(analyzer._createHash('HSTS_HEADER_MISSING:mocha'))
55+
}, makeRequestWithXFordwardedProtoHeader)
56+
57+
testThatRequestHasVulnerability((req, res) => {
58+
res.setHeader('content-type', 'text/html')
59+
res.setHeader('Strict-Transport-Security', 'max-age=-100; includeSubDomains')
60+
res.end('<html><body><h1>Test</h1></body></html>')
61+
}, HSTS_HEADER_MISSING, 1, function (vulnerabilities) {
62+
expect(vulnerabilities[0].evidence.value).to.be.equal('max-age=-100; includeSubDomains')
63+
expect(vulnerabilities[0].hash).to.be.equals(analyzer._createHash('HSTS_HEADER_MISSING:mocha'))
64+
}, makeRequestWithXFordwardedProtoHeader)
65+
66+
testThatRequestHasVulnerability((req, res) => {
67+
res.setHeader('content-type', 'text/html')
68+
res.setHeader('Strict-Transport-Security', 'invalid')
69+
res.end('<html><body><h1>Test</h1></body></html>')
70+
}, HSTS_HEADER_MISSING, 1, function (vulnerabilities) {
71+
expect(vulnerabilities[0].evidence.value).to.be.equal('invalid')
72+
expect(vulnerabilities[0].hash).to.be.equals(analyzer._createHash('HSTS_HEADER_MISSING:mocha'))
73+
}, makeRequestWithXFordwardedProtoHeader)
74+
75+
testThatRequestHasNoVulnerability((req, res) => {
76+
res.setHeader('content-type', 'application/json')
77+
res.end('{"key": "test}')
78+
}, HSTS_HEADER_MISSING, makeRequestWithXFordwardedProtoHeader)
79+
80+
testThatRequestHasNoVulnerability((req, res) => {
81+
res.setHeader('content-type', 'text/html')
82+
res.setHeader('Strict-Transport-Security', 'max-age=100')
83+
res.end('{"key": "test}')
84+
}, HSTS_HEADER_MISSING, makeRequestWithXFordwardedProtoHeader)
85+
86+
testThatRequestHasNoVulnerability((req, res) => {
87+
res.setHeader('content-type', 'text/html')
88+
res.setHeader('Strict-Transport-Security', ' max-age=100 ')
89+
res.end('{"key": "test}')
90+
}, HSTS_HEADER_MISSING, makeRequestWithXFordwardedProtoHeader)
91+
92+
testThatRequestHasNoVulnerability((req, res) => {
93+
res.setHeader('content-type', 'text/html')
94+
res.setHeader('Strict-Transport-Security', 'max-age=100;includeSubDomains')
95+
res.end('{"key": "test}')
96+
}, HSTS_HEADER_MISSING, makeRequestWithXFordwardedProtoHeader)
97+
98+
testThatRequestHasNoVulnerability((req, res) => {
99+
res.setHeader('content-type', 'text/html')
100+
res.setHeader('Strict-Transport-Security', 'max-age=100 ;includeSubDomains')
101+
res.end('{"key": "test}')
102+
}, HSTS_HEADER_MISSING, makeRequestWithXFordwardedProtoHeader)
103+
})
104+
})

0 commit comments

Comments
 (0)