Skip to content

Commit 51cae1b

Browse files
committed
fix(openfeature): support custom agentless endpoints
1 parent 293cf61 commit 51cae1b

6 files changed

Lines changed: 79 additions & 29 deletions

File tree

packages/dd-trace/src/exporters/common/request.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,13 @@ function request (data, options, callback) {
5050
}
5151

5252
// Never put the Datadog API key on a cleartext connection to a non-loopback host; that would
53-
// expose it on the wire. Loopback (local agent, dev proxy, tests) is exempt. Strip the key
53+
// expose it on the wire. Loopback (local agent, dev proxy, tests) and explicit operator-owned
54+
// endpoints are exempt. Strip the key
5455
// rather than drop the request: the agent proxies telemetry with its own key, while an https
5556
// intake URL is required to authenticate agentless traffic.
5657
const hasApiKey = options.headers['dd-api-key'] !== undefined || options.headers['DD-API-KEY'] !== undefined
57-
if (hasApiKey && options.protocol === 'http:' && !isLoopbackHost(options.hostname)) {
58+
const insecureApiKey = options.protocol === 'http:' && !isLoopbackHost(options.hostname)
59+
if (hasApiKey && insecureApiKey && !options.allowInsecureApiKey) {
5860
log.error(
5961
'Not sending the Datadog API key over a non-TLS connection to %s. Configure an https intake URL.',
6062
options.hostname

packages/dd-trace/src/openfeature/agentless_configuration_source.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ const RETRY_JITTER = 0.2
1818
/**
1919
* @typedef {object} AgentlessSourceConfig
2020
* @property {URL} endpoint
21+
* @property {boolean} allowInsecureApiKey
2122
* @property {number} pollIntervalMs
2223
* @property {number} requestTimeoutMs
23-
* @property {string} apiKey
24+
* @property {string | undefined} apiKey
2425
*/
2526

2627
/**
@@ -138,7 +139,7 @@ class AgentlessConfigurationSource {
138139
#request (signal) {
139140
const headers = getClientLibraryHeaders()
140141
headers['Accept-Encoding'] = 'gzip'
141-
headers['DD-API-KEY'] = this.#config.apiKey
142+
if (this.#config.apiKey) headers['DD-API-KEY'] = this.#config.apiKey
142143
if (this.#etag) headers['If-None-Match'] = this.#etag
143144

144145
/**
@@ -164,6 +165,8 @@ class AgentlessConfigurationSource {
164165
url: this.#config.endpoint,
165166
method: 'GET',
166167
headers,
168+
// An explicit operator override may be a cleartext development or dogfooding proxy.
169+
allowInsecureApiKey: this.#config.allowInsecureApiKey,
167170
retry: false,
168171
signal,
169172
timeout: this.#config.requestTimeoutMs,

packages/dd-trace/src/openfeature/configuration_source.js

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
'use strict'
22

3-
const { isLoopbackHost } = require('../exporters/common/url')
43
const log = require('../log')
54

65
const DEFAULT_AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server'
@@ -27,14 +26,17 @@ function create (config, applyConfiguration) {
2726
return
2827
}
2928

29+
const hasCustomEndpoint = Boolean(baseUrl?.trim())
30+
3031
try {
31-
if (!config.DD_API_KEY) {
32-
throw new Error('DD_API_KEY is required for Feature Flagging agentless delivery')
32+
if (!hasCustomEndpoint && !config.DD_API_KEY) {
33+
throw new Error('DD_API_KEY is required for the Datadog Feature Flagging API')
3334
}
3435

3536
const AgentlessConfigurationSource = require('./agentless_configuration_source')
3637
return new AgentlessConfigurationSource({
3738
endpoint: endpoint(config, baseUrl),
39+
allowInsecureApiKey: hasCustomEndpoint,
3840
pollIntervalMs: Math.min(pollIntervalSeconds, MAX_POLL_INTERVAL_SECONDS) * 1000,
3941
requestTimeoutMs: requestTimeoutSeconds * 1000,
4042
apiKey: config.DD_API_KEY,
@@ -74,10 +76,6 @@ function endpoint (config, configuredBaseUrl) {
7476
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
7577
throw new Error('Feature Flagging agentless URL must use HTTP or HTTPS')
7678
}
77-
if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) {
78-
throw new Error('Feature Flagging agentless URL must use HTTPS unless it targets loopback')
79-
}
80-
8179
if (url.pathname === '' || url.pathname === '/') {
8280
url.pathname = DEFAULT_AGENTLESS_PATH
8381
}

packages/dd-trace/test/exporters/common/request.spec.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,23 @@ describe('request', function () {
847847
})
848848
})
849849

850+
it('keeps dd-api-key over http for an explicitly trusted custom endpoint', (done) => {
851+
nock('http://flags.dev.internal', { reqheaders: { 'dd-api-key': 'secret-key' } })
852+
.post('/v1/input')
853+
.reply(200, 'OK')
854+
855+
request(Buffer.from(''), {
856+
method: 'POST',
857+
url: new URL('http://flags.dev.internal/v1/input'),
858+
headers: { 'dd-api-key': 'secret-key' },
859+
allowInsecureApiKey: true,
860+
}, (err, res) => {
861+
assert.strictEqual(res, 'OK')
862+
sinon.assert.notCalled(log.error)
863+
done(err)
864+
})
865+
})
866+
850867
for (const loopbackHost of ['127.0.0.1', '127.1.2.3', 'localhost', '[::1]']) {
851868
it(`keeps dd-api-key over http to the loopback host ${loopbackHost}`, (done) => {
852869
nock(`http://${loopbackHost}:9999`, {

packages/dd-trace/test/openfeature/agentless_configuration_source.spec.js

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ describe('AgentlessConfigurationSource', () => {
4848
applyConfiguration = sinon.stub()
4949
config = {
5050
endpoint: new URL('http://127.0.0.1:8080/api/v2/feature-flagging/config/rules-based/server'),
51+
allowInsecureApiKey: true,
5152
pollIntervalMs: 30_000,
5253
requestTimeoutMs: 2000,
5354
apiKey: 'test-api-key',
@@ -146,6 +147,7 @@ describe('AgentlessConfigurationSource', () => {
146147
assert.strictEqual(requests[0].data, '')
147148
assert.strictEqual(requests[0].options.url, config.endpoint)
148149
assert.strictEqual(requests[0].options.method, 'GET')
150+
assert.strictEqual(requests[0].options.allowInsecureApiKey, config.allowInsecureApiKey)
149151
assert.strictEqual(requests[0].options.retry, false)
150152
assert.strictEqual(requests[0].options.timeout, 2000)
151153
assert.strictEqual(requests[0].options.headers['DD-API-KEY'], 'test-api-key')
@@ -181,13 +183,14 @@ describe('AgentlessConfigurationSource', () => {
181183
clock.restore()
182184
clock = undefined
183185
const body = zlib.gzipSync(responseBody())
184-
nock('http://127.0.0.1:8080', {
186+
config.endpoint = new URL('http://flags.dev.internal:8080/custom/ufc')
187+
nock('http://flags.dev.internal:8080', {
185188
reqheaders: {
186189
'accept-encoding': 'gzip',
187190
'dd-api-key': 'test-api-key',
188191
},
189192
})
190-
.get('/api/v2/feature-flagging/config/rules-based/server')
193+
.get('/custom/ufc')
191194
.reply(200, body, {
192195
'content-encoding': 'gzip',
193196
etag: '"real-path"',
@@ -491,6 +494,22 @@ describe('AgentlessConfigurationSource', () => {
491494
sinon.assert.notCalled(applyConfiguration)
492495
})
493496

497+
it('omits a missing API key and reports the endpoint authentication failure', async () => {
498+
delete config.apiKey
499+
responses.push({ statusCode: 401 })
500+
501+
source().start()
502+
await flush()
503+
504+
assert.strictEqual(Object.hasOwn(requests[0].options.headers, 'DD-API-KEY'), false)
505+
sinon.assert.calledOnceWithExactly(
506+
log.warn,
507+
'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid',
508+
401
509+
)
510+
sinon.assert.notCalled(applyConfiguration)
511+
})
512+
494513
it('uses fixed-delay polling after a request completes', async () => {
495514
responses.push(
496515
{ pending: true },

packages/dd-trace/test/openfeature/configuration_source.spec.js

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe('OpenFeature configuration source', () => {
5252
'https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server?dd_env=my+env'
5353
)
5454
assert.strictEqual(resolved.apiKey, 'test-api-key')
55+
assert.strictEqual(resolved.allowInsecureApiKey, false)
5556
assert.strictEqual(resolved.pollIntervalMs, 30_000)
5657
assert.strictEqual(resolved.requestTimeoutMs, 5000)
5758
})
@@ -93,6 +94,18 @@ describe('OpenFeature configuration source', () => {
9394
})
9495
}
9596

97+
it('allows a cleartext custom endpoint for local development and proxies', () => {
98+
config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL =
99+
'http://flags.dev.internal:8080'
100+
101+
const resolved = createSourceConfig()
102+
assert.strictEqual(
103+
resolved.endpoint.toString(),
104+
'http://flags.dev.internal:8080/api/v2/feature-flagging/config/rules-based/server'
105+
)
106+
assert.strictEqual(resolved.allowInsecureApiKey, true)
107+
})
108+
96109
it('preserves an exact configured path and query', () => {
97110
config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL =
98111
'https://example.com/custom/ufc?tenant=one'
@@ -156,20 +169,6 @@ describe('OpenFeature configuration source', () => {
156169
sinon.assert.notCalled(AgentlessConfigurationSource)
157170
})
158171

159-
it('rejects cleartext non-loopback endpoints', () => {
160-
config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = 'http://flags.example.test'
161-
162-
const source = configurationSource.create(config, sinon.spy())
163-
164-
assert.strictEqual(source, undefined)
165-
sinon.assert.calledOnceWithMatch(
166-
log.error,
167-
'Unable to configure Feature Flagging configuration source',
168-
sinon.match.instanceOf(Error)
169-
)
170-
sinon.assert.notCalled(AgentlessConfigurationSource)
171-
})
172-
173172
it('rejects malformed endpoints without logging their sensitive value', () => {
174173
const sentinel = 'sensitive-value'
175174
config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = `https://${sentinel} value`
@@ -187,15 +186,27 @@ describe('OpenFeature configuration source', () => {
187186
assert.strictEqual(log.error.firstCall.args[1].cause, undefined)
188187
})
189188

190-
it('requires an API key without enabling a source', () => {
189+
it('creates a source for a custom API without a Datadog API key', () => {
190+
delete config.DD_API_KEY
191+
config.featureFlags.DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL =
192+
'https://flags.example.test/custom/ufc'
193+
194+
const source = configurationSource.create(config, sinon.spy())
195+
196+
assert.ok(source instanceof AgentlessConfigurationSource)
197+
assert.strictEqual(AgentlessConfigurationSource.firstCall.args[0].apiKey, undefined)
198+
sinon.assert.notCalled(log.error)
199+
})
200+
201+
it('requires a Datadog API key for the default Datadog API', () => {
191202
delete config.DD_API_KEY
192203

193204
const source = configurationSource.create(config, sinon.spy())
194205

195206
sinon.assert.calledOnceWithMatch(
196207
log.error,
197208
'Unable to configure Feature Flagging configuration source',
198-
sinon.match.instanceOf(Error)
209+
sinon.match.has('message', 'DD_API_KEY is required for the Datadog Feature Flagging API')
199210
)
200211
assert.strictEqual(source, undefined)
201212
sinon.assert.notCalled(AgentlessConfigurationSource)

0 commit comments

Comments
 (0)