apollo-router 2.16.0

A configurable, high-performance routing runtime for Apollo Federation ๐Ÿš€
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::OnceLock;

use http::HeaderMap;
use http::HeaderValue;
use schemars::JsonSchema;
use serde::Deserialize;

use crate::Context;
use crate::configuration::header_masking_config::HeaderMaskingConfig;

/// Per-selector masking override. `Allow` shows the raw header value; `Mask`
/// always replaces it with `***MASKED***`. When unset, the selector defers to
/// the global request/response rules in `MaskingRulesMap`.
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum RedactMode {
    /// Always show the header value, ignoring any global masking rules.
    Allow,
    /// Always mask the header value, regardless of global rules.
    Mask,
}

pub(crate) const MASKED_VALUE: &str = "***MASKED***";

/// Compiled header masking rules for efficient lookup
#[derive(Clone, Debug, Default)]
pub(crate) struct HeaderMaskingRules {
    /// Set of sensitive header names (lowercase) that should be masked
    sensitive_headers: HashSet<String>,
}

impl HeaderMaskingRules {
    /// Create masking rules from configuration. Returns empty rules when `enabled: false`.
    ///
    /// The effective sensitive-header list is the merge of the built-in
    /// defaults and the user-provided `sensitive_headers`, unless
    /// `replace_defaults: true` is set (see
    /// [`HeaderMaskingConfig::effective_sensitive_headers`]).
    pub(crate) fn from_config(config: &HeaderMaskingConfig) -> Self {
        if !config.enabled {
            return Self::default();
        }
        let sensitive_headers: HashSet<String> = config
            .effective_sensitive_headers()
            .into_iter()
            .map(|h| h.to_lowercase())
            .collect();

        if sensitive_headers.is_empty() {
            tracing::warn!(
                "Header masking is enabled but the effective sensitive-headers list is empty \
                 (replace_defaults: true with no sensitive_headers). No headers will be masked \
                 in logs or telemetry, including authorization and cookie. Add entries to \
                 sensitive_headers or remove replace_defaults: true to restore the built-in \
                 fail-secure defaults."
            );
        }

        Self { sensitive_headers }
    }

    /// Check if a header should be masked (case-insensitive).
    ///
    /// The set entries are stored lowercase. `http::HeaderName::as_str()` is
    /// already canonical lowercase, which is the common caller โ€” handle that
    /// without a `String` allocation. Header names are ASCII (RFC 9110 ยง5.1),
    /// so the only-uppercase case falls back to the explicit lowercased
    /// lookup; all other cases reuse the input borrow.
    pub(crate) fn should_mask(&self, header_name: &str) -> bool {
        if header_name.bytes().all(|b| !b.is_ascii_uppercase()) {
            self.sensitive_headers.contains(header_name)
        } else {
            self.sensitive_headers
                .contains(&header_name.to_ascii_lowercase())
        }
    }

    /// Mask already-externalized headers (the `HashMap<String, Vec<String>>`
    /// shape used in coprocessor payloads) for safe debug logging. Sensitive
    /// header values are replaced with `***MASKED***`; non-sensitive headers
    /// pass through unchanged.
    pub(crate) fn mask_externalized_headers(
        &self,
        input: &HashMap<String, Vec<String>>,
    ) -> HashMap<String, Vec<String>> {
        input
            .iter()
            .map(|(k, v)| {
                if self.should_mask(k) {
                    (k.clone(), vec![MASKED_VALUE.to_string()])
                } else {
                    (k.clone(), v.clone())
                }
            })
            .collect()
    }

    /// Mask headers in Debug format string for telemetry events.
    ///
    /// Output is sorted by entry so it is deterministic regardless of
    /// `HeaderMap` iteration order โ€” callers (telemetry events) rely on this
    /// for stable, snapshot-friendly output.
    pub(crate) fn mask_headers_debug(&self, input: &HeaderMap<HeaderValue>) -> String {
        let mut parts = Vec::with_capacity(input.len());

        for (k, v) in input {
            let k_str = k.as_str();
            let value_str = if self.should_mask(k_str) {
                MASKED_VALUE
            } else {
                v.to_str().unwrap_or("<non-utf8>")
            };

            // Use Debug formatting so embedded quotes/backslashes/control chars are
            // properly escaped โ€” avoids invalid JSON and log-injection vectors via
            // attacker-influenceable header values (Cookie, Referer, User-Agent, ...).
            parts.push(format!("{k_str:?}: {value_str:?}"));
        }

        parts.sort();
        format!("{{{}}}", parts.join(", "))
    }
}

