Skip to content

Commit 8305a6c

Browse files
committed
fix(openfeature): isolate malformed flag configuration
2 parents 77cd463 + d4fd0cf commit 8305a6c

7 files changed

Lines changed: 535 additions & 0 deletions

File tree

.github/dependabot.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,3 +402,8 @@ updates:
402402
applies-to: security-updates
403403
patterns:
404404
- "*"
405+
406+
- package-ecosystem: "gitsubmodule"
407+
directory: "/"
408+
schedule:
409+
interval: "weekly"

.github/workflows/openfeature.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ jobs:
2323
id-token: write
2424
steps:
2525
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
26+
with:
27+
submodules: true
2628
- uses: ./.github/actions/node
2729
with:
2830
version: ${{ matrix.version }}

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "packages/dd-trace/test/openfeature/ffe-system-test-data"]
2+
path = packages/dd-trace/test/openfeature/ffe-system-test-data
3+
url = https://github.com/DataDog/ffe-system-test-data.git
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
'use strict'
2+
3+
const SEMVER_OPERATORS = new Set([
4+
'SEMVER_EQ', 'SEMVER_NEQ', 'SEMVER_LT', 'SEMVER_LTE', 'SEMVER_GT', 'SEMVER_GTE',
5+
])
6+
const OPERATORS = new Set([
7+
'LT', 'LTE', 'GT', 'GTE', 'MATCHES', 'NOT_MATCHES', 'ONE_OF', 'NOT_ONE_OF', 'IS_NULL',
8+
...SEMVER_OPERATORS,
9+
])
10+
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
11+
const UINT64_MAX = '18446744073709551615'
12+
13+
/**
14+
* Validates flags independently and converts SemVer conditions into rules the
15+
* upstream evaluator can execute with per-request synthetic attributes.
16+
*
17+
* @param {import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1 | undefined} [configuration]
18+
* @returns {{
19+
* configuration: import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1 | undefined,
20+
* rejected: Set<string>,
21+
* semverConditions: Map<string, Array<{
22+
* attribute: string,
23+
* comparand: string,
24+
* operator: string,
25+
* syntheticAttribute: string
26+
* }>>
27+
* }}
28+
*/
29+
function sanitizeConfiguration (configuration) {
30+
const rejected = new Set()
31+
const semverConditions = new Map()
32+
if (!configuration?.flags || typeof configuration.flags !== 'object' || Array.isArray(configuration.flags)) {
33+
return { configuration, rejected, semverConditions }
34+
}
35+
36+
const flags = { ...configuration.flags }
37+
for (const [key, flag] of Object.entries(configuration.flags)) {
38+
try {
39+
const conditions = []
40+
flags[key] = prepareFlag(key, flag, conditions)
41+
if (conditions.length) semverConditions.set(key, conditions)
42+
} catch {
43+
rejected.add(key)
44+
}
45+
}
46+
47+
return {
48+
configuration: rejected.size || semverConditions.size ? { ...configuration, flags } : configuration,
49+
rejected,
50+
semverConditions,
51+
}
52+
}
53+
54+
/**
55+
* Adds the precomputed attributes used by transformed SemVer rules.
56+
*
57+
* @param {Array<{
58+
* attribute: string,
59+
* comparand: string,
60+
* operator: string,
61+
* syntheticAttribute: string
62+
* }> | undefined} conditions
63+
* @param {import('@openfeature/server-sdk').EvaluationContext} context
64+
* @returns {import('@openfeature/server-sdk').EvaluationContext}
65+
*/
66+
function addSemverContext (conditions, context) {
67+
if (!conditions) return context
68+
69+
const semverContext = { ...context }
70+
for (const condition of conditions) {
71+
const matches = compareSemverOperator(
72+
condition.operator,
73+
context?.[condition.attribute],
74+
condition.comparand
75+
)
76+
semverContext[condition.syntheticAttribute] = matches ? 'true' : 'false'
77+
}
78+
return semverContext
79+
}
80+
81+
function prepareFlag (key, flag, semverConditions) {
82+
if (!flag || flag.key !== key || typeof flag.enabled !== 'boolean' ||
83+
!['BOOLEAN', 'INTEGER', 'NUMERIC', 'STRING', 'JSON'].includes(flag.variationType)) {
84+
throw new Error('invalid flag')
85+
}
86+
if (!flag.variations || typeof flag.variations !== 'object' || Array.isArray(flag.variations)) {
87+
throw new Error('missing variations')
88+
}
89+
for (const [variationKey, variation] of Object.entries(flag.variations)) {
90+
if (!variation || variation.key !== variationKey || !matchesType(variation.value, flag.variationType)) {
91+
throw new Error('invalid variation')
92+
}
93+
}
94+
if (!Array.isArray(flag.allocations)) throw new Error('invalid allocations')
95+
96+
let transformed = false
97+
const allocations = []
98+
for (const allocation of flag.allocations) {
99+
if (!allocation || !Array.isArray(allocation.splits)) throw new Error('invalid allocation')
100+
for (const split of allocation.splits) validateSplit(split, flag.variations)
101+
102+
if (allocation.rules === undefined) {
103+
allocations.push(allocation)
104+
continue
105+
}
106+
if (!Array.isArray(allocation.rules)) throw new Error('invalid rules')
107+
108+
let allocationTransformed = false
109+
const rules = []
110+
for (const rule of allocation.rules) {
111+
if (!rule || !Array.isArray(rule.conditions)) throw new Error('invalid rule')
112+
113+
let ruleTransformed = false
114+
const conditions = []
115+
for (const condition of rule.conditions) {
116+
validateCondition(condition)
117+
if (!SEMVER_OPERATORS.has(condition.operator)) {
118+
conditions.push(condition)
119+
continue
120+
}
121+
122+
const syntheticAttribute = `__datadog_semver_condition_${semverConditions.length}`
123+
semverConditions.push({
124+
attribute: condition.attribute,
125+
comparand: condition.value,
126+
operator: condition.operator,
127+
syntheticAttribute,
128+
})
129+
conditions.push({
130+
...condition,
131+
attribute: syntheticAttribute,
132+
operator: 'ONE_OF',
133+
value: ['true'],
134+
})
135+
ruleTransformed = true
136+
}
137+
rules.push(ruleTransformed ? { ...rule, conditions } : rule)
138+
allocationTransformed ||= ruleTransformed
139+
}
140+
allocations.push(allocationTransformed ? { ...allocation, rules } : allocation)
141+
transformed ||= allocationTransformed
142+
}
143+
144+
return transformed ? { ...flag, allocations } : flag
145+
}
146+
147+
function validateSplit (split, variations) {
148+
if (!split || !Array.isArray(split.shards) || !Object.hasOwn(variations, split.variationKey)) {
149+
throw new Error('invalid split')
150+
}
151+
for (const shard of split.shards) {
152+
if (!shard || !Number.isSafeInteger(shard.totalShards) || shard.totalShards <= 0 ||
153+
shard.totalShards > 0xFF_FF_FF_FF || !Array.isArray(shard.ranges)) {
154+
throw new Error('invalid shard')
155+
}
156+
for (const range of shard.ranges) {
157+
if (!range || !Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.end) ||
158+
range.start < 0 || range.start >= range.end || range.end > shard.totalShards) {
159+
throw new Error('invalid shard range')
160+
}
161+
}
162+
}
163+
}
164+
165+
function validateCondition (condition) {
166+
if (!condition || typeof condition.attribute !== 'string' || !OPERATORS.has(condition.operator)) {
167+
throw new Error('invalid condition')
168+
}
169+
switch (condition.operator) {
170+
case 'MATCHES':
171+
case 'NOT_MATCHES':
172+
if (typeof condition.value !== 'string') throw new Error('invalid regex')
173+
compileRegex(condition.value)
174+
break
175+
case 'LT':
176+
case 'LTE':
177+
case 'GT':
178+
case 'GTE':
179+
if (typeof condition.value !== 'number' || !Number.isFinite(condition.value)) throw new Error('invalid number')
180+
break
181+
case 'ONE_OF':
182+
case 'NOT_ONE_OF':
183+
if (!Array.isArray(condition.value) || condition.value.some(value => typeof value !== 'string')) {
184+
throw new Error('invalid membership')
185+
}
186+
break
187+
case 'IS_NULL':
188+
if (typeof condition.value !== 'boolean') throw new Error('invalid null check')
189+
break
190+
default:
191+
parseSemver(condition.value)
192+
}
193+
}
194+
195+
function matchesType (value, type) {
196+
if (type === 'BOOLEAN') return typeof value === 'boolean'
197+
if (type === 'STRING') return typeof value === 'string'
198+
if (type === 'INTEGER') return Number.isSafeInteger(value)
199+
if (type === 'NUMERIC') return typeof value === 'number' && Number.isFinite(value)
200+
return value !== undefined
201+
}
202+
203+
function compileRegex (pattern) {
204+
const inlineFlags = pattern.match(/^\(\?([imsu]+)\)/)
205+
const flags = inlineFlags ? [...new Set(inlineFlags[1])].join('') : ''
206+
const source = (inlineFlags ? pattern.slice(inlineFlags[0].length) : pattern)
207+
.replaceAll('[:alnum:]', 'A-Za-z0-9')
208+
return new RegExp(source, flags)
209+
}
210+
211+
function parseSemver (value) {
212+
if (typeof value !== 'string') throw new Error('invalid semantic version')
213+
const match = SEMVER.exec(value)
214+
if (!match) throw new Error('invalid semantic version')
215+
216+
const core = match.slice(1, 4)
217+
if (core.some(part => compareNumeric(part, UINT64_MAX) > 0)) throw new Error('invalid semantic version')
218+
const prerelease = match[4]?.split('.')
219+
if (prerelease?.some(part => /^\d+$/.test(part) && compareNumeric(part, UINT64_MAX) > 0)) {
220+
throw new Error('invalid semantic version')
221+
}
222+
return { core, prerelease }
223+
}
224+
225+
function compareSemverOperator (operator, left, right) {
226+
let comparison
227+
try {
228+
comparison = compareSemver(parseSemver(left), parseSemver(right))
229+
} catch {
230+
return false
231+
}
232+
if (operator === 'SEMVER_EQ') return comparison === 0
233+
if (operator === 'SEMVER_NEQ') return comparison !== 0
234+
if (operator === 'SEMVER_LT') return comparison < 0
235+
if (operator === 'SEMVER_LTE') return comparison <= 0
236+
if (operator === 'SEMVER_GT') return comparison > 0
237+
return comparison >= 0
238+
}
239+
240+
function compareSemver (left, right) {
241+
for (let index = 0; index < 3; index++) {
242+
const result = compareNumeric(left.core[index], right.core[index])
243+
if (result) return result
244+
}
245+
if (!left.prerelease && !right.prerelease) return 0
246+
if (!left.prerelease) return 1
247+
if (!right.prerelease) return -1
248+
for (let index = 0; index < Math.min(left.prerelease.length, right.prerelease.length); index++) {
249+
const leftPart = left.prerelease[index]
250+
const rightPart = right.prerelease[index]
251+
const leftNumeric = /^\d+$/.test(leftPart)
252+
const rightNumeric = /^\d+$/.test(rightPart)
253+
let result
254+
if (leftNumeric && rightNumeric) {
255+
result = compareNumeric(leftPart, rightPart)
256+
} else if (leftNumeric) {
257+
result = -1
258+
} else if (rightNumeric) {
259+
result = 1
260+
} else {
261+
result = compareLexical(leftPart, rightPart)
262+
}
263+
if (result) return result
264+
}
265+
return Math.sign(left.prerelease.length - right.prerelease.length)
266+
}
267+
268+
function compareNumeric (left, right) {
269+
return Math.sign(left.length - right.length) || compareLexical(left, right)
270+
}
271+
272+
function compareLexical (left, right) {
273+
return left === right ? 0 : left < right ? -1 : 1
274+
}
275+
276+
module.exports = { addSemverContext, sanitizeConfiguration }

0 commit comments

Comments
 (0)