Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Suspicious Response Blocking #3837

Merged
merged 53 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
071d935
push draft
simon-id Dec 1, 2023
b3328e3
push stuff
simon-id Jan 25, 2024
53831e4
Merge branch 'master' into suspicious_response_blocking
simon-id Feb 2, 2024
89d9585
add RC capability for response blocking
simon-id Feb 16, 2024
144b42a
oupsie
simon-id Feb 16, 2024
e718f5f
Merge branch 'master' into suspicious_response_blocking
simon-id Feb 21, 2024
0db6c41
fix
simon-id Feb 4, 2024
43bc955
merge headers in writeHead()
simon-id Mar 14, 2024
395a660
clear implicit headers before sending blocking response
simon-id Apr 15, 2024
4c1e5dd
delete test prototype
simon-id Apr 15, 2024
82952be
add a few tests
simon-id May 7, 2024
8d57f19
more tests
simon-id May 7, 2024
b92e442
Merge branch 'master' into suspicious_response_blocking
simon-id May 7, 2024
48fc393
update RC capabilities test
simon-id May 7, 2024
b07910b
little fix for the CI only
simon-id May 24, 2024
8f88258
Merge branch 'master' into suspicious_response_blocking
simon-id May 24, 2024
2fa02a6
Merge branch 'master' into suspicious_response_blocking
simon-id May 28, 2024
79865f2
more tests
simon-id May 24, 2024
62734b9
better tests
simon-id May 28, 2024
1727869
support implicit write()
simon-id May 28, 2024
c0e87aa
support array syntax for WriteHead()
simon-id May 28, 2024
da0f93b
move shit around
simon-id May 28, 2024
44f952c
keep state in appsec consumer instead
simon-id May 28, 2024
a69096b
cleanup
simon-id May 28, 2024
f99fa5c
cleanup
simon-id May 28, 2024
2c45573
cleanup
simon-id May 28, 2024
9e38422
cleanup
simon-id May 28, 2024
1936719
change channel name
simon-id May 28, 2024
3fd33b1
don't run request end waf if not useful
simon-id May 28, 2024
f62eb06
avoid double call and write after end
simon-id May 28, 2024
fe93b1e
temporarly fix tests
simon-id May 28, 2024
82f21a9
lint
simon-id May 28, 2024
5b15d0a
temp fix
simon-id May 28, 2024
7332ca8
Merge branch 'master' into suspicious_response_blocking
simon-id May 28, 2024
3265251
remove file ending to not conflict with windows
simon-id May 29, 2024
e4e60ff
remove file
simon-id May 29, 2024
a6b5cc5
add it back without the newline
simon-id May 29, 2024
0cc5066
update test
simon-id May 29, 2024
60d41aa
little polyfill for node 16
simon-id May 29, 2024
bb109d8
Merge branch 'master' into suspicious_response_blocking
simon-id May 29, 2024
6d4e113
add and remove comments
simon-id May 29, 2024
4ded6c6
update blocking tests
simon-id Jun 5, 2024
25ccc0c
update index tests
simon-id Jun 5, 2024
e94494e
Merge branch 'master' into suspicious_response_blocking
simon-id Jun 5, 2024
215d15e
check for subscribers early exit
simon-id Jun 5, 2024
5bf7f48
Update packages/dd-trace/src/appsec/index.js
simon-id Jun 5, 2024
6a3d23f
Update packages/datadog-instrumentations/src/http/server.js
simon-id Jun 5, 2024
140fa82
Merge branch 'master' into suspicious_response_blocking
simon-id Jun 5, 2024
4744dfc
Update packages/dd-trace/test/appsec/response_blocking.spec.js
simon-id Jun 5, 2024
ed2a582
Update packages/dd-trace/src/appsec/index.js
simon-id Jun 5, 2024
b881e2b
Update packages/dd-trace/src/appsec/index.js
simon-id Jun 6, 2024
ed6fe06
Merge branch 'master' into suspicious_response_blocking
simon-id Jun 6, 2024
b813ff2
Merge branch 'master' into suspicious_response_blocking
simon-id Jun 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions packages/datadog-instrumentations/src/http/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const startServerCh = channel('apm:http:server:request:start')
const exitServerCh = channel('apm:http:server:request:exit')
const errorServerCh = channel('apm:http:server:request:error')
const finishServerCh = channel('apm:http:server:request:finish')
const startWriteHeadCh = channel('apm:http:server:response:writeHead:start')
const finishSetHeaderCh = channel('datadog:http:server:response:set-header:finish')

const requestFinishedSet = new WeakSet()
Expand All @@ -20,6 +21,9 @@ const httpsNames = ['https', 'node:https']
addHook({ name: httpNames }, http => {
shimmer.wrap(http.ServerResponse.prototype, 'emit', wrapResponseEmit)
shimmer.wrap(http.Server.prototype, 'emit', wrapEmit)
shimmer.wrap(http.ServerResponse.prototype, 'writeHead', wrapWriteHead)
shimmer.wrap(http.ServerResponse.prototype, 'write', wrapWrite)
shimmer.wrap(http.ServerResponse.prototype, 'end', wrapEnd)
return http
})

