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).