spikard 0.3.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +21 -6
- data/ext/spikard_rb/Cargo.toml +2 -2
- data/lib/spikard/app.rb +33 -14
- data/lib/spikard/testing.rb +47 -12
- data/lib/spikard/version.rb +1 -1
- data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -0
- data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +132 -0
- data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +752 -0
- data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -0
- data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -0
- data/vendor/crates/spikard-bindings-shared/src/error_response.rs +401 -0
- data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +238 -0
- data/vendor/crates/spikard-bindings-shared/src/lib.rs +24 -0
- data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +292 -0
- data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +616 -0
- data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +305 -0
- data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -0
- data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +351 -0
- data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +454 -0
- data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +383 -0
- data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +280 -0
- data/vendor/crates/spikard-core/Cargo.toml +4 -4
- data/vendor/crates/spikard-core/src/debug.rs +64 -0
- data/vendor/crates/spikard-core/src/di/container.rs +3 -27
- data/vendor/crates/spikard-core/src/di/factory.rs +1 -5
- data/vendor/crates/spikard-core/src/di/graph.rs +8 -47
- data/vendor/crates/spikard-core/src/di/mod.rs +1 -1
- data/vendor/crates/spikard-core/src/di/resolved.rs +1 -7
- data/vendor/crates/spikard-core/src/di/value.rs +2 -4
- data/vendor/crates/spikard-core/src/errors.rs +30 -0
- data/vendor/crates/spikard-core/src/http.rs +262 -0
- data/vendor/crates/spikard-core/src/lib.rs +1 -1
- data/vendor/crates/spikard-core/src/lifecycle.rs +764 -0
- data/vendor/crates/spikard-core/src/metadata.rs +389 -0
- data/vendor/crates/spikard-core/src/parameters.rs +1962 -159
- data/vendor/crates/spikard-core/src/problem.rs +34 -0
- data/vendor/crates/spikard-core/src/request_data.rs +966 -1
- data/vendor/crates/spikard-core/src/router.rs +263 -2
- data/vendor/crates/spikard-core/src/validation/error_mapper.rs +688 -0
- data/vendor/crates/spikard-core/src/{validation.rs → validation/mod.rs} +26 -268
- data/vendor/crates/spikard-http/Cargo.toml +12 -16
- data/vendor/crates/spikard-http/examples/sse-notifications.rs +148 -0
- data/vendor/crates/spikard-http/examples/websocket-chat.rs +92 -0
- data/vendor/crates/spikard-http/src/auth.rs +65 -16
- data/vendor/crates/spikard-http/src/background.rs +1614 -3
- data/vendor/crates/spikard-http/src/cors.rs +515 -0
- data/vendor/crates/spikard-http/src/debug.rs +65 -0
- data/vendor/crates/spikard-http/src/di_handler.rs +1322 -77
- data/vendor/crates/spikard-http/src/handler_response.rs +711 -0
- data/vendor/crates/spikard-http/src/handler_trait.rs +607 -5
- data/vendor/crates/spikard-http/src/handler_trait_tests.rs +6 -0
- data/vendor/crates/spikard-http/src/lib.rs +33 -28
- data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +81 -0
- data/vendor/crates/spikard-http/src/lifecycle.rs +765 -0
- data/vendor/crates/spikard-http/src/middleware/mod.rs +372 -117
- data/vendor/crates/spikard-http/src/middleware/multipart.rs +836 -10
- data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +409 -43
- data/vendor/crates/spikard-http/src/middleware/validation.rs +513 -65
- data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +345 -0
- data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +1055 -0
- data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +473 -3
- data/vendor/crates/spikard-http/src/query_parser.rs +455 -31
- data/vendor/crates/spikard-http/src/response.rs +321 -0
- data/vendor/crates/spikard-http/src/server/handler.rs +1572 -9
- data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +136 -0
- data/vendor/crates/spikard-http/src/server/mod.rs +875 -178
- data/vendor/crates/spikard-http/src/server/request_extraction.rs +674 -23
- data/vendor/crates/spikard-http/src/server/routing_factory.rs +599 -0
- data/vendor/crates/spikard-http/src/sse.rs +983 -21
- data/vendor/crates/spikard-http/src/testing/form.rs +38 -0
- data/vendor/crates/spikard-http/src/testing/test_client.rs +0 -2
- data/vendor/crates/spikard-http/src/testing.rs +7 -7
- data/vendor/crates/spikard-http/src/websocket.rs +1055 -4
- data/vendor/crates/spikard-http/tests/background_behavior.rs +832 -0
- data/vendor/crates/spikard-http/tests/common/handlers.rs +309 -0
- data/vendor/crates/spikard-http/tests/common/mod.rs +26 -0
- data/vendor/crates/spikard-http/tests/di_integration.rs +192 -0
- data/vendor/crates/spikard-http/tests/doc_snippets.rs +5 -0
- data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1093 -0
- data/vendor/crates/spikard-http/tests/multipart_behavior.rs +656 -0
- data/vendor/crates/spikard-http/tests/server_config_builder.rs +314 -0
- data/vendor/crates/spikard-http/tests/sse_behavior.rs +620 -0
- data/vendor/crates/spikard-http/tests/websocket_behavior.rs +663 -0
- data/vendor/crates/spikard-rb/Cargo.toml +10 -4
- data/vendor/crates/spikard-rb/build.rs +196 -5
- data/vendor/crates/spikard-rb/src/config/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/{config.rs → config/server_config.rs} +100 -109
- data/vendor/crates/spikard-rb/src/conversion.rs +121 -20
- data/vendor/crates/spikard-rb/src/di/builder.rs +100 -0
- data/vendor/crates/spikard-rb/src/{di.rs → di/mod.rs} +12 -46
- data/vendor/crates/spikard-rb/src/handler.rs +100 -107
- data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -0
- data/vendor/crates/spikard-rb/src/lib.rs +467 -1428
- data/vendor/crates/spikard-rb/src/lifecycle.rs +1 -0
- data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +447 -0
- data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +324 -0
- data/vendor/crates/spikard-rb/src/server.rs +47 -22
- data/vendor/crates/spikard-rb/src/{test_client.rs → testing/client.rs} +187 -40
- data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -0
- data/vendor/crates/spikard-rb/src/testing/websocket.rs +635 -0
- data/vendor/crates/spikard-rb/src/websocket.rs +178 -37
- metadata +46 -13
- data/vendor/crates/spikard-http/src/parameters.rs +0 -1
- data/vendor/crates/spikard-http/src/problem.rs +0 -1
- data/vendor/crates/spikard-http/src/router.rs +0 -1
- data/vendor/crates/spikard-http/src/schema_registry.rs +0 -1
- data/vendor/crates/spikard-http/src/type_hints.rs +0 -1
- data/vendor/crates/spikard-http/src/validation.rs +0 -1
- data/vendor/crates/spikard-rb/src/test_websocket.rs +0 -221
- /data/vendor/crates/spikard-rb/src/{test_sse.rs → testing/sse.rs} +0 -0
|
@@ -487,4 +487,519 @@ mod tests {
|
|
|
487
487
|
let result = validate_cors_request(&headers, &config);
|
|
488
488
|
assert!(result.is_ok());
|
|
489
489
|
}
|
|
490
|
+
|
|
491
|
+
// SECURITY TESTS: CORS Attack Vectors
|
|
492
|
+
|
|
493
|
+
/// SECURITY TEST: Verify credentials=true with wildcard is caught
|
|
494
|
+
/// This is a critical vulnerability - RFC 6454 forbids this
|
|
495
|
+
#[test]
|
|
496
|
+
fn test_credentials_with_wildcard_config_is_security_issue() {
|
|
497
|
+
let config = CorsConfig {
|
|
498
|
+
allowed_origins: vec!["*".to_string()],
|
|
499
|
+
allowed_methods: vec!["GET".to_string()],
|
|
500
|
+
allowed_headers: vec![],
|
|
501
|
+
expose_headers: None,
|
|
502
|
+
max_age: None,
|
|
503
|
+
allow_credentials: Some(true), // SECURITY BUG: This should not be allowed with wildcard
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
let mut headers = HeaderMap::new();
|
|
507
|
+
headers.insert("origin", HeaderValue::from_static("https://evil.com"));
|
|
508
|
+
headers.insert("access-control-request-method", HeaderValue::from_static("GET"));
|
|
509
|
+
|
|
510
|
+
let result = handle_preflight(&headers, &config);
|
|
511
|
+
|
|
512
|
+
// BUG: This should return 500 or reject the config, but instead succeeds
|
|
513
|
+
if let Ok(response) = result {
|
|
514
|
+
let resp_headers = response.headers();
|
|
515
|
+
let has_credentials = resp_headers
|
|
516
|
+
.get("access-control-allow-credentials")
|
|
517
|
+
.map(|v| v == "true")
|
|
518
|
+
.unwrap_or(false);
|
|
519
|
+
let origin_header = resp_headers.get("access-control-allow-origin");
|
|
520
|
+
|
|
521
|
+
if has_credentials && origin_header.is_some() {
|
|
522
|
+
let origin_val = origin_header.unwrap().to_str().unwrap_or("");
|
|
523
|
+
if origin_val == "*" {
|
|
524
|
+
panic!("SECURITY VULNERABILITY: credentials=true with origin=* allowed");
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/// SECURITY TEST: Exact origin matching required
|
|
531
|
+
/// Subdomain like api.evil.example.com must NOT match example.com
|
|
532
|
+
#[test]
|
|
533
|
+
fn test_subdomain_bypass_blocked() {
|
|
534
|
+
let config = CorsConfig {
|
|
535
|
+
allowed_origins: vec!["https://example.com".to_string()],
|
|
536
|
+
allowed_methods: vec!["GET".to_string()],
|
|
537
|
+
allowed_headers: vec![],
|
|
538
|
+
expose_headers: None,
|
|
539
|
+
max_age: None,
|
|
540
|
+
allow_credentials: None,
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
assert!(!is_origin_allowed("https://api.example.com", &config.allowed_origins));
|
|
544
|
+
assert!(!is_origin_allowed("https://evil.example.com", &config.allowed_origins));
|
|
545
|
+
assert!(!is_origin_allowed(
|
|
546
|
+
"https://sub.sub.example.com",
|
|
547
|
+
&config.allowed_origins
|
|
548
|
+
));
|
|
549
|
+
|
|
550
|
+
assert!(is_origin_allowed("https://example.com", &config.allowed_origins));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/// SECURITY TEST: Port exact matching required
|
|
554
|
+
/// localhost:3001 must NOT match localhost:3000
|
|
555
|
+
#[test]
|
|
556
|
+
fn test_port_bypass_blocked() {
|
|
557
|
+
let config = CorsConfig {
|
|
558
|
+
allowed_origins: vec!["http://localhost:3000".to_string()],
|
|
559
|
+
allowed_methods: vec!["GET".to_string()],
|
|
560
|
+
allowed_headers: vec![],
|
|
561
|
+
expose_headers: None,
|
|
562
|
+
max_age: None,
|
|
563
|
+
allow_credentials: None,
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
assert!(!is_origin_allowed("http://localhost:3001", &config.allowed_origins));
|
|
567
|
+
assert!(!is_origin_allowed("http://localhost:8080", &config.allowed_origins));
|
|
568
|
+
assert!(!is_origin_allowed("http://localhost:443", &config.allowed_origins));
|
|
569
|
+
|
|
570
|
+
assert!(is_origin_allowed("http://localhost:3000", &config.allowed_origins));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/// SECURITY TEST: Protocol exact matching required
|
|
574
|
+
/// http://example.com must NOT match https://example.com
|
|
575
|
+
#[test]
|
|
576
|
+
fn test_protocol_downgrade_attack_blocked() {
|
|
577
|
+
let config = CorsConfig {
|
|
578
|
+
allowed_origins: vec!["https://example.com".to_string()],
|
|
579
|
+
allowed_methods: vec!["GET".to_string()],
|
|
580
|
+
allowed_headers: vec![],
|
|
581
|
+
expose_headers: None,
|
|
582
|
+
max_age: None,
|
|
583
|
+
allow_credentials: None,
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
assert!(!is_origin_allowed("http://example.com", &config.allowed_origins));
|
|
587
|
+
assert!(!is_origin_allowed("ws://example.com", &config.allowed_origins));
|
|
588
|
+
assert!(!is_origin_allowed("wss://example.com", &config.allowed_origins));
|
|
589
|
+
|
|
590
|
+
assert!(is_origin_allowed("https://example.com", &config.allowed_origins));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/// SECURITY TEST: Case sensitivity in origin matching
|
|
594
|
+
/// Origins should match exactly (including case)
|
|
595
|
+
#[test]
|
|
596
|
+
fn test_case_sensitive_origin_matching() {
|
|
597
|
+
let config = CorsConfig {
|
|
598
|
+
allowed_origins: vec!["https://Example.Com".to_string()],
|
|
599
|
+
allowed_methods: vec!["GET".to_string()],
|
|
600
|
+
allowed_headers: vec![],
|
|
601
|
+
expose_headers: None,
|
|
602
|
+
max_age: None,
|
|
603
|
+
allow_credentials: None,
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
assert!(!is_origin_allowed("https://example.com", &config.allowed_origins));
|
|
607
|
+
assert!(!is_origin_allowed("https://EXAMPLE.COM", &config.allowed_origins));
|
|
608
|
+
|
|
609
|
+
assert!(is_origin_allowed("https://Example.Com", &config.allowed_origins));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/// SECURITY TEST: Trailing slash normalization
|
|
613
|
+
/// https://example.com/ should be treated differently from https://example.com
|
|
614
|
+
#[test]
|
|
615
|
+
fn test_trailing_slash_origin_not_normalized() {
|
|
616
|
+
let config = CorsConfig {
|
|
617
|
+
allowed_origins: vec!["https://example.com".to_string()],
|
|
618
|
+
allowed_methods: vec!["GET".to_string()],
|
|
619
|
+
allowed_headers: vec![],
|
|
620
|
+
expose_headers: None,
|
|
621
|
+
max_age: None,
|
|
622
|
+
allow_credentials: None,
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
assert!(!is_origin_allowed("https://example.com/", &config.allowed_origins));
|
|
626
|
+
|
|
627
|
+
assert!(is_origin_allowed("https://example.com", &config.allowed_origins));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/// SECURITY TEST: NULL origin and wildcard behavior
|
|
631
|
+
/// Special "null" origin used by file:// and sandboxed iframes
|
|
632
|
+
/// The current implementation treats "null" as a regular origin string,
|
|
633
|
+
/// which means it IS allowed by wildcard (not ideal but documents current behavior)
|
|
634
|
+
#[test]
|
|
635
|
+
fn test_null_origin_with_wildcard() {
|
|
636
|
+
let config = CorsConfig {
|
|
637
|
+
allowed_origins: vec!["*".to_string()],
|
|
638
|
+
allowed_methods: vec!["GET".to_string()],
|
|
639
|
+
allowed_headers: vec![],
|
|
640
|
+
expose_headers: None,
|
|
641
|
+
max_age: None,
|
|
642
|
+
allow_credentials: None,
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
// SECURITY NOTE: "null" origin is allowed by wildcard in current implementation
|
|
646
|
+
assert!(is_origin_allowed("null", &config.allowed_origins));
|
|
647
|
+
|
|
648
|
+
let with_explicit_null = CorsConfig {
|
|
649
|
+
allowed_origins: vec!["null".to_string()],
|
|
650
|
+
allowed_methods: vec!["GET".to_string()],
|
|
651
|
+
allowed_headers: vec![],
|
|
652
|
+
expose_headers: None,
|
|
653
|
+
max_age: None,
|
|
654
|
+
allow_credentials: None,
|
|
655
|
+
};
|
|
656
|
+
assert!(is_origin_allowed("null", &with_explicit_null.allowed_origins));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/// SECURITY TEST: Empty origin is always rejected
|
|
660
|
+
#[test]
|
|
661
|
+
fn test_empty_origin_always_rejected() {
|
|
662
|
+
let config_with_wildcard = CorsConfig {
|
|
663
|
+
allowed_origins: vec!["*".to_string()],
|
|
664
|
+
allowed_methods: vec!["GET".to_string()],
|
|
665
|
+
allowed_headers: vec![],
|
|
666
|
+
expose_headers: None,
|
|
667
|
+
max_age: None,
|
|
668
|
+
allow_credentials: None,
|
|
669
|
+
};
|
|
670
|
+
assert!(!is_origin_allowed("", &config_with_wildcard.allowed_origins));
|
|
671
|
+
|
|
672
|
+
let config_with_explicit = CorsConfig {
|
|
673
|
+
allowed_origins: vec!["https://example.com".to_string()],
|
|
674
|
+
allowed_methods: vec!["GET".to_string()],
|
|
675
|
+
allowed_headers: vec![],
|
|
676
|
+
expose_headers: None,
|
|
677
|
+
max_age: None,
|
|
678
|
+
allow_credentials: None,
|
|
679
|
+
};
|
|
680
|
+
assert!(!is_origin_allowed("", &config_with_explicit.allowed_origins));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/// SECURITY TEST: Preflight with invalid origin should reject
|
|
684
|
+
#[test]
|
|
685
|
+
fn test_preflight_rejects_invalid_origin() {
|
|
686
|
+
let config = make_cors_config();
|
|
687
|
+
let mut headers = HeaderMap::new();
|
|
688
|
+
headers.insert("origin", HeaderValue::from_static("https://untrusted.com"));
|
|
689
|
+
headers.insert("access-control-request-method", HeaderValue::from_static("POST"));
|
|
690
|
+
|
|
691
|
+
let result = handle_preflight(&headers, &config);
|
|
692
|
+
assert!(result.is_err());
|
|
693
|
+
|
|
694
|
+
let response = *result.unwrap_err();
|
|
695
|
+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/// SECURITY TEST: Multiple origins - each must be exact match
|
|
699
|
+
#[test]
|
|
700
|
+
fn test_multiple_origins_exact_matching() {
|
|
701
|
+
let config = CorsConfig {
|
|
702
|
+
allowed_origins: vec!["https://trusted1.com".to_string(), "https://trusted2.com".to_string()],
|
|
703
|
+
allowed_methods: vec!["GET".to_string()],
|
|
704
|
+
allowed_headers: vec![],
|
|
705
|
+
expose_headers: None,
|
|
706
|
+
max_age: None,
|
|
707
|
+
allow_credentials: None,
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
assert!(is_origin_allowed("https://trusted1.com", &config.allowed_origins));
|
|
711
|
+
assert!(is_origin_allowed("https://trusted2.com", &config.allowed_origins));
|
|
712
|
+
|
|
713
|
+
assert!(!is_origin_allowed(
|
|
714
|
+
"https://trusted1.com.evil.com",
|
|
715
|
+
&config.allowed_origins
|
|
716
|
+
));
|
|
717
|
+
assert!(!is_origin_allowed("https://trusted3.com", &config.allowed_origins));
|
|
718
|
+
assert!(!is_origin_allowed("https://trusted.com", &config.allowed_origins));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/// SECURITY TEST: Wildcard origin should allow any origin (but check config)
|
|
722
|
+
#[test]
|
|
723
|
+
fn test_wildcard_allows_all_but_check_credentials() {
|
|
724
|
+
let config = CorsConfig {
|
|
725
|
+
allowed_origins: vec!["*".to_string()],
|
|
726
|
+
allowed_methods: vec!["GET".to_string()],
|
|
727
|
+
allowed_headers: vec![],
|
|
728
|
+
expose_headers: None,
|
|
729
|
+
max_age: None,
|
|
730
|
+
allow_credentials: None,
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
assert!(is_origin_allowed("https://example.com", &config.allowed_origins));
|
|
734
|
+
assert!(is_origin_allowed("https://evil.com", &config.allowed_origins));
|
|
735
|
+
assert!(is_origin_allowed("http://localhost:3000", &config.allowed_origins));
|
|
736
|
+
|
|
737
|
+
assert!(!is_origin_allowed("", &config.allowed_origins));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/// SECURITY TEST: Preflight response headers must match config exactly
|
|
741
|
+
#[test]
|
|
742
|
+
fn test_preflight_response_has_correct_allowed_origins() {
|
|
743
|
+
let config = CorsConfig {
|
|
744
|
+
allowed_origins: vec!["https://trusted.com".to_string()],
|
|
745
|
+
allowed_methods: vec!["GET".to_string(), "POST".to_string()],
|
|
746
|
+
allowed_headers: vec!["content-type".to_string()],
|
|
747
|
+
expose_headers: None,
|
|
748
|
+
max_age: Some(3600),
|
|
749
|
+
allow_credentials: Some(false),
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
let mut headers = HeaderMap::new();
|
|
753
|
+
headers.insert("origin", HeaderValue::from_static("https://trusted.com"));
|
|
754
|
+
headers.insert("access-control-request-method", HeaderValue::from_static("POST"));
|
|
755
|
+
headers.insert(
|
|
756
|
+
"access-control-request-headers",
|
|
757
|
+
HeaderValue::from_static("content-type"),
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
let result = handle_preflight(&headers, &config);
|
|
761
|
+
assert!(result.is_ok());
|
|
762
|
+
|
|
763
|
+
let response = result.unwrap();
|
|
764
|
+
let resp_headers = response.headers();
|
|
765
|
+
|
|
766
|
+
assert_eq!(
|
|
767
|
+
resp_headers.get("access-control-allow-origin").unwrap(),
|
|
768
|
+
"https://trusted.com"
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
assert!(
|
|
772
|
+
resp_headers
|
|
773
|
+
.get("access-control-allow-methods")
|
|
774
|
+
.unwrap()
|
|
775
|
+
.to_str()
|
|
776
|
+
.unwrap()
|
|
777
|
+
.contains("GET")
|
|
778
|
+
);
|
|
779
|
+
assert!(
|
|
780
|
+
resp_headers
|
|
781
|
+
.get("access-control-allow-methods")
|
|
782
|
+
.unwrap()
|
|
783
|
+
.to_str()
|
|
784
|
+
.unwrap()
|
|
785
|
+
.contains("POST")
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
assert!(resp_headers.get("access-control-allow-credentials").is_none());
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/// SECURITY TEST: Origin not in allowed list must be rejected in preflight
|
|
792
|
+
#[test]
|
|
793
|
+
fn test_preflight_all_origins_require_validation() {
|
|
794
|
+
let config = CorsConfig {
|
|
795
|
+
allowed_origins: vec!["https://trusted.com".to_string()],
|
|
796
|
+
allowed_methods: vec!["GET".to_string()],
|
|
797
|
+
allowed_headers: vec![],
|
|
798
|
+
expose_headers: None,
|
|
799
|
+
max_age: None,
|
|
800
|
+
allow_credentials: None,
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
let test_cases = vec![
|
|
804
|
+
"https://trusted.com",
|
|
805
|
+
"https://evil.com",
|
|
806
|
+
"https://trusted.com.evil",
|
|
807
|
+
"http://trusted.com",
|
|
808
|
+
"",
|
|
809
|
+
];
|
|
810
|
+
|
|
811
|
+
for origin in test_cases {
|
|
812
|
+
let mut headers = HeaderMap::new();
|
|
813
|
+
headers.insert(
|
|
814
|
+
"origin",
|
|
815
|
+
HeaderValue::from_str(origin).unwrap_or_else(|_| HeaderValue::from_static("")),
|
|
816
|
+
);
|
|
817
|
+
headers.insert("access-control-request-method", HeaderValue::from_static("GET"));
|
|
818
|
+
|
|
819
|
+
let result = handle_preflight(&headers, &config);
|
|
820
|
+
|
|
821
|
+
if origin == "https://trusted.com" {
|
|
822
|
+
assert!(result.is_ok(), "Valid origin {} should be allowed", origin);
|
|
823
|
+
} else {
|
|
824
|
+
assert!(result.is_err(), "Invalid origin {} should be rejected", origin);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/// SECURITY TEST: Requested headers must be in allowed list
|
|
830
|
+
#[test]
|
|
831
|
+
fn test_preflight_validates_all_requested_headers() {
|
|
832
|
+
let config = CorsConfig {
|
|
833
|
+
allowed_origins: vec!["https://trusted.com".to_string()],
|
|
834
|
+
allowed_methods: vec!["POST".to_string()],
|
|
835
|
+
allowed_headers: vec!["content-type".to_string(), "authorization".to_string()],
|
|
836
|
+
expose_headers: None,
|
|
837
|
+
max_age: None,
|
|
838
|
+
allow_credentials: None,
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
let test_cases = vec![
|
|
842
|
+
("content-type", true),
|
|
843
|
+
("authorization", true),
|
|
844
|
+
("content-type, authorization", true),
|
|
845
|
+
("x-custom-header", false),
|
|
846
|
+
("content-type, x-custom", false),
|
|
847
|
+
];
|
|
848
|
+
|
|
849
|
+
for (headers_str, should_pass) in test_cases {
|
|
850
|
+
let mut headers = HeaderMap::new();
|
|
851
|
+
headers.insert("origin", HeaderValue::from_static("https://trusted.com"));
|
|
852
|
+
headers.insert("access-control-request-method", HeaderValue::from_static("POST"));
|
|
853
|
+
headers.insert(
|
|
854
|
+
"access-control-request-headers",
|
|
855
|
+
HeaderValue::from_str(headers_str).unwrap(),
|
|
856
|
+
);
|
|
857
|
+
|
|
858
|
+
let result = handle_preflight(&headers, &config);
|
|
859
|
+
|
|
860
|
+
if should_pass {
|
|
861
|
+
assert!(
|
|
862
|
+
result.is_ok(),
|
|
863
|
+
"Preflight with valid headers '{}' should pass",
|
|
864
|
+
headers_str
|
|
865
|
+
);
|
|
866
|
+
} else {
|
|
867
|
+
assert!(
|
|
868
|
+
result.is_err(),
|
|
869
|
+
"Preflight with invalid headers '{}' should fail",
|
|
870
|
+
headers_str
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/// SECURITY TEST: add_cors_headers should respect origin validation
|
|
877
|
+
#[test]
|
|
878
|
+
fn test_add_cors_headers_respects_origin() {
|
|
879
|
+
let config = CorsConfig {
|
|
880
|
+
allowed_origins: vec!["https://trusted.com".to_string()],
|
|
881
|
+
allowed_methods: vec!["GET".to_string()],
|
|
882
|
+
allowed_headers: vec![],
|
|
883
|
+
expose_headers: Some(vec!["x-custom".to_string()]),
|
|
884
|
+
max_age: None,
|
|
885
|
+
allow_credentials: Some(true),
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
let mut response = Response::new(Body::empty());
|
|
889
|
+
|
|
890
|
+
add_cors_headers(&mut response, "https://trusted.com", &config);
|
|
891
|
+
|
|
892
|
+
let headers = response.headers();
|
|
893
|
+
assert_eq!(
|
|
894
|
+
headers.get("access-control-allow-origin").unwrap(),
|
|
895
|
+
"https://trusted.com"
|
|
896
|
+
);
|
|
897
|
+
assert_eq!(headers.get("access-control-expose-headers").unwrap(), "x-custom");
|
|
898
|
+
assert_eq!(headers.get("access-control-allow-credentials").unwrap(), "true");
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/// SECURITY TEST: validate_cors_request respects allowed origins
|
|
902
|
+
#[test]
|
|
903
|
+
fn test_validate_cors_request_origin_must_match() {
|
|
904
|
+
let config = CorsConfig {
|
|
905
|
+
allowed_origins: vec!["https://trusted.com".to_string()],
|
|
906
|
+
allowed_methods: vec!["GET".to_string()],
|
|
907
|
+
allowed_headers: vec![],
|
|
908
|
+
expose_headers: None,
|
|
909
|
+
max_age: None,
|
|
910
|
+
allow_credentials: None,
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
let mut headers = HeaderMap::new();
|
|
914
|
+
headers.insert("origin", HeaderValue::from_static("https://trusted.com"));
|
|
915
|
+
assert!(validate_cors_request(&headers, &config).is_ok());
|
|
916
|
+
|
|
917
|
+
let mut headers = HeaderMap::new();
|
|
918
|
+
headers.insert("origin", HeaderValue::from_static("https://evil.com"));
|
|
919
|
+
assert!(validate_cors_request(&headers, &config).is_err());
|
|
920
|
+
|
|
921
|
+
let headers = HeaderMap::new();
|
|
922
|
+
assert!(validate_cors_request(&headers, &config).is_ok());
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/// SECURITY TEST: Preflight without requested method should fail
|
|
926
|
+
#[test]
|
|
927
|
+
fn test_preflight_requires_access_control_request_method() {
|
|
928
|
+
let config = make_cors_config();
|
|
929
|
+
let mut headers = HeaderMap::new();
|
|
930
|
+
headers.insert("origin", HeaderValue::from_static("https://example.com"));
|
|
931
|
+
|
|
932
|
+
let result = handle_preflight(&headers, &config);
|
|
933
|
+
assert!(result.is_ok());
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/// SECURITY TEST: Case-insensitive method matching
|
|
937
|
+
#[test]
|
|
938
|
+
fn test_preflight_method_case_insensitive() {
|
|
939
|
+
let config = CorsConfig {
|
|
940
|
+
allowed_origins: vec!["https://example.com".to_string()],
|
|
941
|
+
allowed_methods: vec!["GET".to_string(), "POST".to_string()],
|
|
942
|
+
allowed_headers: vec![],
|
|
943
|
+
expose_headers: None,
|
|
944
|
+
max_age: None,
|
|
945
|
+
allow_credentials: None,
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
let test_cases = vec!["GET", "get", "Get", "POST", "post"];
|
|
949
|
+
|
|
950
|
+
for method in test_cases {
|
|
951
|
+
let mut headers = HeaderMap::new();
|
|
952
|
+
headers.insert("origin", HeaderValue::from_static("https://example.com"));
|
|
953
|
+
headers.insert("access-control-request-method", HeaderValue::from_str(method).unwrap());
|
|
954
|
+
|
|
955
|
+
let result = handle_preflight(&headers, &config);
|
|
956
|
+
assert!(
|
|
957
|
+
result.is_ok(),
|
|
958
|
+
"Method '{}' should be allowed (case-insensitive)",
|
|
959
|
+
method
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/// SECURITY TEST: Ensure preflight max-age is set correctly
|
|
965
|
+
#[test]
|
|
966
|
+
fn test_preflight_max_age_header() {
|
|
967
|
+
let config = CorsConfig {
|
|
968
|
+
allowed_origins: vec!["https://example.com".to_string()],
|
|
969
|
+
allowed_methods: vec!["GET".to_string()],
|
|
970
|
+
allowed_headers: vec![],
|
|
971
|
+
expose_headers: None,
|
|
972
|
+
max_age: Some(7200),
|
|
973
|
+
allow_credentials: None,
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
let mut headers = HeaderMap::new();
|
|
977
|
+
headers.insert("origin", HeaderValue::from_static("https://example.com"));
|
|
978
|
+
headers.insert("access-control-request-method", HeaderValue::from_static("GET"));
|
|
979
|
+
|
|
980
|
+
let result = handle_preflight(&headers, &config);
|
|
981
|
+
assert!(result.is_ok());
|
|
982
|
+
|
|
983
|
+
let response = result.unwrap();
|
|
984
|
+
assert_eq!(response.headers().get("access-control-max-age").unwrap(), "7200");
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/// SECURITY TEST: Wildcard partial patterns should not work
|
|
988
|
+
/// *.example.com style patterns are not supported (good!)
|
|
989
|
+
#[test]
|
|
990
|
+
fn test_wildcard_patterns_not_supported() {
|
|
991
|
+
let config = CorsConfig {
|
|
992
|
+
allowed_origins: vec!["*.example.com".to_string()],
|
|
993
|
+
allowed_methods: vec!["GET".to_string()],
|
|
994
|
+
allowed_headers: vec![],
|
|
995
|
+
expose_headers: None,
|
|
996
|
+
max_age: None,
|
|
997
|
+
allow_credentials: None,
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
assert!(!is_origin_allowed("https://api.example.com", &config.allowed_origins));
|
|
1001
|
+
assert!(!is_origin_allowed("https://example.com", &config.allowed_origins));
|
|
1002
|
+
|
|
1003
|
+
assert!(is_origin_allowed("*.example.com", &config.allowed_origins));
|
|
1004
|
+
}
|
|
490
1005
|
}
|
|
@@ -61,3 +61,68 @@ macro_rules! debug_log_value {
|
|
|
61
61
|
}
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
#[cfg(test)]
|
|
66
|
+
mod tests {
|
|
67
|
+
use super::*;
|
|
68
|
+
use std::sync::Mutex;
|
|
69
|
+
use std::sync::atomic::Ordering;
|
|
70
|
+
|
|
71
|
+
static FLAG_LOCK: Mutex<()> = Mutex::new(());
|
|
72
|
+
|
|
73
|
+
struct DebugFlagGuard {
|
|
74
|
+
previous_flag: bool,
|
|
75
|
+
previous_env: Option<String>,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
impl Drop for DebugFlagGuard {
|
|
79
|
+
fn drop(&mut self) {
|
|
80
|
+
DEBUG_ENABLED.store(self.previous_flag, Ordering::Relaxed);
|
|
81
|
+
if let Some(prev) = &self.previous_env {
|
|
82
|
+
unsafe { std::env::set_var("SPIKARD_DEBUG", prev) };
|
|
83
|
+
} else {
|
|
84
|
+
unsafe { std::env::remove_var("SPIKARD_DEBUG") };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#[test]
|
|
90
|
+
fn init_enables_debug_when_requested() {
|
|
91
|
+
let _lock = FLAG_LOCK.lock().unwrap();
|
|
92
|
+
let previous = DEBUG_ENABLED.load(Ordering::Relaxed);
|
|
93
|
+
let previous_env = std::env::var("SPIKARD_DEBUG").ok();
|
|
94
|
+
let _guard = DebugFlagGuard {
|
|
95
|
+
previous_flag: previous,
|
|
96
|
+
previous_env,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
unsafe { std::env::set_var("SPIKARD_DEBUG", "1") };
|
|
100
|
+
|
|
101
|
+
init();
|
|
102
|
+
|
|
103
|
+
assert!(is_enabled(), "init should enable debug in test builds");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#[test]
|
|
107
|
+
fn macros_respect_debug_flag() {
|
|
108
|
+
let _lock = FLAG_LOCK.lock().unwrap();
|
|
109
|
+
let previous = DEBUG_ENABLED.load(Ordering::Relaxed);
|
|
110
|
+
let previous_env = std::env::var("SPIKARD_DEBUG").ok();
|
|
111
|
+
let _guard = DebugFlagGuard {
|
|
112
|
+
previous_flag: previous,
|
|
113
|
+
previous_env,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
DEBUG_ENABLED.store(false, Ordering::Relaxed);
|
|
117
|
+
debug_log!("should not print while disabled");
|
|
118
|
+
debug_log_module!("middleware", "disabled branch");
|
|
119
|
+
debug_log_value!("key", 1_u8);
|
|
120
|
+
assert!(!is_enabled());
|
|
121
|
+
|
|
122
|
+
DEBUG_ENABLED.store(true, Ordering::Relaxed);
|
|
123
|
+
debug_log!("now printing {}", 2);
|
|
124
|
+
debug_log_module!("router", "enabled branch");
|
|
125
|
+
debug_log_value!("value", 3_i32);
|
|
126
|
+
assert!(is_enabled());
|
|
127
|
+
}
|
|
128
|
+
}
|