/// Per-direction rules: a global default plus optional per-subgraph overrides.
#[derive(Debug, Default)]
pub(crate) struct DirectionRules {
    global: Arc<HeaderMaskingRules>,
    per_subgraph: HashMap<String, Arc<HeaderMaskingRules>>,
}

impl DirectionRules {
    pub(crate) fn new(
        global: Arc<HeaderMaskingRules>,
        per_subgraph: HashMap<String, Arc<HeaderMaskingRules>>,
    ) -> Self {
        Self {
            global,
            per_subgraph,
        }
    }

    fn get(&self, subgraph_name: Option<&str>) -> &Arc<HeaderMaskingRules> {
        subgraph_name
            .and_then(|n| self.per_subgraph.get(n))
            .unwrap_or(&self.global)
    }
}

/// A write-once masking rules map stored in the request context.
///
/// Inserted by the headers plugin at router-service time so all stages (router,
/// supergraph, subgraph, connector) read a consistent, immutable snapshot.
/// Request and response directions are configured independently; callers must
/// pick the matching direction via [`get_request`] or [`get_response`].
#[derive(Debug)]
pub(crate) struct MaskingRulesMap {
    request: DirectionRules,
    response: DirectionRules,
}

impl MaskingRulesMap {
    pub(crate) fn new(request: DirectionRules, response: DirectionRules) -> Self {
        Self { request, response }
    }

    /// Test helper: build a map that applies the same rules in both directions.
    /// Real config builds the two directions independently.
    #[cfg(test)]
    pub(crate) fn new_test(
        global: Arc<HeaderMaskingRules>,
        per_subgraph: HashMap<String, Arc<HeaderMaskingRules>>,
    ) -> Self {
        Self::new(
            DirectionRules::new(global.clone(), per_subgraph.clone()),
            DirectionRules::new(global, per_subgraph),
        )
    }

    /// Returns the request-side masking rules for the given subgraph (or the
    /// global request rules when `subgraph_name` is `None` or unknown).
    pub(crate) fn get_request(&self, subgraph_name: Option<&str>) -> &Arc<HeaderMaskingRules> {
        self.request.get(subgraph_name)
    }

    /// Returns the response-side masking rules for the given subgraph (or the
    /// global response rules when `subgraph_name` is `None` or unknown).
    pub(crate) fn get_response(&self, subgraph_name: Option<&str>) -> &Arc<HeaderMaskingRules> {
        self.response.get(subgraph_name)
    }

    fn rules_for(&self, direction: Direction, subgraph: Option<&str>) -> &Arc<HeaderMaskingRules> {
        match direction {
            Direction::Request => self.get_request(subgraph),
            Direction::Response => self.get_response(subgraph),
        }
    }
}

/// Request vs response masking direction. Selects which rule set a caller
/// consults via [`MaskingRulesMap::get_request`] / [`MaskingRulesMap::get_response`].
#[derive(Clone, Copy)]
pub(crate) enum Direction {
    Request,
    Response,
}

/// The built-in fail-secure masking rules (the default sensitive-header list).
///
/// Used when no per-request [`MaskingRulesMap`] is installed in context, so a
/// code path that bypasses the headers plugin still masks known secrets
/// (authorization, cookie, set-cookie, ...) rather than logging them in the
/// clear. The headers plugin is mandatory, so in the normal pipeline a map is
/// always present and this fallback only guards stray/synthesized requests.
pub(crate) fn default_masking_rules() -> &'static HeaderMaskingRules {
    static DEFAULT: OnceLock<HeaderMaskingRules> = OnceLock::new();
    DEFAULT.get_or_init(|| HeaderMaskingRules::from_config(&HeaderMaskingConfig::default()))
}

/// Whether `header_name` should be masked per the masking rules installed in
/// `context` for the given direction/subgraph. Falls back to the built-in
/// [`default_masking_rules`] (fail-secure) when no rules are present.
fn should_mask_header(
    context: &Context,
    direction: Direction,
    subgraph: Option<&str>,
    header_name: &str,
) -> bool {
    context
        .extensions()
        .with_lock(|lock| match lock.get::<Arc<MaskingRulesMap>>() {
            Some(m) => m.rules_for(direction, subgraph).should_mask(header_name),
            None => default_masking_rules().should_mask(header_name),
        })
}

/// Whether a request header is sensitive per the global (non-subgraph) masking
/// rules. Exposed for redaction paths that don't have a subgraph context โ€” e.g.
/// Apollo trace-report header forwarding โ€” so they share the same sensitivity
/// source as the rest of header masking instead of a separate hardcoded list.
/// Fail-secure: uses the built-in defaults when no rules are installed.
pub(crate) fn is_sensitive_request_header(context: &Context, header_name: &str) -> bool {
    should_mask_header(context, Direction::Request, None, header_name)
}

