From 3806cffcac27e7b068a6e4ab04faa3a978fa099d Mon Sep 17 00:00:00 2001 From: Jeremy Lempereur Date: Mon, 11 Sep 2023 14:13:22 +0200 Subject: [PATCH] Subgraph authentication: Make sure Request signing happens after Compression and APQ (#3735) Fix #3608 The router now adds SigningParams to the private context, which the subgraph service can use to sign http calls before the HTTP fetch is made (for websocket connection and regular http calls) --- .changesets/fix_igni_sigv4_http_level.md | 12 + .../src/plugins/authentication/mod.rs | 2 +- .../src/plugins/authentication/subgraph.rs | 233 ++++++++++++------ .../src/services/subgraph_service.rs | 60 ++++- docs/source/configuration/authn-subgraph.mdx | 6 + 5 files changed, 231 insertions(+), 82 deletions(-) create mode 100644 .changesets/fix_igni_sigv4_http_level.md diff --git a/.changesets/fix_igni_sigv4_http_level.md b/.changesets/fix_igni_sigv4_http_level.md new file mode 100644 index 0000000000..5679499772 --- /dev/null +++ b/.changesets/fix_igni_sigv4_http_level.md @@ -0,0 +1,12 @@ +### Subgraph authentication: Make sure Request signing happens after Compression and APQ ([Issue #3608](https://github.com/apollographql/router/issues/3608)) + +[Subgraph authentication](https://www.apollographql.com/docs/router/configuration/authn-subgraph) is available since router v1.27.0. + +Unfortunately this first version didn't work well with features that operate with the SubgraphService, for example: + - Subgraph APQ + - Subgraph HTTP compression + - Custom plugins that operate on the Subgraph level, written either via coprocessors, in rhai, or native. + +The router will now sign subgraph requests just before they are sent to subgraphs. + +By [@o0Ignition0o](https://github.com/o0Ignition0o) in https://github.com/apollographql/router/pull/3735 diff --git a/apollo-router/src/plugins/authentication/mod.rs b/apollo-router/src/plugins/authentication/mod.rs index b90c814b7a..2c8d81fefc 100644 --- a/apollo-router/src/plugins/authentication/mod.rs +++ b/apollo-router/src/plugins/authentication/mod.rs @@ -44,7 +44,7 @@ use crate::services::router; use crate::Context; mod jwks; -mod subgraph; +pub(crate) mod subgraph; #[cfg(test)] mod tests; diff --git a/apollo-router/src/plugins/authentication/subgraph.rs b/apollo-router/src/plugins/authentication/subgraph.rs index 7839d1628d..0ad2abb7f5 100644 --- a/apollo-router/src/plugins/authentication/subgraph.rs +++ b/apollo-router/src/plugins/authentication/subgraph.rs @@ -1,4 +1,3 @@ -use core::ops::ControlFlow; use std::collections::HashMap; use std::sync::Arc; use std::time::SystemTime; @@ -11,14 +10,16 @@ use aws_sigv4::http_request::PayloadChecksumKind; use aws_sigv4::http_request::SignableBody; use aws_sigv4::http_request::SignableRequest; use aws_sigv4::http_request::SigningSettings; +use aws_sigv4::signing_params; use aws_types::region::Region; +use http::Request; +use hyper::Body; use schemars::JsonSchema; use serde::Deserialize; use tower::BoxError; use tower::ServiceBuilder; use tower::ServiceExt; -use crate::layers::ServiceBuilderExt; use crate::services::SubgraphRequest; /// Hardcoded Config using access_key and secret. @@ -193,6 +194,114 @@ pub(crate) struct SigningParamsConfig { credentials_provider: Arc, region: Region, service_name: String, + subgraph_name: String, +} + +impl SigningParamsConfig { + pub(crate) async fn sign( + self, + mut req: Request, + subgraph_name: &str, + ) -> Result, BoxError> { + let credentials = self.credentials().await?; + let builder = self.signing_params_builder(&credentials).await?; + let (parts, body) = req.into_parts(); + // Depending on the servicve, AWS refuses sigv4 payloads that contain specific headers. + // We'll go with default signed headers + let headers = Default::default(); + // UnsignedPayload only applies to lattice + let body_bytes = hyper::body::to_bytes(body).await?.to_vec(); + let signable_request = SignableRequest::new( + &parts.method, + &parts.uri, + &headers, + match self.service_name.as_str() { + "vpc-lattice-svcs" => SignableBody::UnsignedPayload, + _ => SignableBody::Bytes(body_bytes.as_slice()), + }, + ); + + let signing_params = builder.build().expect("all required fields set"); + + let (signing_instructions, _signature) = sign(signable_request, &signing_params) + .map_err(|err| { + increment_failure_counter(subgraph_name); + let error = format!("failed to sign GraphQL body for AWS SigV4: {}", err); + tracing::error!("{}", error); + error + })? + .into_parts(); + req = Request::::from_parts(parts, body_bytes.into()); + signing_instructions.apply_to_request(&mut req); + increment_success_counter(subgraph_name); + Ok(req) + } + // This function is the same as above, except it's a new one because () doesn't implement HttpBody` + pub(crate) async fn sign_empty( + self, + mut req: Request<()>, + subgraph_name: &str, + ) -> Result, BoxError> { + let credentials = self.credentials().await?; + let builder = self.signing_params_builder(&credentials).await?; + let (parts, _) = req.into_parts(); + // Depending on the servicve, AWS refuses sigv4 payloads that contain specific headers. + // We'll go with default signed headers + let headers = Default::default(); + // UnsignedPayload only applies to lattice + let signable_request = SignableRequest::new( + &parts.method, + &parts.uri, + &headers, + match self.service_name.as_str() { + "vpc-lattice-svcs" => SignableBody::UnsignedPayload, + _ => SignableBody::Bytes(&[]), + }, + ); + + let signing_params = builder.build().expect("all required fields set"); + + let (signing_instructions, _signature) = sign(signable_request, &signing_params) + .map_err(|err| { + increment_failure_counter(subgraph_name); + let error = format!("failed to sign GraphQL body for AWS SigV4: {}", err); + tracing::error!("{}", error); + error + })? + .into_parts(); + req = Request::<()>::from_parts(parts, ()); + signing_instructions.apply_to_request(&mut req); + increment_success_counter(subgraph_name); + Ok(req) + } + + async fn signing_params_builder<'s>( + &'s self, + credentials: &'s Credentials, + ) -> Result, BoxError> { + let settings = get_signing_settings(self); + let mut builder = http_request::SigningParams::builder() + .access_key(credentials.access_key_id()) + .secret_key(credentials.secret_access_key()) + .region(self.region.as_ref()) + .service_name(&self.service_name) + .time(SystemTime::now()) + .settings(settings); + builder.set_security_token(credentials.session_token()); + Ok(builder) + } + + async fn credentials(&self) -> Result { + self.credentials_provider + .provide_credentials() + .await + .map_err(|err| { + increment_failure_counter(self.subgraph_name.as_str()); + let error = format!("failed to get credentials for AWS SigV4 signing: {}", err); + tracing::error!("{}", error); + error.into() + }) + } } fn increment_success_counter(subgraph_name: &str) { @@ -234,6 +343,7 @@ pub(super) async fn make_signing_params( region: config.region(), service_name: config.service_name(), credentials_provider, + subgraph_name: subgraph_name.to_string(), }) } } @@ -244,7 +354,7 @@ pub(super) async fn make_signing_params( fn get_signing_settings(signing_params: &SigningParamsConfig) -> SigningSettings { let mut settings = SigningSettings::default(); settings.payload_checksum_kind = match signing_params.service_name.as_str() { - "s3" | "vpc-lattice-svcs" => PayloadChecksumKind::XAmzSha256, + "appsync" | "s3" | "vpc-lattice-svcs" => PayloadChecksumKind::XAmzSha256, _ => PayloadChecksumKind::NoHeader, }; settings @@ -261,77 +371,12 @@ impl SubgraphAuth { service: crate::services::subgraph::BoxService, ) -> crate::services::subgraph::BoxService { if let Some(signing_params) = self.params_for_service(name) { - let name = name.to_string(); ServiceBuilder::new() - .checkpoint_async(move |mut req: SubgraphRequest| { + .map_request(move |req: SubgraphRequest| { let signing_params = signing_params.clone(); - let name = name.clone(); - async move { - let credentials = signing_params - .credentials_provider - .provide_credentials() - .await - .map_err(|err| { - increment_failure_counter(name.as_str()); - let error = format!( - "failed to get credentials for AWS SigV4 signing: {}", - err - ); - tracing::error!("{}", error); - error - })?; - - let settings = get_signing_settings(&signing_params); - let mut builder = http_request::SigningParams::builder() - .access_key(credentials.access_key_id()) - .secret_key(credentials.secret_access_key()) - .region(signing_params.region.as_ref()) - .service_name(&signing_params.service_name) - .time(SystemTime::now()) - .settings(settings); - builder.set_security_token(credentials.session_token()); - let body_bytes = - serde_json::to_vec(&req.subgraph_request.body()).map_err(|err| { - increment_failure_counter(name.as_str()); - let error = format!( - "failed to serialize GraphQL body for AWS SigV4 signing: {}", - err - ); - tracing::error!("{}", error); - error - })?; - - // UnsignedPayload only applies to lattice - let signable_request = SignableRequest::new( - req.subgraph_request.method(), - req.subgraph_request.uri(), - req.subgraph_request.headers(), - match signing_params.service_name.as_str() { - "vpc-lattice-svcs" => SignableBody::UnsignedPayload, - _ => SignableBody::Bytes(&body_bytes), - }, - ); - - let signing_params = builder.build().expect("all required fields set"); - - let (signing_instructions, _signature) = - sign(signable_request, &signing_params) - .map_err(|err| { - increment_failure_counter(name.as_str()); - let error = format!( - "failed to sign GraphQL body for AWS SigV4: {}", - err - ); - tracing::error!("{}", error); - error - })? - .into_parts(); - signing_instructions.apply_to_request(&mut req.subgraph_request); - increment_success_counter(name.as_str()); - Ok(ControlFlow::Continue(req)) - } + req.context.private_entries.lock().insert(signing_params); + req }) - .buffered() .service(service) .boxed() } else { @@ -396,6 +441,10 @@ mod test { .await .payload_checksum_kind ); + assert_eq!( + PayloadChecksumKind::XAmzSha256, + test_signing_settings("appsync").await.payload_checksum_kind + ); assert_eq!( PayloadChecksumKind::NoHeader, test_signing_settings("something-else") @@ -464,10 +513,10 @@ mod test { mock.expect_call() .times(1) .withf(|request| { + let http_request = get_signed_request(request, "products".to_string()); assert_eq!( "UNSIGNED-PAYLOAD", - request - .subgraph_request + http_request .headers() .get("x-amz-content-sha256") .unwrap() @@ -509,21 +558,22 @@ mod test { mock.expect_call() .times(1) .withf(|request| { - let authorization_regex = Regex::new(r"AWS4-HMAC-SHA256 Credential=id/\d{8}/us-east-1/s3/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-content-sha256;x-amz-date, Signature=[a-f0-9]{64}").unwrap(); - let authorization_header_str = request.subgraph_request.headers().get("authorization").unwrap().to_str().unwrap(); + let http_request = get_signed_request(request, "products".to_string()); + let authorization_regex = Regex::new(r"AWS4-HMAC-SHA256 Credential=id/\d{8}/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=[a-f0-9]{64}").unwrap(); + let authorization_header_str = http_request.headers().get("authorization").unwrap().to_str().unwrap(); assert_eq!(match authorization_regex.find(authorization_header_str) { Some(m) => m.as_str(), None => "no match" }, authorization_header_str); let x_amz_date_regex = Regex::new(r"\d{8}T\d{6}Z").unwrap(); - let x_amz_date_header_str = request.subgraph_request.headers().get("x-amz-date").unwrap().to_str().unwrap(); + let x_amz_date_header_str = http_request.headers().get("x-amz-date").unwrap().to_str().unwrap(); assert_eq!(match x_amz_date_regex.find(x_amz_date_header_str) { Some(m) => m.as_str(), None => "no match" }, x_amz_date_header_str); - assert_eq!(request.subgraph_request.headers().get("x-amz-content-sha256").unwrap(), "255959b4c6e11c1080f61ce0d75eb1b565c1772173335a7828ba9c13c25c0d8c"); + assert_eq!(http_request.headers().get("x-amz-content-sha256").unwrap(), "255959b4c6e11c1080f61ce0d75eb1b565c1772173335a7828ba9c13c25c0d8c"); true }) @@ -579,6 +629,7 @@ mod test { .header(HOST, "rhost") .header(CONTENT_LENGTH, "22") .header(CONTENT_TYPE, "graphql") + .uri("https://test-endpoint.com") .body(Request::builder().query("query").build()) .expect("expecting valid request"), ) @@ -586,4 +637,32 @@ mod test { .context(Context::new()) .build() } + + fn get_signed_request( + request: &SubgraphRequest, + service_name: String, + ) -> hyper::Request { + let signing_params = { + let ctx = request.context.private_entries.lock(); + let sp = ctx.get::(); + sp.cloned().unwrap() + }; + + let http_request = request + .clone() + .subgraph_request + .map(|body| hyper::Body::from(serde_json::to_string(&body).unwrap())); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + signing_params + .sign(http_request, service_name.as_str()) + .await + .unwrap() + }) + }) + .join() + .unwrap() + } } diff --git a/apollo-router/src/services/subgraph_service.rs b/apollo-router/src/services/subgraph_service.rs index c99e482786..8ba73b4068 100644 --- a/apollo-router/src/services/subgraph_service.rs +++ b/apollo-router/src/services/subgraph_service.rs @@ -60,6 +60,7 @@ use super::Plugins; use crate::error::FetchError; use crate::graphql; use crate::json_ext::Object; +use crate::plugins::authentication::subgraph::SigningParamsConfig; use crate::plugins::subscription::create_verifier; use crate::plugins::subscription::CallbackMode; use crate::plugins::subscription::SubscriptionConfig; @@ -488,11 +489,28 @@ async fn call_websocket( }; let request = get_websocket_request(service_name.clone(), parts, subgraph_cfg)?; + let display_headers = context.contains_key(LOGGING_DISPLAY_HEADERS); let display_body = context.contains_key(LOGGING_DISPLAY_BODY); + + let signing_params = context + .private_entries + .lock() + .get::() + .cloned(); + + let request = if let Some(signing_params) = signing_params { + signing_params + .sign_empty(request, service_name.as_str()) + .await? + } else { + request + }; + if display_headers { tracing::info!(http.request.headers = ?request.headers(), apollo.subgraph.name = %service_name, "Websocket request headers to subgraph {service_name:?}"); } + if display_body { tracing::info!(http.request.body = ?request.body(), apollo.subgraph.name = %service_name, "Websocket request body to subgraph {service_name:?}"); } @@ -530,14 +548,24 @@ async fn call_websocket( } _ => connect_async(request).instrument(subgraph_req_span).await, } - .map_err(|err| FetchError::SubrequestWsError { - service: service_name.clone(), - reason: format!("cannot connect websocket to subgraph: {err}"), + .map_err(|err| { + if display_body || display_headers { + tracing::info!( + http.response.error = format!("{:?}", &err), apollo.subgraph.name = %service_name, "Websocket connection error from subgraph {service_name:?} received" + ); + } + FetchError::SubrequestWsError { + service: service_name.clone(), + reason: format!("cannot connect websocket to subgraph: {err}"), + } })?; + if display_headers { + tracing::info!(response.headers = ?resp.headers(), apollo.subgraph.name = %service_name, "Websocket response headers to subgraph {service_name:?}"); + } if display_body { tracing::info!( - response.body = %String::from_utf8_lossy(&resp.body_mut().take().unwrap_or_default()), apollo.subgraph.name = %service_name, "Raw response body from subgraph {service_name:?} received" + response.body = %String::from_utf8_lossy(&resp.body_mut().take().unwrap_or_default()), apollo.subgraph.name = %service_name, "Websocket response body from subgraph {service_name:?} received" ); } @@ -674,6 +702,18 @@ async fn call_http( let display_headers = context.contains_key(LOGGING_DISPLAY_HEADERS); let display_body = context.contains_key(LOGGING_DISPLAY_BODY); + let signing_params = context + .private_entries + .lock() + .get::() + .cloned(); + + let request = if let Some(signing_params) = signing_params { + signing_params.sign(request, service_name).await? + } else { + request + }; + // Print out the debug for the request if display_headers { tracing::info!(http.request.headers = ?request.headers(), apollo.subgraph.name = %service_name, "Request headers to subgraph {service_name:?}"); @@ -694,6 +734,18 @@ async fn call_http( .instrument(subgraph_req_span) .await?; + // Print out the debug for the response + if display_headers { + tracing::info!(response.headers = ?parts.headers, apollo.subgraph.name = %service_name, "Response headers from subgraph {service_name:?}"); + } + if display_body { + if let Some(Ok(b)) = &body { + tracing::info!( + response.body = %String::from_utf8_lossy(b), apollo.subgraph.name = %service_name, "Raw response body from subgraph {service_name:?} received" + ); + } + } + let mut graphql_response = match (content_type, body, parts.status.is_success()) { (Ok(ContentType::ApplicationGraphqlResponseJson), Some(Ok(body)), _) | (Ok(ContentType::ApplicationJson), Some(Ok(body)), true) => { diff --git a/docs/source/configuration/authn-subgraph.mdx b/docs/source/configuration/authn-subgraph.mdx index 63a1f98c85..771af8ac1e 100644 --- a/docs/source/configuration/authn-subgraph.mdx +++ b/docs/source/configuration/authn-subgraph.mdx @@ -7,6 +7,12 @@ The Apollo Router supports subgraph request authentication and key rotation via This allows you to secure communication to AWS subgraphs by making sure a subgraph request was made by the Apollo Router, and the payload hasn't been tampered with. +We have tested the feature against the following services: + - AWS Lambda URL + - AWS Appsync + - AWS Amazon API Gateway + - VPC Lattice ⚠️ VPC Lattice doesn't support websockets, you won't be able to use Subscriptions in passthrough mode. + **To use this feature:** To use this feature, your AWS hosted subgraphs must be configured with IAM to accept [signed requests](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-signing.html).