Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
107 changes: 106 additions & 1 deletion crates/capabilities/src/http_transport.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,106 @@
const http = require('node:http')
const https = require('node:https')
const fs = require('node:fs')

let storage = f => f()

// libdatadog's automatic container-id / entity-id detection (libdd-common's
// entity_id module) is gated `#[cfg(unix)]` and therefore inert on the
// `wasm32-unknown-unknown` target we build for, and `DD_EXTERNAL_ENV` is also
// unreachable from wasm. Node, however, can read `/proc` and `process.env`, so
// we detect the same values here and add them as the standard Datadog exporter
// headers (`datadog-container-id`, `datadog-entity-id`, `datadog-external-env`)
// — the headers native libdatadog adds via `Endpoint::set_standard_headers`.
//
// The detection mirrors dd-trace-js's `exporters/common/docker.js` (the proven
// legacy-exporter path) and libdd-common's `compute_entity_id`
// (`ci-<container_id>` else `in-<cgroup_inode>`).

// The second alternative is the PCF / Garden regexp; no suffix ($) to avoid
// matching pod UIDs. See
// https://github.com/DataDog/datadog-agent/blob/7.40.x/pkg/util/cgroups/reader.go#L50
const uuidSource = String.raw`[0-9a-f]{8}[-_][0-9a-f]{4}[-_][0-9a-f]{4}[-_][0-9a-f]{4}[-_][0-9a-f]{12}|[0-9a-f]{8}(?:-[0-9a-f]{4}){4}$`
Comment thread
bengl marked this conversation as resolved.
const containerSource = '[0-9a-f]{64}'
const taskSource = String.raw`[0-9a-f]{32}-\d+`
const lineReg = /^(\d+):([^:]*):(.+)$/m
const entityReg = new RegExp(String.raw`.*(${uuidSource}|${containerSource}|${taskSource})(?:\.scope)?$`, 'm')

// Detect the entity headers. Parameterized for unit testing; production callers
// use the cached `getEntityHeaders()` with the real cgroup paths / environment.
function detectEntityHeaders (opts = {}) {
const cgroupPath = opts.cgroupPath ?? '/proc/self/cgroup'
const cgroupMount = opts.cgroupMount ?? '/sys/fs/cgroup'
const externalEnv = 'externalEnv' in opts ? opts.externalEnv : process.env.DD_EXTERNAL_ENV

const headers = {}

let cgroup = ''
let containerId
try {
cgroup = fs.readFileSync(cgroupPath, 'utf8').trim()
containerId = cgroup.match(entityReg)?.[1]
} catch { /* not in a cgroup, or not Linux */ }

let inode = 0
const inodePath = cgroup.match(lineReg)?.[3]
if (inodePath) {
const strippedPath = inodePath.replaceAll(/^\/|\/$/g, '')
try {
inode = fs.statSync(`${cgroupMount}/${strippedPath}`).ino
} catch { /* mount not present */ }
}

// `ci-<container_id>` when a container id is found, else `in-<cgroup_inode>`
// — matching libdd-common's `compute_entity_id`.
const entityId = containerId ? `ci-${containerId}` : (inode ? `in-${inode}` : undefined)

if (containerId) headers['datadog-container-id'] = containerId
if (entityId) headers['datadog-entity-id'] = entityId
// Only emit external-env if it is a clean header value (visible ASCII + space/
// tab). This rejects CR/LF (header-injection / request-smuggling vector) and
// non-latin1 that the latin1-encoded head rewrite couldn't represent — native
// libdatadog likewise rejects invalid bytes via the http crate's HeaderValue.
if (externalEnv && /^[\t\u0020-\u007E]*$/.test(externalEnv)) {
headers['datadog-external-env'] = externalEnv
}

return headers
}

let cachedEntityHeaders
function getEntityHeaders () {
if (cachedEntityHeaders === undefined) {
cachedEntityHeaders = detectEntityHeaders()
}
return cachedEntityHeaders
}

// Rewrite the Rust-rendered HTTP/1.1 request head (a `\r\n`-delimited byte
// buffer terminated by a blank line) to carry the detected entity headers.
// Any pre-existing line for a name we set is dropped first, so libdatadog's
// empty `datadog-container-id` is replaced rather than duplicated. Header
// names/values are ASCII, so latin1 is a lossless round-trip.
function applyEntityHeaders (headView, entity = getEntityHeaders()) {
const names = Object.keys(entity)
if (names.length === 0) return Buffer.from(headView)

const head = Buffer.from(headView).toString('latin1')
const term = head.indexOf('\r\n\r\n')
if (term === -1) return Buffer.from(headView) // malformed; leave untouched

const drop = new Set(names)
const lines = head.slice(0, term).split('\r\n')
const kept = lines.filter((line, i) => {
if (i === 0) return true // request line
const colon = line.indexOf(':')
const name = (colon === -1 ? line : line.slice(0, colon)).trim().toLowerCase()
return !drop.has(name)
})
for (const name of names) kept.push(`${name}: ${entity[name]}`)

return Buffer.from(`${kept.join('\r\n')}\r\n\r\n`, 'latin1')
}