/// Resolve the value a telemetry header selector should emit, applying โ€” in
/// priority order โ€” the per-selector `redact` override and then the
/// global/per-subgraph masking rules from `context`. `value` is the raw header
/// value (`None` if the header is absent).
///
/// Defined once so every selector surface (router, supergraph, subgraph,
/// connector, http-client) shares identical redaction precedence rather than
/// re-implementing it per call site.
pub(crate) fn redact_header_value(
    context: &Context,
    direction: Direction,
    subgraph: Option<&str>,
    header_name: &str,
    value: Option<String>,
    redact: Option<&RedactMode>,
) -> Option<String> {
    match (redact, &value) {
        // An explicit per-selector override always wins.
        (Some(RedactMode::Allow), _) => value,
        (Some(RedactMode::Mask), Some(_)) => Some(MASKED_VALUE.to_string()),
        // Otherwise defer to the configured masking rules.
        (None, Some(_)) if should_mask_header(context, direction, subgraph, header_name) => {
            Some(MASKED_VALUE.to_string())
        }
        // Nothing to mask: absent value, or the rules say "show".
        _ => value,
    }
}

/// Render `headers` for debug logging, masking sensitive values per the rules
/// in `context` for the given direction/subgraph. Falls back to the built-in
/// [`default_masking_rules`] (fail-secure) when no masking rules are installed,
/// so a stray request can't log secrets in the clear.
///
/// Defined once so a new header-logging site can't forget to consult the rules.
pub(crate) fn masked_headers_for_log(
    context: &Context,
    direction: Direction,
    subgraph: Option<&str>,
    headers: &HeaderMap<HeaderValue>,
) -> String {
    context
        .extensions()
        .with_lock(|lock| match lock.get::<Arc<MaskingRulesMap>>() {
            Some(m) => m.rules_for(direction, subgraph).mask_headers_debug(headers),
            None => default_masking_rules().mask_headers_debug(headers),
        })
}

#[cfg(test)]
mod tests {
    use http::header::HeaderName;

    use super::*;

    fn create_test_rules() -> HeaderMaskingRules {
        let config = HeaderMaskingConfig {
            enabled: true,
            sensitive_headers: vec![
                "authorization".to_string(),
                "cookie".to_string(),
                "x-api-key".to_string(),
            ],
            replace_defaults: false,
        };
        HeaderMaskingRules::from_config(&config)
    }

    #[test]
    fn test_should_mask_case_insensitive() {
        let rules = create_test_rules();

        // Test exact match
        assert!(rules.should_mask("authorization"));
        assert!(rules.should_mask("cookie"));
        assert!(rules.should_mask("x-api-key"));

        // Test case insensitivity
        assert!(rules.should_mask("Authorization"));
        assert!(rules.should_mask("AUTHORIZATION"));
        assert!(rules.should_mask("Cookie"));
        assert!(rules.should_mask("X-API-KEY"));
        assert!(rules.should_mask("X-Api-Key"));

        // Test non-matching headers
        assert!(!rules.should_mask("content-type"));
        assert!(!rules.should_mask("accept"));
        assert!(!rules.should_mask("x-custom-header"));
    }

    #[test]
    fn test_mask_headers_debug() {
        let rules = create_test_rules();
        let mut headers = HeaderMap::new();

        headers.insert(
            HeaderName::from_static("authorization"),
            HeaderValue::from_static("Bearer secret-token"), // gitleaks:allow
        );
        headers.insert(
            HeaderName::from_static("content-type"),
            HeaderValue::from_static("application/json"),
        );

        let result = rules.mask_headers_debug(&headers);

        // Should contain masked authorization
        assert!(result.contains("authorization"));
        assert!(result.contains(MASKED_VALUE));
        assert!(!result.contains("secret-token"));

        // Should contain unmasked content-type
        assert!(result.contains("content-type"));
        assert!(result.contains("application/json"));
    }