Expand Down Expand Up @@ -86,3 +90,97 @@ function wrapSetHeader (res) {
}
})
}

function wrapWriteHead (writeHead) {
return function wrappedWriteHead (statusCode, reason, obj) {
if (!startWriteHeadCh.hasSubscribers) {
return writeHead.apply(this, arguments)
}

const abortController = new AbortController()
uurien marked this conversation as resolved.
Show resolved Hide resolved

if (typeof reason !== 'string') {
obj ??= reason
}

// support writeHead(200, ['key1', 'val1', 'key2', 'val2'])
if (Array.isArray(obj)) {
const headers = {}

for (let i = 0; i < obj.length; i += 2) {
headers[obj[i]] = obj[i + 1]
iunanua marked this conversation as resolved.
Show resolved Hide resolved
}

obj = headers
}

// this doesn't support explicit duplicate headers, but it's an edge case
const responseHeaders = Object.assign(this.getHeaders(), obj)

startWriteHeadCh.publish({
req: this.req,
res: this,
abortController,
statusCode,
responseHeaders
})

if (abortController.signal.aborted) {
return this
}

return writeHead.apply(this, arguments)
}
}

function wrapWrite (write) {
return function wrappedWrite () {
if (!startWriteHeadCh.hasSubscribers) {
return write.apply(this, arguments)
}

const abortController = new AbortController()

const responseHeaders = this.getHeaders()

startWriteHeadCh.publish({
req: this.req,
res: this,
abortController,
statusCode: this.statusCode,
responseHeaders
})

if (abortController.signal.aborted) {
return true
}

return write.apply(this, arguments)
}
}