// A retried write can race a wasm-memory detach; treat that as a transient error.
function isDetachedBufferError (err) {
return err instanceof TypeError && /detached/i.test(err.message)
Expand Down Expand Up @@ -38,6 +136,13 @@ module.exports.setResponseHeaderObserver = function (new_observer) {
responseHeaderObserver = new_observer
}

// Exposed for unit tests.
module.exports.detectEntityHeaders = detectEntityHeaders
module.exports.applyEntityHeaders = applyEntityHeaders
module.exports._resetEntityHeadersCache = () => {
cachedEntityHeaders = undefined
}

module.exports.httpRequest = function (host, port, isHttps, socketPath, head_ptr, head_len, body_ptr, body_len, wasm_memory) {
// A non-empty socketPath routes over a Unix domain socket (or Windows named
// pipe) instead of TCP. Sockets are always plaintext HTTP/1.1, so https is
Expand Down Expand Up @@ -92,7 +197,7 @@ module.exports.httpRequest = function (host, port, isHttps, socketPath, head_ptr
// makes write/end skip _implicitHeader and _send prepends our bytes.

try {
req._header = Buffer.from(headView)
req._header = applyEntityHeaders(headView)
req.write(bodyView)
req.end()
} catch (error) {
Expand Down
168 changes: 168 additions & 0 deletions test/http_transport.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,171 @@ describe('http_transport unix socket', { skip: process.platform === 'win32' }, (
assert.strictEqual(Buffer.from(body).toString('utf8'), RESPONSE_BODY)
})
})

// Entity-header injection: container-id / entity-id / external-env detection
// (Node reads /proc + env; libdatadog's own detection is inert on wasm) and the
// rewrite of the Rust-rendered request head that carries them.
const { detectEntityHeaders, applyEntityHeaders } = transport

const DOCKER_CGROUP = '12:memory:/docker/3726184226f5d3147c25fdeab5b60097e378e8a720503a5e19ecfdf29f869860'
const DOCKER_ID = '3726184226f5d3147c25fdeab5b60097e378e8a720503a5e19ecfdf29f869860'

function writeTmpCgroup (contents) {
const p = path.join(os.tmpdir(), `ldn-cgroup-${process.pid}-${Math.random().toString(36).slice(2)}`)
fs.writeFileSync(p, contents)
return p
}

function headBytes (lines) {
return Buffer.from(lines.join('\r\n') + '\r\n\r\n', 'latin1')
}

describe('http_transport entity headers', () => {
describe('detectEntityHeaders', () => {
it('extracts a docker container-id and derives ci-<id> entity-id', () => {
const cgroupPath = writeTmpCgroup(DOCKER_CGROUP)
try {
const h = detectEntityHeaders({ cgroupPath, cgroupMount: '/nonexistent', externalEnv: undefined })
assert.strictEqual(h['datadog-container-id'], DOCKER_ID)
assert.strictEqual(h['datadog-entity-id'], `ci-${DOCKER_ID}`)
assert.strictEqual('datadog-external-env' in h, false)
} finally {
fs.rmSync(cgroupPath, { force: true })
}
})

it('falls back to in-<inode> entity-id when no container-id is present', () => {
const cgroupPath = writeTmpCgroup('0::/')
try {
const h = detectEntityHeaders({ cgroupPath, cgroupMount: os.tmpdir(), externalEnv: undefined })
assert.strictEqual('datadog-container-id' in h, false)
assert.match(h['datadog-entity-id'], /^in-\d+$/)
} finally {
fs.rmSync(cgroupPath, { force: true })
}
})

it('emits datadog-external-env from the provided value', () => {
const h = detectEntityHeaders({ cgroupPath: '/nonexistent', cgroupMount: '/nonexistent', externalEnv: 'it-false,cn-svc,pu-x' })
assert.strictEqual(h['datadog-external-env'], 'it-false,cn-svc,pu-x')
})

it('emits nothing without cgroup, mount, or external-env', () => {
const h = detectEntityHeaders({ cgroupPath: '/nonexistent', cgroupMount: '/nonexistent', externalEnv: undefined })
assert.deepStrictEqual(h, {})
})

it('rejects an external-env containing CR/LF (header-injection guard)', () => {
const h = detectEntityHeaders({
cgroupPath: '/nonexistent',
cgroupMount: '/nonexistent',
externalEnv: 'ok\r\nx-evil: 1',
})
assert.strictEqual('datadog-external-env' in h, false)
})
})

describe('applyEntityHeaders (head rewrite)', () => {
const entity = {
'datadog-container-id': DOCKER_ID,
'datadog-entity-id': `ci-${DOCKER_ID}`,
'datadog-external-env': 'it-false,cn-svc,pu-x',
}

it('appends entity headers and preserves the request line + framing headers', () => {
const head = headBytes([
'POST /v0.4/traces HTTP/1.1',
'Host: localhost:8126',
'Content-Length: 42',
'datadog-meta-lang: nodejs',
])
const out = applyEntityHeaders(head, entity).toString('latin1')
const lines = out.split('\r\n')
assert.strictEqual(lines[0], 'POST /v0.4/traces HTTP/1.1')
assert.ok(lines.includes('Host: localhost:8126'))
assert.ok(lines.includes('Content-Length: 42'))
assert.ok(lines.includes('datadog-meta-lang: nodejs'))
assert.ok(lines.includes(`datadog-container-id: ${DOCKER_ID}`))
assert.ok(lines.includes(`datadog-entity-id: ci-${DOCKER_ID}`))
assert.ok(lines.includes('datadog-external-env: it-false,cn-svc,pu-x'))
assert.ok(out.endsWith('\r\n\r\n'))
})

it('replaces libdatadog\'s empty datadog-container-id instead of duplicating it', () => {
const head = headBytes([
'POST /v0.4/traces HTTP/1.1',
'Host: localhost',
'Content-Length: 0',
'datadog-container-id: ',
])
const out = applyEntityHeaders(head, entity).toString('latin1')
const count = out.split('\r\n').filter(l => l.toLowerCase().startsWith('datadog-container-id:')).length
assert.strictEqual(count, 1)
assert.ok(out.includes(`datadog-container-id: ${DOCKER_ID}`))
})

it('returns the head unchanged when no entity headers are detected', () => {
const head = headBytes(['POST / HTTP/1.1', 'Host: x', 'Content-Length: 0'])
const out = applyEntityHeaders(head, {})
assert.deepStrictEqual(out, Buffer.from(head))
})

it('leaves a malformed head (no terminator) untouched', () => {
const bad = Buffer.from('POST / HTTP/1.1\r\nHost: x', 'latin1')
const out = applyEntityHeaders(bad, entity)
assert.deepStrictEqual(out, Buffer.from(bad))
})
})

describe('httpRequest end-to-end (real transport)', () => {
let server
let port
let received
const prevExternalEnv = process.env.DD_EXTERNAL_ENV

before(async () => {
// Set the env then clear the memoized detection so this request re-reads
// it (earlier tests may have already populated the cache).
process.env.DD_EXTERNAL_ENV = 'it-false,cn-e2e,pu-1'
transport._resetEntityHeadersCache()
server = http.createServer((req, res) => {
received = req.headers
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{}')
})
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
port = server.address().port
})

after(() => new Promise(resolve => server.close(() => {
if (prevExternalEnv === undefined) delete process.env.DD_EXTERNAL_ENV
else process.env.DD_EXTERNAL_ENV = prevExternalEnv
transport._resetEntityHeadersCache()
resolve()
})))

it('sends the detected entity headers on the wire (via the Rust-rendered head)', async () => {
const body = Buffer.from('[]', 'latin1')
const head = Buffer.from(
`POST /v0.4/traces HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nContent-Length: ${body.length}\r\n`
+ 'datadog-meta-lang: nodejs\r\ndatadog-container-id: \r\n\r\n',
'latin1',
)
const mem = new ArrayBuffer(head.length + body.length)
const view = new Uint8Array(mem)
view.set(head, 0)
view.set(body, head.length)

const [status] = await transport.httpRequest(
'127.0.0.1', port, false, '', 0, head.length, head.length, body.length, { buffer: mem },
)
assert.strictEqual(status, 200)
assert.strictEqual(received['datadog-meta-lang'], 'nodejs')
assert.strictEqual(received['datadog-external-env'], 'it-false,cn-e2e,pu-1')
const detected = detectEntityHeaders()
if (detected['datadog-container-id']) {
assert.strictEqual(received['datadog-container-id'], detected['datadog-container-id'])
}
})
})
})
Loading