    #[test]
    fn test_mask_headers_debug_escapes_special_characters() {
        let rules = create_test_rules();
        let mut headers = HeaderMap::new();

        // A header value containing quotes and backslashes โ€” exactly the shape
        // that broke the prior naive "{}": "{}" formatter.
        headers.insert(
            HeaderName::from_static("etag"),
            HeaderValue::from_static(r#""abc\123""#),
        );

        let result = rules.mask_headers_debug(&headers);

        // Quotes inside the value should be escaped (Debug formatting), keeping
        // the rendered string a valid JSON-ish key/value pair.
        assert!(
            result.contains(r#""etag": "\"abc\\123\"""#),
            "expected escaped value, got: {result}"
        );
    }

    #[test]
    fn test_empty_config_with_replace_defaults_masks_nothing() {
        let config = HeaderMaskingConfig {
            enabled: true,
            sensitive_headers: vec![],
            // Explicitly opt out of built-in defaults to make the empty list
            // authoritative.
            replace_defaults: true,
        };
        let rules = HeaderMaskingRules::from_config(&config);

        assert!(!rules.should_mask("authorization"));
        assert!(!rules.should_mask("cookie"));
    }

    #[test]
    fn empty_user_list_with_default_replace_defaults_still_masks_built_in_headers() {
        let config = HeaderMaskingConfig::default();
        let rules = HeaderMaskingRules::from_config(&config);

        // The fail-secure default: even without user config, the built-in
        // sensitive-header list is in effect.
        assert!(rules.should_mask("authorization"));
        assert!(rules.should_mask("cookie"));
    }

    #[test]
    fn test_masking_rules_map_separates_request_and_response() {
        // Use replace_defaults: true so each rule set contains exactly the
        // listed headers, isolating the request/response separation under test.
        let request_rules = Arc::new(HeaderMaskingRules::from_config(&HeaderMaskingConfig {
            enabled: true,
            sensitive_headers: vec!["authorization".to_string()],
            replace_defaults: true,
        }));
        let response_rules = Arc::new(HeaderMaskingRules::from_config(&HeaderMaskingConfig {
            enabled: true,
            sensitive_headers: vec!["set-cookie".to_string()],
            replace_defaults: true,
        }));
        let per_subgraph_response: HashMap<String, Arc<HeaderMaskingRules>> = [(
            "products".to_string(),
            Arc::new(HeaderMaskingRules::from_config(&HeaderMaskingConfig {
                enabled: true,
                sensitive_headers: vec!["x-products-secret".to_string()],
                replace_defaults: true,
            })),
        )]
        .into_iter()
        .collect();

        let map = MaskingRulesMap::new(
            DirectionRules::new(request_rules, HashMap::new()),
            DirectionRules::new(response_rules, per_subgraph_response),
        );

        // Request side masks authorization, NOT set-cookie.
        assert!(map.get_request(None).should_mask("authorization"));
        assert!(!map.get_request(None).should_mask("set-cookie"));

        // Response side masks set-cookie (global), NOT authorization.
        assert!(map.get_response(None).should_mask("set-cookie"));
        assert!(!map.get_response(None).should_mask("authorization"));

        // Per-subgraph response override applies.
        assert!(
            map.get_response(Some("products"))
                .should_mask("x-products-secret")
        );
        // Unknown subgraph falls back to global response rules.
        assert!(map.get_response(Some("nobody")).should_mask("set-cookie"));
    }

    #[test]
    fn masked_headers_for_log_masks_via_default_rules_without_map() {
        // No MaskingRulesMap in context: fail-secure defaults must still mask.
        let ctx = Context::new();
        let mut headers = HeaderMap::new();
        headers.insert(
            HeaderName::from_static("authorization"),
            HeaderValue::from_static("Bearer secret"), // gitleaks:allow
        );
        headers.insert(
            HeaderName::from_static("content-type"),
            HeaderValue::from_static("application/json"),
        );

        let out = masked_headers_for_log(&ctx, Direction::Request, None, &headers);
        assert!(
            out.contains(MASKED_VALUE),
            "authorization should be masked by fail-secure defaults: {out}"
        );
        assert!(!out.contains("secret"));
        assert!(out.contains("application/json"));
    }

    #[test]
    fn redact_header_value_precedence() {
        let ctx = Context::new();

        // `allow` override shows the raw value even for a default-sensitive header.
        assert_eq!(
            redact_header_value(
                &ctx,
                Direction::Request,
                None,
                "authorization",
                Some("Bearer x".to_string()), // gitleaks:allow
                Some(&RedactMode::Allow),
            ),
            Some("Bearer x".to_string()) // gitleaks:allow
        );

        // `mask` override masks any header.
        assert_eq!(
            redact_header_value(
                &ctx,
                Direction::Request,
                None,
                "x-custom",
                Some("v".to_string()),
                Some(&RedactMode::Mask),
            ),
            Some(MASKED_VALUE.to_string())
        );

        // No override: defer to rules โ€” fail-secure default masks authorization.
        assert_eq!(
            redact_header_value(
                &ctx,
                Direction::Request,
                None,
                "authorization",
                Some("Bearer x".to_string()), // gitleaks:allow
                None,
            ),
            Some(MASKED_VALUE.to_string())
        );

        // No override: a non-sensitive header passes through unmasked.
        assert_eq!(
            redact_header_value(
                &ctx,
                Direction::Request,
                None,
                "x-not-sensitive",
                Some("v".to_string()),
                None,
            ),
            Some("v".to_string())
        );
    }
}