Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const AGENTLESS_RESPONSE = zlib.gzipSync(JSON.stringify({
}))

/** @typedef {'absent'|'true'|'false'} BooleanSetting */
/** @typedef {'agentless'|'remote_config'|'disabled'} Delivery */
/** @typedef {'agentless'|'remote_config'|'disabled'|'error'} Delivery */
/** @typedef {{ name: string, value?: string }} SourceSetting */
/**
* @typedef {object} ConfigurationCase
Expand Down Expand Up @@ -135,9 +135,10 @@ describe('OpenFeature configuration source contract', () => {

assert.strictEqual(configurationCases.length, 63)
assert.deepStrictEqual(countDeliveries(configurationCases), {
agentless: 16,
remote_config: 16,
disabled: 31,
agentless: 12,
remote_config: 12,
disabled: 27,
error: 12,
})

await runCases(configurationCases)
Expand Down Expand Up @@ -241,12 +242,19 @@ async function runCase (testCase) {
waitForObservation(agentlessRequests, testCase.identifier, 'agentless', hasObservation),
sendCommand(proc, accessCommand),
])
} else if (testCase.expected === 'error') {
await assert.rejects(
sendCommand(proc, accessCommand),
/Unsupported Feature Flagging configuration source: (offline|unsupported)/
)
} else {
await sendCommand(proc, accessCommand)
}

const { details } = await sendCommand(proc, { command: 'evaluate' })
assertEvaluation(testCase, details)
if (testCase.expected !== 'error') {
const { details } = await sendCommand(proc, { command: 'evaluate' })
assertEvaluation(testCase, details)
}

await waitForObservation(
remoteConfigRequests,
Expand Down Expand Up @@ -639,6 +647,7 @@ function countDeliveries (cases) {
agentless: 0,
remote_config: 0,
disabled: 0,
error: 0,
}
for (const testCase of cases) counts[testCase.expected]++
return counts
Expand Down Expand Up @@ -677,6 +686,7 @@ function expectedDelivery (stable, source, legacy) {
if (stable === 'false') return 'disabled'
if (source.name === 'agentless') return 'agentless'
if (source.name === 'remote_config') return 'remote_config'
if (source.name === 'offline' || source.name === 'invalid') return 'error'
if (legacy === 'true') return 'remote_config'
if (legacy === 'false') return 'disabled'
return 'agentless'
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@
"@datadog/native-appsec": "11.0.1",
"@datadog/native-iast-taint-tracking": "4.2.0",
"@datadog/native-metrics": "3.1.2",
"@datadog/openfeature-node-server": "2.0.0",
"@datadog/openfeature-node-server": "2.0.1",
"@datadog/pprof": "5.15.1",
"@datadog/wasm-js-rewriter": "5.0.1",
"@opentelemetry/api": ">=1.0.0 <1.10.0",
Expand Down
13 changes: 8 additions & 5 deletions packages/dd-trace/src/config/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,14 @@ const parseErrors = new Map()
* @param {string} source - The source of the value.
* @param {string} baseMessage - The base message to use for the warning.
* @param {Error} [error] - An error that was thrown while parsing the value.
* @param {boolean} [pickedDefault] - Whether the invalid value was discarded in favor of a fallback.
*/
function warnInvalidValue (value, optionName, source, baseMessage, error) {
function warnInvalidValue (value, optionName, source, baseMessage, error, pickedDefault = true) {
const canonicalName = (optionsTable[optionName]?.canonicalName ?? optionName) + source
// Lazy load log module to avoid circular dependency
if (!parseErrors.has(canonicalName)) {
// TODO: Rephrase: It will fallback to former source (or default if not set)
let message = `${baseMessage}: ${util.inspect(value)} for ${optionName} (source: ${source}), picked default`
let message = `${baseMessage}: ${util.inspect(value)} for ${optionName} (source: ${source})`
if (pickedDefault) message += ', picked default'
if (error) {
error.stack = error.toString()
message += `\n\n${util.inspect(error)}`
Expand Down Expand Up @@ -242,8 +243,10 @@ for (const [canonicalName, entries] of Object.entries(supportedConfigurations))
const originalTransform = transformer
transformer = (value, optionName, source) => {
if (!allowed.test(value)) {
warnInvalidValue(value, optionName, source, 'Invalid value')
return
const preserveInvalidSource = canonicalName === 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE' &&
String(value).trim() !== ''
warnInvalidValue(value, optionName, source, 'Invalid value', undefined, !preserveInvalidSource)
if (!preserveInvalidSource) return
}
if (originalTransform) {
value = originalTransform(value)
Expand Down
4 changes: 2 additions & 2 deletions packages/dd-trace/src/config/generated-config-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ export interface GeneratedConfig {
};
};
featureFlags: {
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: "agentless" | "remote_config";
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: "agentless" | "remote_config" | "offline";
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: string | undefined;
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: number;
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: number;
Expand Down Expand Up @@ -697,7 +697,7 @@ export interface GeneratedEnvVarConfig {
DD_EXPERIMENTAL_TEST_OPT_VITEST_NO_WORKER_INIT: boolean | undefined;
DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE: boolean;
DD_EXTERNAL_ENV: string | undefined;
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: "agentless" | "remote_config";
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: "agentless" | "remote_config" | "offline";
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: string | undefined;
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: number;
DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: number;
Expand Down
4 changes: 2 additions & 2 deletions packages/dd-trace/src/config/supported-configurations.json
Original file line number Diff line number Diff line change
Expand Up @@ -840,8 +840,8 @@
"implementation": "A",
"type": "string",
"default": "agentless",
"allowed": "agentless|remote_config",
"description": "Experimental: Select where Feature Flagging loads Universal Flag Configuration. Supported values are agentless and remote_config.",
"allowed": "agentless|remote_config|offline",
"description": "Experimental: Select where Feature Flagging loads Universal Flag Configuration. Supported values are agentless and remote_config; offline is reserved and currently unsupported.",
"namespace": "featureFlags",
"transform": "toLowerCase"
}
Expand Down
6 changes: 4 additions & 2 deletions packages/dd-trace/src/exporters/common/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ function request (data, options, callback) {
}

// Never put the Datadog API key on a cleartext connection to a non-loopback host; that would
// expose it on the wire. Loopback (local agent, dev proxy, tests) is exempt. Strip the key
// expose it on the wire. Loopback (local agent, dev proxy, tests) and explicit operator-owned
// endpoints are exempt. Strip the key
// rather than drop the request: the agent proxies telemetry with its own key, while an https
// intake URL is required to authenticate agentless traffic.
const hasApiKey = options.headers['dd-api-key'] !== undefined || options.headers['DD-API-KEY'] !== undefined
if (hasApiKey && options.protocol === 'http:' && !isLoopbackHost(options.hostname)) {
const insecureApiKey = options.protocol === 'http:' && !isLoopbackHost(options.hostname)
if (hasApiKey && insecureApiKey && !options.allowInsecureApiKey) {
log.error(
'Not sending the Datadog API key over a non-TLS connection to %s. Configure an https intake URL.',
options.hostname
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ const RETRY_JITTER = 0.2
/**
* @typedef {object} AgentlessSourceConfig
* @property {URL} endpoint
* @property {boolean} allowInsecureApiKey
* @property {number} pollIntervalMs
* @property {number} requestTimeoutMs
* @property {string} apiKey
* @property {string | undefined} apiKey
*/

/**
Expand Down Expand Up @@ -138,7 +139,7 @@ class AgentlessConfigurationSource {
#request (signal) {
const headers = getClientLibraryHeaders()
headers['Accept-Encoding'] = 'gzip'
headers['DD-API-KEY'] = this.#config.apiKey
if (this.#config.apiKey) headers['DD-API-KEY'] = this.#config.apiKey
if (this.#etag) headers['If-None-Match'] = this.#etag

/**
Expand All @@ -164,6 +165,8 @@ class AgentlessConfigurationSource {
url: this.#config.endpoint,
method: 'GET',
headers,
// An explicit operator override may be a cleartext development or dogfooding proxy.
allowInsecureApiKey: this.#config.allowInsecureApiKey,
retry: false,
signal,
timeout: this.#config.requestTimeoutMs,
Expand Down
16 changes: 6 additions & 10 deletions packages/dd-trace/src/openfeature/configuration_source.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
'use strict'

const { isLoopbackHost } = require('../exporters/common/url')
const log = require('../log')

const DEFAULT_AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server'
Expand All @@ -23,18 +22,19 @@ function create (config, applyConfiguration) {
DD_FEATURE_FLAGS_ENABLED: enabled,
} = config.featureFlags

if (!enabled || source !== 'agentless') {
if (!enabled || source === 'remote_config') {
return
}

try {
if (!config.DD_API_KEY) {
throw new Error('DD_API_KEY is required for Feature Flagging agentless delivery')
}
if (source !== 'agentless') {
throw new Error(`Unsupported Feature Flagging configuration source: ${source}`)
}

try {
const AgentlessConfigurationSource = require('./agentless_configuration_source')
return new AgentlessConfigurationSource({
endpoint: endpoint(config, baseUrl),
allowInsecureApiKey: Boolean(baseUrl?.trim()),
pollIntervalMs: Math.min(pollIntervalSeconds, MAX_POLL_INTERVAL_SECONDS) * 1000,
requestTimeoutMs: requestTimeoutSeconds * 1000,
apiKey: config.DD_API_KEY,
Expand Down Expand Up @@ -74,10 +74,6 @@ function endpoint (config, configuredBaseUrl) {
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error('Feature Flagging agentless URL must use HTTP or HTTPS')
}
if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) {
throw new Error('Feature Flagging agentless URL must use HTTPS unless it targets loopback')
}

if (url.pathname === '' || url.pathname === '/') {
url.pathname = DEFAULT_AGENTLESS_PATH
}
Expand Down
26 changes: 18 additions & 8 deletions packages/dd-trace/test/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5034,16 +5034,26 @@ rules:
expected: { enabled: true, source: 'remote_config' },
},
{
name: 'falls back from an invalid source to legacy enablement',
name: 'fails closed for an invalid source',
source: 'other',
expected: { enabled: true, source: 'other' },
},
{
name: 'fails closed for the reserved offline source',
source: 'offline',
expected: { enabled: true, source: 'offline' },
},
{
name: 'fails closed for an invalid source despite legacy enablement',
source: 'other',
legacyEnabled: 'true',
expected: { enabled: true, source: 'remote_config' },
expected: { enabled: true, source: 'other' },
},
{
name: 'falls back from the reserved offline source to legacy enablement',
name: 'fails closed for the reserved offline source despite legacy enablement',
source: 'offline',
legacyEnabled: 'true',
expected: { enabled: true, source: 'remote_config' },
expected: { enabled: true, source: 'offline' },
},
]) {
it(name, () => {
Expand All @@ -5069,22 +5079,22 @@ rules:
})
}

it('falls back from an invalid source through calculated legacy precedence', () => {
it('preserves an explicit invalid source over calculated legacy precedence', () => {
process.env.DD_FEATURE_FLAGS_ENABLED = 'true'
process.env.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = 'offline'
process.env.DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = 'true'

const config = getConfig()

assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_ENABLED, true)
assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, 'remote_config')
assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE, 'offline')
assert.strictEqual(config.experimental.flaggingProvider.enabled, true)
assert.strictEqual(config.getOrigin('featureFlags.DD_FEATURE_FLAGS_ENABLED'), 'env_var')
assert.strictEqual(config.getOrigin('featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE'), 'calculated')
assert.strictEqual(config.getOrigin('featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE'), 'env_var')
assert.strictEqual(config.getOrigin('experimental.flaggingProvider.enabled'), 'env_var')
assertConfigUpdateContains(updateConfig.getCall(0).args[0], [
{ name: 'DD_FEATURE_FLAGS_ENABLED', value: true, origin: 'env_var' },
{ name: 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE', value: 'remote_config', origin: 'calculated' },
{ name: 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE', value: 'offline', origin: 'env_var' },
{ name: 'DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED', value: true, origin: 'env_var' },
])

Expand Down
17 changes: 17 additions & 0 deletions packages/dd-trace/test/exporters/common/request.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,23 @@ describe('request', function () {
})
})

it('keeps dd-api-key over http for an explicitly trusted custom endpoint', (done) => {
nock('http://flags.dev.internal', { reqheaders: { 'dd-api-key': 'secret-key' } })
.post('/v1/input')
.reply(200, 'OK')

request(Buffer.from(''), {
method: 'POST',
url: new URL('http://flags.dev.internal/v1/input'),
headers: { 'dd-api-key': 'secret-key' },
allowInsecureApiKey: true,
}, (err, res) => {
assert.strictEqual(res, 'OK')
sinon.assert.notCalled(log.error)
done(err)
})
})

for (const loopbackHost of ['127.0.0.1', '127.1.2.3', 'localhost', '[::1]']) {
it(`keeps dd-api-key over http to the loopback host ${loopbackHost}`, (done) => {
nock(`http://${loopbackHost}:9999`, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ describe('AgentlessConfigurationSource', () => {
applyConfiguration = sinon.stub()
config = {
endpoint: new URL('http://127.0.0.1:8080/api/v2/feature-flagging/config/rules-based/server'),
allowInsecureApiKey: true,
pollIntervalMs: 30_000,
requestTimeoutMs: 2000,
apiKey: 'test-api-key',
Expand Down Expand Up @@ -146,6 +147,7 @@ describe('AgentlessConfigurationSource', () => {
assert.strictEqual(requests[0].data, '')
assert.strictEqual(requests[0].options.url, config.endpoint)
assert.strictEqual(requests[0].options.method, 'GET')
assert.strictEqual(requests[0].options.allowInsecureApiKey, config.allowInsecureApiKey)
assert.strictEqual(requests[0].options.retry, false)
assert.strictEqual(requests[0].options.timeout, 2000)
assert.strictEqual(requests[0].options.headers['DD-API-KEY'], 'test-api-key')
Expand Down Expand Up @@ -181,13 +183,14 @@ describe('AgentlessConfigurationSource', () => {
clock.restore()
clock = undefined
const body = zlib.gzipSync(responseBody())
nock('http://127.0.0.1:8080', {
config.endpoint = new URL('http://flags.dev.internal:8080/custom/ufc')
nock('http://flags.dev.internal:8080', {
reqheaders: {
'accept-encoding': 'gzip',
'dd-api-key': 'test-api-key',
},
})
.get('/api/v2/feature-flagging/config/rules-based/server')
.get('/custom/ufc')
.reply(200, body, {
'content-encoding': 'gzip',
etag: '"real-path"',
Expand Down Expand Up @@ -491,6 +494,22 @@ describe('AgentlessConfigurationSource', () => {
sinon.assert.notCalled(applyConfiguration)
})

it('omits a missing API key and reports the endpoint authentication failure', async () => {
delete config.apiKey
responses.push({ statusCode: 401 })

source().start()
await flush()

assert.strictEqual(Object.hasOwn(requests[0].options.headers, 'DD-API-KEY'), false)
sinon.assert.calledOnceWithExactly(
log.warn,
'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid',
401
)
sinon.assert.notCalled(applyConfiguration)
})

it('uses fixed-delay polling after a request completes', async () => {
responses.push(
{ pending: true },
Expand Down
Loading
Loading