spikard 0.12.0 → 0.15.2

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.
Files changed (206) hide show
  1. checksums.yaml +4 -4
  2. data/Steepfile +6 -0
  3. data/ext/spikard_rb/extconf.rb +1 -2
  4. data/ext/spikard_rb/{Cargo.lock → native/Cargo.lock} +897 -451
  5. data/ext/spikard_rb/native/Cargo.toml +24 -0
  6. data/ext/spikard_rb/src/lib.rs +5366 -3
  7. data/lib/spikard/native.rb +86 -0
  8. data/lib/spikard/version.rb +6 -1
  9. data/lib/spikard.rb +8 -45
  10. data/lib/spikard_rb.so +0 -0
  11. data/sig/types.rbs +427 -0
  12. metadata +14 -242
  13. data/LICENSE +0 -1
  14. data/README.md +0 -267
  15. data/ext/spikard_rb/Cargo.toml +0 -17
  16. data/lib/spikard/app.rb +0 -428
  17. data/lib/spikard/background.rb +0 -58
  18. data/lib/spikard/config.rb +0 -506
  19. data/lib/spikard/converters.rb +0 -13
  20. data/lib/spikard/grpc.rb +0 -182
  21. data/lib/spikard/handler_wrapper.rb +0 -113
  22. data/lib/spikard/provide.rb +0 -214
  23. data/lib/spikard/response.rb +0 -173
  24. data/lib/spikard/schema.rb +0 -243
  25. data/lib/spikard/sse.rb +0 -111
  26. data/lib/spikard/streaming_response.rb +0 -44
  27. data/lib/spikard/testing.rb +0 -432
  28. data/lib/spikard/upload_file.rb +0 -131
  29. data/lib/spikard/websocket.rb +0 -59
  30. data/sig/spikard.rbs +0 -719
  31. data/vendor/crates/spikard-bindings-shared/Cargo.toml +0 -80
  32. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +0 -132
  33. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +0 -905
  34. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +0 -210
  35. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +0 -252
  36. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +0 -404
  37. data/vendor/crates/spikard-bindings-shared/src/grpc_metadata.rs +0 -199
  38. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +0 -252
  39. data/vendor/crates/spikard-bindings-shared/src/json_conversion.rs +0 -829
  40. data/vendor/crates/spikard-bindings-shared/src/lazy_cache.rs +0 -587
  41. data/vendor/crates/spikard-bindings-shared/src/lib.rs +0 -33
  42. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +0 -298
  43. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +0 -594
  44. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +0 -743
  45. data/vendor/crates/spikard-bindings-shared/src/response_interpreter.rs +0 -944
  46. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +0 -260
  47. data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +0 -369
  48. data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +0 -192
  49. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +0 -383
  50. data/vendor/crates/spikard-bindings-shared/tests/full_coverage.rs +0 -459
  51. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +0 -280
  52. data/vendor/crates/spikard-bindings-shared/tests/integration_tests.rs +0 -669
  53. data/vendor/crates/spikard-core/Cargo.toml +0 -60
  54. data/vendor/crates/spikard-core/src/bindings/mod.rs +0 -3
  55. data/vendor/crates/spikard-core/src/bindings/response.rs +0 -130
  56. data/vendor/crates/spikard-core/src/debug.rs +0 -127
  57. data/vendor/crates/spikard-core/src/di/container.rs +0 -702
  58. data/vendor/crates/spikard-core/src/di/dependency.rs +0 -273
  59. data/vendor/crates/spikard-core/src/di/error.rs +0 -118
  60. data/vendor/crates/spikard-core/src/di/factory.rs +0 -538
  61. data/vendor/crates/spikard-core/src/di/graph.rs +0 -507
  62. data/vendor/crates/spikard-core/src/di/mod.rs +0 -192
  63. data/vendor/crates/spikard-core/src/di/resolved.rs +0 -428
  64. data/vendor/crates/spikard-core/src/di/value.rs +0 -282
  65. data/vendor/crates/spikard-core/src/errors.rs +0 -72
  66. data/vendor/crates/spikard-core/src/http.rs +0 -492
  67. data/vendor/crates/spikard-core/src/lib.rs +0 -29
  68. data/vendor/crates/spikard-core/src/lifecycle.rs +0 -1273
  69. data/vendor/crates/spikard-core/src/metadata.rs +0 -378
  70. data/vendor/crates/spikard-core/src/parameters.rs +0 -2546
  71. data/vendor/crates/spikard-core/src/problem.rs +0 -358
  72. data/vendor/crates/spikard-core/src/request_data.rs +0 -1146
  73. data/vendor/crates/spikard-core/src/router.rs +0 -530
  74. data/vendor/crates/spikard-core/src/schema_registry.rs +0 -197
  75. data/vendor/crates/spikard-core/src/type_hints.rs +0 -311
  76. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +0 -710
  77. data/vendor/crates/spikard-core/src/validation/mod.rs +0 -470
  78. data/vendor/crates/spikard-core/tests/bindings_response_tests.rs +0 -136
  79. data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +0 -37
  80. data/vendor/crates/spikard-core/tests/error_mapper.rs +0 -761
  81. data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +0 -106
  82. data/vendor/crates/spikard-core/tests/parameters_full.rs +0 -701
  83. data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +0 -301
  84. data/vendor/crates/spikard-core/tests/request_data_roundtrip.rs +0 -67
  85. data/vendor/crates/spikard-core/tests/validation_coverage.rs +0 -250
  86. data/vendor/crates/spikard-core/tests/validation_error_paths.rs +0 -45
  87. data/vendor/crates/spikard-http/Cargo.toml +0 -87
  88. data/vendor/crates/spikard-http/examples/sse-notifications.rs +0 -148
  89. data/vendor/crates/spikard-http/examples/websocket-chat.rs +0 -92
  90. data/vendor/crates/spikard-http/src/auth.rs +0 -301
  91. data/vendor/crates/spikard-http/src/background.rs +0 -1860
  92. data/vendor/crates/spikard-http/src/bindings/mod.rs +0 -3
  93. data/vendor/crates/spikard-http/src/bindings/response.rs +0 -1
  94. data/vendor/crates/spikard-http/src/body_metadata.rs +0 -8
  95. data/vendor/crates/spikard-http/src/cors.rs +0 -1026
  96. data/vendor/crates/spikard-http/src/debug.rs +0 -128
  97. data/vendor/crates/spikard-http/src/di_handler.rs +0 -1672
  98. data/vendor/crates/spikard-http/src/grpc/framing.rs +0 -469
  99. data/vendor/crates/spikard-http/src/grpc/handler.rs +0 -1122
  100. data/vendor/crates/spikard-http/src/grpc/mod.rs +0 -434
  101. data/vendor/crates/spikard-http/src/grpc/service.rs +0 -622
  102. data/vendor/crates/spikard-http/src/grpc/streaming.rs +0 -319
  103. data/vendor/crates/spikard-http/src/handler_response.rs +0 -901
  104. data/vendor/crates/spikard-http/src/handler_trait.rs +0 -1015
  105. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +0 -290
  106. data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +0 -502
  107. data/vendor/crates/spikard-http/src/jsonrpc/method_registry.rs +0 -648
  108. data/vendor/crates/spikard-http/src/jsonrpc/mod.rs +0 -58
  109. data/vendor/crates/spikard-http/src/jsonrpc/protocol.rs +0 -1207
  110. data/vendor/crates/spikard-http/src/jsonrpc/router.rs +0 -2262
  111. data/vendor/crates/spikard-http/src/lib.rs +0 -548
  112. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +0 -230
  113. data/vendor/crates/spikard-http/src/lifecycle.rs +0 -1193
  114. data/vendor/crates/spikard-http/src/middleware/mod.rs +0 -560
  115. data/vendor/crates/spikard-http/src/middleware/multipart.rs +0 -912
  116. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +0 -513
  117. data/vendor/crates/spikard-http/src/middleware/validation.rs +0 -768
  118. data/vendor/crates/spikard-http/src/openapi/mod.rs +0 -309
  119. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +0 -535
  120. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +0 -1363
  121. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +0 -667
  122. data/vendor/crates/spikard-http/src/query_parser.rs +0 -793
  123. data/vendor/crates/spikard-http/src/response.rs +0 -720
  124. data/vendor/crates/spikard-http/src/server/fast_router.rs +0 -186
  125. data/vendor/crates/spikard-http/src/server/grpc_routing.rs +0 -858
  126. data/vendor/crates/spikard-http/src/server/handler.rs +0 -1661
  127. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +0 -253
  128. data/vendor/crates/spikard-http/src/server/mod.rs +0 -1649
  129. data/vendor/crates/spikard-http/src/server/request_extraction.rs +0 -871
  130. data/vendor/crates/spikard-http/src/server/routing_factory.rs +0 -618
  131. data/vendor/crates/spikard-http/src/sse.rs +0 -1409
  132. data/vendor/crates/spikard-http/src/testing/form.rs +0 -52
  133. data/vendor/crates/spikard-http/src/testing/multipart.rs +0 -64
  134. data/vendor/crates/spikard-http/src/testing/test_client.rs +0 -787
  135. data/vendor/crates/spikard-http/src/testing.rs +0 -617
  136. data/vendor/crates/spikard-http/src/websocket.rs +0 -1477
  137. data/vendor/crates/spikard-http/tests/auth_integration.rs +0 -645
  138. data/vendor/crates/spikard-http/tests/background_behavior.rs +0 -832
  139. data/vendor/crates/spikard-http/tests/common/grpc_helpers.rs +0 -1012
  140. data/vendor/crates/spikard-http/tests/common/handlers.rs +0 -309
  141. data/vendor/crates/spikard-http/tests/common/mod.rs +0 -33
  142. data/vendor/crates/spikard-http/tests/common/test_builders.rs +0 -628
  143. data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +0 -162
  144. data/vendor/crates/spikard-http/tests/di_integration.rs +0 -192
  145. data/vendor/crates/spikard-http/tests/doc_snippets.rs +0 -5
  146. data/vendor/crates/spikard-http/tests/grpc_bidirectional_streaming.rs +0 -430
  147. data/vendor/crates/spikard-http/tests/grpc_client_streaming.rs +0 -738
  148. data/vendor/crates/spikard-http/tests/grpc_error_handling_test.rs +0 -652
  149. data/vendor/crates/spikard-http/tests/grpc_integration_test.rs +0 -334
  150. data/vendor/crates/spikard-http/tests/grpc_metadata_test.rs +0 -532
  151. data/vendor/crates/spikard-http/tests/grpc_server_integration.rs +0 -495
  152. data/vendor/crates/spikard-http/tests/grpc_server_streaming.rs +0 -974
  153. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +0 -1093
  154. data/vendor/crates/spikard-http/tests/middleware_stack_integration.rs +0 -389
  155. data/vendor/crates/spikard-http/tests/multipart_behavior.rs +0 -656
  156. data/vendor/crates/spikard-http/tests/request_extraction_full.rs +0 -513
  157. data/vendor/crates/spikard-http/tests/server_auth_middleware_behavior.rs +0 -328
  158. data/vendor/crates/spikard-http/tests/server_config_builder.rs +0 -314
  159. data/vendor/crates/spikard-http/tests/server_configured_router_behavior.rs +0 -200
  160. data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +0 -83
  161. data/vendor/crates/spikard-http/tests/server_handler_wrappers.rs +0 -464
  162. data/vendor/crates/spikard-http/tests/server_method_router_additional_behavior.rs +0 -286
  163. data/vendor/crates/spikard-http/tests/server_method_router_coverage.rs +0 -118
  164. data/vendor/crates/spikard-http/tests/server_middleware_behavior.rs +0 -99
  165. data/vendor/crates/spikard-http/tests/server_middleware_branches.rs +0 -204
  166. data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +0 -421
  167. data/vendor/crates/spikard-http/tests/server_router_behavior.rs +0 -121
  168. data/vendor/crates/spikard-http/tests/sse_behavior.rs +0 -620
  169. data/vendor/crates/spikard-http/tests/sse_full_behavior.rs +0 -584
  170. data/vendor/crates/spikard-http/tests/sse_handler_behavior.rs +0 -130
  171. data/vendor/crates/spikard-http/tests/test_client_requests.rs +0 -167
  172. data/vendor/crates/spikard-http/tests/testing_helpers.rs +0 -87
  173. data/vendor/crates/spikard-http/tests/testing_module_coverage.rs +0 -155
  174. data/vendor/crates/spikard-http/tests/urlencoded_content_type.rs +0 -82
  175. data/vendor/crates/spikard-http/tests/websocket_behavior.rs +0 -663
  176. data/vendor/crates/spikard-http/tests/websocket_full_behavior.rs +0 -440
  177. data/vendor/crates/spikard-http/tests/websocket_integration.rs +0 -150
  178. data/vendor/crates/spikard-rb/Cargo.toml +0 -68
  179. data/vendor/crates/spikard-rb/build.rs +0 -200
  180. data/vendor/crates/spikard-rb/src/background.rs +0 -63
  181. data/vendor/crates/spikard-rb/src/config/mod.rs +0 -5
  182. data/vendor/crates/spikard-rb/src/config/server_config.rs +0 -401
  183. data/vendor/crates/spikard-rb/src/conversion.rs +0 -688
  184. data/vendor/crates/spikard-rb/src/di/builder.rs +0 -100
  185. data/vendor/crates/spikard-rb/src/di/mod.rs +0 -375
  186. data/vendor/crates/spikard-rb/src/grpc/handler.rs +0 -834
  187. data/vendor/crates/spikard-rb/src/grpc/mod.rs +0 -13
  188. data/vendor/crates/spikard-rb/src/gvl.rs +0 -80
  189. data/vendor/crates/spikard-rb/src/handler.rs +0 -699
  190. data/vendor/crates/spikard-rb/src/integration/mod.rs +0 -3
  191. data/vendor/crates/spikard-rb/src/lib.rs +0 -2264
  192. data/vendor/crates/spikard-rb/src/lifecycle.rs +0 -303
  193. data/vendor/crates/spikard-rb/src/metadata/mod.rs +0 -5
  194. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +0 -507
  195. data/vendor/crates/spikard-rb/src/request.rs +0 -439
  196. data/vendor/crates/spikard-rb/src/runtime/mod.rs +0 -5
  197. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +0 -344
  198. data/vendor/crates/spikard-rb/src/server.rs +0 -307
  199. data/vendor/crates/spikard-rb/src/sse.rs +0 -231
  200. data/vendor/crates/spikard-rb/src/testing/client.rs +0 -698
  201. data/vendor/crates/spikard-rb/src/testing/mod.rs +0 -7
  202. data/vendor/crates/spikard-rb/src/testing/sse.rs +0 -108
  203. data/vendor/crates/spikard-rb/src/testing/websocket.rs +0 -573
  204. data/vendor/crates/spikard-rb/src/websocket.rs +0 -475
  205. data/vendor/crates/spikard-rb-macros/Cargo.toml +0 -25
  206. data/vendor/crates/spikard-rb-macros/src/lib.rs +0 -51