function wrapEnd (end) {
return function wrappedEnd () {
if (!startWriteHeadCh.hasSubscribers) {
return end.apply(this, arguments)
}

const abortController = new AbortController()

const responseHeaders = this.getHeaders()

startWriteHeadCh.publish({
req: this.req,
res: this,
abortController,
statusCode: this.statusCode,
responseHeaders
})

if (abortController.signal.aborted) {
return this
}

return end.apply(this, arguments)
}
}
4 changes: 4 additions & 0 deletions packages/dd-trace/src/appsec/blocking.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ function block (req, res, rootSpan, abortController, actionParameters) {

const { body, headers, statusCode } = getBlockingData(req, null, rootSpan, actionParameters)

for (const headerName of res.getHeaderNames()) {
res.removeHeader(headerName)
iunanua marked this conversation as resolved.
Show resolved Hide resolved
}

res.writeHead(statusCode, headers).end(body)

abortController?.abort()
simon-id marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
1 change: 1 addition & 0 deletions packages/dd-trace/src/appsec/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ module.exports = {
nextBodyParsed: dc.channel('apm:next:body-parsed'),
nextQueryParsed: dc.channel('apm:next:query-parsed'),
responseBody: dc.channel('datadog:express:response:json:start'),
responseWriteHead: dc.channel('apm:http:server:response:writeHead:start'),
httpClientRequestStart: dc.channel('apm:http:client:request:start')
}
54 changes: 44 additions & 10 deletions packages/dd-trace/src/appsec/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ const {
queryParser,
nextBodyParsed,
nextQueryParsed,
responseBody
responseBody,
responseWriteHead
} = require('./channels')
const waf = require('./waf')
const addresses = require('./addresses')
Expand Down Expand Up @@ -60,6 +61,7 @@ function enable (_config) {
queryParser.subscribe(onRequestQueryParsed)
cookieParser.subscribe(onRequestCookieParser)
responseBody.subscribe(onResponseBody)
responseWriteHead.subscribe(onResponseWriteHead)

if (_config.appsec.eventTracking.enabled) {
passportVerify.subscribe(onPassportVerify)
Expand Down Expand Up @@ -110,14 +112,7 @@ function incomingHttpStartTranslator ({ req, res, abortController }) {
}

function incomingHttpEndTranslator ({ req, res }) {
// TODO: this doesn't support headers sent with res.writeHead()
const responseHeaders = Object.assign({}, res.getHeaders())
delete responseHeaders['set-cookie']

const persistent = {
[addresses.HTTP_INCOMING_RESPONSE_CODE]: '' + res.statusCode,
[addresses.HTTP_INCOMING_RESPONSE_HEADERS]: responseHeaders
}
const persistent = {}

// we need to keep this to support other body parsers
// TODO: no need to analyze it if it was already done by the body-parser hook
Expand All @@ -139,7 +134,9 @@ function incomingHttpEndTranslator ({ req, res }) {
persistent[addresses.HTTP_INCOMING_QUERY] = req.query
}

waf.run({ persistent }, req)
if (Object.keys(persistent).length) {
waf.run({ persistent }, req)
}

waf.disposeContext(req)

Expand Down Expand Up @@ -225,12 +222,48 @@ function onPassportVerify ({ credentials, user }) {
passportTrackEvent(credentials, user, rootSpan, config.appsec.eventTracking.mode)
}

const responseAnalyzedSet = new WeakSet()
const responseBlockedSet = new WeakSet()

function onResponseWriteHead ({ req, res, abortController, statusCode, responseHeaders }) {
// avoid "write after end" error
if (responseBlockedSet.has(res)) {
abortController?.abort()
return
}

// avoid double waf call
if (responseAnalyzedSet.has(res)) {
return
}

const rootSpan = web.root(req)
if (!rootSpan) return

responseHeaders = Object.assign({}, responseHeaders)
delete responseHeaders['set-cookie']
iunanua marked this conversation as resolved.
Show resolved Hide resolved

const results = waf.run({
persistent: {
[addresses.HTTP_INCOMING_RESPONSE_CODE]: '' + statusCode,
[addresses.HTTP_INCOMING_RESPONSE_HEADERS]: responseHeaders
}
}, req)

responseAnalyzedSet.add(res)

handleResults(results, req, res, rootSpan, abortController)
}

function handleResults (actions, req, res, rootSpan, abortController) {
if (!actions || !req || !res || !rootSpan || !abortController) return

const blockingAction = getBlockingAction(actions)
if (blockingAction) {
block(req, res, rootSpan, abortController, blockingAction)
if (!abortController.signal || abortController.signal.aborted) {
responseBlockedSet.add(res)
}
}
}

Expand All @@ -256,6 +289,7 @@ function disable () {
if (cookieParser.hasSubscribers) cookieParser.unsubscribe(onRequestCookieParser)
if (responseBody.hasSubscribers) responseBody.unsubscribe(onResponseBody)
if (passportVerify.hasSubscribers) passportVerify.unsubscribe(onPassportVerify)
if (responseWriteHead.hasSubscribers) responseWriteHead.unsubscribe(onResponseWriteHead)
}

module.exports = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module.exports = {
ASM_DD_RULES: 1n << 3n,
ASM_EXCLUSIONS: 1n << 4n,
ASM_REQUEST_BLOCKING: 1n << 5n,
ASM_RESPONSE_BLOCKING: 1n << 6n,
ASM_USER_BLOCKING: 1n << 7n,
ASM_CUSTOM_RULES: 1n << 8n,
ASM_CUSTOM_BLOCKING_RESPONSE: 1n << 9n,
Expand Down
2 changes: 2 additions & 0 deletions packages/dd-trace/src/appsec/remote_config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function enableWafUpdate (appsecConfig) {
rc.updateCapabilities(RemoteConfigCapabilities.ASM_DD_RULES, true)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_EXCLUSIONS, true)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_REQUEST_BLOCKING, true)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_RESPONSE_BLOCKING, true)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_CUSTOM_RULES, true)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_CUSTOM_BLOCKING_RESPONSE, true)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_TRUSTED_IPS, true)
Expand All @@ -92,6 +93,7 @@ function disableWafUpdate () {
rc.updateCapabilities(RemoteConfigCapabilities.ASM_DD_RULES, false)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_EXCLUSIONS, false)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_REQUEST_BLOCKING, false)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_RESPONSE_BLOCKING, false)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_CUSTOM_RULES, false)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_CUSTOM_BLOCKING_RESPONSE, false)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_TRUSTED_IPS, false)
Expand Down
20 changes: 19 additions & 1 deletion packages/dd-trace/test/appsec/blocking.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ describe('blocking', () => {
res = {
setHeader: sinon.stub(),
writeHead: sinon.stub(),
end: sinon.stub()
end: sinon.stub(),
getHeaderNames: sinon.stub().returns([]),
removeHeader: sinon.stub()
}
res.writeHead.returns(res)

Expand Down Expand Up @@ -109,6 +111,22 @@ describe('blocking', () => {
expect(res.end).to.have.been.calledOnceWithExactly('jsonBody')
expect(abortController.signal.aborted).to.be.true
})

it('should remove all headers before sending blocking response', () => {
res.getHeaderNames.returns(['header1', 'header2'])

block(req, res, rootSpan)

expect(rootSpan.addTags).to.have.been.calledOnceWithExactly({ 'appsec.blocked': 'true' })
expect(res.removeHeader).to.have.been.calledTwice
expect(res.removeHeader.firstCall).to.have.been.calledWithExactly('header1')
expect(res.removeHeader.secondCall).to.have.been.calledWithExactly('header2')
expect(res.writeHead).to.have.been.calledOnceWithExactly(403, {
'Content-Type': 'application/json',
'Content-Length': 8
})
expect(res.end).to.have.been.calledOnceWithExactly('jsonBody')
})
})

describe('block with default templates', () => {
Expand Down
Loading
Loading