Skip to content

Commit e159a46

Browse files
bm1549claude
andcommitted
fix(rc): fail closed on malformed adaptive-sampling RC payloads
Two correctness fixes surfaced by adversarial review: 1. normalize_rc_tags previously dropped malformed list entries silently. A rule with bad tags could lose constraints and become broader than intended (worst case: a 0% drop rule becomes a service-wide or wildcard drop). Now: if any list entry is malformed, leave the rule's tags in their original (rejected) list shape so libdatadog's parse rejects the entire update. 2. tracing_sampling_rate of a non-null, non-number type (e.g. string) previously fell through to None and, with any_field_present=true, silently cleared the active remote override. Now: reject with an anyhow::Error so the RC dispatcher reports apply_state=3 and the prior policy survives. Regression tests cover both: prior override is preserved when a new update is malformed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8713abb commit e159a46

1 file changed

Lines changed: 105 additions & 16 deletions

File tree

datadog-opentelemetry/src/core/configuration/remote_config.rs

Lines changed: 105 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,14 @@ where
189189
/// (list of `{key, value_glob}` objects) into the shape `libdd-sampling`'s
190190
/// `SamplingRuleConfig` accepts (a `{key: value}` map). Map-shape tags are
191191
/// left untouched. Rules without `tags`, or with `tags` of an unexpected
192-
/// type, are left untouched.
192+
/// type, are left untouched (libdatadog's parse will reject if necessary).
193193
///
194-
/// Malformed list entries (missing `key` or `value_glob`, or non-string values)
195-
/// are skipped rather than failing the whole update — RC schema validation is
196-
/// the agent's job; the tracer should be lenient.
194+
/// If any list entry is malformed (missing `key`/`value_glob`, or non-string
195+
/// values), the rule's tags are left in their original (list) shape. This
196+
/// fails closed: libdatadog will reject the list-shape parse, the RC update
197+
/// is dropped, and the agent is informed via apply_state=3. We deliberately
198+
/// do not drop bad entries silently — doing so could broaden a tag-constrained
199+
/// rule into a less-constrained (or fully wildcard) rule.
197200
fn normalize_rc_tags(rules: &mut [serde_json::Value]) {
198201
for rule in rules {
199202
let Some(obj) = rule.as_object_mut() else {
@@ -207,19 +210,25 @@ fn normalize_rc_tags(rules: &mut [serde_json::Value]) {
207210
continue;
208211
};
209212
let mut map = serde_json::Map::with_capacity(entries.len());
213+
let mut all_ok = true;
210214
for entry in entries {
211215
let (Some(key), Some(value)) = (
212216
entry.get("key").and_then(|v| v.as_str()),
213217
entry.get("value_glob").and_then(|v| v.as_str()),
214218
) else {
215-
continue;
219+
all_ok = false;
220+
break;
216221
};
217222
map.insert(
218223
key.to_string(),
219224
serde_json::Value::String(value.to_string()),
220225
);
221226
}
222-
obj.insert("tags".to_string(), serde_json::Value::Object(map));
227+
if all_ok {
228+
obj.insert("tags".to_string(), serde_json::Value::Object(map));
229+
}
230+
// else: leave the original list-shape tags in place; libdatadog's
231+
// parse will reject and the RC update is rejected as a whole.
223232
}
224233
}
225234

@@ -835,7 +844,21 @@ impl ProductHandler for ApmTracingHandler {
835844

836845
let any_field_present =
837846
lib.tracing_sampling_rules.is_some() || lib.tracing_sampling_rate.is_some();
838-
let rate: Option<f64> = lib.tracing_sampling_rate.as_ref().and_then(|v| v.as_f64());
847+
848+
// tracing_sampling_rate must be either null (clear) or a JSON number.
849+
// Any other present-but-non-numeric value (string, bool, object) is a
850+
// malformed payload — reject rather than silently treating it as a
851+
// clear, which would wipe an active remote sampling policy.
852+
let rate: Option<f64> = match &lib.tracing_sampling_rate {
853+
None | Some(serde_json::Value::Null) => None,
854+
Some(serde_json::Value::Number(n)) => n.as_f64(),
855+
Some(other) => {
856+
return Err(anyhow::anyhow!(
857+
"tracing_sampling_rate must be a JSON number or null, got: {}",
858+
other
859+
));
860+
}
861+
};
839862
let rules_value = match lib.tracing_sampling_rules {
840863
Some(v) if !v.is_null() => Some(v),
841864
_ => None,
@@ -2389,20 +2412,86 @@ mod tests {
23892412
}
23902413

23912414
#[test]
2392-
fn test_normalize_rc_tags_drops_malformed_list_entries() {
2393-
// A list entry missing `value_glob` or `key` is dropped silently —
2394-
// we trust the agent / RC schema to deliver well-formed entries, but
2395-
// we don't want one bad entry to kill the entire update.
2396-
let mut rules: Vec<serde_json::Value> = vec![serde_json::json!({
2415+
fn test_normalize_rc_tags_leaves_malformed_list_untouched() {
2416+
// If any list entry is malformed (missing key/value_glob), the rule's
2417+
// tags are left in their original list shape — libdatadog's parse will
2418+
// then reject the update as a whole. We must not drop bad entries
2419+
// silently, which could broaden a tag-constrained rule.
2420+
let original = serde_json::json!({
23972421
"sample_rate": 0.5,
23982422
"tags": [
23992423
{"key": "env", "value_glob": "prod"},
24002424
{"key": "region"}
24012425
]
2402-
})];
2426+
});
2427+
let mut rules: Vec<serde_json::Value> = vec![original.clone()];
24032428
normalize_rc_tags(&mut rules);
2404-
let tags = rules[0]["tags"].as_object().unwrap();
2405-
assert_eq!(tags.get("env").and_then(|v| v.as_str()), Some("prod"));
2406-
assert!(tags.get("region").is_none());
2429+
// Tags remain in the original (rejected) list shape.
2430+
assert_eq!(rules[0], original);
2431+
}
2432+
2433+
#[test]
2434+
fn test_handler_malformed_tags_rejects_update() {
2435+
// Bug B fail-closed guard: a sampling rule with malformed list-shape
2436+
// tags must not be installed in a broadened form. With the prior
2437+
// override of sample_rate=0.5 in place, sending a rule with one bad
2438+
// tag entry must leave the prior override intact.
2439+
let config = build_config_for_handler();
2440+
// 1. Install a working override.
2441+
let install = br#"{
2442+
"id": "rc-install",
2443+
"lib_config": {"tracing_sampling_rate": 0.5}
2444+
}"#;
2445+
ApmTracingHandler.process_config(install, &config).unwrap();
2446+
assert_eq!(config.trace_sampling_rules().len(), 1);
2447+
2448+
// 2. Send a rule with a malformed tag entry.
2449+
let bad = br#"{
2450+
"id": "rc-bad-tags",
2451+
"lib_config": {
2452+
"tracing_sampling_rules": [
2453+
{
2454+
"sample_rate": 0.0,
2455+
"service": "svc",
2456+
"tags": [
2457+
{"key": "env", "value_glob": "prod"},
2458+
{"key": "region"}
2459+
]
2460+
}
2461+
]
2462+
}
2463+
}"#;
2464+
// The libdatadog parse rejects list-shape tags, so the update fails
2465+
// internally and is logged at dd_debug! — process_config still returns
2466+
// Ok. The key invariant is that the prior remote override is not
2467+
// overwritten or cleared.
2468+
ApmTracingHandler.process_config(bad, &config).unwrap();
2469+
let rules = config.trace_sampling_rules().to_vec();
2470+
assert_eq!(rules.len(), 1, "prior override must remain installed");
2471+
assert_eq!(rules[0].sample_rate, 0.5);
2472+
}
2473+
2474+
#[test]
2475+
fn test_handler_non_numeric_rate_rejects_update() {
2476+
// A schema-drifted rate (e.g. string) must be rejected as a malformed
2477+
// payload, not silently treated as a clear that would wipe an active
2478+
// remote override.
2479+
let config = build_config_for_handler();
2480+
let install = br#"{
2481+
"id": "rc-install",
2482+
"lib_config": {"tracing_sampling_rate": 0.5}
2483+
}"#;
2484+
ApmTracingHandler.process_config(install, &config).unwrap();
2485+
assert_eq!(config.trace_sampling_rules().len(), 1);
2486+
2487+
let bad = br#"{
2488+
"id": "rc-bad-rate",
2489+
"lib_config": {"tracing_sampling_rate": "0.5"}
2490+
}"#;
2491+
let result = ApmTracingHandler.process_config(bad, &config);
2492+
assert!(result.is_err(), "non-numeric rate must be rejected");
2493+
// Prior override survives.
2494+
assert_eq!(config.trace_sampling_rules().len(), 1);
2495+
assert_eq!(config.trace_sampling_rules()[0].sample_rate, 0.5);
24072496
}
24082497
}

0 commit comments

Comments
 (0)