@@ -1,1026 +0,0 @@
1
- //! CORS (Cross-Origin Resource Sharing) handling
2
- //!
3
- //! Handles CORS preflight requests and adds CORS headers to responses
4
-
5
- use crate::CorsConfig;
6
- use axum::body::Body;
7
- use axum::http::{HeaderMap, HeaderValue, Response, StatusCode};
8
- use axum::response::IntoResponse;
9
-
10
- /// Check if an origin is allowed by the CORS configuration
11
- ///
12
- /// Supports exact matches and wildcard ("*") for any origin.
13
- /// Empty origins always return false for security.
14
- ///
15
- /// # Arguments
16
- /// * `origin` - The origin string from the HTTP request (e.g., "https://example.com")
17
- /// * `allowed_origins` - List of allowed origins configured for CORS
18
- ///
19
- /// # Returns
20
- /// `true` if the origin is allowed, `false` otherwise
21
- fn is_origin_allowed(origin: &str, allowed_origins: &[String]) -> bool {
22
- if origin.is_empty() {
23
- return false;
24
- }
25
-
26
- allowed_origins
27
- .iter()
28
- .any(|allowed| allowed == "*" || allowed == origin)
29
- }
30
-
31
- /// Check if a method is allowed by the CORS configuration
32
- ///
33
- /// Supports exact matches and wildcard ("*") for any method.
34
- /// Comparison is case-insensitive (e.g., "get" matches "GET").
35
- ///
36
- /// # Arguments
37
- /// * `method` - The HTTP method requested (e.g., "GET", "POST")
38
- /// * `allowed_methods` - List of allowed HTTP methods configured for CORS
39
- ///
40
- /// # Returns
41
- /// `true` if the method is allowed, `false` otherwise
42
- fn is_method_allowed(method: &str, allowed_methods: &[String]) -> bool {
43
- allowed_methods
44
- .iter()
45
- .any(|allowed| allowed == "*" || allowed.eq_ignore_ascii_case(method))
46
- }
47
-
48
- /// Check if all requested headers are allowed by CORS configuration
49
- ///
50
- /// Headers are case-insensitive. Supports wildcard ("*") for allowing any header.
51
- /// If a wildcard is configured, all requested headers are allowed.
52
- ///
53
- /// # Arguments
54
- /// * `requested` - Array of header names requested by the client
55
- /// * `allowed` - List of allowed header names configured for CORS
56
- ///
57
- /// # Returns
58
- /// `true` if all requested headers are allowed, `false` if any header is not allowed
59
- fn are_headers_allowed(requested: impl IntoIterator<Item = impl AsRef<str>>, allowed: &[String]) -> bool {
60
- if allowed.iter().any(|h| h == "*") {
61
- return true;
62
- }
63
-
64
- requested.into_iter().all(|req_header| {
65
- let req_str = req_header.as_ref();
66
- allowed
67
- .iter()
68
- .any(|allowed_header| allowed_header.eq_ignore_ascii_case(req_str))
69
- })
70
- }
71
-
72
- /// Handle CORS preflight (OPTIONS) request
73
- ///
74
- /// Validates the request against the CORS configuration and returns appropriate
75
- /// response or error. This function processes OPTIONS requests as defined in the
76
- /// CORS specification (RFC 7231).
77
- ///
78
- /// # Validation
79
- ///
80
- /// Checks the following conditions:
81
- /// 1. **Origin Header:** Must be present and match configured allowed origins
82
- /// 2. **Access-Control-Request-Method:** Must match configured allowed methods
83
- /// 3. **Access-Control-Request-Headers:** All requested headers must match configured allowed headers
84
- ///
85
- /// # Success Response
86
- ///
87
- /// Returns HTTP 204 (No Content) with the following response headers:
88
- /// - `Access-Control-Allow-Origin` - The origin that is allowed
89
- /// - `Access-Control-Allow-Methods` - Comma-separated list of allowed methods
90
- /// - `Access-Control-Allow-Headers` - Comma-separated list of allowed headers
91
- /// - `Access-Control-Max-Age` - Caching duration in seconds (if configured)
92
- /// - `Access-Control-Allow-Credentials` - "true" if credentials are allowed
93
- ///
94
- /// # Error Response
95
- ///
96
- /// Returns HTTP 403 (Forbidden) if validation fails for:
97
- /// - Origin not in allowed list
98
- /// - Requested method not allowed
99
- /// - Requested headers not allowed
100
- ///
101
- /// # Arguments
102
- /// * `headers` - Request headers containing CORS preflight information
103
- /// * `cors_config` - CORS configuration to validate against
104
- ///
105
- /// # Returns
106
- /// * `Ok(Response)` - 204 No Content with CORS headers
107
- /// * `Err(Response)` - 403 Forbidden or 500 Internal Server Error
108
- pub fn handle_preflight(headers: &HeaderMap, cors_config: &CorsConfig) -> Result<Response<Body>, Box<Response<Body>>> {
109
- let origin = headers.get("origin").and_then(|v| v.to_str().ok()).unwrap_or("");
110
-
111
- if origin.is_empty() || !is_origin_allowed(origin, &cors_config.allowed_origins) {
112
- return Err(Box::new(
113
- (
114
- StatusCode::FORBIDDEN,
115
- axum::Json(serde_json::json!({
116
- "detail": format!("CORS request from origin '{}' not allowed", origin)
117
- })),
118
- )
119
- .into_response(),
120
- ));
121
- }
122
-
123
- let requested_method = headers
124
- .get("access-control-request-method")
125
- .and_then(|v| v.to_str().ok())
126
- .unwrap_or("");
127
-
128
- if !requested_method.is_empty() && !is_method_allowed(requested_method, &cors_config.allowed_methods) {
129
- return Err(Box::new((StatusCode::FORBIDDEN).into_response()));
130
- }
131
-
132
- let requested_headers_str = headers
133
- .get("access-control-request-headers")
134
- .and_then(|v| v.to_str().ok());
135
-
136
- if let Some(req_headers) = requested_headers_str {
137
- // Pass iterator directly without collecting into Vec
138
- if !are_headers_allowed(req_headers.split(',').map(|h| h.trim()), &cors_config.allowed_headers) {
139
- return Err(Box::new((StatusCode::FORBIDDEN).into_response()));
140
- }
141
- }
142
-
143
- let mut response = Response::builder().status(StatusCode::NO_CONTENT);
144
-
145
- let headers_mut = match response.headers_mut() {
146
- Some(headers) => headers,
147
- None => {
148
- return Err(Box::new(
149
- (
150
- StatusCode::INTERNAL_SERVER_ERROR,
151
- axum::Json(serde_json::json!({
152
- "detail": "Failed to construct response headers"
153
- })),
154
- )
155
- .into_response(),
156
- ));
157
- }
158
- };
159
-
160
- headers_mut.insert(
161
- "access-control-allow-origin",
162
- HeaderValue::from_str(origin).unwrap_or_else(|_| HeaderValue::from_static("*")),
163
- );
164
-
165
- let methods = cors_config.allowed_methods_joined();
166
- headers_mut.insert(
167
- "access-control-allow-methods",
168
- HeaderValue::from_str(methods).unwrap_or_else(|_| HeaderValue::from_static("*")),
169
- );
170
-
171
- let allowed_headers = cors_config.allowed_headers_joined();
172
- headers_mut.insert(
173
- "access-control-allow-headers",
174
- HeaderValue::from_str(allowed_headers).unwrap_or_else(|_| HeaderValue::from_static("*")),
175
- );
176
-
177
- if let Some(max_age) = cors_config.max_age
178
- && let Ok(header_val) = HeaderValue::from_str(&max_age.to_string())
179
- {
180
- headers_mut.insert("access-control-max-age", header_val);
181
- }
182
-
183
- if let Some(true) = cors_config.allow_credentials {
184
- headers_mut.insert("access-control-allow-credentials", HeaderValue::from_static("true"));
185
- }
186
-
187
- match response.body(Body::empty()) {
188
- Ok(resp) => Ok(resp),
189
- Err(_) => Err(Box::new(
190
- (
191
- StatusCode::INTERNAL_SERVER_ERROR,
192
- axum::Json(serde_json::json!({
193
- "detail": "Failed to construct response body"
194
- })),
195
- )
196
- .into_response(),
197
- )),
198
- }
199
- }
200
-
201
- /// Add CORS headers to a successful response
202
- ///
203
- /// Adds appropriate CORS headers to the response based on the configuration.
204
- /// This function should be called for successful (non-error) responses to
205
- /// cross-origin requests.
206
- ///
207
- /// # Headers Added
208
- ///
209
- /// - `Access-Control-Allow-Origin` - The origin that is allowed (if valid)
210
- /// - `Access-Control-Expose-Headers` - Headers that are safe to expose to the client
211
- /// - `Access-Control-Allow-Credentials` - "true" if credentials are allowed
212
- ///
213
- /// # Arguments
214
- /// * `response` - Mutable reference to the response to modify
215
- /// * `origin` - The origin from the request (e.g., `<https://example.com>`)
216
- /// * `cors_config` - CORS configuration to apply
217
- ///
218
- /// # Example
219
- ///
220
- /// ```ignore
221
- /// let mut response = Response::new(Body::empty());
222
- /// add_cors_headers(&mut response, "https://example.com", &cors_config);
223
- /// ```
224
- pub fn add_cors_headers(response: &mut Response<Body>, origin: &str, cors_config: &CorsConfig) {
225
- let headers = response.headers_mut();
226
-
227
- if let Ok(origin_value) = HeaderValue::from_str(origin) {
228
- headers.insert("access-control-allow-origin", origin_value);
229
- }
230
-
231
- if let Some(ref expose_headers) = cors_config.expose_headers {
232
- let expose = expose_headers.join(", ");
233
- if let Ok(expose_value) = HeaderValue::from_str(&expose) {
234
- headers.insert("access-control-expose-headers", expose_value);
235
- }
236
- }
237
-
238
- if let Some(true) = cors_config.allow_credentials {
239
- headers.insert("access-control-allow-credentials", HeaderValue::from_static("true"));
240
- }
241
- }
242
-
243
- /// Validate a non-preflight CORS request
244
- ///
245
- /// Checks if the Origin header is present and allowed for non-preflight (actual) requests.
246
- /// Returns an error response if validation fails.
247
- ///
248
- /// # Validation
249
- ///
250
- /// - If no Origin header is present, the request is allowed (not a CORS request)
251
- /// - If Origin header is present, it must match the allowed origins
252
- ///
253
- /// # Arguments
254
- /// * `headers` - Request headers containing origin information
255
- /// * `cors_config` - CORS configuration to validate against
256
- ///
257
- /// # Returns
258
- /// * `Ok(())` - Request is allowed
259
- /// * `Err(Response)` - 403 Forbidden with error details
260
- ///
261
- /// # Note
262
- ///
263
- /// This function is for actual requests, not OPTIONS preflight requests.
264
- /// Use `handle_preflight` for OPTIONS requests.
265
- pub fn validate_cors_request(headers: &HeaderMap, cors_config: &CorsConfig) -> Result<(), Box<Response<Body>>> {
266
- let origin = headers.get("origin").and_then(|v| v.to_str().ok()).unwrap_or("");
267
-
268
- if !origin.is_empty() && !is_origin_allowed(origin, &cors_config.allowed_origins) {
269
- return Err(Box::new(
270
- (
271
- StatusCode::FORBIDDEN,
272
- axum::Json(serde_json::json!({
273
- "detail": format!("CORS request from origin '{}' not allowed", origin)
274
- })),
275
- )
276
- .into_response(),
277
- ));
278
- }
279
- Ok(())
280
- }
281
-
282
- #[cfg(test)]
283
- mod tests {
284
- use super::*;
285
-
286
- fn make_cors_config() -> CorsConfig {
287
- CorsConfig {
288
- allowed_origins: vec!["https://example.com".to_string()],
289
- allowed_methods: vec!["GET".to_string(), "POST".to_string()],
290
- allowed_headers: vec!["content-type".to_string(), "authorization".to_string()],
291
- expose_headers: Some(vec!["x-custom-header".to_string()]),
292
- max_age: Some(3600),
293
- allow_credentials: Some(true),
294
- ..Default::default()
295
- }
296
- }
297
-
298
- #[test]
299
- fn test_is_origin_allowed_exact_match() {
300
- let allowed = vec!["https://example.com".to_string()];
301
- assert!(is_origin_allowed("https://example.com", &allowed));
302
- assert!(!is_origin_allowed("https://evil.com", &allowed));
303
- }
304
-
305
- #[test]
306
- fn test_is_origin_allowed_wildcard() {
307
- let allowed = vec!["*".to_string()];
308
- assert!(is_origin_allowed("https://example.com", &allowed));
309
- assert!(is_origin_allowed("https://any-domain.com", &allowed));
310
- }
311
-
312
- #[test]
313
- fn test_is_origin_allowed_empty_origin() {
314
- let allowed = vec!["*".to_string()];
315
- assert!(!is_origin_allowed("", &allowed));
316
- }
317
-
318
- #[test]
319
- fn test_is_method_allowed_case_insensitive() {
320
- let allowed = vec!["GET".to_string(), "POST".to_string()];
321
- assert!(is_method_allowed("GET", &allowed));
322
- assert!(is_method_allowed("get", &allowed));
323
- assert!(is_method_allowed("POST", &allowed));
324
- assert!(is_method_allowed("post", &allowed));
325
- assert!(!is_method_allowed("DELETE", &allowed));
326
- }
327
-
328
- #[test]
329
- fn test_is_method_allowed_wildcard() {
330
- let allowed = vec!["*".to_string()];
331
- assert!(is_method_allowed("GET", &allowed));
332
- assert!(is_method_allowed("DELETE", &allowed));
333
- assert!(is_method_allowed("PATCH", &allowed));
334
- }
335
-
336
- #[test]
337
- fn test_are_headers_allowed_case_insensitive() {
338
- let allowed = vec!["Content-Type".to_string(), "Authorization".to_string()];
339
- assert!(are_headers_allowed(&["content-type"], &allowed));
340
- assert!(are_headers_allowed(&["AUTHORIZATION"], &allowed));
341
- assert!(are_headers_allowed(&["content-type", "authorization"], &allowed));
342
- assert!(!are_headers_allowed(&["x-custom"], &allowed));
343
- }
344
-
345
- #[test]
346
- fn test_are_headers_allowed_wildcard() {
347
- let allowed = vec!["*".to_string()];
348
- assert!(are_headers_allowed(&["any-header"], &allowed));
349
- assert!(are_headers_allowed(&["multiple", "headers"], &allowed));
350
- }
351
-
352
- #[test]
353
- fn test_handle_preflight_success() {
354
- let config = make_cors_config();
355
- let mut headers = HeaderMap::new();
356
- headers.insert("origin", HeaderValue::from_static("https://example.com"));
357
- headers.insert("access-control-request-method", HeaderValue::from_static("POST"));
358
- headers.insert(
359
- "access-control-request-headers",
360
- HeaderValue::from_static("content-type"),
361
- );
362
-
363
- let result = handle_preflight(&headers, &config);
364
- assert!(result.is_ok());
365
-
366
- let response = result.unwrap();
367
- assert_eq!(response.status(), StatusCode::NO_CONTENT);
368
-
369
- let resp_headers = response.headers();
370
- assert_eq!(
371
- resp_headers.get("access-control-allow-origin").unwrap(),
372
- "https://example.com"
373
- );
374
- assert!(
375
- resp_headers
376
- .get("access-control-allow-methods")
377
- .unwrap()
378
- .to_str()
379
- .unwrap()
380
- .contains("POST")
381
- );
382
- assert_eq!(resp_headers.get("access-control-max-age").unwrap(), "3600");
383
- assert_eq!(resp_headers.get("access-control-allow-credentials").unwrap(), "true");
384
- }
385
-
386
- #[test]
387
- fn test_handle_preflight_origin_not_allowed() {
388
- let config = make_cors_config();
389
- let mut headers = HeaderMap::new();
390
- headers.insert("origin", HeaderValue::from_static("https://evil.com"));
391
- headers.insert("access-control-request-method", HeaderValue::from_static("GET"));
392
-
393
- let result = handle_preflight(&headers, &config);
394
- assert!(result.is_err());
395
-
396
- let response = *result.unwrap_err();
397
- assert_eq!(response.status(), StatusCode::FORBIDDEN);
398
- }
399
-
400
- #[test]
401
- fn test_handle_preflight_method_not_allowed() {
402
- let config = make_cors_config();
403
- let mut headers = HeaderMap::new();
404
- headers.insert("origin", HeaderValue::from_static("https://example.com"));
405
- headers.insert("access-control-request-method", HeaderValue::from_static("DELETE"));
406
-
407
- let result = handle_preflight(&headers, &config);
408
- assert!(result.is_err());
409
-
410
- let response = *result.unwrap_err();
411
- assert_eq!(response.status(), StatusCode::FORBIDDEN);
412
- }
413
-
414
- #[test]
415
- fn test_handle_preflight_header_not_allowed() {
416
- let config = make_cors_config();
417
- let mut headers = HeaderMap::new();
418
- headers.insert("origin", HeaderValue::from_static("https://example.com"));
419
- headers.insert("access-control-request-method", HeaderValue::from_static("POST"));
420
- headers.insert(
421
- "access-control-request-headers",
422
- HeaderValue::from_static("x-forbidden-header"),
423
- );
424
-
425
- let result = handle_preflight(&headers, &config);
426
- assert!(result.is_err());
427
-
428
- let response = *result.unwrap_err();
429
- assert_eq!(response.status(), StatusCode::FORBIDDEN);
430
- }
431
-
432
- #[test]
433
- fn test_handle_preflight_empty_origin() {
434
- let config = make_cors_config();
435
- let headers = HeaderMap::new();
436
-
437
- let result = handle_preflight(&headers, &config);
438
- assert!(result.is_err());
439
-
440
- let response = *result.unwrap_err();
441
- assert_eq!(response.status(), StatusCode::FORBIDDEN);
442
- }
443
-
444
- #[test]
445
- fn test_add_cors_headers() {
446
- let config = make_cors_config();
447
- let mut response = Response::new(Body::empty());
448
-
449
- add_cors_headers(&mut response, "https://example.com", &config);
450
-
451
- let headers = response.headers();
452
- assert_eq!(
453
- headers.get("access-control-allow-origin").unwrap(),
454
- "https://example.com"
455
- );
456
- assert_eq!(headers.get("access-control-expose-headers").unwrap(), "x-custom-header");
457
- assert_eq!(headers.get("access-control-allow-credentials").unwrap(), "true");
458
- }
459
-
460
- #[test]
461
- fn test_validate_cors_request_allowed() {
462
- let config = make_cors_config();
463
- let mut headers = HeaderMap::new();
464
- headers.insert("origin", HeaderValue::from_static("https://example.com"));
465
-
466
- let result = validate_cors_request(&headers, &config);
467
- assert!(result.is_ok());
468
- }
469
-
470
- #[test]
471
- fn test_validate_cors_request_not_allowed() {
472
- let config = make_cors_config();
473
- let mut headers = HeaderMap::new();
474
- headers.insert("origin", HeaderValue::from_static("https://evil.com"));
475
-
476
- let result = validate_cors_request(&headers, &config);
477
- assert!(result.is_err());
478
-
479
- let response = *result.unwrap_err();
480
- assert_eq!(response.status(), StatusCode::FORBIDDEN);
481
- }
482
-
483
- #[test]
484
- fn test_validate_cors_request_no_origin() {
485
- let config = make_cors_config();
486
- let headers = HeaderMap::new();
487
-
488
- let result = validate_cors_request(&headers, &config);
489
- assert!(result.is_ok());
490
- }
491
-
492
- // SECURITY TESTS: CORS Attack Vectors
493
-
494
- /// SECURITY TEST: Verify credentials=true with wildcard is caught
495
- /// This is a critical vulnerability - RFC 6454 forbids this
496
- #[test]
497
- fn test_credentials_with_wildcard_config_is_security_issue() {
498
- let config = CorsConfig {
499
- allowed_origins: vec!["*".to_string()],
500
- allowed_methods: vec!["GET".to_string()],
501
- allowed_headers: vec![],
502
- expose_headers: None,
503
- max_age: None,
504
- allow_credentials: Some(true), // SECURITY BUG: This should not be allowed with wildcard,
505
- ..Default::default()
506
- };
507
-
508
- let mut headers = HeaderMap::new();
509
- headers.insert("origin", HeaderValue::from_static("https://evil.com"));
510
- headers.insert("access-control-request-method", HeaderValue::from_static("GET"));
511
-
512
- let result = handle_preflight(&headers, &config);
513
-
514
- // BUG: This should return 500 or reject the config, but instead succeeds
515
- if let Ok(response) = result {
516
- let resp_headers = response.headers();
517
- let has_credentials = resp_headers
518
- .get("access-control-allow-credentials")
519
- .map(|v| v == "true")
520
- .unwrap_or(false);
521
- let origin_header = resp_headers.get("access-control-allow-origin");
522
-
523
- if has_credentials && origin_header.is_some() {
524
- let origin_val = origin_header.unwrap().to_str().unwrap_or("");
525
- if origin_val == "*" {
526
- panic!("SECURITY VULNERABILITY: credentials=true with origin=* allowed");
527
- }
528
- }
529
- }
530
- }
531
-
532
- /// SECURITY TEST: Exact origin matching required
533
- /// Subdomain like api.evil.example.com must NOT match example.com
534
- #[test]
535
- fn test_subdomain_bypass_blocked() {
536
- let config = CorsConfig {
537
- allowed_origins: vec!["https://example.com".to_string()],
538
- allowed_methods: vec!["GET".to_string()],
539
- allowed_headers: vec![],
540
- expose_headers: None,
541
- max_age: None,
542
- allow_credentials: None,
543
- ..Default::default()
544
- };
545
-
546
- assert!(!is_origin_allowed("https://api.example.com", &config.allowed_origins));
547
- assert!(!is_origin_allowed("https://evil.example.com", &config.allowed_origins));
548
- assert!(!is_origin_allowed(
549
- "https://sub.sub.example.com",
550
- &config.allowed_origins
551
- ));
552
-
553
- assert!(is_origin_allowed("https://example.com", &config.allowed_origins));
554
- }
555
-
556
- /// SECURITY TEST: Port exact matching required
557
- /// localhost:3001 must NOT match localhost:3000
558
- #[test]
559
- fn test_port_bypass_blocked() {
560
- let config = CorsConfig {
561
- allowed_origins: vec!["http://localhost:3000".to_string()],
562
- allowed_methods: vec!["GET".to_string()],
563
- allowed_headers: vec![],
564
- expose_headers: None,
565
- max_age: None,
566
- allow_credentials: None,
567
- ..Default::default()
568
- };
569
-
570
- assert!(!is_origin_allowed("http://localhost:3001", &config.allowed_origins));
571
- assert!(!is_origin_allowed("http://localhost:8080", &config.allowed_origins));
572
- assert!(!is_origin_allowed("http://localhost:443", &config.allowed_origins));
573
-
574
- assert!(is_origin_allowed("http://localhost:3000", &config.allowed_origins));
575
- }
576
-
577
- /// SECURITY TEST: Protocol exact matching required
578
- /// http://example.com must NOT match https://example.com
579
- #[test]
580
- fn test_protocol_downgrade_attack_blocked() {
581
- let config = CorsConfig {
582
- allowed_origins: vec!["https://example.com".to_string()],
583
- allowed_methods: vec!["GET".to_string()],
584
- allowed_headers: vec![],
585
- expose_headers: None,
586
- max_age: None,
587
- allow_credentials: None,
588
- ..Default::default()
589
- };
590
-
591
- assert!(!is_origin_allowed("http://example.com", &config.allowed_origins));
592
- assert!(!is_origin_allowed("ws://example.com", &config.allowed_origins));
593
- assert!(!is_origin_allowed("wss://example.com", &config.allowed_origins));
594
-
595
- assert!(is_origin_allowed("https://example.com", &config.allowed_origins));
596
- }
597
-
598
- /// SECURITY TEST: Case sensitivity in origin matching
599
- /// Origins should match exactly (including case)
600
- #[test]
601
- fn test_case_sensitive_origin_matching() {
602
- let config = CorsConfig {
603
- allowed_origins: vec!["https://Example.Com".to_string()],
604
- allowed_methods: vec!["GET".to_string()],
605
- allowed_headers: vec![],
606
- expose_headers: None,
607
- max_age: None,
608
- allow_credentials: None,
609
- ..Default::default()
610
- };
611
-
612
- assert!(!is_origin_allowed("https://example.com", &config.allowed_origins));
613
- assert!(!is_origin_allowed("https://EXAMPLE.COM", &config.allowed_origins));
614
-
615
- assert!(is_origin_allowed("https://Example.Com", &config.allowed_origins));
616
- }
617
-
618
- /// SECURITY TEST: Trailing slash normalization
619
- /// https://example.com/ should be treated differently from https://example.com
620
- #[test]
621
- fn test_trailing_slash_origin_not_normalized() {
622
- let config = CorsConfig {
623
- allowed_origins: vec!["https://example.com".to_string()],
624
- allowed_methods: vec!["GET".to_string()],
625
- allowed_headers: vec![],
626
- expose_headers: None,
627
- max_age: None,
628
- allow_credentials: None,
629
- ..Default::default()
630
- };
631
-
632
- assert!(!is_origin_allowed("https://example.com/", &config.allowed_origins));
633
-
634
- assert!(is_origin_allowed("https://example.com", &config.allowed_origins));
635
- }
636
-
637
- /// SECURITY TEST: NULL origin and wildcard behavior
638
- /// Special "null" origin used by file:// and sandboxed iframes
639
- /// The current implementation treats "null" as a regular origin string,
640
- /// which means it IS allowed by wildcard (not ideal but documents current behavior)
641
- #[test]
642
- fn test_null_origin_with_wildcard() {
643
- let config = CorsConfig {
644
- allowed_origins: vec!["*".to_string()],
645
- allowed_methods: vec!["GET".to_string()],
646
- allowed_headers: vec![],
647
- expose_headers: None,
648
- max_age: None,
649
- allow_credentials: None,
650
- ..Default::default()
651
- };
652
-
653
- // SECURITY NOTE: "null" origin is allowed by wildcard in current implementation
654
- assert!(is_origin_allowed("null", &config.allowed_origins));
655
-
656
- let with_explicit_null = CorsConfig {
657
- allowed_origins: vec!["null".to_string()],
658
- allowed_methods: vec!["GET".to_string()],
659
- allowed_headers: vec![],
660
- expose_headers: None,
661
- max_age: None,
662
- allow_credentials: None,
663
- ..Default::default()
664
- };
665
- assert!(is_origin_allowed("null", &with_explicit_null.allowed_origins));
666
- }
667
-
668
- /// SECURITY TEST: Empty origin is always rejected
669
- #[test]
670
- fn test_empty_origin_always_rejected() {
671
- let config_with_wildcard = CorsConfig {
672
- allowed_origins: vec!["*".to_string()],
673
- allowed_methods: vec!["GET".to_string()],
674
- allowed_headers: vec![],
675
- expose_headers: None,
676
- max_age: None,
677
- allow_credentials: None,
678
- ..Default::default()
679
- };
680
- assert!(!is_origin_allowed("", &config_with_wildcard.allowed_origins));
681
-
682
- let config_with_explicit = CorsConfig {
683
- allowed_origins: vec!["https://example.com".to_string()],
684
- allowed_methods: vec!["GET".to_string()],
685
- allowed_headers: vec![],
686
- expose_headers: None,
687
- max_age: None,
688
- allow_credentials: None,
689
- ..Default::default()
690
- };
691
- assert!(!is_origin_allowed("", &config_with_explicit.allowed_origins));
692
- }
693
-
694
- /// SECURITY TEST: Preflight with invalid origin should reject
695
- #[test]
696
- fn test_preflight_rejects_invalid_origin() {
697
- let config = make_cors_config();
698
- let mut headers = HeaderMap::new();
699
- headers.insert("origin", HeaderValue::from_static("https://untrusted.com"));
700
- headers.insert("access-control-request-method", HeaderValue::from_static("POST"));
701
-
702
- let result = handle_preflight(&headers, &config);
703
- assert!(result.is_err());
704
-
705
- let response = *result.unwrap_err();
706
- assert_eq!(response.status(), StatusCode::FORBIDDEN);
707
- }
708
-
709
- /// SECURITY TEST: Multiple origins - each must be exact match
710
- #[test]
711
- fn test_multiple_origins_exact_matching() {
712
- let config = CorsConfig {
713
- allowed_origins: vec!["https://trusted1.com".to_string(), "https://trusted2.com".to_string()],
714
- allowed_methods: vec!["GET".to_string()],
715
- allowed_headers: vec![],
716
- expose_headers: None,
717
- max_age: None,
718
- allow_credentials: None,
719
- ..Default::default()
720
- };
721
-
722
- assert!(is_origin_allowed("https://trusted1.com", &config.allowed_origins));
723
- assert!(is_origin_allowed("https://trusted2.com", &config.allowed_origins));
724
-
725
- assert!(!is_origin_allowed(
726
- "https://trusted1.com.evil.com",
727
- &config.allowed_origins
728
- ));
729
- assert!(!is_origin_allowed("https://trusted3.com", &config.allowed_origins));
730
- assert!(!is_origin_allowed("https://trusted.com", &config.allowed_origins));
731
- }
732
-
733
- /// SECURITY TEST: Wildcard origin should allow any origin (but check config)
734
- #[test]
735
- fn test_wildcard_allows_all_but_check_credentials() {
736
- let config = CorsConfig {
737
- allowed_origins: vec!["*".to_string()],
738
- allowed_methods: vec!["GET".to_string()],
739
- allowed_headers: vec![],
740
- expose_headers: None,
741
- max_age: None,
742
- allow_credentials: None,
743
- ..Default::default()
744
- };
745
-
746
- assert!(is_origin_allowed("https://example.com", &config.allowed_origins));
747
- assert!(is_origin_allowed("https://evil.com", &config.allowed_origins));
748
- assert!(is_origin_allowed("http://localhost:3000", &config.allowed_origins));
749
-
750
- assert!(!is_origin_allowed("", &config.allowed_origins));
751
- }
752
-
753
- /// SECURITY TEST: Preflight response headers must match config exactly
754
- #[test]
755
- fn test_preflight_response_has_correct_allowed_origins() {
756
- let config = CorsConfig {
757
- allowed_origins: vec!["https://trusted.com".to_string()],
758
- allowed_methods: vec!["GET".to_string(), "POST".to_string()],
759
- allowed_headers: vec!["content-type".to_string()],
760
- expose_headers: None,
761
- max_age: Some(3600),
762
- allow_credentials: Some(false),
763
- ..Default::default()
764
- };
765
-
766
- let mut headers = HeaderMap::new();
767
- headers.insert("origin", HeaderValue::from_static("https://trusted.com"));
768
- headers.insert("access-control-request-method", HeaderValue::from_static("POST"));
769
- headers.insert(
770
- "access-control-request-headers",
771
- HeaderValue::from_static("content-type"),
772
- );
773
-
774
- let result = handle_preflight(&headers, &config);
775
- assert!(result.is_ok());
776
-
777
- let response = result.unwrap();
778
- let resp_headers = response.headers();
779
-
780
- assert_eq!(
781
- resp_headers.get("access-control-allow-origin").unwrap(),
782
- "https://trusted.com"
783
- );
784
-
785
- assert!(
786
- resp_headers
787
- .get("access-control-allow-methods")
788
- .unwrap()
789
- .to_str()
790
- .unwrap()
791
- .contains("GET")
792
- );
793
- assert!(
794
- resp_headers
795
- .get("access-control-allow-methods")
796
- .unwrap()
797
- .to_str()
798
- .unwrap()
799
- .contains("POST")
800
- );
801
-
802
- assert!(resp_headers.get("access-control-allow-credentials").is_none());
803
- }
804
-
805
- /// SECURITY TEST: Origin not in allowed list must be rejected in preflight
806
- #[test]
807
- fn test_preflight_all_origins_require_validation() {
808
- let config = CorsConfig {
809
- allowed_origins: vec!["https://trusted.com".to_string()],
810
- allowed_methods: vec!["GET".to_string()],
811
- allowed_headers: vec![],
812
- expose_headers: None,
813
- max_age: None,
814
- allow_credentials: None,
815
- ..Default::default()
816
- };
817
-
818
- let test_cases = vec![
819
- "https://trusted.com",
820
- "https://evil.com",
821
- "https://trusted.com.evil",
822
- "http://trusted.com",
823
- "",
824
- ];
825
-
826
- for origin in test_cases {
827
- let mut headers = HeaderMap::new();
828
- headers.insert(
829
- "origin",
830
- HeaderValue::from_str(origin).unwrap_or_else(|_| HeaderValue::from_static("")),
831
- );
832
- headers.insert("access-control-request-method", HeaderValue::from_static("GET"));
833
-
834
- let result = handle_preflight(&headers, &config);
835
-
836
- if origin == "https://trusted.com" {
837
- assert!(result.is_ok(), "Valid origin {} should be allowed", origin);
838
- } else {
839
- assert!(result.is_err(), "Invalid origin {} should be rejected", origin);
840
- }
841
- }
842
- }
843
-
844
- /// SECURITY TEST: Requested headers must be in allowed list
845
- #[test]
846
- fn test_preflight_validates_all_requested_headers() {
847
- let config = CorsConfig {
848
- allowed_origins: vec!["https://trusted.com".to_string()],
849
- allowed_methods: vec!["POST".to_string()],
850
- allowed_headers: vec!["content-type".to_string(), "authorization".to_string()],
851
- expose_headers: None,
852
- max_age: None,
853
- allow_credentials: None,
854
- ..Default::default()
855
- };
856
-
857
- let test_cases = vec![
858
- ("content-type", true),
859
- ("authorization", true),
860
- ("content-type, authorization", true),
861
- ("x-custom-header", false),
862
- ("content-type, x-custom", false),
863
- ];
864
-
865
- for (headers_str, should_pass) in test_cases {
866
- let mut headers = HeaderMap::new();
867
- headers.insert("origin", HeaderValue::from_static("https://trusted.com"));
868
- headers.insert("access-control-request-method", HeaderValue::from_static("POST"));
869
- headers.insert(
870
- "access-control-request-headers",
871
- HeaderValue::from_str(headers_str).unwrap(),
872
- );
873
-
874
- let result = handle_preflight(&headers, &config);
875
-
876
- if should_pass {
877
- assert!(
878
- result.is_ok(),
879
- "Preflight with valid headers '{}' should pass",
880
- headers_str
881
- );
882
- } else {
883
- assert!(
884
- result.is_err(),
885
- "Preflight with invalid headers '{}' should fail",
886
- headers_str
887
- );
888
- }
889
- }
890
- }
891
-
892
- /// SECURITY TEST: add_cors_headers should respect origin validation
893
- #[test]
894
- fn test_add_cors_headers_respects_origin() {
895
- let config = CorsConfig {
896
- allowed_origins: vec!["https://trusted.com".to_string()],
897
- allowed_methods: vec!["GET".to_string()],
898
- allowed_headers: vec![],
899
- expose_headers: Some(vec!["x-custom".to_string()]),
900
- max_age: None,
901
- allow_credentials: Some(true),
902
- ..Default::default()
903
- };
904
-
905
- let mut response = Response::new(Body::empty());
906
-
907
- add_cors_headers(&mut response, "https://trusted.com", &config);
908
-
909
- let headers = response.headers();
910
- assert_eq!(
911
- headers.get("access-control-allow-origin").unwrap(),
912
- "https://trusted.com"
913
- );
914
- assert_eq!(headers.get("access-control-expose-headers").unwrap(), "x-custom");
915
- assert_eq!(headers.get("access-control-allow-credentials").unwrap(), "true");
916
- }
917
-
918
- /// SECURITY TEST: validate_cors_request respects allowed origins
919
- #[test]
920
- fn test_validate_cors_request_origin_must_match() {
921
- let config = CorsConfig {
922
- allowed_origins: vec!["https://trusted.com".to_string()],
923
- allowed_methods: vec!["GET".to_string()],
924
- allowed_headers: vec![],
925
- expose_headers: None,
926
- max_age: None,
927
- allow_credentials: None,
928
- ..Default::default()
929
- };
930
-
931
- let mut headers = HeaderMap::new();
932
- headers.insert("origin", HeaderValue::from_static("https://trusted.com"));
933
- assert!(validate_cors_request(&headers, &config).is_ok());
934
-
935
- let mut headers = HeaderMap::new();
936
- headers.insert("origin", HeaderValue::from_static("https://evil.com"));
937
- assert!(validate_cors_request(&headers, &config).is_err());
938
-
939
- let headers = HeaderMap::new();
940
- assert!(validate_cors_request(&headers, &config).is_ok());
941
- }
942
-
943
- /// SECURITY TEST: Preflight without requested method should fail
944
- #[test]
945
- fn test_preflight_requires_access_control_request_method() {
946
- let config = make_cors_config();
947
- let mut headers = HeaderMap::new();
948
- headers.insert("origin", HeaderValue::from_static("https://example.com"));
949
-
950
- let result = handle_preflight(&headers, &config);
951
- assert!(result.is_ok());
952
- }
953
-
954
- /// SECURITY TEST: Case-insensitive method matching
955
- #[test]
956
- fn test_preflight_method_case_insensitive() {
957
- let config = CorsConfig {
958
- allowed_origins: vec!["https://example.com".to_string()],
959
- allowed_methods: vec!["GET".to_string(), "POST".to_string()],
960
- allowed_headers: vec![],
961
- expose_headers: None,
962
- max_age: None,
963
- allow_credentials: None,
964
- ..Default::default()
965
- };
966
-
967
- let test_cases = vec!["GET", "get", "Get", "POST", "post"];
968
-
969
- for method in test_cases {
970
- let mut headers = HeaderMap::new();
971
- headers.insert("origin", HeaderValue::from_static("https://example.com"));
972
- headers.insert("access-control-request-method", HeaderValue::from_str(method).unwrap());
973
-
974
- let result = handle_preflight(&headers, &config);
975
- assert!(
976
- result.is_ok(),
977
- "Method '{}' should be allowed (case-insensitive)",
978
- method
979
- );
980
- }
981
- }
982
-
983
- /// SECURITY TEST: Ensure preflight max-age is set correctly
984
- #[test]
985
- fn test_preflight_max_age_header() {
986
- let config = CorsConfig {
987
- allowed_origins: vec!["https://example.com".to_string()],
988
- allowed_methods: vec!["GET".to_string()],
989
- allowed_headers: vec![],
990
- expose_headers: None,
991
- max_age: Some(7200),
992
- allow_credentials: None,
993
- ..Default::default()
994
- };
995
-
996
- let mut headers = HeaderMap::new();
997
- headers.insert("origin", HeaderValue::from_static("https://example.com"));
998
- headers.insert("access-control-request-method", HeaderValue::from_static("GET"));
999
-
1000
- let result = handle_preflight(&headers, &config);
1001
- assert!(result.is_ok());
1002
-
1003
- let response = result.unwrap();
1004
- assert_eq!(response.headers().get("access-control-max-age").unwrap(), "7200");
1005
- }
1006
-
1007
- /// SECURITY TEST: Wildcard partial patterns should not work
1008
- /// *.example.com style patterns are not supported (good!)
1009
- #[test]
1010
- fn test_wildcard_patterns_not_supported() {
1011
- let config = CorsConfig {
1012
- allowed_origins: vec!["*.example.com".to_string()],
1013
- allowed_methods: vec!["GET".to_string()],
1014
- allowed_headers: vec![],
1015
- expose_headers: None,
1016
- max_age: None,
1017
- allow_credentials: None,
1018
- ..Default::default()
1019
- };
1020
-
1021
- assert!(!is_origin_allowed("https://api.example.com", &config.allowed_origins));
1022
- assert!(!is_origin_allowed("https://example.com", &config.allowed_origins));
1023
-
1024
- assert!(is_origin_allowed("*.example.com", &config.allowed_origins));
1025
- }
1026
- }