Skip to content

Commit

Permalink
exploit prevention blocking - missing safeguards for writes after blocks
Browse files Browse the repository at this point in the history
  • Loading branch information
uurien committed Jun 6, 2024
1 parent bdfedfe commit c92a8d5
Show file tree
Hide file tree
Showing 8 changed files with 347 additions and 22 deletions.
113 changes: 113 additions & 0 deletions integration-tests/appsec/index.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
'use strict'

const getPort = require('get-port')
const { createSandbox, FakeAgent, spawnProc } = require('../helpers')
const path = require('path')
const Axios = require('axios')
const { assert } = require('chai')

describe('RASP', () => {
let axios, sandbox, cwd, appPort, appFile, agent, proc, stdioHandler

function stdOutputHandler (data) {
stdioHandler && stdioHandler(data)
}

before(async () => {
sandbox = await createSandbox(['express'])
appPort = await getPort()
cwd = sandbox.folder
appFile = path.join(cwd, 'appsec/rasp/index.js')
axios = Axios.create({
baseURL: `http://localhost:${appPort}`
})
})

beforeEach(async () => {
agent = await new FakeAgent().start()
proc = await spawnProc(appFile, {
cwd,
env: {
AGENT_PORT: agent.port,
APP_PORT: appPort,
DD_APPSEC_ENABLED: true,
DD_APPSEC_RASP_ENABLED: true,
DD_APPSEC_RULES: path.join(cwd, 'appsec/rasp/rasp_rules.json')
}
}, stdOutputHandler, stdOutputHandler)
})

afterEach(async () => {
proc.kill()
await agent.stop()
})

after(async () => {
await sandbox.remove()
})

async function testNotCrashedAfterBlocking (path) {
let hasOutput = false
stdioHandler = () => {
hasOutput = true
}
try {
await axios.get(`${path}?host=ifconfig.pro`)
assert.fail('Request should have failed')
} catch (e) {
if (!e.response) {
throw e
}
assert.strictEqual(e.response.status, 403)
}
return new Promise((resolve, reject) => {
setTimeout(() => {
if (hasOutput) {
reject(new Error('Unexpected output in stdout/stderr after blocking request'))
} else {
resolve()
}
}, 50)
})
}

describe('ssrf', () => {
it('should block when error is unhandled', async () => {
try {
await axios.get('/ssrf/http/unhandled-error?host=ifconfig.pro')
assert.fail('Request should have failed')
} catch (e) {
assert.strictEqual(e.response.status, 403)
}
})

// Not implemented yet
it('should not crash the app when app send data after blocking', () => {
return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-A')
})

it('should not crash the app when app stream data after blocking', () => {
return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-B')
})

it('should not crash the app when setHeader, writeHead or end after blocking', () => {
return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-C')
})

it('should not crash the app when appendHeader, flushHeaders, removeHeader after blocking', () => {
return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-D')
})

it('should not crash the app when writeContinue after blocking', () => {
return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-E')
})

it('should not crash the app when writeProcessing after blocking', () => {
return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-F')
})

it('should not crash the app when writeEarlyHints after blocking', () => {
return testNotCrashedAfterBlocking('/ssrf/http/unhandled-async-write-G')
})
})
})
97 changes: 97 additions & 0 deletions integration-tests/appsec/rasp/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use strict'

const path = require('path')
const fs = require('fs')
require('dd-trace').init()

const http = require('https')
const express = require('express')

const app = express()
const port = process.env.APP_PORT || 3000

app.get('/ping', (req, res) => {
res.end('pong')
})

function makeOutgoingRequestAndCbAfterTimeout (req, res, cb) {
let finished = false
setTimeout(() => {
if (!finished && cb) {
cb()
}
}, 10)

http.get(`https://${req.query.host}`, () => {
finished = true
res.send('end')
})
}

app.get('/ssrf/http/unhandled-error', (req, res) => {
makeOutgoingRequestAndCbAfterTimeout(req, res)
})
app.get('/ssrf/http/unhandled-async-write-A', (req, res) => {
makeOutgoingRequestAndCbAfterTimeout(req, res, () => {
res.send('Late end')
})
})

app.get('/ssrf/http/unhandled-async-write-B', (req, res) => {
makeOutgoingRequestAndCbAfterTimeout(req, res, () => {
streamFile(res)
})
})

app.get('/ssrf/http/unhandled-async-write-C', (req, res) => {
makeOutgoingRequestAndCbAfterTimeout(req, res, () => {
res.setHeader('key', 'value')
res.writeHead(200, 'OK', ['key2', 'value2'])
res.write('test\n')
res.end('end')
})
})

app.get('/ssrf/http/unhandled-async-write-D', (req, res) => {
makeOutgoingRequestAndCbAfterTimeout(req, res, () => {
res.setHeader('key', 'value')
res.appendHeader('key2', 'value2')
res.removeHeader('key')
res.flushHeaders()
res.end('end')
})
})

