1use std::{num::NonZeroU32, num::NonZeroU64, time::Duration};
4
5use http::{HeaderName, Method, Uri};
6use soaprs_auth::AuthorizationName;
7use soaprs_core::{SoapError, SoapResult};
8
9#[derive(Debug, Clone, PartialEq, Eq, Default)]
11pub enum RateLimitScope {
12 Global,
14 #[default]
16 ClientIp,
17 Principal,
19 ApiKey,
21 Custom(AuthorizationName),
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RateLimitPolicy {
28 pub requests: NonZeroU32,
30 pub period: Duration,
32 pub burst: Option<NonZeroU32>,
34 pub scope: RateLimitScope,
36}
37
38impl RateLimitPolicy {
39 pub fn new(requests: NonZeroU32, period: Duration) -> SoapResult<Self> {
41 if period.is_zero() {
42 return Err(SoapError::validation(
43 "rate-limit period must be greater than zero",
44 ));
45 }
46 Ok(Self {
47 requests,
48 period,
49 burst: None,
50 scope: RateLimitScope::ClientIp,
51 })
52 }
53
54 #[must_use]
56 pub fn scope(mut self, scope: RateLimitScope) -> Self {
57 self.scope = scope;
58 self
59 }
60
61 #[must_use]
63 pub fn burst(mut self, burst: NonZeroU32) -> Self {
64 self.burst = Some(burst);
65 self
66 }
67
68 pub fn validate(&self) -> SoapResult<()> {
70 if self.period.is_zero() {
71 Err(SoapError::validation(
72 "rate-limit period must be greater than zero",
73 ))
74 } else {
75 Ok(())
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct BodyLimitPolicy {
83 pub max_bytes: NonZeroU64,
85}
86
87impl BodyLimitPolicy {
88 pub const fn new(max_bytes: NonZeroU64) -> Self {
90 Self { max_bytes }
91 }
92}
93
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
96pub enum CsrfPolicy {
97 #[default]
99 Disabled,
100 Required,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum AllowedOrigins {
107 Any,
109 Exact(Vec<Uri>),
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct CorsPolicy {
116 pub origins: AllowedOrigins,
118 pub methods: Vec<Method>,
120 pub allow_headers: Vec<HeaderName>,
122 pub expose_headers: Vec<HeaderName>,
124 pub allow_credentials: bool,
126 pub max_age: Option<Duration>,
128}
129
130impl CorsPolicy {
131 pub fn any(methods: Vec<Method>) -> SoapResult<Self> {
133 validate_methods(&methods)?;
134 Ok(Self {
135 origins: AllowedOrigins::Any,
136 methods,
137 allow_headers: Vec::new(),
138 expose_headers: Vec::new(),
139 allow_credentials: false,
140 max_age: None,
141 })
142 }
143
144 pub fn exact<I, S>(origins: I, methods: Vec<Method>) -> SoapResult<Self>
146 where
147 I: IntoIterator<Item = S>,
148 S: AsRef<str>,
149 {
150 validate_methods(&methods)?;
151 let origins = origins
152 .into_iter()
153 .map(|origin| validate_origin(origin.as_ref()))
154 .collect::<SoapResult<Vec<_>>>()?;
155 if origins.is_empty() {
156 return Err(SoapError::validation("CORS origins cannot be empty"));
157 }
158 Ok(Self {
159 origins: AllowedOrigins::Exact(origins),
160 methods,
161 allow_headers: Vec::new(),
162 expose_headers: Vec::new(),
163 allow_credentials: false,
164 max_age: None,
165 })
166 }
167
168 pub fn allow_credentials(mut self) -> SoapResult<Self> {
170 if matches!(self.origins, AllowedOrigins::Any) {
171 return Err(SoapError::validation(
172 "credentialed CORS cannot allow every origin",
173 ));
174 }
175 self.allow_credentials = true;
176 Ok(self)
177 }
178
179 #[must_use]
181 pub fn allow_headers(mut self, headers: Vec<HeaderName>) -> Self {
182 self.allow_headers = headers;
183 self
184 }
185
186 #[must_use]
188 pub fn expose_headers(mut self, headers: Vec<HeaderName>) -> Self {
189 self.expose_headers = headers;
190 self
191 }
192
193 pub fn max_age(mut self, max_age: Duration) -> SoapResult<Self> {
195 if max_age.is_zero() {
196 return Err(SoapError::validation(
197 "CORS max age must be greater than zero",
198 ));
199 }
200 self.max_age = Some(max_age);
201 Ok(self)
202 }
203
204 pub fn validate(&self) -> SoapResult<()> {
206 validate_methods(&self.methods)?;
207 match &self.origins {
208 AllowedOrigins::Any if self.allow_credentials => {
209 return Err(SoapError::validation(
210 "credentialed CORS cannot allow every origin",
211 ));
212 }
213 AllowedOrigins::Exact(origins) if origins.is_empty() => {
214 return Err(SoapError::validation("CORS origins cannot be empty"));
215 }
216 AllowedOrigins::Exact(origins) => {
217 origins
218 .iter()
219 .try_for_each(|origin| validate_origin(&origin.to_string()).map(|_| ()))?;
220 }
221 AllowedOrigins::Any => {}
222 }
223 if self.max_age.is_some_and(|max_age| max_age.is_zero()) {
224 return Err(SoapError::validation(
225 "CORS max age must be greater than zero",
226 ));
227 }
228 Ok(())
229 }
230}
231
232fn validate_methods(methods: &[Method]) -> SoapResult<()> {
233 if methods.is_empty() {
234 Err(SoapError::validation("CORS methods cannot be empty"))
235 } else {
236 Ok(())
237 }
238}
239
240fn validate_origin(origin: &str) -> SoapResult<Uri> {
241 let uri = origin
242 .parse::<Uri>()
243 .map_err(|_| SoapError::validation(format!("invalid CORS origin `{origin}`")))?;
244 let has_root_or_empty_path = uri.path().is_empty() || uri.path() == "/";
245 if !matches!(uri.scheme_str(), Some("http" | "https"))
246 || uri.authority().is_none()
247 || uri
248 .authority()
249 .is_some_and(|authority| authority.as_str().contains('@'))
250 || !has_root_or_empty_path
251 || uri.query().is_some()
252 {
253 return Err(SoapError::validation(format!(
254 "invalid CORS origin `{origin}`"
255 )));
256 }
257 Ok(uri)
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum FrameOptions {
263 Deny,
265 SameOrigin,
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum ReferrerPolicy {
272 NoReferrer,
274 StrictOriginWhenCrossOrigin,
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub struct HstsPolicy {
281 pub max_age: Duration,
283 pub include_subdomains: bool,
285 pub preload: bool,
287}
288
289impl HstsPolicy {
290 pub fn new(max_age: Duration) -> SoapResult<Self> {
292 if max_age.is_zero() {
293 return Err(SoapError::validation(
294 "HSTS max age must be greater than zero",
295 ));
296 }
297 Ok(Self {
298 max_age,
299 include_subdomains: false,
300 preload: false,
301 })
302 }
303
304 #[must_use]
306 pub const fn include_subdomains(mut self) -> Self {
307 self.include_subdomains = true;
308 self
309 }
310
311 #[must_use]
313 pub const fn preload(mut self) -> Self {
314 self.preload = true;
315 self
316 }
317}
318
319#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct SecurityHeadersPolicy {
322 pub no_sniff: bool,
324 pub frame_options: Option<FrameOptions>,
326 pub referrer_policy: Option<ReferrerPolicy>,
328 pub content_security_policy: Option<String>,
330 pub hsts: Option<HstsPolicy>,
332}
333
334impl SecurityHeadersPolicy {
335 pub const fn secure_defaults() -> Self {
337 Self {
338 no_sniff: true,
339 frame_options: Some(FrameOptions::Deny),
340 referrer_policy: Some(ReferrerPolicy::NoReferrer),
341 content_security_policy: None,
342 hsts: None,
343 }
344 }
345
346 pub fn content_security_policy(mut self, policy: impl Into<String>) -> SoapResult<Self> {
348 let policy = policy.into();
349 if policy.trim().is_empty() || policy.chars().any(char::is_control) {
350 return Err(SoapError::validation(
351 "content security policy is empty or contains control characters",
352 ));
353 }
354 self.content_security_policy = Some(policy);
355 Ok(self)
356 }
357
358 #[must_use]
360 pub const fn hsts(mut self, policy: HstsPolicy) -> Self {
361 self.hsts = Some(policy);
362 self
363 }
364
365 pub fn validate(&self) -> SoapResult<()> {
367 if self
368 .content_security_policy
369 .as_ref()
370 .is_some_and(|policy| policy.trim().is_empty() || policy.chars().any(char::is_control))
371 {
372 return Err(SoapError::validation(
373 "content security policy is empty or contains control characters",
374 ));
375 }
376 if self.hsts.is_some_and(|policy| policy.max_age.is_zero()) {
377 return Err(SoapError::validation(
378 "HSTS max age must be greater than zero",
379 ));
380 }
381 Ok(())
382 }
383}
384
385impl Default for SecurityHeadersPolicy {
386 fn default() -> Self {
387 Self::secure_defaults()
388 }
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393pub enum CacheVisibility {
394 NoStore,
396 Private,
398 Public,
400}
401
402#[derive(Debug, Clone, PartialEq, Eq)]
404pub struct ResponseCachePolicy {
405 pub visibility: CacheVisibility,
407 pub max_age: Option<Duration>,
409 pub vary: Vec<HeaderName>,
411}
412
413impl ResponseCachePolicy {
414 pub const fn no_store() -> Self {
416 Self {
417 visibility: CacheVisibility::NoStore,
418 max_age: None,
419 vary: Vec::new(),
420 }
421 }
422
423 pub fn private(max_age: Duration) -> SoapResult<Self> {
425 Self::cacheable(CacheVisibility::Private, max_age)
426 }
427
428 pub fn public(max_age: Duration) -> SoapResult<Self> {
430 Self::cacheable(CacheVisibility::Public, max_age)
431 }
432
433 fn cacheable(visibility: CacheVisibility, max_age: Duration) -> SoapResult<Self> {
434 if max_age.is_zero() {
435 return Err(SoapError::validation(
436 "response cache max age must be greater than zero",
437 ));
438 }
439 Ok(Self {
440 visibility,
441 max_age: Some(max_age),
442 vary: Vec::new(),
443 })
444 }
445
446 #[must_use]
448 pub fn vary(mut self, vary: Vec<HeaderName>) -> Self {
449 self.vary = vary;
450 self
451 }
452
453 pub fn validate(&self) -> SoapResult<()> {
455 match (self.visibility, self.max_age) {
456 (CacheVisibility::NoStore, Some(_)) => Err(SoapError::validation(
457 "no-store response cache policy cannot declare max age",
458 )),
459 (CacheVisibility::Private | CacheVisibility::Public, None) => Err(
460 SoapError::validation("cacheable response policy requires max age"),
461 ),
462 (_, Some(max_age)) if max_age.is_zero() => Err(SoapError::validation(
463 "response cache max age must be greater than zero",
464 )),
465 _ => Ok(()),
466 }
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use std::{num::NonZeroU32, time::Duration};
473
474 use http::Method;
475
476 use soaprs_auth::AuthorizationPolicy;
477
478 use super::{
479 CacheVisibility, CorsPolicy, RateLimitPolicy, RateLimitScope, ResponseCachePolicy,
480 SecurityHeadersPolicy,
481 };
482
483 #[test]
484 fn authorization_names_and_lists_are_validated() {
485 assert!(AuthorizationPolicy::strategy("jwt").is_ok());
486 assert!(AuthorizationPolicy::optional_strategy("session").is_ok());
487 assert!(AuthorizationPolicy::all_permissions(["orders:read", "orders:write"]).is_ok());
488 assert!(AuthorizationPolicy::any_role(Vec::<String>::new()).is_err());
489 assert!(AuthorizationPolicy::named("bad policy name").is_err());
490 assert!(!AuthorizationPolicy::Optional.requires_identity());
491 assert!(AuthorizationPolicy::Optional.authenticates_when_present());
492 assert!(!AuthorizationPolicy::Optional.allows_public_response_cache());
493 }
494
495 #[test]
496 fn rate_limits_include_scope_and_burst_without_choosing_storage() {
497 let Some(requests) = NonZeroU32::new(100) else {
498 panic!("non-zero fixture");
499 };
500 let result = RateLimitPolicy::new(requests, Duration::from_secs(60)).map(|policy| {
501 policy
502 .scope(RateLimitScope::Principal)
503 .burst(NonZeroU32::MIN)
504 });
505 assert!(result.is_ok());
506 }
507
508 #[test]
509 fn cors_rejects_wildcard_credentials_and_non_origins() {
510 let wildcard = CorsPolicy::any(vec![Method::GET]);
511 assert!(wildcard.and_then(CorsPolicy::allow_credentials).is_err());
512 assert!(CorsPolicy::exact(["https://example.com"], vec![Method::GET]).is_ok());
513 assert!(CorsPolicy::exact(["https://example.com/path"], vec![Method::GET]).is_err());
514 assert!(CorsPolicy::exact(["ftp://example.com"], vec![Method::GET]).is_err());
515 assert!(CorsPolicy::exact(["https://user@example.com"], vec![Method::GET]).is_err());
516 }
517
518 #[test]
519 fn public_policy_fields_are_revalidated_before_registration() {
520 let Some(requests) = NonZeroU32::new(10) else {
521 panic!("non-zero fixture");
522 };
523 let Some(mut rate_limit) = RateLimitPolicy::new(requests, Duration::from_secs(1)).ok()
524 else {
525 panic!("valid rate limit");
526 };
527 rate_limit.period = Duration::ZERO;
528 assert!(rate_limit.validate().is_err());
529
530 let mut cors = CorsPolicy::any(vec![Method::GET]).unwrap_or_else(|error| {
531 panic!("valid CORS fixture failed: {error}");
532 });
533 cors.allow_credentials = true;
534 assert!(cors.validate().is_err());
535
536 let mut cache = ResponseCachePolicy::no_store();
537 cache.visibility = CacheVisibility::Public;
538 assert!(cache.validate().is_err());
539
540 let mut headers = SecurityHeadersPolicy::secure_defaults();
541 headers.content_security_policy = Some("\r\ninjected: true".to_owned());
542 assert!(headers.validate().is_err());
543 }
544}