diff --git a/CHANGELOG.md b/CHANGELOG.md index 37453699..a38c537c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- **Security:** instrumented Rust clients (`--instrument`) no longer record request bodies and headers as tracing span fields. The generated `#[tracing::instrument]` attribute now uses `skip_all`, so credentials in request payloads (passwords, tokens) can no longer reach logs in cleartext via their `Debug` output. Spans are still named after the endpoint path. Regenerate clients to pick this up. + ## 0.17.6 - New `#[reflectapi(hidden)]` field attribute: the field stays in the schema (marked `"hidden": true`) and remains functional at runtime (e.g. header extraction by the axum adapter), but is excluded from generated clients, documentation, and OpenAPI specs. Not allowed on unnamed (tuple) fields, since removing a positional element would shift indices and break wire compatibility. diff --git a/reflectapi-demo/clients/rust/generated/src/generated.rs b/reflectapi-demo/clients/rust/generated/src/generated.rs index cbb53d4b..4da7697e 100644 --- a/reflectapi-demo/clients/rust/generated/src/generated.rs +++ b/reflectapi-demo/clients/rust/generated/src/generated.rs @@ -55,7 +55,7 @@ pub mod interface { Self { client } } /// Check the health of the service - #[tracing::instrument(name = "/health.check", skip(self, headers))] + #[tracing::instrument(name = "/health.check", skip_all)] pub async fn check( &self, input: reflectapi::Empty, @@ -78,7 +78,7 @@ pub mod interface { Self { client } } /// List available pets - #[tracing::instrument(name = "/pets.list", skip(self, headers))] + #[tracing::instrument(name = "/pets.list", skip_all)] pub async fn list( &self, input: super::types::myapi::proto::PetsListRequest, @@ -90,7 +90,7 @@ pub mod interface { reflectapi::rt::__request_impl(&self.client, "/pets.list", input, headers).await } /// Create a new pet - #[tracing::instrument(name = "/pets.create", skip(self, headers))] + #[tracing::instrument(name = "/pets.create", skip_all)] pub async fn create( &self, input: super::types::myapi::proto::PetsCreateRequest, @@ -102,7 +102,7 @@ pub mod interface { reflectapi::rt::__request_impl(&self.client, "/pets.create", input, headers).await } /// Update an existing pet - #[tracing::instrument(name = "/pets.update", skip(self, headers))] + #[tracing::instrument(name = "/pets.update", skip_all)] pub async fn update( &self, input: super::types::myapi::proto::PetsUpdateRequest, @@ -114,7 +114,7 @@ pub mod interface { reflectapi::rt::__request_impl(&self.client, "/pets.update", input, headers).await } /// Remove an existing pet - #[tracing::instrument(name = "/pets.remove", skip(self, headers))] + #[tracing::instrument(name = "/pets.remove", skip_all)] pub async fn remove( &self, input: super::types::myapi::proto::PetsRemoveRequest, @@ -127,7 +127,7 @@ pub mod interface { } #[deprecated(note = "Use pets.remove instead")] /// Remove an existing pet - #[tracing::instrument(name = "/pets.delete", skip(self, headers))] + #[tracing::instrument(name = "/pets.delete", skip_all)] pub async fn delete( &self, input: super::types::myapi::proto::PetsRemoveRequest, @@ -139,7 +139,7 @@ pub mod interface { reflectapi::rt::__request_impl(&self.client, "/pets.delete", input, headers).await } /// Fetch first pet, if any exists - #[tracing::instrument(name = "/pets.get-first", skip(self, headers))] + #[tracing::instrument(name = "/pets.get-first", skip_all)] pub async fn get_first( &self, input: reflectapi::Empty, @@ -151,7 +151,7 @@ pub mod interface { reflectapi::rt::__request_impl(&self.client, "/pets.get-first", input, headers).await } /// Stream of change data capture events for pets - #[tracing::instrument(name = "/pets.cdc-events", skip(self, headers))] + #[tracing::instrument(name = "/pets.cdc-events", skip_all)] pub async fn cdc_events( &self, input: reflectapi::Empty, diff --git a/reflectapi-demo/src/tests/mod.rs b/reflectapi-demo/src/tests/mod.rs index b54cd10b..b8e14fef 100644 --- a/reflectapi-demo/src/tests/mod.rs +++ b/reflectapi-demo/src/tests/mod.rs @@ -160,6 +160,38 @@ fn write_rust_client() { .unwrap(); } +#[test] +fn instrumented_rust_client_does_not_record_request_bodies() { + // Request bodies can carry credentials (passwords, tokens); recording + // them as span fields would print them to logs in cleartext via their + // `Debug` impl. Every generated `#[tracing::instrument]` attribute must + // therefore skip all function arguments. + let (schema, _) = crate::builder().build().unwrap(); + let src = reflectapi::codegen::rust::generate( + schema, + reflectapi::codegen::rust::Config::default() + .format(true) + .instrument(true), + ) + .unwrap(); + + let instrument_attributes: Vec<&str> = src + .lines() + .filter(|line| line.contains("tracing::instrument")) + .collect(); + assert!( + !instrument_attributes.is_empty(), + "expected instrumented codegen to emit #[tracing::instrument] attributes" + ); + for attribute in instrument_attributes { + assert!( + attribute.contains("skip_all"), + "instrument attribute records function arguments (request body \ + may contain credentials): {attribute}" + ); + } +} + #[test] fn write_typescript_client() { let (schema, _) = crate::builder().build().unwrap(); diff --git a/reflectapi/src/codegen/rust.rs b/reflectapi/src/codegen/rust.rs index 64d62536..6b81c96f 100644 --- a/reflectapi/src/codegen/rust.rs +++ b/reflectapi/src/codegen/rust.rs @@ -1143,8 +1143,11 @@ fn __interface_types_from_function_group( .next_back() .unwrap_or_default() .replace('-', "_"); + // skip_all: request bodies must not be recorded into span fields — + // `input` may carry credentials (passwords, tokens) whose `Debug` + // output would land in logs in cleartext. let attributes = if config.instrument { - format!(r#"#[tracing::instrument(name = "{path}", skip(self, headers))]"#) + format!(r#"#[tracing::instrument(name = "{path}", skip_all)]"#) } else { String::new() };