app.get('/ssrf/http/unhandled-async-write-E', (req, res) => {
makeOutgoingRequestAndCbAfterTimeout(req, res, () => {
res.writeContinue()
res.end()
})
})

app.get('/ssrf/http/unhandled-async-write-F', (req, res) => {
makeOutgoingRequestAndCbAfterTimeout(req, res, () => {
res.writeProcessing()
res.end()
})
})

app.get('/ssrf/http/unhandled-async-write-G', (req, res) => {
makeOutgoingRequestAndCbAfterTimeout(req, res, () => {
const earlyHintsLink = '</styles.css>; rel=preload; as=style'
res.writeEarlyHints({
link: earlyHintsLink
})
res.end()
})
})

function streamFile (res) {
const stream = fs.createReadStream(path.join(__dirname, 'streamtest.txt'), { encoding: 'utf8' })
stream.pipe(res, { end: false })
stream.on('end', () => res.end('end'))
}

app.listen(port, () => {
process.send({ port })
})
59 changes: 59 additions & 0 deletions integration-tests/appsec/rasp/rasp_rules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"version": "2.2",
"metadata": {
"rules_version": "1.99.0"
},
"rules": [
{
"id": "test-rule-id-2",
"name": "Server-side request forgery exploit",
"enabled": true,
"tags": {
"type": "ssrf",
"category": "vulnerability_trigger",
"cwe": "918",
"capec": "1000/225/115/664",
"confidence": "0",
"module": "rasp"
},
"conditions": [
{
"parameters": {
"resource": [
{
"address": "server.io.net.url"
}
],
"params": [
{
"address": "server.request.query"
},
{
"address": "server.request.body"
},
{
"address": "server.request.path_params"
},
{
"address": "grpc.server.request.message"
},
{
"address": "graphql.server.all_resolvers"
},
{
"address": "graphql.server.resolver"
}
]
},
"operator": "ssrf_detector"
}
],
"transformers": [],
"on_match": [
"block",
"stack_trace"
]
}
]
}

1 change: 1 addition & 0 deletions integration-tests/appsec/rasp/streamtest.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test
5 changes: 4 additions & 1 deletion integration-tests/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ class FakeAgent extends EventEmitter {
}
}

function spawnProc (filename, options = {}, stdioHandler) {
function spawnProc (filename, options = {}, stdioHandler, stderrHandler) {
const proc = fork(filename, { ...options, stdio: 'pipe' })
return new Promise((resolve, reject) => {
proc
Expand All @@ -187,6 +187,9 @@ function spawnProc (filename, options = {}, stdioHandler) {
})

proc.stderr.on('data', data => {
if (stderrHandler) {
stderrHandler(data)
}
// eslint-disable-next-line no-console
if (!options.silent) console.error(data.toString())
})
Expand Down
2 changes: 2 additions & 0 deletions packages/dd-trace/src/appsec/blocking.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ function block (req, res, rootSpan, abortController, actionParameters) {
res.writeHead(statusCode, headers).end(body)

abortController?.abort()

// TODO add res in blocked weak set when response blocking is merged
}

function getBlockingAction (actions) {
Expand Down
43 changes: 39 additions & 4 deletions packages/dd-trace/src/appsec/rasp.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,39 @@
const { storage } = require('../../../datadog-core')
const addresses = require('./addresses')
const { httpClientRequestStart } = require('./channels')
const web = require('../plugins/util/web')
const waf = require('./waf')
const { getBlockingAction, block } = require('./blocking')

class AbortError extends Error {
constructor (req, res, blockingAction) {
super('AbortError')
this.name = 'AbortError'
this.req = req
this.res = res
this.blockingAction = blockingAction
}
}

function handleUncaughtException (err) {
if (err instanceof AbortError) {
const { req, res, blockingAction } = err
block(req, res, web.root(req), null, blockingAction)
} else {
throw err
}
}

function enable () {
httpClientRequestStart.subscribe(analyzeSsrf)

process.on('uncaughtException', handleUncaughtException)
}

function disable () {
if (httpClientRequestStart.hasSubscribers) httpClientRequestStart.unsubscribe(analyzeSsrf)

process.off('uncaughtException', handleUncaughtException)
}

function analyzeSsrf (ctx) {
Expand All @@ -23,10 +48,20 @@ function analyzeSsrf (ctx) {
const persistent = {
[addresses.HTTP_OUTGOING_URL]: url
}
// TODO: Currently this is only monitoring, we should
// block the request if SSRF attempt and
// generate stack traces
waf.run({ persistent }, req)
// TODO: Currently this is monitoring/blocking, we should
// generate stack traces if SSRF attempts
const actions = waf.run({ persistent }, req)

const res = store?.res
handleWafResults(actions, ctx.abortData, req, res)
}

function handleWafResults (actions, abortData, req, res) {
const blockingAction = getBlockingAction(actions)
if (blockingAction && abortData) {
abortData.abortController.abort()
abortData.error = new AbortError(req, res, blockingAction)
}
}

module.exports = {
Expand Down
Loading

0 comments on commit c92a8d5

Please sign in to comment.