spikard 0.13.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 (207) 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} +819 -424
  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 -52
  10. data/lib/spikard_rb.so +0 -0
  11. data/sig/types.rbs +427 -0
  12. metadata +14 -243
  13. data/LICENSE +0 -1
  14. data/README.md +0 -285
  15. data/ext/spikard_rb/Cargo.toml +0 -17
  16. data/lib/spikard/app.rb +0 -458
  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 -232
  21. data/lib/spikard/handler_wrapper.rb +0 -113
  22. data/lib/spikard/provide.rb +0 -315
  23. data/lib/spikard/response.rb +0 -198
  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 -474
  28. data/lib/spikard/upload_file.rb +0 -131
  29. data/lib/spikard/websocket.rb +0 -59
  30. data/sig/spikard.rbs +0 -739
  31. data/vendor/crates/spikard-bindings-shared/Cargo.toml +0 -75
  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 -55
  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 -711
  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 -548
  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 -82
  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 -1859
  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 -653
  99. data/vendor/crates/spikard-http/src/grpc/handler.rs +0 -1211
  100. data/vendor/crates/spikard-http/src/grpc/mod.rs +0 -556
  101. data/vendor/crates/spikard-http/src/grpc/service.rs +0 -706
  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 -60
  109. data/vendor/crates/spikard-http/src/jsonrpc/openrpc.rs +0 -325
  110. data/vendor/crates/spikard-http/src/jsonrpc/protocol.rs +0 -1207
  111. data/vendor/crates/spikard-http/src/jsonrpc/router.rs +0 -2262
  112. data/vendor/crates/spikard-http/src/lib.rs +0 -566
  113. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +0 -230
  114. data/vendor/crates/spikard-http/src/lifecycle.rs +0 -1193
  115. data/vendor/crates/spikard-http/src/middleware/mod.rs +0 -560
  116. data/vendor/crates/spikard-http/src/middleware/multipart.rs +0 -912
  117. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +0 -513
  118. data/vendor/crates/spikard-http/src/middleware/validation.rs +0 -768
  119. data/vendor/crates/spikard-http/src/openapi/mod.rs +0 -309
  120. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +0 -535
  121. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +0 -1363
  122. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +0 -667
  123. data/vendor/crates/spikard-http/src/query_parser.rs +0 -793
  124. data/vendor/crates/spikard-http/src/response.rs +0 -720
  125. data/vendor/crates/spikard-http/src/server/fast_router.rs +0 -186
  126. data/vendor/crates/spikard-http/src/server/grpc_routing.rs +0 -1243
  127. data/vendor/crates/spikard-http/src/server/handler.rs +0 -1661
  128. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +0 -253
  129. data/vendor/crates/spikard-http/src/server/mod.rs +0 -1717
  130. data/vendor/crates/spikard-http/src/server/request_extraction.rs +0 -871
  131. data/vendor/crates/spikard-http/src/server/routing_factory.rs +0 -618
  132. data/vendor/crates/spikard-http/src/sse.rs +0 -1409
  133. data/vendor/crates/spikard-http/src/testing/form.rs +0 -52
  134. data/vendor/crates/spikard-http/src/testing/multipart.rs +0 -64
  135. data/vendor/crates/spikard-http/src/testing/test_client.rs +0 -825
  136. data/vendor/crates/spikard-http/src/testing.rs +0 -617
  137. data/vendor/crates/spikard-http/src/websocket.rs +0 -1477
  138. data/vendor/crates/spikard-http/tests/auth_integration.rs +0 -645
  139. data/vendor/crates/spikard-http/tests/background_behavior.rs +0 -832
  140. data/vendor/crates/spikard-http/tests/common/grpc_helpers.rs +0 -1012
  141. data/vendor/crates/spikard-http/tests/common/handlers.rs +0 -309
  142. data/vendor/crates/spikard-http/tests/common/mod.rs +0 -33
  143. data/vendor/crates/spikard-http/tests/common/test_builders.rs +0 -628
  144. data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +0 -162
  145. data/vendor/crates/spikard-http/tests/di_integration.rs +0 -192
  146. data/vendor/crates/spikard-http/tests/doc_snippets.rs +0 -5
  147. data/vendor/crates/spikard-http/tests/grpc_bidirectional_streaming.rs +0 -430
  148. data/vendor/crates/spikard-http/tests/grpc_client_streaming.rs +0 -738
  149. data/vendor/crates/spikard-http/tests/grpc_error_handling_test.rs +0 -652
  150. data/vendor/crates/spikard-http/tests/grpc_integration_test.rs +0 -334
  151. data/vendor/crates/spikard-http/tests/grpc_metadata_test.rs +0 -532
  152. data/vendor/crates/spikard-http/tests/grpc_server_integration.rs +0 -495
  153. data/vendor/crates/spikard-http/tests/grpc_server_streaming.rs +0 -975
  154. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +0 -1093
  155. data/vendor/crates/spikard-http/tests/middleware_stack_integration.rs +0 -389
  156. data/vendor/crates/spikard-http/tests/multipart_behavior.rs +0 -656
  157. data/vendor/crates/spikard-http/tests/request_extraction_full.rs +0 -513
  158. data/vendor/crates/spikard-http/tests/server_auth_middleware_behavior.rs +0 -328
  159. data/vendor/crates/spikard-http/tests/server_config_builder.rs +0 -335
  160. data/vendor/crates/spikard-http/tests/server_configured_router_behavior.rs +0 -374
  161. data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +0 -83
  162. data/vendor/crates/spikard-http/tests/server_handler_wrappers.rs +0 -464
  163. data/vendor/crates/spikard-http/tests/server_method_router_additional_behavior.rs +0 -286
  164. data/vendor/crates/spikard-http/tests/server_method_router_coverage.rs +0 -118
  165. data/vendor/crates/spikard-http/tests/server_middleware_behavior.rs +0 -99
  166. data/vendor/crates/spikard-http/tests/server_middleware_branches.rs +0 -204
  167. data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +0 -427
  168. data/vendor/crates/spikard-http/tests/server_router_behavior.rs +0 -121
  169. data/vendor/crates/spikard-http/tests/sse_behavior.rs +0 -620
  170. data/vendor/crates/spikard-http/tests/sse_full_behavior.rs +0 -584
  171. data/vendor/crates/spikard-http/tests/sse_handler_behavior.rs +0 -130
  172. data/vendor/crates/spikard-http/tests/test_client_requests.rs +0 -167
  173. data/vendor/crates/spikard-http/tests/testing_helpers.rs +0 -87
  174. data/vendor/crates/spikard-http/tests/testing_module_coverage.rs +0 -155
  175. data/vendor/crates/spikard-http/tests/urlencoded_content_type.rs +0 -82
  176. data/vendor/crates/spikard-http/tests/websocket_behavior.rs +0 -663
  177. data/vendor/crates/spikard-http/tests/websocket_full_behavior.rs +0 -440
  178. data/vendor/crates/spikard-http/tests/websocket_integration.rs +0 -150
  179. data/vendor/crates/spikard-rb/Cargo.toml +0 -63
  180. data/vendor/crates/spikard-rb/build.rs +0 -200
  181. data/vendor/crates/spikard-rb/src/background.rs +0 -63
  182. data/vendor/crates/spikard-rb/src/config/mod.rs +0 -5
  183. data/vendor/crates/spikard-rb/src/config/server_config.rs +0 -401
  184. data/vendor/crates/spikard-rb/src/conversion.rs +0 -688
  185. data/vendor/crates/spikard-rb/src/di/builder.rs +0 -100
  186. data/vendor/crates/spikard-rb/src/di/mod.rs +0 -410
  187. data/vendor/crates/spikard-rb/src/grpc/handler.rs +0 -875
  188. data/vendor/crates/spikard-rb/src/grpc/mod.rs +0 -13
  189. data/vendor/crates/spikard-rb/src/gvl.rs +0 -80
  190. data/vendor/crates/spikard-rb/src/handler.rs +0 -699
  191. data/vendor/crates/spikard-rb/src/integration/mod.rs +0 -3
  192. data/vendor/crates/spikard-rb/src/lib.rs +0 -2268
  193. data/vendor/crates/spikard-rb/src/lifecycle.rs +0 -334
  194. data/vendor/crates/spikard-rb/src/metadata/mod.rs +0 -5
  195. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +0 -507
  196. data/vendor/crates/spikard-rb/src/request.rs +0 -439
  197. data/vendor/crates/spikard-rb/src/runtime/mod.rs +0 -5
  198. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +0 -368
  199. data/vendor/crates/spikard-rb/src/server.rs +0 -304
  200. data/vendor/crates/spikard-rb/src/sse.rs +0 -231
  201. data/vendor/crates/spikard-rb/src/testing/client.rs +0 -698
  202. data/vendor/crates/spikard-rb/src/testing/mod.rs +0 -7
  203. data/vendor/crates/spikard-rb/src/testing/sse.rs +0 -108
  204. data/vendor/crates/spikard-rb/src/testing/websocket.rs +0 -573
  205. data/vendor/crates/spikard-rb/src/websocket.rs +0 -521
  206. data/vendor/crates/spikard-rb-macros/Cargo.toml +0 -20
  207. 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
- }