From 51cae1bdd2378ecf0f3c77147e2934e33ca2ceb4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 21 Jul 2026 22:38:28 -0600 Subject: [PATCH 1/5] fix(openfeature): support custom agentless endpoints --- .../dd-trace/src/exporters/common/request.js | 6 ++- .../agentless_configuration_source.js | 7 ++- .../src/openfeature/configuration_source.js | 12 +++--- .../test/exporters/common/request.spec.js | 17 ++++++++ .../agentless_configuration_source.spec.js | 23 +++++++++- .../openfeature/configuration_source.spec.js | 43 ++++++++++++------- 6 files changed, 79 insertions(+), 29 deletions(-) diff --git a/packages/dd-trace/src/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index dc050a4522..251cda8938 100644 --- a/packages/dd-trace/src/exporters/common/request.js +++ b/packages/dd-trace/src/exporters/common/request.js @@ -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 diff --git a/packages/dd-trace/src/openfeature/agentless_configuration_source.js b/packages/dd-trace/src/openfeature/agentless_configuration_source.js index fc50bf1711..f11c581373 100644 --- a/packages/dd-trace/src/openfeature/agentless_configuration_source.js +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -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 */ /** @@ -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 /** @@ -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, diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index 0bd7327953..e2e2ee6e1a 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -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' @@ -27,14 +26,17 @@ function create (config, applyConfiguration) { return } + const hasCustomEndpoint = Boolean(baseUrl?.trim()) + try { - if (!config.DD_API_KEY) { - throw new Error('DD_API_KEY is required for Feature Flagging agentless delivery') + if (!hasCustomEndpoint && !config.DD_API_KEY) { + throw new Error('DD_API_KEY is required for the Datadog Feature Flagging API') } const AgentlessConfigurationSource = require('./agentless_configuration_source') return new AgentlessConfigurationSource({ endpoint: endpoint(config, baseUrl), + allowInsecureApiKey: hasCustomEndpoint, pollIntervalMs: Math.min(pollIntervalSeconds, MAX_POLL_INTERVAL_SECONDS) * 1000, requestTimeoutMs: requestTimeoutSeconds * 1000, apiKey: config.DD_API_KEY, @@ -74,10 +76,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 } diff --git a/packages/dd-trace/test/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index eceaf687c6..f4b8d058ff 100644 --- a/packages/dd-trace/test/exporters/common/request.spec.js +++ b/packages/dd-trace/test/exporters/common/request.spec.js @@ -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`, { diff --git a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js index d8816abb8f..e5f1e6fb18 100644 --- a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -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', @@ -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') @@ -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"', @@ -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 }, diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index 8ac81dba43..a1260ca118 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -52,6 +52,7 @@ describe('OpenFeature configuration source', () => { 'https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server?dd_env=my+env' ) assert.strictEqual(resolved.apiKey, 'test-api-key') + assert.strictEqual(resolved.allowInsecureApiKey, false) assert.strictEqual(resolved.pollIntervalMs, 30_000) assert.strictEqual(resolved.requestTimeoutMs, 5000) }) @@ -93,6 +94,18 @@ describe('OpenFeature configuration source', () => { }) } + it('allows a cleartext custom endpoint for local development and proxies', () => { + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = + 'http://flags.dev.internal:8080' + + const resolved = createSourceConfig() + assert.strictEqual( + resolved.endpoint.toString(), + 'http://flags.dev.internal:8080/api/v2/feature-flagging/config/rules-based/server' + ) + assert.strictEqual(resolved.allowInsecureApiKey, true) + }) + it('preserves an exact configured path and query', () => { config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'https://example.com/custom/ufc?tenant=one' @@ -156,20 +169,6 @@ describe('OpenFeature configuration source', () => { sinon.assert.notCalled(AgentlessConfigurationSource) }) - it('rejects cleartext non-loopback endpoints', () => { - config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'http://flags.example.test' - - const source = configurationSource.create(config, sinon.spy()) - - assert.strictEqual(source, undefined) - sinon.assert.calledOnceWithMatch( - log.error, - 'Unable to configure Feature Flagging configuration source', - sinon.match.instanceOf(Error) - ) - sinon.assert.notCalled(AgentlessConfigurationSource) - }) - it('rejects malformed endpoints without logging their sensitive value', () => { const sentinel = 'sensitive-value' config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = `https://${sentinel} value` @@ -187,7 +186,19 @@ describe('OpenFeature configuration source', () => { assert.strictEqual(log.error.firstCall.args[1].cause, undefined) }) - it('requires an API key without enabling a source', () => { + it('creates a source for a custom API without a Datadog API key', () => { + delete config.DD_API_KEY + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = + 'https://flags.example.test/custom/ufc' + + const source = configurationSource.create(config, sinon.spy()) + + assert.ok(source instanceof AgentlessConfigurationSource) + assert.strictEqual(AgentlessConfigurationSource.firstCall.args[0].apiKey, undefined) + sinon.assert.notCalled(log.error) + }) + + it('requires a Datadog API key for the default Datadog API', () => { delete config.DD_API_KEY const source = configurationSource.create(config, sinon.spy()) @@ -195,7 +206,7 @@ describe('OpenFeature configuration source', () => { sinon.assert.calledOnceWithMatch( log.error, 'Unable to configure Feature Flagging configuration source', - sinon.match.instanceOf(Error) + sinon.match.has('message', 'DD_API_KEY is required for the Datadog Feature Flagging API') ) assert.strictEqual(source, undefined) sinon.assert.notCalled(AgentlessConfigurationSource) From 3395eccc707ff0b06ea504ae668a80017e6787b4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 21 Jul 2026 23:00:49 -0600 Subject: [PATCH 2/5] fix(openfeature): preserve unsupported configuration sources --- .../openfeature-configuration-sources.spec.js | 22 +++++++++++----- packages/dd-trace/src/config/defaults.js | 13 ++++++---- .../src/config/generated-config-types.d.ts | 4 +-- .../src/config/supported-configurations.json | 10 +++---- .../src/openfeature/configuration_source.js | 6 ++++- packages/dd-trace/test/config/index.spec.js | 26 +++++++++++++------ .../openfeature/configuration_source.spec.js | 12 +++++++++ 7 files changed, 66 insertions(+), 27 deletions(-) diff --git a/integration-tests/openfeature/openfeature-configuration-sources.spec.js b/integration-tests/openfeature/openfeature-configuration-sources.spec.js index 566c36639b..3efc09eabb 100644 --- a/integration-tests/openfeature/openfeature-configuration-sources.spec.js +++ b/integration-tests/openfeature/openfeature-configuration-sources.spec.js @@ -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 @@ -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) @@ -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, @@ -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 @@ -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' diff --git a/packages/dd-trace/src/config/defaults.js b/packages/dd-trace/src/config/defaults.js index d5b1f2e882..e586647340 100644 --- a/packages/dd-trace/src/config/defaults.js +++ b/packages/dd-trace/src/config/defaults.js @@ -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)}` @@ -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) diff --git a/packages/dd-trace/src/config/generated-config-types.d.ts b/packages/dd-trace/src/config/generated-config-types.d.ts index 9dec6c4d28..9cf5124c5d 100644 --- a/packages/dd-trace/src/config/generated-config-types.d.ts +++ b/packages/dd-trace/src/config/generated-config-types.d.ts @@ -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; @@ -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; diff --git a/packages/dd-trace/src/config/supported-configurations.json b/packages/dd-trace/src/config/supported-configurations.json index bb8fbb2d4c..2de205fac7 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -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": "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" } @@ -851,7 +851,7 @@ "implementation": "A", "type": "string", "default": null, - "description": "Experimental: Override the agentless Feature Flagging UFC endpoint or base URL.", + "description": "Override the agentless Feature Flagging UFC endpoint or base URL.", "namespace": "featureFlags", "sensitive": true } @@ -861,7 +861,7 @@ "implementation": "A", "type": "int", "default": "30", - "description": "Experimental: Set the agentless Feature Flagging UFC polling interval in seconds, capped at one hour.", + "description": "Set the agentless Feature Flagging UFC polling interval in seconds, capped at one hour.", "allowed": "[1-9]\\d*", "namespace": "featureFlags" } @@ -871,7 +871,7 @@ "implementation": "A", "type": "int", "default": "5", - "description": "Experimental: Set the agentless Feature Flagging UFC request timeout in seconds.", + "description": "Set the agentless Feature Flagging UFC request timeout in seconds.", "allowed": "[1-9]\\d*", "namespace": "featureFlags" } diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index e2e2ee6e1a..d689ae2b41 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -22,10 +22,14 @@ function create (config, applyConfiguration) { DD_FEATURE_FLAGS_ENABLED: enabled, } = config.featureFlags - if (!enabled || source !== 'agentless') { + if (!enabled || source === 'remote_config') { return } + if (source !== 'agentless') { + throw new Error(`Unsupported Feature Flagging configuration source: ${source}`) + } + const hasCustomEndpoint = Boolean(baseUrl?.trim()) try { diff --git a/packages/dd-trace/test/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index 1c3bdcb307..23456ce169 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -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, () => { @@ -5069,7 +5079,7 @@ 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' @@ -5077,14 +5087,14 @@ rules: 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' }, ]) diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index a1260ca118..0db358ac5e 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -222,6 +222,18 @@ describe('OpenFeature configuration source', () => { sinon.assert.notCalled(AgentlessConfigurationSource) }) + for (const source of ['offline', 'invalid']) { + it(`throws for the unsupported ${source} source`, () => { + config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = source + + assert.throws( + () => configurationSource.create(config, sinon.spy()), + new RegExp(`Unsupported Feature Flagging configuration source: ${source}`) + ) + sinon.assert.notCalled(AgentlessConfigurationSource) + }) + } + it('does not create an agentless source when Feature Flags are disabled', () => { config.featureFlags.DD_FEATURE_FLAGS_ENABLED = false delete config.site From 783aee47e4b192c7a20c595f7ef9ccc08779a68d Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 22 Jul 2026 18:43:24 -0600 Subject: [PATCH 3/5] fix(openfeature): never send API keys to custom endpoints --- .../openfeature-configuration-sources.spec.js | 2 +- .../dd-trace/src/exporters/common/request.js | 6 ++---- .../agentless_configuration_source.js | 8 +------- .../src/openfeature/configuration_source.js | 3 +-- .../test/exporters/common/request.spec.js | 17 ----------------- .../agentless_configuration_source.spec.js | 11 +++++------ .../openfeature/configuration_source.spec.js | 11 +++++++---- 7 files changed, 17 insertions(+), 41 deletions(-) diff --git a/integration-tests/openfeature/openfeature-configuration-sources.spec.js b/integration-tests/openfeature/openfeature-configuration-sources.spec.js index 566c36639b..9923933241 100644 --- a/integration-tests/openfeature/openfeature-configuration-sources.spec.js +++ b/integration-tests/openfeature/openfeature-configuration-sources.spec.js @@ -341,7 +341,7 @@ function assertDeliveryTraffic (testCase) { for (const request of cdnRequests) { assert.strictEqual(request.url, `${AGENTLESS_PATH}?case=${testCase.identifier}`, testCase.label) assert.strictEqual(request.headers['accept-encoding'], 'gzip', testCase.label) - assert.strictEqual(request.headers['dd-api-key'], 'integration-api-key', testCase.label) + assert.strictEqual(request.headers['dd-api-key'], undefined, testCase.label) assert.strictEqual(request.headers['dd-client-library-language'], 'nodejs', testCase.label) assert.strictEqual(request.headers['dd-client-library-version'], VERSION, testCase.label) } diff --git a/packages/dd-trace/src/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index 251cda8938..dc050a4522 100644 --- a/packages/dd-trace/src/exporters/common/request.js +++ b/packages/dd-trace/src/exporters/common/request.js @@ -50,13 +50,11 @@ 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) and explicit operator-owned - // endpoints are exempt. Strip the key + // expose it on the wire. Loopback (local agent, dev proxy, tests) is 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 - const insecureApiKey = options.protocol === 'http:' && !isLoopbackHost(options.hostname) - if (hasApiKey && insecureApiKey && !options.allowInsecureApiKey) { + if (hasApiKey && options.protocol === 'http:' && !isLoopbackHost(options.hostname)) { log.error( 'Not sending the Datadog API key over a non-TLS connection to %s. Configure an https intake URL.', options.hostname diff --git a/packages/dd-trace/src/openfeature/agentless_configuration_source.js b/packages/dd-trace/src/openfeature/agentless_configuration_source.js index f11c581373..fd86a16acc 100644 --- a/packages/dd-trace/src/openfeature/agentless_configuration_source.js +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -18,7 +18,6 @@ const RETRY_JITTER = 0.2 /** * @typedef {object} AgentlessSourceConfig * @property {URL} endpoint - * @property {boolean} allowInsecureApiKey * @property {number} pollIntervalMs * @property {number} requestTimeoutMs * @property {string | undefined} apiKey @@ -165,8 +164,6 @@ 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, @@ -231,10 +228,7 @@ class AgentlessConfigurationSource { this.#failureWarnings.add(category) if (statusCode === 401 || statusCode === 403) { - log.warn( - 'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid', - statusCode - ) + log.warn('Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication', statusCode) } else if (statusCode) { log.warn('Feature Flagging agentless endpoint returned HTTP %d after %d attempts', statusCode, attempts) } else if (attempts > 1) { diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index e2e2ee6e1a..92c2154f0a 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -36,10 +36,9 @@ function create (config, applyConfiguration) { const AgentlessConfigurationSource = require('./agentless_configuration_source') return new AgentlessConfigurationSource({ endpoint: endpoint(config, baseUrl), - allowInsecureApiKey: hasCustomEndpoint, pollIntervalMs: Math.min(pollIntervalSeconds, MAX_POLL_INTERVAL_SECONDS) * 1000, requestTimeoutMs: requestTimeoutSeconds * 1000, - apiKey: config.DD_API_KEY, + apiKey: hasCustomEndpoint ? undefined : config.DD_API_KEY, }, applyConfiguration) } catch (error) { log.error('Unable to configure Feature Flagging configuration source', error) diff --git a/packages/dd-trace/test/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index f4b8d058ff..eceaf687c6 100644 --- a/packages/dd-trace/test/exporters/common/request.spec.js +++ b/packages/dd-trace/test/exporters/common/request.spec.js @@ -847,23 +847,6 @@ 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`, { diff --git a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js index e5f1e6fb18..11d8032d82 100644 --- a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -48,7 +48,6 @@ 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', @@ -147,7 +146,6 @@ 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') @@ -184,10 +182,11 @@ describe('AgentlessConfigurationSource', () => { clock = undefined const body = zlib.gzipSync(responseBody()) config.endpoint = new URL('http://flags.dev.internal:8080/custom/ufc') + delete config.apiKey nock('http://flags.dev.internal:8080', { + badheaders: ['dd-api-key'], reqheaders: { 'accept-encoding': 'gzip', - 'dd-api-key': 'test-api-key', }, }) .get('/custom/ufc') @@ -417,7 +416,7 @@ describe('AgentlessConfigurationSource', () => { 'third', ]) assert.deepStrictEqual(log.warn.thirdCall.args, [ - 'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid', + 'Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication', 401, ]) }) @@ -483,7 +482,7 @@ describe('AgentlessConfigurationSource', () => { assert.strictEqual(requests.length, 1) sinon.assert.calledOnceWithExactly( log.warn, - 'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid', + 'Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication', 401 ) @@ -504,7 +503,7 @@ describe('AgentlessConfigurationSource', () => { 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', + 'Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication', 401 ) sinon.assert.notCalled(applyConfiguration) diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index a1260ca118..dfbc53a80e 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -52,7 +52,6 @@ describe('OpenFeature configuration source', () => { 'https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server?dd_env=my+env' ) assert.strictEqual(resolved.apiKey, 'test-api-key') - assert.strictEqual(resolved.allowInsecureApiKey, false) assert.strictEqual(resolved.pollIntervalMs, 30_000) assert.strictEqual(resolved.requestTimeoutMs, 5000) }) @@ -87,10 +86,12 @@ describe('OpenFeature configuration source', () => { it(`allows the loopback endpoint ${baseUrl}`, () => { config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = baseUrl + const resolved = createSourceConfig() assert.strictEqual( - createSourceConfig().endpoint.toString(), + resolved.endpoint.toString(), `${baseUrl}/api/v2/feature-flagging/config/rules-based/server` ) + assert.strictEqual(resolved.apiKey, undefined) }) } @@ -103,17 +104,19 @@ describe('OpenFeature configuration source', () => { resolved.endpoint.toString(), 'http://flags.dev.internal:8080/api/v2/feature-flagging/config/rules-based/server' ) - assert.strictEqual(resolved.allowInsecureApiKey, true) + assert.strictEqual(resolved.apiKey, undefined) }) it('preserves an exact configured path and query', () => { config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'https://example.com/custom/ufc?tenant=one' + const resolved = createSourceConfig() assert.strictEqual( - createSourceConfig().endpoint.toString(), + resolved.endpoint.toString(), 'https://example.com/custom/ufc?tenant=one' ) + assert.strictEqual(resolved.apiKey, undefined) }) it('derives and creates the managed GovCloud endpoint without hard-coding availability', () => { From cea76a74fee1d527eee79b2dea6e17a0c62b23ea Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 22 Jul 2026 18:43:31 -0600 Subject: [PATCH 4/5] fix(openfeature): disable unsupported configuration sources --- .../openfeature-configuration-sources.spec.js | 21 ++++------- packages/dd-trace/src/config/defaults.js | 6 ++-- .../src/config/generated-config-types.d.ts | 4 +-- packages/dd-trace/src/config/index.js | 7 ++++ packages/dd-trace/src/config/parsers.js | 26 ++++++++++++++ .../src/config/supported-configurations.json | 3 +- .../dd-trace/src/exporters/common/request.js | 6 ++-- .../agentless_configuration_source.js | 8 +---- .../src/openfeature/configuration_source.js | 11 ++---- packages/dd-trace/test/config/index.spec.js | 35 ++++++++++++------- .../test/exporters/common/request.spec.js | 17 --------- .../agentless_configuration_source.spec.js | 11 +++--- .../openfeature/configuration_source.spec.js | 21 ++++++----- 13 files changed, 88 insertions(+), 88 deletions(-) diff --git a/integration-tests/openfeature/openfeature-configuration-sources.spec.js b/integration-tests/openfeature/openfeature-configuration-sources.spec.js index 3efc09eabb..084ea88561 100644 --- a/integration-tests/openfeature/openfeature-configuration-sources.spec.js +++ b/integration-tests/openfeature/openfeature-configuration-sources.spec.js @@ -65,7 +65,7 @@ const AGENTLESS_RESPONSE = zlib.gzipSync(JSON.stringify({ })) /** @typedef {'absent'|'true'|'false'} BooleanSetting */ -/** @typedef {'agentless'|'remote_config'|'disabled'|'error'} Delivery */ +/** @typedef {'agentless'|'remote_config'|'disabled'} Delivery */ /** @typedef {{ name: string, value?: string }} SourceSetting */ /** * @typedef {object} ConfigurationCase @@ -137,8 +137,7 @@ describe('OpenFeature configuration source contract', () => { assert.deepStrictEqual(countDeliveries(configurationCases), { agentless: 12, remote_config: 12, - disabled: 27, - error: 12, + disabled: 39, }) await runCases(configurationCases) @@ -242,19 +241,12 @@ 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) } - if (testCase.expected !== 'error') { - const { details } = await sendCommand(proc, { command: 'evaluate' }) - assertEvaluation(testCase, details) - } + const { details } = await sendCommand(proc, { command: 'evaluate' }) + assertEvaluation(testCase, details) await waitForObservation( remoteConfigRequests, @@ -349,7 +341,7 @@ function assertDeliveryTraffic (testCase) { for (const request of cdnRequests) { assert.strictEqual(request.url, `${AGENTLESS_PATH}?case=${testCase.identifier}`, testCase.label) assert.strictEqual(request.headers['accept-encoding'], 'gzip', testCase.label) - assert.strictEqual(request.headers['dd-api-key'], 'integration-api-key', testCase.label) + assert.strictEqual(request.headers['dd-api-key'], undefined, testCase.label) assert.strictEqual(request.headers['dd-client-library-language'], 'nodejs', testCase.label) assert.strictEqual(request.headers['dd-client-library-version'], VERSION, testCase.label) } @@ -647,7 +639,6 @@ function countDeliveries (cases) { agentless: 0, remote_config: 0, disabled: 0, - error: 0, } for (const testCase of cases) counts[testCase.expected]++ return counts @@ -686,7 +677,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 (source.name === 'offline' || source.name === 'invalid') return 'disabled' if (legacy === 'true') return 'remote_config' if (legacy === 'false') return 'disabled' return 'agentless' diff --git a/packages/dd-trace/src/config/defaults.js b/packages/dd-trace/src/config/defaults.js index e586647340..1a2d44f852 100644 --- a/packages/dd-trace/src/config/defaults.js +++ b/packages/dd-trace/src/config/defaults.js @@ -243,10 +243,8 @@ for (const [canonicalName, entries] of Object.entries(supportedConfigurations)) const originalTransform = transformer transformer = (value, optionName, source) => { if (!allowed.test(value)) { - const preserveInvalidSource = canonicalName === 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE' && - String(value).trim() !== '' - warnInvalidValue(value, optionName, source, 'Invalid value', undefined, !preserveInvalidSource) - if (!preserveInvalidSource) return + warnInvalidValue(value, optionName, source, 'Invalid value') + return } if (originalTransform) { value = originalTransform(value) diff --git a/packages/dd-trace/src/config/generated-config-types.d.ts b/packages/dd-trace/src/config/generated-config-types.d.ts index 9cf5124c5d..70b25c2bf9 100644 --- a/packages/dd-trace/src/config/generated-config-types.d.ts +++ b/packages/dd-trace/src/config/generated-config-types.d.ts @@ -432,7 +432,7 @@ export interface GeneratedConfig { }; }; featureFlags: { - DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: "agentless" | "remote_config" | "offline"; + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: string; 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; @@ -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" | "offline"; + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: string; 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; diff --git a/packages/dd-trace/src/config/index.js b/packages/dd-trace/src/config/index.js index 5e952a6653..d9518503c7 100644 --- a/packages/dd-trace/src/config/index.js +++ b/packages/dd-trace/src/config/index.js @@ -343,6 +343,13 @@ class Config extends ConfigBase { } } + const configurationSource = this.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE + if (this.featureFlags.DD_FEATURE_FLAGS_ENABLED && + configurationSource !== 'agentless' && + configurationSource !== 'remote_config') { + setAndTrack(this, 'featureFlags.DD_FEATURE_FLAGS_ENABLED', false) + } + if (this.url || os.type() !== 'Windows_NT' && !trackedConfigOrigins.has('hostname') && diff --git a/packages/dd-trace/src/config/parsers.js b/packages/dd-trace/src/config/parsers.js index beb5a59a17..8caff79b66 100644 --- a/packages/dd-trace/src/config/parsers.js +++ b/packages/dd-trace/src/config/parsers.js @@ -58,6 +58,32 @@ const transformers = { toLowerCase (value) { return toCase(value, 'toLowerCase') }, + /** + * Normalizes a Feature Flagging configuration source while preserving unsupported values for + * diagnostics. A blank value is treated as unset so legacy configuration can still apply. + * + * @param {string} value + * @param {string} optionName + * @param {string} source + * @returns {string | undefined} + */ + configurationSource (value, optionName, source) { + const normalized = value.trim().toLowerCase() + if (normalized === '') return + + if (normalized !== 'agentless' && normalized !== 'remote_config') { + warnInvalidValue( + value, + optionName, + source, + 'Unsupported Feature Flagging configuration source; provider disabled', + undefined, + false + ) + } + + return normalized + }, toUpperCase (value) { return toCase(value, 'toUpperCase') }, diff --git a/packages/dd-trace/src/config/supported-configurations.json b/packages/dd-trace/src/config/supported-configurations.json index 2de205fac7..747dd64a5e 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -840,10 +840,9 @@ "implementation": "A", "type": "string", "default": "agentless", - "allowed": "agentless|remote_config|offline", "description": "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" + "transform": "configurationSource" } ], "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL": [ diff --git a/packages/dd-trace/src/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index 251cda8938..dc050a4522 100644 --- a/packages/dd-trace/src/exporters/common/request.js +++ b/packages/dd-trace/src/exporters/common/request.js @@ -50,13 +50,11 @@ 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) and explicit operator-owned - // endpoints are exempt. Strip the key + // expose it on the wire. Loopback (local agent, dev proxy, tests) is 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 - const insecureApiKey = options.protocol === 'http:' && !isLoopbackHost(options.hostname) - if (hasApiKey && insecureApiKey && !options.allowInsecureApiKey) { + if (hasApiKey && options.protocol === 'http:' && !isLoopbackHost(options.hostname)) { log.error( 'Not sending the Datadog API key over a non-TLS connection to %s. Configure an https intake URL.', options.hostname diff --git a/packages/dd-trace/src/openfeature/agentless_configuration_source.js b/packages/dd-trace/src/openfeature/agentless_configuration_source.js index f11c581373..fd86a16acc 100644 --- a/packages/dd-trace/src/openfeature/agentless_configuration_source.js +++ b/packages/dd-trace/src/openfeature/agentless_configuration_source.js @@ -18,7 +18,6 @@ const RETRY_JITTER = 0.2 /** * @typedef {object} AgentlessSourceConfig * @property {URL} endpoint - * @property {boolean} allowInsecureApiKey * @property {number} pollIntervalMs * @property {number} requestTimeoutMs * @property {string | undefined} apiKey @@ -165,8 +164,6 @@ 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, @@ -231,10 +228,7 @@ class AgentlessConfigurationSource { this.#failureWarnings.add(category) if (statusCode === 401 || statusCode === 403) { - log.warn( - 'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid', - statusCode - ) + log.warn('Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication', statusCode) } else if (statusCode) { log.warn('Feature Flagging agentless endpoint returned HTTP %d after %d attempts', statusCode, attempts) } else if (attempts > 1) { diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index d689ae2b41..4eb1223332 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -22,13 +22,7 @@ function create (config, applyConfiguration) { DD_FEATURE_FLAGS_ENABLED: enabled, } = config.featureFlags - if (!enabled || source === 'remote_config') { - return - } - - if (source !== 'agentless') { - throw new Error(`Unsupported Feature Flagging configuration source: ${source}`) - } + if (!enabled || source !== 'agentless') return const hasCustomEndpoint = Boolean(baseUrl?.trim()) @@ -40,10 +34,9 @@ function create (config, applyConfiguration) { const AgentlessConfigurationSource = require('./agentless_configuration_source') return new AgentlessConfigurationSource({ endpoint: endpoint(config, baseUrl), - allowInsecureApiKey: hasCustomEndpoint, pollIntervalMs: Math.min(pollIntervalSeconds, MAX_POLL_INTERVAL_SECONDS) * 1000, requestTimeoutMs: requestTimeoutSeconds * 1000, - apiKey: config.DD_API_KEY, + apiKey: hasCustomEndpoint ? undefined : config.DD_API_KEY, }, applyConfiguration) } catch (error) { log.error('Unable to configure Feature Flagging configuration source', error) diff --git a/packages/dd-trace/test/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index 23456ce169..e4383ba053 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -5034,26 +5034,26 @@ rules: expected: { enabled: true, source: 'remote_config' }, }, { - name: 'fails closed for an invalid source', + name: 'disables the provider for an invalid source', source: 'other', - expected: { enabled: true, source: 'other' }, + expected: { enabled: false }, }, { - name: 'fails closed for the reserved offline source', + name: 'disables the provider for the reserved offline source', source: 'offline', - expected: { enabled: true, source: 'offline' }, + expected: { enabled: false }, }, { - name: 'fails closed for an invalid source despite legacy enablement', + name: 'disables the provider for an invalid source despite legacy enablement', source: 'other', legacyEnabled: 'true', - expected: { enabled: true, source: 'other' }, + expected: { enabled: false }, }, { - name: 'fails closed for the reserved offline source despite legacy enablement', + name: 'disables the provider for the reserved offline source despite legacy enablement', source: 'offline', legacyEnabled: 'true', - expected: { enabled: true, source: 'offline' }, + expected: { enabled: false }, }, ]) { it(name, () => { @@ -5079,27 +5079,36 @@ rules: }) } - it('preserves an explicit invalid source over calculated legacy precedence', () => { + it('disables an explicit unsupported source while preserving diagnostic context', () => { 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() + const warning = 'Unsupported Feature Flagging configuration source; provider disabled: ' + + "'offline' for DD_FEATURE_FLAGS_CONFIGURATION_SOURCE (source: env_var)" - assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_ENABLED, true) + assert.strictEqual(config.featureFlags.DD_FEATURE_FLAGS_ENABLED, false) 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_ENABLED'), '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: 'offline', origin: 'env_var' }, + { name: 'DD_FEATURE_FLAGS_ENABLED', value: false, origin: 'calculated' }, + { + name: 'DD_FEATURE_FLAGS_CONFIGURATION_SOURCE', + value: 'offline', + origin: 'env_var', + error: { message: warning }, + }, { name: 'DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED', value: true, origin: 'env_var' }, ]) + sinon.assert.calledOnceWithExactly(log.warn, warning) config.setRemoteConfig({}) + sinon.assert.calledOnce(log.warn) sinon.assert.notCalled(log.error) }) diff --git a/packages/dd-trace/test/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index f4b8d058ff..eceaf687c6 100644 --- a/packages/dd-trace/test/exporters/common/request.spec.js +++ b/packages/dd-trace/test/exporters/common/request.spec.js @@ -847,23 +847,6 @@ 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`, { diff --git a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js index e5f1e6fb18..11d8032d82 100644 --- a/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js @@ -48,7 +48,6 @@ 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', @@ -147,7 +146,6 @@ 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') @@ -184,10 +182,11 @@ describe('AgentlessConfigurationSource', () => { clock = undefined const body = zlib.gzipSync(responseBody()) config.endpoint = new URL('http://flags.dev.internal:8080/custom/ufc') + delete config.apiKey nock('http://flags.dev.internal:8080', { + badheaders: ['dd-api-key'], reqheaders: { 'accept-encoding': 'gzip', - 'dd-api-key': 'test-api-key', }, }) .get('/custom/ufc') @@ -417,7 +416,7 @@ describe('AgentlessConfigurationSource', () => { 'third', ]) assert.deepStrictEqual(log.warn.thirdCall.args, [ - 'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid', + 'Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication', 401, ]) }) @@ -483,7 +482,7 @@ describe('AgentlessConfigurationSource', () => { assert.strictEqual(requests.length, 1) sinon.assert.calledOnceWithExactly( log.warn, - 'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid', + 'Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication', 401 ) @@ -504,7 +503,7 @@ describe('AgentlessConfigurationSource', () => { 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', + 'Feature Flagging agentless endpoint returned HTTP %d; verify endpoint authentication', 401 ) sinon.assert.notCalled(applyConfiguration) diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index 0db358ac5e..4887a130a7 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -52,7 +52,6 @@ describe('OpenFeature configuration source', () => { 'https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server?dd_env=my+env' ) assert.strictEqual(resolved.apiKey, 'test-api-key') - assert.strictEqual(resolved.allowInsecureApiKey, false) assert.strictEqual(resolved.pollIntervalMs, 30_000) assert.strictEqual(resolved.requestTimeoutMs, 5000) }) @@ -87,10 +86,12 @@ describe('OpenFeature configuration source', () => { it(`allows the loopback endpoint ${baseUrl}`, () => { config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = baseUrl + const resolved = createSourceConfig() assert.strictEqual( - createSourceConfig().endpoint.toString(), + resolved.endpoint.toString(), `${baseUrl}/api/v2/feature-flagging/config/rules-based/server` ) + assert.strictEqual(resolved.apiKey, undefined) }) } @@ -103,17 +104,19 @@ describe('OpenFeature configuration source', () => { resolved.endpoint.toString(), 'http://flags.dev.internal:8080/api/v2/feature-flagging/config/rules-based/server' ) - assert.strictEqual(resolved.allowInsecureApiKey, true) + assert.strictEqual(resolved.apiKey, undefined) }) it('preserves an exact configured path and query', () => { config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'https://example.com/custom/ufc?tenant=one' + const resolved = createSourceConfig() assert.strictEqual( - createSourceConfig().endpoint.toString(), + resolved.endpoint.toString(), 'https://example.com/custom/ufc?tenant=one' ) + assert.strictEqual(resolved.apiKey, undefined) }) it('derives and creates the managed GovCloud endpoint without hard-coding availability', () => { @@ -223,14 +226,14 @@ describe('OpenFeature configuration source', () => { }) for (const source of ['offline', 'invalid']) { - it(`throws for the unsupported ${source} source`, () => { + it(`does not create a source for the unsupported ${source} source`, () => { config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE = source - assert.throws( - () => configurationSource.create(config, sinon.spy()), - new RegExp(`Unsupported Feature Flagging configuration source: ${source}`) - ) + const resolved = configurationSource.create(config, sinon.spy()) + + assert.strictEqual(resolved, undefined) sinon.assert.notCalled(AgentlessConfigurationSource) + sinon.assert.notCalled(log.error) }) } From d82bd2b04dc56209139bd24d3534b63967875324 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 22 Jul 2026 21:48:20 -0600 Subject: [PATCH 5/5] fix(openfeature): clarify default endpoint error --- packages/dd-trace/src/openfeature/configuration_source.js | 2 +- .../dd-trace/test/openfeature/configuration_source.spec.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/dd-trace/src/openfeature/configuration_source.js b/packages/dd-trace/src/openfeature/configuration_source.js index 92c2154f0a..4510cfdf79 100644 --- a/packages/dd-trace/src/openfeature/configuration_source.js +++ b/packages/dd-trace/src/openfeature/configuration_source.js @@ -30,7 +30,7 @@ function create (config, applyConfiguration) { try { if (!hasCustomEndpoint && !config.DD_API_KEY) { - throw new Error('DD_API_KEY is required for the Datadog Feature Flagging API') + throw new Error('DD_API_KEY is required for the default Datadog Feature Flagging endpoint') } const AgentlessConfigurationSource = require('./agentless_configuration_source') diff --git a/packages/dd-trace/test/openfeature/configuration_source.spec.js b/packages/dd-trace/test/openfeature/configuration_source.spec.js index dfbc53a80e..17aa2ae9b3 100644 --- a/packages/dd-trace/test/openfeature/configuration_source.spec.js +++ b/packages/dd-trace/test/openfeature/configuration_source.spec.js @@ -201,7 +201,7 @@ describe('OpenFeature configuration source', () => { sinon.assert.notCalled(log.error) }) - it('requires a Datadog API key for the default Datadog API', () => { + it('requires a Datadog API key for the default Datadog Feature Flagging endpoint', () => { delete config.DD_API_KEY const source = configurationSource.create(config, sinon.spy()) @@ -209,7 +209,7 @@ describe('OpenFeature configuration source', () => { sinon.assert.calledOnceWithMatch( log.error, 'Unable to configure Feature Flagging configuration source', - sinon.match.has('message', 'DD_API_KEY is required for the Datadog Feature Flagging API') + sinon.match.has('message', 'DD_API_KEY is required for the default Datadog Feature Flagging endpoint') ) assert.strictEqual(source, undefined) sinon.assert.notCalled(AgentlessConfigurationSource)