diff --git a/backend/src/field_selection/README.md b/backend/src/field_selection/README.md new file mode 100644 index 00000000..35e1f04a --- /dev/null +++ b/backend/src/field_selection/README.md @@ -0,0 +1,246 @@ +# Field Selection Module + +## Why This Module Exists Before We Have an HTTP Server + +The backend is currently a Rust library without an HTTP server. However, the frontend is already written to expect REST endpoints (e.g., `/api/snapshots?fields=epoch,snapshot_hash`). + +This module **preemptively implements the validation and allowlisting infrastructure** for sparse fieldsets, so that when the HTTP layer is added, it can use this module immediately without retrofitting security logic later. + +## Security Model + +This module addresses **semantic access control**, not cryptographic input escaping: + +### What It Does ✅ + +- **Explicit allowlist per endpoint**: Each endpoint defines exactly which fields can be requested. +- **Allowlist is a subset**: Adding a new field to a Rust struct does **NOT** automatically expose it via the API. +- **Client input is never interpolated**: Field names from the client are **only** used as keys in HashMap lookups. The value returned by the HashMap is the only string that reaches any downstream layer. + +### What It Does NOT Do ❌ + +- **SQL escaping**: This module contains no SQL layer (that comes when the HTTP server is added). +- **Encoding/sanitization**: Input is validated by allowlist, not by escaping or encoding. +- **Authorization**: This module does NOT check if the user has permission to request these fields. That's the HTTP layer's job. + +## Example: Two Real Domains + +### Snapshots Endpoint + +Based on `backend/src/event_indexer/dispatch.rs::NormalizedSnapshotSubmitted`: + +**Rust struct has:** +```rust +pub struct NormalizedSnapshotSubmitted { + pub epoch: u64, + pub snapshot_hash: String, + pub source_data_hash: String, // ← Internal reconciliation state + pub submitted_at: u64, + pub submitter: String, // ← Operator identity (privacy concern) +} +``` + +**Allowlist exposes (only):** +``` +epoch +snapshot_hash +submitted_at +``` + +**Rationale:** +- `epoch` and `submitted_at` are audit metadata, safe for public consumption. +- `snapshot_hash` is the canonical identifier clients need for verification. +- `source_data_hash` is excluded: it's internal state used for reconciliation, not a public concern. +- `submitter` is excluded: reveals private operator identity. + +**Result:** +A client requesting `?fields=epoch,submitter` receives a 400 error: +``` +field 'submitter' is not available for endpoint 'snapshots' +``` + +### Aggregates Endpoint + +Based on `backend/src/reconciliation/spec.rs::OffChainAggregate`: + +**Rust struct has:** +```rust +pub struct OffChainAggregate { + pub period: u64, + pub snapshot_hash: [u8; 32], + pub source_data_hash: [u8; 32], // ← Internal reconciliation state +} +``` + +**Allowlist exposes (only):** +``` +period +snapshot_hash +``` + +**Rationale:** +- Clients request aggregates by their period and snapshot hash. +- `source_data_hash` is internal; clients don't request by it. + +## How to Integrate When the HTTP Server Exists + +### Step 1: Add the HTTP Handler + +```rust +use stellar_insights_backend::field_selection::{parse_fields, FieldSelectionError}; + +async fn get_snapshots( + Query(params): Query, +) -> Result, ApiError> { + // If `?fields=...` is provided, validate and filter + let selected_fields = if let Some(fields_param) = params.fields { + parse_fields(&fields_param, "snapshots") + .map_err(|e| ApiError::BadRequest(e.to_string()))? + } else { + // Default to a standard set if no fields requested + vec!["epoch", "snapshot_hash", "submitted_at"] + }; + + // Now build the response with only selected_fields + // ... +} +``` + +### Step 2: Map Errors to HTTP + +```rust +use stellar_insights_backend::field_selection::FieldSelectionError; + +impl From for ApiError { + fn from(e: FieldSelectionError) -> Self { + // FieldSelectionError::UnknownField -> 400 Bad Request + ApiError::BadRequest(e.to_string()) + } +} +``` + +### Step 3: Serialize Only Selected Fields + +If using `serde_json`, you can dynamically include/exclude fields or use a custom serializer. Or implement a simple projection struct. + +## Testing + +Two test files validate the security properties: + +### `backend/tests/non_allowlisted_field_test.rs` + +For each endpoint with an allowlist, this test requests a field that **exists in the Rust struct** but is **deliberately excluded** from the allowlist: + +```rust +// Attempt to request `submitter` from snapshots endpoint +let result = parse_fields("epoch,submitter", "snapshots"); +assert_eq!( + result, + Err(FieldSelectionError::UnknownField { + endpoint: "snapshots", + field: "submitter".to_string(), + }) +); +``` + +**This validates:** The allowlist enforces a security boundary, not just rejects truly nonexistent fields. + +### `backend/tests/injection_shaped_field_test.rs` + +This test attempts a field name that contains SQL injection patterns: + +```rust +// Client tries to inject SQL +let result = parse_fields(r#"epoch,"; DROP TABLE users; --"#, "snapshots"); +assert_eq!( + result, + Err(FieldSelectionError::UnknownField { + endpoint: "snapshots", + field: r#""; DROP TABLE users; --"#.to_string(), + }) +); +``` + +**This validates:** The injection attempt is caught at the allowlist lookup stage (HashMap::get returns None), **not** by a SQL sanitizer. The comment in the test explains that this is the correct defense: when the HTTP server adds a SQL layer later, it will never receive this malformed field name because it was rejected at the allowlist layer. + +## Design Rationale + +### Why Not Auto-Reflect the Struct? + +❌ **Bad:** +```rust +// ← DON'T DO THIS +let allowlist = derive_allowlist_from_struct::(); +// Now any new field in the struct is automatically exposed. +// Adding `submitter` to the struct = it leaks to the API. +``` + +✅ **Good:** +```rust +// ← DO THIS +lazy_static! { + static ref SNAPSHOTS_ALLOWLIST: HashMap<&'static str, &'static str> = { + [("epoch", "epoch"), ("snapshot_hash", "snapshot_hash"), ...] + .iter() + .copied() + .collect() + }; +} +// New fields in the struct are NOT exposed until explicitly added to the allowlist. +``` + +### Why Not Just Escape? + +❌ **Wrong security model:** +```rust +// ← DON'T DO THIS +let field = client_input; // e.g., "submitter"; DROP TABLE users; --" +let sql = format!("SELECT {} FROM ...", escape_sql(field)); +// The field passed validation, so we build the query. +// escaping prevents injection, but the field was never supposed to exist. +``` + +✅ **Correct security model:** +```rust +// ← DO THIS +let field = client_input; // e.g., "submitter"; DROP TABLE users; --" +let allowed_fields = get_allowlist("snapshots")?; +let sql_expr = allowed_fields.get(field)?; // Returns None; request fails at 400 +// The field never reaches SQL at all. +``` + +The first model escapes at the wrong layer. The second model prevents the problem entirely. + +## Future Extensions + +### Per-Tenant Allowlists + +If you need different field sets per customer/tenant: + +```rust +pub trait AllowlistRegistry { + fn get_allowlist(&self, endpoint: &str, tenant: &str) -> Option<&'static HashMap<&'static str, &'static str>>; +} +``` + +### Dynamic Field Aliases + +If the Rust field name doesn't match the public API name: + +```rust +static ref SNAPSHOTS_ALLOWLIST: HashMap<&'static str, &'static str> = { + [ + ("submittedAt", "submitted_at"), // ← client sees "submittedAt", Rust uses "submitted_at" + ("epoch", "epoch"), + ] + .iter() + .copied() + .collect() +}; +``` + +This already works! The HashMap values can differ from the keys. + +--- + +**Last updated:** 2026-08-30 +**Status:** Ready for HTTP layer integration diff --git a/backend/src/field_selection/allowlist.rs b/backend/src/field_selection/allowlist.rs new file mode 100644 index 00000000..1a8bf8a0 --- /dev/null +++ b/backend/src/field_selection/allowlist.rs @@ -0,0 +1,121 @@ +//! Field allowlists per endpoint. +//! +//! Each endpoint/struct that supports field selection must have an explicit allowlist +//! defined here. The allowlist maps client-provided field names to their SQL/output representation. +//! +//! **Design principle**: The allowlist is a deliberate subset of the struct's fields. +//! Adding a new field to the Rust struct does NOT automatically expose it via the API. + +use std::collections::HashMap; + +/// Retrieves the field allowlist for a given endpoint. +/// +/// # Arguments +/// +/// * `endpoint` - The endpoint identifier (e.g., "snapshots", "aggregates") +/// +/// # Returns +/// +/// A reference to the allowlist `HashMap`, or `None` if no allowlist is defined for this endpoint. +pub fn get_allowlist(endpoint: &str) -> Option<&'static HashMap<&'static str, &'static str>> { + match endpoint { + "snapshots" => Some(&SNAPSHOTS_ALLOWLIST), + "aggregates" => Some(&AGGREGATES_ALLOWLIST), + _ => None, + } +} + +// ─── SNAPSHOTS ENDPOINT ───────────────────────────────────────────────────────── +// +// Based on: backend/src/event_indexer/dispatch.rs::NormalizedSnapshotSubmitted +// +// Real struct fields: +// pub epoch: u64, +// pub snapshot_hash: String, +// pub source_data_hash: String, <- DELIBERATELY EXCLUDED +// pub submitted_at: u64, +// pub submitter: String, <- DELIBERATELY EXCLUDED (privacy) +// +// Allowlist rationale: +// - `epoch` and `submitted_at` are audit metadata, safe to expose. +// - `snapshot_hash` is the canonical identifier, needed by clients. +// - `source_data_hash` is internal reconciliation state; clients don't need it. +// - `submitter` could reveal private operator identity; excluded for privacy. +// +// When this endpoint becomes HTTP, the handler will: +// 1. Call `parse_fields(request_query_param, "snapshots")?` +// 2. Receive `Vec<&'static str>` with validated field names (e.g., ["epoch", "snapshot_hash"]) +// 3. Build the response projection using only those fields +// +lazy_static::lazy_static! { + static ref SNAPSHOTS_ALLOWLIST: HashMap<&'static str, &'static str> = { + [ + ("epoch", "epoch"), + ("snapshot_hash", "snapshot_hash"), + ("submitted_at", "submitted_at"), + ] + .iter() + .copied() + .collect() + }; +} + +// ─── AGGREGATES ENDPOINT ──────────────────────────────────────────────────────── +// +// Based on: backend/src/reconciliation/spec.rs::OffChainAggregate +// +// Real struct fields: +// pub period: u64, +// pub snapshot_hash: [u8; 32], +// pub source_data_hash: [u8; 32], <- DELIBERATELY EXCLUDED +// +// Allowlist rationale: +// - `period` identifies the reconciliation epoch, needed by clients. +// - `snapshot_hash` is the canonical proof, needed by clients. +// - `source_data_hash` is internal implementation detail; clients request snapshots +// by comparing hashes, not raw source data. +// +// When this endpoint becomes HTTP, the handler will: +// 1. Call `parse_fields(request_query_param, "aggregates")?` +// 2. Receive `Vec<&'static str>` with validated field names +// 3. Serialize only selected fields to JSON +// +lazy_static::lazy_static! { + static ref AGGREGATES_ALLOWLIST: HashMap<&'static str, &'static str> = { + [ + ("period", "period"), + ("snapshot_hash", "snapshot_hash"), + ] + .iter() + .copied() + .collect() + }; +} + +// ─── REGISTRY & EXTENSION POINTS ─────────────────────────────────────────────── +// +// For more complex scenarios (e.g., dynamic field mapping, per-tenant allowlists), +// consider implementing an `AllowlistRegistry` trait: +// +// pub trait AllowlistRegistry { +// fn get_allowlist(&self, endpoint: &str) -> Option<&'static HashMap<&'static str, &'static str>>; +// } +// +// This is not implemented by default because the current design favors explicitness: +// each endpoint's allowlist is a top-level definition, not a plugin. + +/// Marker trait for future dynamic allowlist registration. +/// Currently unused; provided for documentation and future expansion. +pub trait AllowlistRegistry { + /// Retrieve an allowlist for the given endpoint. + fn get_allowlist(&self, endpoint: &str) -> Option<&'static HashMap<&'static str, &'static str>>; +} + +/// A simple in-memory registry that delegates to `get_allowlist()`. +pub struct StaticRegistry; + +impl AllowlistRegistry for StaticRegistry { + fn get_allowlist(&self, endpoint: &str) -> Option<&'static HashMap<&'static str, &'static str>> { + get_allowlist(endpoint) + } +} diff --git a/backend/src/field_selection/mod.rs b/backend/src/field_selection/mod.rs new file mode 100644 index 00000000..5f881420 --- /dev/null +++ b/backend/src/field_selection/mod.rs @@ -0,0 +1,56 @@ +//! Sparse fieldset selection with explicit allowlisting. +//! +//! This module provides a security-focused infrastructure for filtering fields in API responses. +//! It is **transport-agnostic**: no HTTP status codes, no framework dependencies. +//! +//! Key design principles: +//! 1. **Explicit allowlist per endpoint** (not per global schema). +//! 2. **Allowlist is a subset** of the struct's fields — new struct fields don't auto-expose. +//! 3. **Client input is never interpolated** into queries — only used as a lookup key. +//! 4. **Errors are semantic types**, not HTTP status codes (those are the HTTP layer's responsibility). + +pub mod allowlist; +pub mod parse; + +use std::error::Error; +use std::fmt; + +/// Sparse fieldset selection error. +/// +/// This is a semantic error type with no HTTP coupling. +/// The HTTP layer (when it exists) should map `UnknownField` to 400 Bad Request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FieldSelectionError { + /// A requested field is not in the allowlist for this endpoint. + /// + /// This rejects both: + /// - Fields that don't exist in the struct + /// - Fields that exist but are deliberately excluded from the API + /// + /// Failing early and explicitly on unknown fields prevents silent data loss + /// and signals to the client that their request is malformed. + UnknownField { + endpoint: &'static str, + field: String, + }, +} + +impl fmt::Display for FieldSelectionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownField { endpoint, field } => { + write!( + f, + "field '{}' is not available for endpoint '{}'", + field, endpoint + ) + } + } + } +} + +impl Error for FieldSelectionError {} + +/// Public API exports +pub use allowlist::{AllowlistRegistry, get_allowlist}; +pub use parse::parse_fields; diff --git a/backend/src/field_selection/parse.rs b/backend/src/field_selection/parse.rs new file mode 100644 index 00000000..a327ef31 --- /dev/null +++ b/backend/src/field_selection/parse.rs @@ -0,0 +1,153 @@ +//! Field selection parsing and validation. +//! +//! Parses the `fields` query parameter (comma-separated list) and validates +//! each field against the endpoint's allowlist. + +use crate::field_selection::{FieldSelectionError, allowlist}; + +/// Parses and validates a comma-separated field list for a given endpoint. +/// +/// # Arguments +/// +/// * `fields_param` - The comma-separated field names requested by the client (e.g., "epoch,snapshot_hash") +/// * `endpoint` - The endpoint identifier (e.g., "snapshots", "aggregates") +/// +/// # Returns +/// +/// - `Ok(Vec<&'static str>)` if all fields are valid and in the allowlist +/// - `Err(FieldSelectionError::UnknownField)` if any field is unknown or not allowed +/// +/// # Behavior +/// +/// - **Fail-fast**: Returns immediately on the first invalid field. +/// - **Never silent**: If any field is invalid, the request is rejected entirely. +/// (No partial success or graceful downgrade to a default set.) +/// - **No interpolation**: Field names are only used as keys in HashMap lookups. +/// +/// # Example +/// +/// ```ignore +/// // Valid request +/// let fields = parse_fields("epoch,snapshot_hash", "snapshots")?; +/// // Returns: vec!["epoch", "snapshot_hash"] +/// +/// // Request with unknown field +/// let fields = parse_fields("epoch,submitter", "snapshots")?; +/// // Returns Err: UnknownField { endpoint: "snapshots", field: "submitter" } +/// +/// // Request with injection attempt (treated as unknown field) +/// let fields = parse_fields(r#"epoch,"; DROP TABLE users; --"#, "snapshots")?; +/// // Returns Err: UnknownField { endpoint: "snapshots", field: "...DROP..." } +/// // (rejected by allowlist lookup, not by SQL escaping logic) +/// ``` +pub fn parse_fields( + fields_param: &str, + endpoint: &'static str, +) -> Result, FieldSelectionError> { + // Get the allowlist for this endpoint. + let allowlist = allowlist::get_allowlist(endpoint).ok_or_else(|| { + FieldSelectionError::UnknownField { + endpoint, + field: "(endpoint not found)".to_string(), + } + })?; + + // Split the field list and validate each field. + let mut validated_fields = Vec::new(); + + for field_name in fields_param.split(',').map(|s| s.trim()) { + if field_name.is_empty() { + continue; // Skip empty segments (e.g., "a,,b" -> skip middle empty string) + } + + // The only operation on the client-provided field name is a lookup in the allowlist. + // If the field is not in the allowlist, we reject it. + // This is where SQL injection attempts are caught (as unknown fields). + match allowlist.get(field_name) { + Some(&sql_expr) => { + validated_fields.push(sql_expr); + } + None => { + // Client requested a field that doesn't exist or isn't allowed. + // Return immediately with the original field name for diagnostics. + return Err(FieldSelectionError::UnknownField { + endpoint, + field: field_name.to_string(), + }); + } + } + } + + Ok(validated_fields) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_valid_fields() { + let result = parse_fields("epoch,snapshot_hash", "snapshots"); + assert!(result.is_ok()); + let fields = result.unwrap(); + assert_eq!(fields.len(), 2); + assert!(fields.contains(&"epoch")); + assert!(fields.contains(&"snapshot_hash")); + } + + #[test] + fn test_parse_with_whitespace() { + let result = parse_fields("epoch, snapshot_hash , submitted_at", "snapshots"); + assert!(result.is_ok()); + let fields = result.unwrap(); + assert_eq!(fields.len(), 3); + } + + #[test] + fn test_parse_unknown_field() { + let result = parse_fields("epoch,submitter", "snapshots"); + assert!(matches!( + result, + Err(FieldSelectionError::UnknownField { + endpoint: "snapshots", + field + }) if field == "submitter" + )); + } + + #[test] + fn test_parse_injection_attempt_rejected() { + // SQL injection attempt is treated as an unknown field name. + // It fails at the allowlist lookup, not at a SQL layer. + let result = parse_fields(r#"epoch,"; DROP TABLE users; --"#, "snapshots"); + assert!(matches!(result, Err(FieldSelectionError::UnknownField { .. }))); + } + + #[test] + fn test_parse_unknown_endpoint() { + let result = parse_fields("some_field", "nonexistent_endpoint"); + assert!(matches!( + result, + Err(FieldSelectionError::UnknownField { + endpoint: "nonexistent_endpoint", + .. + }) + )); + } + + #[test] + fn test_parse_empty_string() { + let result = parse_fields("", "snapshots"); + assert!(result.is_ok()); + let fields = result.unwrap(); + assert_eq!(fields.len(), 0); + } + + #[test] + fn test_parse_only_whitespace() { + let result = parse_fields(" , , ", "snapshots"); + assert!(result.is_ok()); + let fields = result.unwrap(); + assert_eq!(fields.len(), 0); + } +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index e689d1cb..d84cc7b9 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -4,10 +4,12 @@ //! - Real-time data processing and fan-out //! - Distributed locking for safe concurrent job execution //! - WebSocket connection management +//! - Sparse fieldset selection with explicit allowlisting (ready for HTTP layer) pub mod contract_ops; pub mod distributed_lock; pub mod event_indexer; +pub mod field_selection; pub mod network; pub mod observability; pub mod realtime; diff --git a/backend/tests/injection_shaped_field_test.rs b/backend/tests/injection_shaped_field_test.rs new file mode 100644 index 00000000..a570cb55 --- /dev/null +++ b/backend/tests/injection_shaped_field_test.rs @@ -0,0 +1,131 @@ +//! Test: Verify that SQL injection attempts are caught at the allowlist layer. +//! +//! This test demonstrates a critical security property: the field_selection module +//! catches injection attempts by **rejecting unknown field names**, not by escaping. +//! +//! When a client requests a field name like `"; DROP TABLE users; --"`, it: +//! 1. ✅ Fails the allowlist lookup (HashMap::get returns None) +//! 2. ❌ DOES NOT reach a SQL sanitizer or escaper +//! +//! This is the correct defense: the malicious input never reaches the database layer +//! at all because the HTTP layer rejected it as an invalid field name. +//! +//! When a real SQL layer is added to the HTTP server, it will never see these +//! field names because the field_selection module acts as a gate. + +use stellar_insights_backend::field_selection::parse_fields; + +#[test] +fn test_sql_injection_in_field_name_rejected_by_allowlist() { + // A classic SQL injection attempt: `"; DROP TABLE users; --" + // + // This is treated as an unknown field name. It's rejected because: + // 1. The allowlist.get(field_name) is called + // 2. HashMap does NOT contain `"; DROP TABLE users; --"` as a key + // 3. HashMap::get returns None + // 4. We return Err(UnknownField) + // + // NO SQL layer is involved. NO escaping happens. NO database is touched. + // The validation is purely semantic: "is this field name in the allowlist?" + + let injection_field = r#""; DROP TABLE users; --"#; + let result = parse_fields(&format!("epoch,{}", injection_field), "snapshots"); + + assert!(result.is_err(), "SQL injection attempt should be rejected"); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("DROP")); +} + +#[test] +fn test_sql_injection_with_union_attempt() { + // Another common injection: `name" UNION SELECT * FROM users; --` + // + // Same result: rejected as unknown field at the allowlist layer. + + let injection_field = r#"name" UNION SELECT * FROM users; --"#; + let result = parse_fields(&format!("epoch,{}", injection_field), "snapshots"); + + assert!(result.is_err(), "UNION injection attempt should be rejected"); +} + +#[test] +fn test_sql_injection_with_multiple_statements() { + // `field1; DELETE FROM logs; --` + // + // Rejected at allowlist layer. + + let injection_field = "field1; DELETE FROM logs; --"; + let result = parse_fields(&format!("epoch,{}", injection_field), "snapshots"); + + assert!(result.is_err(), "Multi-statement injection should be rejected"); +} + +#[test] +fn test_valid_field_with_special_chars_not_in_allowlist_also_rejected() { + // Even a benign-looking field name with special characters is rejected + // if it's not in the allowlist. This confirms that the allowlist is + // the enforcement mechanism, not any kind of character filtering. + + let result = parse_fields("epoch,field@with$special", "snapshots"); + + assert!(result.is_err(), "Special characters in unknown fields should be rejected"); +} + +#[test] +fn test_valid_field_is_accepted_even_with_special_handling() { + // On the other side: if a field IS in the allowlist, it's accepted + // without any additional validation (e.g., no character filtering). + // The allowlist is the ONLY criterion. + // + // This test uses real allowlisted fields. + + let result = parse_fields("epoch,snapshot_hash", "snapshots"); + assert!(result.is_ok(), "Valid allowlisted fields should be accepted"); +} + +#[test] +fn test_comment_injection_attempt_rejected() { + // SQL comment syntax attempts + let result = parse_fields("epoch,-- comment", "snapshots"); + assert!(result.is_err(), "SQL comment injection should be rejected"); + + let result = parse_fields("epoch,/* comment */", "snapshots"); + assert!(result.is_err(), "Block comment injection should be rejected"); +} + +#[test] +fn test_unicode_escape_attempt_rejected() { + // Unicode/hex escape sequences (would bypass some filters) + let result = parse_fields("epoch,\\x27 OR \\x27", "snapshots"); + assert!(result.is_err(), "Unicode escape injection should be rejected"); +} + +#[test] +fn test_allowlist_as_only_defense_explained() { + // This test explicitly documents that the allowlist is the ONLY defense + // against injection attempts in field names. + // + // When the HTTP server layer is added and starts building SQL queries, + // it can safely do something like: + // + // let fields = parse_fields(request.query.fields, "snapshots")?; + // // At this point, each field in `fields` is: + // // - A valid &'static str borrowed from the allowlist + // // - NOT from the client input + // // - Safe to use in SQL construction (no escaping needed) + // + // for field in fields { + // query_builder.select(field); // Safe! + // } + // + // The key insight: we use the returned value, not the client input. + // The client's string never enters the query. + + let result = parse_fields("epoch", "snapshots"); + assert!(result.is_ok(), "Valid field should be accepted"); + + let fields = result.unwrap(); + // `fields` contains only values from the allowlist, not from client input + assert!(fields.iter().all(|f| !f.contains("client"))); +} + diff --git a/backend/tests/non_allowlisted_field_test.rs b/backend/tests/non_allowlisted_field_test.rs new file mode 100644 index 00000000..62b87f85 --- /dev/null +++ b/backend/tests/non_allowlisted_field_test.rs @@ -0,0 +1,86 @@ +//! Test: Verify that fields deliberately excluded from the allowlist are rejected. +//! +//! These tests validate that the allowlist enforces a **security boundary**, +//! not just a "field existence" check. +//! +//! The fields tested here exist in the real Rust structs but are intentionally +//! excluded from the API via the allowlist. A successful attack would be if a +//! client could request these fields and receive them in the response. +//! +//! These tests verify that the field_selection module rejects them. + +use stellar_insights_backend::field_selection::parse_fields; + +#[test] +fn test_snapshots_excludes_source_data_hash_from_allowlist() { + // NormalizedSnapshotSubmitted has source_data_hash, but it's excluded from + // the allowlist because it's internal reconciliation state. + + let result = parse_fields("epoch,source_data_hash", "snapshots"); + + // Verify it's an error + assert!(result.is_err(), "Expected error for excluded field source_data_hash"); + + // Verify error message contains the field name + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("source_data_hash")); + assert!(err_msg.contains("snapshots")); +} + +#[test] +fn test_snapshots_excludes_submitter_from_allowlist() { + // NormalizedSnapshotSubmitted has submitter, but it's excluded for privacy + // (reveals operator identity). + + let result = parse_fields("epoch,submitter", "snapshots"); + + assert!(result.is_err(), "Expected error for excluded field submitter"); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("submitter")); +} + +#[test] +fn test_snapshots_allows_valid_fields_only() { + // Allowed fields for snapshots endpoint + let result = parse_fields("epoch,snapshot_hash,submitted_at", "snapshots"); + + assert!(result.is_ok(), "Expected success for valid fields"); + let fields = result.unwrap(); + assert_eq!(fields.len(), 3); +} + +#[test] +fn test_aggregates_excludes_source_data_hash_from_allowlist() { + // OffChainAggregate has source_data_hash, but it's excluded because + // clients don't request by it; they request by period and snapshot_hash. + + let result = parse_fields("period,source_data_hash", "aggregates"); + + assert!(result.is_err(), "Expected error for excluded field source_data_hash"); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("source_data_hash")); +} + +#[test] +fn test_aggregates_allows_valid_fields_only() { + // Allowed fields for aggregates endpoint + let result = parse_fields("period,snapshot_hash", "aggregates"); + + assert!(result.is_ok(), "Expected success for valid fields"); + let fields = result.unwrap(); + assert_eq!(fields.len(), 2); +} + +#[test] +fn test_partial_invalid_field_list_rejected_entirely() { + // If even one field is invalid, the entire request is rejected. + // This is fail-fast behavior: no partial results. + + let result = parse_fields("epoch,submitter,snapshot_hash", "snapshots"); + + // Should fail on the second field (submitter) + assert!(result.is_err(), "Expected fail-fast on invalid field in list"); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("submitter")); +} +