1use async_trait::async_trait;
31use ring::{digest, hmac};
32use std::collections::HashMap;
33use std::sync::Arc;
34use std::time::{Duration, SystemTime, UNIX_EPOCH};
35
36use crate::context::{Context, GatewayError};
37use crate::outbound::{OutboundClient, OutboundError, OutboundRequest, OutboundResponse};
38use crate::plugins::resources::PluginResources;
39use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
40
41#[derive(Clone)]
43struct IamAuth {
44 access_key: String,
45 secret_key: String,
46 region: String,
47 service: String,
48 session_token: Option<String>,
49}
50
51#[derive(Clone)]
53enum Authorization {
54 None,
56 ApiKey(String),
58 Iam(IamAuth),
60}
61
62pub struct AwsLambdaPlugin {
64 function_uri: String,
66 authorization: Authorization,
67 ssl_verify: bool,
68 timeout: Duration,
69 client: Arc<OutboundClient>,
70}
71
72impl AwsLambdaPlugin {
73 pub fn from_config(
105 config: &HashMap<String, serde_json::Value>,
106 resources: &Arc<PluginResources>,
107 ) -> Result<Self, String> {
108 let function_uri = config
109 .get("function_uri")
110 .and_then(|v| v.as_str())
111 .filter(|s| !s.is_empty())
112 .ok_or_else(|| "aws-lambda plugin requires 'function_uri'".to_string())?
113 .to_string();
114
115 let authorization = match config.get("authorization").and_then(|v| v.as_object()) {
116 None => Authorization::None,
117 Some(authz) => {
118 if let Some(iam) = authz.get("iam").and_then(|v| v.as_object()) {
119 let access_key = iam
120 .get("accesskey")
121 .and_then(|v| v.as_str())
122 .filter(|s| !s.is_empty())
123 .ok_or_else(|| {
124 "aws-lambda plugin: authorization.iam requires 'accesskey'".to_string()
125 })?
126 .to_string();
127 let secret_key = iam
128 .get("secretkey")
129 .and_then(|v| v.as_str())
130 .filter(|s| !s.is_empty())
131 .ok_or_else(|| {
132 "aws-lambda plugin: authorization.iam requires 'secretkey'".to_string()
133 })?
134 .to_string();
135 let region = iam
136 .get("aws_region")
137 .and_then(|v| v.as_str())
138 .filter(|s| !s.is_empty())
139 .unwrap_or("us-east-1")
140 .to_string();
141 let service = iam
142 .get("aws_service")
143 .and_then(|v| v.as_str())
144 .filter(|s| !s.is_empty())
145 .unwrap_or("lambda")
146 .to_string();
147 let session_token = iam
148 .get("session_token")
149 .and_then(|v| v.as_str())
150 .filter(|s| !s.is_empty())
151 .map(String::from);
152 Authorization::Iam(IamAuth {
153 access_key,
154 secret_key,
155 region,
156 service,
157 session_token,
158 })
159 } else if let Some(apikey) = authz.get("apikey").and_then(|v| v.as_str()) {
160 Authorization::ApiKey(apikey.to_string())
161 } else {
162 Authorization::None
163 }
164 }
165 };
166
167 let ssl_verify = config
168 .get("ssl_verify")
169 .and_then(|v| v.as_bool())
170 .unwrap_or(false);
171
172 let timeout = Duration::from_millis(
173 config
174 .get("timeout")
175 .and_then(|v| v.as_u64())
176 .unwrap_or(3000),
177 );
178
179 Ok(Self {
180 function_uri,
181 authorization,
182 ssl_verify,
183 timeout,
184 client: resources.outbound.clone(),
185 })
186 }
187
188 fn build_request(&self, ctx: &Context, amz_time: u64) -> Result<OutboundRequest, String> {
193 let parsed: http::Uri = self
194 .function_uri
195 .parse()
196 .map_err(|e| format!("aws-lambda: invalid function_uri: {}", e))?;
197 let host = parsed
198 .authority()
199 .map(|a| a.as_str().to_string())
200 .ok_or_else(|| "aws-lambda: function_uri has no host".to_string())?;
201 let canonical_uri = normalize_path(parsed.path());
202 let canonical_query = canonical_query_string(&ctx.request.query_params);
203
204 let mut headers: Vec<(String, String)> = Vec::new();
207 for (name, values) in &ctx.request.headers {
208 let lname = name.to_ascii_lowercase();
209 if matches!(
210 lname.as_str(),
211 "host" | "connection" | "content-length" | "x-amz-date" | "authorization"
212 ) {
213 continue;
214 }
215 for value in values {
216 headers.push((lname.clone(), value.clone()));
217 }
218 }
219 headers.push(("host".to_string(), host.clone()));
220
221 match &self.authorization {
222 Authorization::None => {}
223 Authorization::ApiKey(key) => {
224 if !ctx
225 .request
226 .headers
227 .keys()
228 .any(|k| k.eq_ignore_ascii_case("x-api-key"))
229 {
230 headers.push(("x-api-key".to_string(), key.clone()));
231 }
232 }
233 Authorization::Iam(iam) => {
234 let amz_date = format_amz_date(amz_time);
235 let datestamp = amz_date[..8].to_string();
236 headers.push(("x-amz-date".to_string(), amz_date.clone()));
237 if let Some(token) = &iam.session_token {
238 headers.push(("x-amz-security-token".to_string(), token.clone()));
239 }
240 let mut signed: Vec<(String, String)> = vec![
242 ("host".to_string(), host.clone()),
243 ("x-amz-date".to_string(), amz_date.clone()),
244 ];
245 if let Some(token) = &iam.session_token {
246 signed.push(("x-amz-security-token".to_string(), token.clone()));
247 }
248 let sig = sign_v4(&SigV4Input {
249 method: &ctx.request.method,
250 canonical_uri: &canonical_uri,
251 canonical_query: &canonical_query,
252 headers: &signed,
253 payload: &ctx.request.body,
254 access_key: &iam.access_key,
255 secret_key: &iam.secret_key,
256 region: &iam.region,
257 service: &iam.service,
258 amz_date: &amz_date,
259 datestamp: &datestamp,
260 });
261 headers.push(("authorization".to_string(), sig.authorization));
262 }
263 }
264
265 let scheme = parsed.scheme_str().unwrap_or("https");
266 let url = if canonical_query.is_empty() {
267 format!("{}://{}{}", scheme, host, canonical_uri)
268 } else {
269 format!("{}://{}{}?{}", scheme, host, canonical_uri, canonical_query)
270 };
271
272 let method: http::Method = ctx.request.method.parse().unwrap_or(http::Method::POST);
273
274 Ok(OutboundRequest {
275 method,
276 url,
277 headers,
278 body: ctx.request.body.clone(),
279 timeout: self.timeout,
280 ssl_verify: self.ssl_verify,
281 tls: None,
282 })
283 }
284}
285
286fn apply_response(ctx: &mut Context, response: OutboundResponse) {
288 ctx.response.status_code = response.status;
289 ctx.response.headers = response.headers;
290 ctx.response.body = response.body;
291}
292
293#[async_trait]
294impl Plugin for AwsLambdaPlugin {
295 fn plugin_type(&self) -> &str {
296 "aws-lambda"
297 }
298
299 async fn execute(
300 &self,
301 mut ctx: Context,
302 _named_inputs: &HashMap<String, serde_json::Value>,
303 ) -> PluginResult {
304 let now = SystemTime::now()
305 .duration_since(UNIX_EPOCH)
306 .map(|d| d.as_secs())
307 .unwrap_or(0);
308 let request = match self.build_request(&ctx, now) {
309 Ok(req) => req,
310 Err(message) => {
311 return Err(reject(ctx, 502, message));
312 }
313 };
314
315 match self.client.request(request).await {
316 Ok(response) => {
317 apply_response(&mut ctx, response);
318 Ok(PluginOutput {
319 context: ctx,
320 named_outputs: HashMap::new(),
321 })
322 }
323 Err(e) => {
324 let (status, message) = match &e {
325 OutboundError::Timeout(d) => {
326 (504, format!("aws-lambda callout timed out after {:?}", d))
327 }
328 OutboundError::InvalidRequest(m) => {
329 (502, format!("aws-lambda request build error: {}", m))
330 }
331 OutboundError::Transport(m) => {
332 (503, format!("aws-lambda callout failed: {}", m))
333 }
334 };
335 Err(reject(ctx, status, message))
336 }
337 }
338 }
339}
340
341fn reject(mut ctx: Context, status: u16, message: String) -> PluginExecutionError {
343 ctx.response.status_code = status;
344 PluginExecutionError {
345 context: ctx,
346 error: GatewayError {
347 node_id: String::new(),
348 code: "AWS_LAMBDA_CALLOUT_ERROR".to_string(),
349 message,
350 metadata: HashMap::new(),
351 },
352 }
353}
354
355struct SigV4Input<'a> {
363 method: &'a str,
364 canonical_uri: &'a str,
365 canonical_query: &'a str,
366 headers: &'a [(String, String)],
367 payload: &'a [u8],
368 access_key: &'a str,
369 secret_key: &'a str,
370 region: &'a str,
371 service: &'a str,
372 amz_date: &'a str,
373 datestamp: &'a str,
374}
375
376struct SigV4Output {
378 authorization: String,
380 #[allow(dead_code)]
382 signed_headers: String,
383 #[allow(dead_code)]
385 signature: String,
386 #[allow(dead_code)]
388 canonical_request: String,
389 #[allow(dead_code)]
391 string_to_sign: String,
392}
393
394const ALGO: &str = "AWS4-HMAC-SHA256";
395
396fn sign_v4(input: &SigV4Input) -> SigV4Output {
403 let mut headers: Vec<(String, String)> = input
405 .headers
406 .iter()
407 .map(|(k, v)| (k.to_ascii_lowercase(), v.trim().to_string()))
408 .collect();
409 headers.sort_by(|a, b| a.0.cmp(&b.0));
410
411 let canonical_headers: String = headers
412 .iter()
413 .map(|(k, v)| format!("{}:{}\n", k, v))
414 .collect();
415 let signed_headers = headers
416 .iter()
417 .map(|(k, _)| k.as_str())
418 .collect::<Vec<_>>()
419 .join(";");
420
421 let payload_hash = hex(sha256(input.payload).as_ref());
422
423 let canonical_request = format!(
424 "{}\n{}\n{}\n{}\n{}\n{}",
425 input.method.to_uppercase(),
426 input.canonical_uri,
427 input.canonical_query,
428 canonical_headers,
429 signed_headers,
430 payload_hash
431 );
432
433 let credential_scope = format!(
434 "{}/{}/{}/aws4_request",
435 input.datestamp, input.region, input.service
436 );
437 let string_to_sign = format!(
438 "{}\n{}\n{}\n{}",
439 ALGO,
440 input.amz_date,
441 credential_scope,
442 hex(sha256(canonical_request.as_bytes()).as_ref())
443 );
444
445 let signing_key = derive_signing_key(
446 input.secret_key,
447 input.datestamp,
448 input.region,
449 input.service,
450 );
451 let signature = hex(hmac_sha256(&signing_key, string_to_sign.as_bytes()).as_ref());
452
453 let authorization = format!(
454 "{} Credential={}/{}, SignedHeaders={}, Signature={}",
455 ALGO, input.access_key, credential_scope, signed_headers, signature
456 );
457
458 SigV4Output {
459 authorization,
460 signed_headers,
461 signature,
462 canonical_request,
463 string_to_sign,
464 }
465}
466
467fn derive_signing_key(secret: &str, datestamp: &str, region: &str, service: &str) -> Vec<u8> {
469 let k_secret = format!("AWS4{}", secret);
470 let k_date = hmac_sha256(k_secret.as_bytes(), datestamp.as_bytes());
471 let k_region = hmac_sha256(k_date.as_ref(), region.as_bytes());
472 let k_service = hmac_sha256(k_region.as_ref(), service.as_bytes());
473 let k_signing = hmac_sha256(k_service.as_ref(), b"aws4_request");
474 k_signing.as_ref().to_vec()
475}
476
477fn hmac_sha256(key: &[u8], msg: &[u8]) -> hmac::Tag {
479 let key = hmac::Key::new(hmac::HMAC_SHA256, key);
480 hmac::sign(&key, msg)
481}
482
483fn sha256(data: &[u8]) -> digest::Digest {
485 digest::digest(&digest::SHA256, data)
486}
487
488fn hex(bytes: &[u8]) -> String {
490 let mut s = String::with_capacity(bytes.len() * 2);
491 for b in bytes {
492 s.push_str(&format!("{:02x}", b));
493 }
494 s
495}
496
497fn uri_encode(s: &str) -> String {
500 let mut out = String::with_capacity(s.len());
501 for b in s.bytes() {
502 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
503 out.push(b as char);
504 } else {
505 out.push_str(&format!("%{:02X}", b));
506 }
507 }
508 out
509}
510
511fn normalize_path(path: &str) -> String {
514 if path.is_empty() {
515 return "/".to_string();
516 }
517 if path != "/" && path.ends_with('/') {
518 return path.trim_end_matches('/').to_string();
519 }
520 path.to_string()
521}
522
523fn canonical_query_string(query: &HashMap<String, Vec<String>>) -> String {
526 let mut pairs: Vec<(String, String)> = Vec::new();
527 for (name, values) in query {
528 let name = uri_encode(name);
529 for value in values {
530 pairs.push((name.clone(), uri_encode(value)));
531 }
532 }
533 pairs.sort();
534 pairs
535 .iter()
536 .map(|(k, v)| format!("{}={}", k, v))
537 .collect::<Vec<_>>()
538 .join("&")
539}
540
541fn format_amz_date(secs: u64) -> String {
543 let days = (secs / 86400) as i64;
544 let rem = secs % 86400;
545 let (hour, minute, second) = (rem / 3600, (rem % 3600) / 60, rem % 60);
546 let (year, month, day) = civil_from_days(days);
547 format!(
548 "{:04}{:02}{:02}T{:02}{:02}{:02}Z",
549 year, month, day, hour, minute, second
550 )
551}
552
553fn civil_from_days(days: i64) -> (i64, u32, u32) {
556 let z = days + 719468;
557 let era = if z >= 0 { z } else { z - 146096 } / 146097;
558 let doe = z - era * 146097;
559 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
560 let y = yoe + era * 400;
561 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
562 let mp = (5 * doy + 2) / 153;
563 let d = doy - (153 * mp + 2) / 5 + 1;
564 let m = if mp < 10 { mp + 3 } else { mp - 9 };
565 let year = if m <= 2 { y + 1 } else { y };
566 (year, m as u32, d as u32)
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
573 use bytes::Bytes;
574
575 fn ctx_with(method: &str, path: &str) -> Context {
576 Context {
577 request: GatewayRequest {
578 method: method.to_string(),
579 path: path.to_string(),
580 host: "gw".to_string(),
581 scheme: "http".to_string(),
582 headers: HashMap::new(),
583 query_params: HashMap::new(),
584 body: Bytes::new(),
585 remote_addr: "1.2.3.4:5".to_string(),
586 protocol: Protocol::Http1,
587 },
588 response: GatewayResponse {
589 status_code: 0,
590 headers: HashMap::new(),
591 body: Bytes::new(),
592 },
593 message: HashMap::new(),
594 errors: Vec::new(),
595 }
596 }
597
598 fn plugin(config: serde_json::Value) -> AwsLambdaPlugin {
599 let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
600 AwsLambdaPlugin::from_config(&map, &PluginResources::empty()).unwrap()
601 }
602
603 #[test]
604 fn test_requires_function_uri() {
605 assert!(AwsLambdaPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
606 }
607
608 #[test]
609 fn test_iam_requires_keys() {
610 let map: HashMap<String, serde_json::Value> = serde_json::from_value(serde_json::json!({
611 "function_uri": "https://x.on.aws/",
612 "authorization": { "iam": { "accesskey": "AK" } }
613 }))
614 .unwrap();
615 assert!(AwsLambdaPlugin::from_config(&map, &PluginResources::empty()).is_err());
616 }
617
618 #[test]
619 fn test_iam_defaults() {
620 let p = plugin(serde_json::json!({
621 "function_uri": "https://x.on.aws/",
622 "authorization": { "iam": { "accesskey": "AK", "secretkey": "SK" } }
623 }));
624 match p.authorization {
625 Authorization::Iam(ref iam) => {
626 assert_eq!(iam.region, "us-east-1");
627 assert_eq!(iam.service, "lambda");
628 }
629 _ => panic!("expected IAM auth"),
630 }
631 assert!(!p.ssl_verify);
633 }
634
635 #[test]
638 fn test_sigv4_get_vanilla_vector() {
639 let headers = vec![
640 ("host".to_string(), "example.amazonaws.com".to_string()),
641 ("x-amz-date".to_string(), "20150830T123600Z".to_string()),
642 ];
643 let out = sign_v4(&SigV4Input {
644 method: "GET",
645 canonical_uri: "/",
646 canonical_query: "",
647 headers: &headers,
648 payload: b"",
649 access_key: "AKIDEXAMPLE",
650 secret_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
651 region: "us-east-1",
652 service: "service",
653 amz_date: "20150830T123600Z",
654 datestamp: "20150830",
655 });
656 assert_eq!(
657 out.signature,
658 "5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
659 );
660 assert_eq!(out.signed_headers, "host;x-amz-date");
661 assert_eq!(
662 out.authorization,
663 "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \
664 SignedHeaders=host;x-amz-date, \
665 Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
666 );
667 assert!(out.string_to_sign.starts_with(
670 "AWS4-HMAC-SHA256\n20150830T123600Z\n20150830/us-east-1/service/aws4_request\n"
671 ));
672 assert!(out.canonical_request.starts_with("GET\n/\n\n"));
674 assert!(out
675 .canonical_request
676 .ends_with("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"));
677 }
678
679 #[test]
680 fn test_sigv4_is_deterministic() {
681 let headers = vec![("host".to_string(), "h".to_string())];
682 let mk = || {
683 sign_v4(&SigV4Input {
684 method: "POST",
685 canonical_uri: "/f",
686 canonical_query: "a=1&b=2",
687 headers: &headers,
688 payload: b"body",
689 access_key: "AK",
690 secret_key: "SK",
691 region: "us-east-1",
692 service: "lambda",
693 amz_date: "20240101T000000Z",
694 datestamp: "20240101",
695 })
696 .signature
697 };
698 assert_eq!(mk(), mk());
699 }
700
701 #[test]
702 fn test_canonical_query_sorted_and_encoded() {
703 let mut q = HashMap::new();
704 q.insert("b".to_string(), vec!["2".to_string()]);
705 q.insert("a".to_string(), vec!["hello world".to_string()]);
706 assert_eq!(canonical_query_string(&q), "a=hello%20world&b=2");
707 }
708
709 #[test]
710 fn test_format_amz_date_vector() {
711 assert_eq!(format_amz_date(1440938160), "20150830T123600Z");
713 }
714
715 #[test]
716 fn test_apikey_sets_header() {
717 let p = plugin(serde_json::json!({
718 "function_uri": "https://x.on.aws/fn",
719 "authorization": { "apikey": "secret-key" }
720 }));
721 let req = p
722 .build_request(&ctx_with("POST", "/ignored"), 1440938160)
723 .unwrap();
724 let hdr = req
725 .headers
726 .iter()
727 .find(|(k, _)| k == "x-api-key")
728 .map(|(_, v)| v.as_str());
729 assert_eq!(hdr, Some("secret-key"));
730 assert!(req.url.starts_with("https://x.on.aws/fn"));
731 }
732
733 #[test]
734 fn test_iam_build_request_adds_signature_headers() {
735 let p = plugin(serde_json::json!({
736 "function_uri": "https://example.amazonaws.com/",
737 "authorization": {
738 "iam": { "accesskey": "AK", "secretkey": "SK", "session_token": "TOK" }
739 }
740 }));
741 let req = p.build_request(&ctx_with("GET", "/"), 1440938160).unwrap();
742 let has = |name: &str| req.headers.iter().any(|(k, _)| k == name);
743 assert!(has("x-amz-date"));
744 assert!(has("x-amz-security-token"));
745 let authz = req
746 .headers
747 .iter()
748 .find(|(k, _)| k == "authorization")
749 .map(|(_, v)| v.clone())
750 .unwrap();
751 assert!(authz.starts_with("AWS4-HMAC-SHA256 Credential=AK/"));
752 assert!(authz.contains("x-amz-security-token"));
754 }
755
756 #[tokio::test]
757 async fn test_callout_failure_routes_error() {
758 let p = plugin(serde_json::json!({
760 "function_uri": "http://127.0.0.1:1/fn",
761 "timeout": 200
762 }));
763 let err = p
764 .execute(ctx_with("POST", "/"), &HashMap::new())
765 .await
766 .unwrap_err();
767 assert_eq!(err.error.code, "AWS_LAMBDA_CALLOUT_ERROR");
768 assert!(err.context.response.status_code >= 502);
769 }
770
771 #[test]
772 fn test_apply_response_maps_fields() {
773 let mut ctx = ctx_with("GET", "/");
774 let mut headers = HashMap::new();
775 headers.insert(
776 "content-type".to_string(),
777 vec!["application/json".to_string()],
778 );
779 apply_response(
780 &mut ctx,
781 OutboundResponse {
782 status: 201,
783 headers,
784 body: Bytes::from_static(b"{\"ok\":true}"),
785 },
786 );
787 assert_eq!(ctx.response.status_code, 201);
788 assert_eq!(ctx.response.body, Bytes::from_static(b"{\"ok\":true}"));
789 assert_eq!(
790 ctx.response.headers.get("content-type"),
791 Some(&vec!["application/json".to_string()])
792 );
793 }
794}