Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions crates/aisix-core/src/models/provider_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,88 @@ pub struct ProviderKey {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response: Option<ResponseOverrides>,

/// Inbound request headers to strip before forwarding to the
/// upstream provider on the passthrough endpoint (#411).
///
/// Defaults (when the field is absent on the wire) to the 4
/// canonical credential headers: `authorization`, `cookie`,
/// `set-cookie`, `x-api-key`. Customers can:
/// - Remove a default entry → that header reaches upstream
/// (the dashboard warns when removing a default).
/// - Add custom entries → extra headers stripped.
///
/// Case-insensitive. Compared lowercased against the inbound
/// header name. Non-configurable headers (`host`, `content-length`,
/// RFC 7230 §6.1 hop-by-hop) are stripped separately by the
/// passthrough handler and cannot be removed via this list.
///
/// Entries are normalised on deserialize via
/// `normalize_strip_headers`: trimmed, lowercased, dedup'd,
/// empties dropped. This prevents the "operator typed `' cookie '`,
/// the strip set has `' cookie '` but the inbound header is
/// `'cookie'` → no match → silent credential leak" footgun.
Comment on lines +121 to +124
#[serde(
default = "default_strip_headers",
deserialize_with = "deserialize_normalized_strip_headers"
)]
pub strip_headers: Vec<String>,

Comment on lines +105 to +130
/// Filled in by the snapshot loader from the etcd key path.
#[serde(skip)]
pub(crate) runtime_id: String,
}

/// Default header-strip list for a freshly-created ProviderKey
/// on the passthrough endpoint, per issue #411. These four headers
/// are credentials that the upstream LLM provider has no legitimate
/// use for; stripping by default protects against accidental
/// session-token disclosure. Customers can remove entries via the
/// dashboard (with a warning) if they have a specific audit /
/// forwarding need.
pub fn default_strip_headers() -> Vec<String> {
vec![
"authorization".to_string(),
"cookie".to_string(),
"set-cookie".to_string(),
"x-api-key".to_string(),
]
}

/// Normalize a single strip-list entry: trim whitespace, lowercase
/// ASCII. Returns `None` for entries that, post-trim, are empty or
/// reference-invalid HTTP header names. Non-ASCII chars survive
/// `to_ascii_lowercase` (no-op for them) but are unusual in practice;
/// the passthrough handler's `to_ascii_lowercase` comparison will
/// still match correctly.
fn normalize_strip_entry(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.to_ascii_lowercase())
}
Comment on lines +152 to +164

/// Deserialize + normalize: drop empties, lowercase, dedup. Preserves
/// first-occurrence order so a hand-curated list reads sanely in the
/// dashboard. Per issue #411 audit MEDIUM-1.
fn deserialize_normalized_strip_headers<'de, D>(de: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize as _;
let raw: Vec<String> = Vec::deserialize(de)?;
let mut seen = std::collections::HashSet::new();
let mut out = Vec::with_capacity(raw.len());
for entry in raw {
if let Some(normalized) = normalize_strip_entry(&entry) {
if seen.insert(normalized.clone()) {
out.push(normalized);
}
}
}
Ok(out)
}

/// Telemetry attribution tags emitted alongside requests routed
/// through this `ProviderKey`. Introduced as a skeleton for issue
/// #302 Phase A — no metric/log path consumes these fields yet.
Expand Down Expand Up @@ -437,6 +514,7 @@ mod tests {
telemetry_tags: TelemetryTags::default(),
request: None,
response: None,
strip_headers: default_strip_headers(),
runtime_id: String::new(),
};
let s = serde_json::to_string(&original).unwrap();
Expand Down Expand Up @@ -610,4 +688,60 @@ mod tests {
let r: Result<ParamConstraints, _> = serde_json::from_str(r#"{"top_p_max": 0.9}"#);
assert!(r.is_err());
}

// ---- Issue #411 strip_headers deserialize/normalize ----

fn pk_with_strip(strip_json: &str) -> ProviderKey {
let json = format!(r#"{{"display_name":"x","secret":"sk","strip_headers":{strip_json}}}"#);
serde_json::from_str(&json).unwrap()
}

#[test]
fn strip_headers_default_applies_when_field_absent() {
let pk: ProviderKey =
serde_json::from_str(r#"{"display_name":"x","secret":"sk"}"#).unwrap();
assert_eq!(pk.strip_headers, default_strip_headers());
}

#[test]
fn strip_headers_explicit_empty_array_is_preserved() {
// The "customer cleared all defaults" override case must
// produce an empty Vec, NOT fall through to the default.
let pk = pk_with_strip("[]");
assert!(pk.strip_headers.is_empty());
}

#[test]
fn strip_headers_trims_whitespace() {
// Without the normalize hook, " cookie " would never match
// an inbound `cookie` header → silent credential leak.
let pk = pk_with_strip(r#"[" cookie ", "\tauthorization\n"]"#);
assert_eq!(pk.strip_headers, vec!["cookie", "authorization"]);
}

#[test]
fn strip_headers_lowercases_input() {
let pk = pk_with_strip(r#"["Authorization", "COOKIE", "X-Custom-Header"]"#);
assert_eq!(
pk.strip_headers,
vec!["authorization", "cookie", "x-custom-header"]
);
}

#[test]
fn strip_headers_drops_empty_entries() {
// Operators pasting from a comma-split tool may end up with
// stray empty strings. Silently ignored, not fatal.
let pk = pk_with_strip(r#"["", " ", "cookie", ""]"#);
assert_eq!(pk.strip_headers, vec!["cookie"]);
}

#[test]
fn strip_headers_dedupes_case_insensitively() {
// Customer accidentally added "Cookie" and "cookie" both.
// Dedup post-lowercase. First-occurrence order is preserved
// so the dashboard reads sanely.
let pk = pk_with_strip(r#"["Cookie", "x-trace", "cookie", "X-Trace"]"#);
assert_eq!(pk.strip_headers, vec!["cookie", "x-trace"]);
}
}
11 changes: 11 additions & 0 deletions crates/aisix-core/src/models/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,17 @@ fn provider_key_schema() -> Value {
"error_envelope": { "type": "string" },
"reasoning_field": { "type": "string" }
}
},
// Issue #411 — per-PK passthrough header strip list.
// Optional (defaults applied DP-side via
// `#[serde(default = "default_strip_headers")]`); when
// present, must be an array of strings. Entries are
// normalised (trim/lowercase/dedup/drop-empties) on
// deserialize so this validator doesn't enforce
// formatting beyond the type shape.
"strip_headers": {
"type": "array",
"items": { "type": "string" }
}
}
})
Expand Down
Loading
Loading