use std::sync::Arc;
use serde::Deserialize;
pub const MIN_SECRET_LEN: usize = 32;
const DEMO_VALUES: &[&str] = &[
"changeme",
"change_me",
"change-me",
"secret",
"supersecret",
"super-secret",
"super_secret",
"your-secret-here",
"your_secret_here",
"insert-secret-here",
"replace-this",
"replace_me",
"todo",
"fixme",
"example",
"placeholder",
"dev_only",
"dev-only",
"test_secret",
"test-secret",
"test",
"password",
];
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SigningSecretConfig {
pub secret: Option<String>,
#[serde(default)]
pub previous_secrets: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SigningSecretError {
MissingInProduction,
TooShort {
actual: usize,
required: usize,
},
KnownWeakValue(String),
}
impl std::fmt::Display for SigningSecretError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingInProduction => write!(
f,
"signing secret is required in production; set \
AUTUMN_SECURITY__SIGNING_SECRET (generate with `openssl rand -hex 32`)"
),
Self::TooShort { actual, required } => write!(
f,
"signing secret is too short ({actual} bytes, minimum {required}); \
generate one with `openssl rand -hex 32`"
),
Self::KnownWeakValue(v) => write!(
f,
"signing secret looks like a template/demo value ({v:?}); \
generate one with `openssl rand -hex 32`"
),
}
}
}
pub fn validate_signing_secret(
secret: Option<&str>,
is_production: bool,
) -> Result<(), SigningSecretError> {
if !is_production {
return Ok(());
}
let secret = secret.ok_or(SigningSecretError::MissingInProduction)?;
let lower = secret.to_ascii_lowercase();
for &demo in DEMO_VALUES {
if lower == demo {
return Err(SigningSecretError::KnownWeakValue(secret.to_owned()));
}
}
let byte_len = secret.len();
if byte_len < MIN_SECRET_LEN {
return Err(SigningSecretError::TooShort {
actual: byte_len,
required: MIN_SECRET_LEN,
});
}
Ok(())
}
#[must_use]
pub fn hmac_sha256_hex(key: &[u8], message: &[u8]) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(message);
let bytes = mac.finalize().into_bytes();
bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
use std::fmt::Write as _;
let _ = write!(acc, "{b:02x}");
acc
})
}
fn ct_eq_str(a: &str, b: &str) -> bool {
use subtle::ConstantTimeEq;
a.as_bytes().ct_eq(b.as_bytes()).into()
}
fn generate_ephemeral_key() -> Vec<u8> {
let a = uuid::Uuid::new_v4();
let b = uuid::Uuid::new_v4();
let mut bytes = vec![0u8; 32];
bytes[..16].copy_from_slice(a.as_bytes());
bytes[16..].copy_from_slice(b.as_bytes());
bytes
}
#[derive(Clone, Debug)]
pub struct ResolvedSigningKeys {
pub current: Arc<[u8]>,
pub previous: Vec<Arc<[u8]>>,
}
impl ResolvedSigningKeys {
pub fn new(current: Vec<u8>, previous: Vec<Vec<u8>>) -> Self {
Self {
current: current.into(),
previous: previous.into_iter().map(|v: Vec<u8>| v.into()).collect(),
}
}
pub fn sign(&self, message: &[u8]) -> String {
hmac_sha256_hex(&self.current, message)
}
pub fn verify(&self, message: &[u8], hex_sig: &str) -> bool {
if ct_eq_str(&hmac_sha256_hex(&self.current, message), hex_sig) {
return true;
}
for prev in &self.previous {
if ct_eq_str(&hmac_sha256_hex(prev, message), hex_sig) {
return true;
}
}
false
}
}
pub fn resolve_signing_keys(config: &SigningSecretConfig) -> ResolvedSigningKeys {
let current = config
.secret
.as_deref()
.map_or_else(generate_ephemeral_key, |s| s.as_bytes().to_vec());
let previous = config
.previous_secrets
.iter()
.map(|s| s.as_bytes().to_vec())
.collect();
ResolvedSigningKeys::new(current, previous)
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SecurityConfig {
#[serde(default)]
pub headers: HeadersConfig,
#[serde(default)]
pub csrf: CsrfConfig,
#[serde(default)]
pub rate_limit: RateLimitConfig,
#[serde(default)]
pub upload: UploadConfig,
#[serde(default)]
pub webhooks: crate::webhook::WebhookConfig,
#[serde(default)]
pub forbidden_response: crate::authorization::ForbiddenResponse,
#[serde(default)]
pub allow_unauthorized_repository_api: bool,
#[serde(default)]
pub signing_secret: SigningSecretConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct HeadersConfig {
#[serde(default = "default_x_frame_options")]
pub x_frame_options: String,
#[serde(default = "default_true")]
pub x_content_type_options: bool,
#[serde(default = "default_true")]
pub xss_protection: bool,
#[serde(default)]
pub strict_transport_security: bool,
#[serde(default = "default_hsts_max_age")]
pub hsts_max_age_secs: u64,
#[serde(default = "default_true")]
pub hsts_include_subdomains: bool,
#[serde(default = "default_content_security_policy")]
pub content_security_policy: String,
#[serde(default = "default_referrer_policy")]
pub referrer_policy: String,
#[serde(default)]
pub permissions_policy: String,
}
impl Default for HeadersConfig {
fn default() -> Self {
Self {
x_frame_options: default_x_frame_options(),
x_content_type_options: true,
xss_protection: true,
strict_transport_security: false,
hsts_max_age_secs: default_hsts_max_age(),
hsts_include_subdomains: true,
content_security_policy: default_content_security_policy(),
referrer_policy: default_referrer_policy(),
permissions_policy: String::new(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct CsrfConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_csrf_header")]
pub token_header: String,
#[serde(default = "default_csrf_field")]
pub form_field: String,
#[serde(default = "default_csrf_cookie")]
pub cookie_name: String,
#[serde(default = "default_safe_methods")]
pub safe_methods: Vec<String>,
#[serde(default)]
pub exempt_paths: Vec<String>,
}
impl Default for CsrfConfig {
fn default() -> Self {
Self {
enabled: false,
token_header: default_csrf_header(),
form_field: default_csrf_field(),
cookie_name: default_csrf_cookie(),
safe_methods: default_safe_methods(),
exempt_paths: Vec::new(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct RateLimitConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_rps")]
pub requests_per_second: f64,
#[serde(default = "default_burst")]
pub burst: u32,
#[serde(default)]
pub trust_forwarded_headers: bool,
#[serde(default)]
pub trusted_proxies: Vec<String>,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: false,
requests_per_second: default_rps(),
burst: default_burst(),
trust_forwarded_headers: false,
trusted_proxies: Vec::new(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct UploadConfig {
#[serde(default = "default_max_request_size_bytes")]
pub max_request_size_bytes: usize,
#[serde(default = "default_max_file_size_bytes")]
pub max_file_size_bytes: usize,
#[serde(default)]
pub allowed_mime_types: Vec<String>,
}
impl Default for UploadConfig {
fn default() -> Self {
Self {
max_request_size_bytes: default_max_request_size_bytes(),
max_file_size_bytes: default_max_file_size_bytes(),
allowed_mime_types: Vec::new(),
}
}
}
const fn default_true() -> bool {
true
}
fn default_x_frame_options() -> String {
"DENY".to_owned()
}
const fn default_hsts_max_age() -> u64 {
31_536_000 }
fn default_referrer_policy() -> String {
"strict-origin-when-cross-origin".to_owned()
}
#[must_use]
pub fn default_content_security_policy() -> String {
"default-src 'self'; \
img-src 'self' data:; \
style-src 'self' 'unsafe-inline'; \
script-src 'self'; \
connect-src 'self'; \
form-action 'self'; \
frame-ancestors 'none'; \
base-uri 'self'"
.to_owned()
}
fn default_csrf_header() -> String {
"X-CSRF-Token".to_owned()
}
fn default_csrf_field() -> String {
"_csrf".to_owned()
}
fn default_csrf_cookie() -> String {
"autumn-csrf".to_owned()
}
fn default_safe_methods() -> Vec<String> {
vec![
"GET".to_owned(),
"HEAD".to_owned(),
"OPTIONS".to_owned(),
"TRACE".to_owned(),
]
}
const fn default_rps() -> f64 {
10.0
}
const fn default_burst() -> u32 {
20
}
const fn default_max_request_size_bytes() -> usize {
32 * 1024 * 1024
}
const fn default_max_file_size_bytes() -> usize {
16 * 1024 * 1024
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signing_secret_dev_skips_validation_with_none() {
assert!(validate_signing_secret(None, false).is_ok());
}
#[test]
fn signing_secret_dev_skips_validation_with_weak_value() {
assert!(validate_signing_secret(Some("changeme"), false).is_ok());
}
#[test]
fn signing_secret_dev_skips_validation_with_short_value() {
assert!(validate_signing_secret(Some("short"), false).is_ok());
}
#[test]
fn signing_secret_prod_missing_is_error() {
let err = validate_signing_secret(None, true).unwrap_err();
assert!(matches!(err, SigningSecretError::MissingInProduction));
}
#[test]
fn signing_secret_prod_too_short_is_error() {
let short = "a".repeat(MIN_SECRET_LEN - 1);
let err = validate_signing_secret(Some(&short), true).unwrap_err();
assert!(matches!(err, SigningSecretError::TooShort { .. }));
}
#[test]
fn signing_secret_prod_exact_min_length_passes() {
let exactly_min = "a".repeat(MIN_SECRET_LEN);
assert!(validate_signing_secret(Some(&exactly_min), true).is_ok());
}
#[test]
fn signing_secret_prod_known_demo_value_is_error() {
let err = validate_signing_secret(Some("changeme"), true).unwrap_err();
assert!(matches!(err, SigningSecretError::KnownWeakValue(_)));
}
#[test]
fn signing_secret_prod_demo_value_case_insensitive() {
let err = validate_signing_secret(Some("CHANGEME"), true).unwrap_err();
assert!(matches!(err, SigningSecretError::KnownWeakValue(_)));
}
#[test]
fn signing_secret_prod_valid_64char_hex_passes() {
let secret = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
assert!(validate_signing_secret(Some(secret), true).is_ok());
}
#[test]
fn signing_secret_config_defaults_to_none() {
let config = SigningSecretConfig::default();
assert!(config.secret.is_none());
assert!(config.previous_secrets.is_empty());
}
#[test]
fn signing_secret_error_missing_display_mentions_env_var() {
let err = SigningSecretError::MissingInProduction;
assert!(err.to_string().contains("AUTUMN_SECURITY__SIGNING_SECRET"));
}
#[test]
fn signing_secret_error_too_short_display_shows_lengths() {
let err = SigningSecretError::TooShort {
actual: 8,
required: 32,
};
let s = err.to_string();
assert!(s.contains('8'));
assert!(s.contains("32"));
}
#[test]
fn signing_secret_error_weak_value_display_mentions_demo() {
let err = SigningSecretError::KnownWeakValue("changeme".to_owned());
assert!(err.to_string().contains("template/demo"));
}
#[test]
fn signing_secret_prod_too_short_error_reports_actual_length() {
let short = "tooshort"; let err = validate_signing_secret(Some(short), true).unwrap_err();
if let SigningSecretError::TooShort { actual, required } = err {
assert_eq!(actual, 8);
assert_eq!(required, MIN_SECRET_LEN);
} else {
panic!("expected TooShort error");
}
}
#[test]
fn signing_secret_prod_secret_key_demo_value_fails() {
assert!(matches!(
validate_signing_secret(Some("secret"), true),
Err(SigningSecretError::KnownWeakValue(_))
));
}
#[test]
fn signing_secret_prod_supersecret_demo_value_fails() {
assert!(matches!(
validate_signing_secret(Some("supersecret"), true),
Err(SigningSecretError::KnownWeakValue(_))
));
}
#[test]
fn signing_secret_config_deserialize_from_toml() {
let toml_str = r#"
secret = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
previous_secrets = ["oldsecret01234567890123456789012"]
"#;
let config: SigningSecretConfig = toml::from_str(toml_str).unwrap();
assert_eq!(
config.secret.as_deref(),
Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4")
);
assert_eq!(config.previous_secrets.len(), 1);
}
#[test]
fn security_config_defaults() {
let config = SecurityConfig::default();
assert_eq!(config.headers.x_frame_options, "DENY");
assert!(config.headers.x_content_type_options);
assert!(config.headers.xss_protection);
assert!(!config.headers.strict_transport_security);
assert_eq!(config.headers.hsts_max_age_secs, 31_536_000);
assert!(!config.headers.content_security_policy.is_empty());
assert!(
config
.headers
.content_security_policy
.contains("default-src 'self'")
);
assert!(
config
.headers
.content_security_policy
.contains("script-src 'self'")
);
assert_eq!(
config.headers.referrer_policy,
"strict-origin-when-cross-origin"
);
}
#[test]
fn default_csp_does_not_allow_unsafe_eval() {
let csp = default_content_security_policy();
assert!(!csp.contains("'unsafe-eval'"), "csp = {csp}");
assert!(
!csp.contains("'unsafe-inline' 'unsafe-eval'"),
"csp = {csp}"
);
}
#[test]
fn csp_can_be_disabled_via_toml_empty_string() {
let toml_str = r#"
content_security_policy = ""
"#;
let config: HeadersConfig = toml::from_str(toml_str).unwrap();
assert!(config.content_security_policy.is_empty());
}
#[test]
fn csp_can_be_overridden_via_toml() {
let toml_str = r#"
content_security_policy = "default-src 'none'"
"#;
let config: HeadersConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.content_security_policy, "default-src 'none'");
}
#[test]
fn csrf_config_defaults() {
let config = CsrfConfig::default();
assert!(!config.enabled);
assert_eq!(config.token_header, "X-CSRF-Token");
assert_eq!(config.form_field, "_csrf");
assert_eq!(config.cookie_name, "autumn-csrf");
assert_eq!(config.safe_methods.len(), 4);
}
#[test]
fn headers_config_deserialize() {
let toml_str = r#"
x_frame_options = "SAMEORIGIN"
strict_transport_security = true
content_security_policy = "default-src 'self'"
"#;
let config: HeadersConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.x_frame_options, "SAMEORIGIN");
assert!(config.strict_transport_security);
assert_eq!(config.content_security_policy, "default-src 'self'");
assert!(config.x_content_type_options);
assert!(config.xss_protection);
}
#[test]
fn csrf_config_deserialize() {
let toml_str = r#"
enabled = true
token_header = "X-XSRF-Token"
"#;
let config: CsrfConfig = toml::from_str(toml_str).unwrap();
assert!(config.enabled);
assert_eq!(config.token_header, "X-XSRF-Token");
assert_eq!(config.form_field, "_csrf"); }
#[test]
fn rate_limit_config_defaults() {
let config = RateLimitConfig::default();
assert!(!config.enabled);
assert!((config.requests_per_second - 10.0).abs() < f64::EPSILON);
assert_eq!(config.burst, 20);
assert!(!config.trust_forwarded_headers);
assert!(config.trusted_proxies.is_empty());
}
#[test]
fn rate_limit_config_deserialize() {
let toml_str = r#"
enabled = true
requests_per_second = 5.0
burst = 100
trust_forwarded_headers = true
trusted_proxies = ["10.0.0.10", "203.0.113.0/24"]
"#;
let config: RateLimitConfig = toml::from_str(toml_str).unwrap();
assert!(config.enabled);
assert!((config.requests_per_second - 5.0).abs() < f64::EPSILON);
assert_eq!(config.burst, 100);
assert!(config.trust_forwarded_headers);
assert_eq!(config.trusted_proxies, vec!["10.0.0.10", "203.0.113.0/24"]);
}
#[test]
fn rate_limit_config_partial_deserialize_uses_defaults() {
let toml_str = "enabled = true";
let config: RateLimitConfig = toml::from_str(toml_str).unwrap();
assert!(config.enabled);
assert!((config.requests_per_second - 10.0).abs() < f64::EPSILON);
assert_eq!(config.burst, 20);
assert!(!config.trust_forwarded_headers);
assert!(config.trusted_proxies.is_empty());
}
#[test]
fn upload_config_defaults() {
let config = UploadConfig::default();
assert_eq!(config.max_request_size_bytes, 32 * 1024 * 1024);
assert_eq!(config.max_file_size_bytes, 16 * 1024 * 1024);
assert!(config.allowed_mime_types.is_empty());
}
#[test]
fn upload_config_deserialize() {
let toml_str = r#"
max_request_size_bytes = 1024
max_file_size_bytes = 256
allowed_mime_types = ["image/png", "image/jpeg"]
"#;
let config: UploadConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.max_request_size_bytes, 1024);
assert_eq!(config.max_file_size_bytes, 256);
assert_eq!(config.allowed_mime_types.len(), 2);
}
#[test]
fn full_security_config_deserialize() {
let toml_str = r#"
[headers]
x_frame_options = "DENY"
strict_transport_security = true
[csrf]
enabled = true
[rate_limit]
enabled = true
requests_per_second = 50.0
burst = 100
[upload]
max_request_size_bytes = 4096
max_file_size_bytes = 1024
allowed_mime_types = ["text/plain"]
"#;
let config: SecurityConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.headers.x_frame_options, "DENY");
assert!(config.headers.strict_transport_security);
assert!(config.csrf.enabled);
assert!(config.rate_limit.enabled);
assert!((config.rate_limit.requests_per_second - 50.0).abs() < f64::EPSILON);
assert_eq!(config.rate_limit.burst, 100);
assert_eq!(config.upload.max_request_size_bytes, 4096);
assert_eq!(config.upload.max_file_size_bytes, 1024);
assert_eq!(config.upload.allowed_mime_types, vec!["text/plain"]);
}
#[test]
fn resolve_signing_keys_dev_generates_non_empty_ephemeral() {
let config = SigningSecretConfig::default();
let keys = resolve_signing_keys(&config);
assert!(keys.current.len() >= MIN_SECRET_LEN);
}
#[test]
fn resolve_signing_keys_prod_uses_secret_bytes() {
let secret = "a".repeat(MIN_SECRET_LEN);
let config = SigningSecretConfig {
secret: Some(secret.clone()),
previous_secrets: vec![],
};
let keys = resolve_signing_keys(&config);
assert_eq!(keys.current.as_ref(), secret.as_bytes());
}
#[test]
fn resolve_signing_keys_includes_previous_secrets() {
let config = SigningSecretConfig {
secret: Some("a".repeat(MIN_SECRET_LEN)),
previous_secrets: vec!["b".repeat(MIN_SECRET_LEN)],
};
let keys = resolve_signing_keys(&config);
assert_eq!(keys.previous.len(), 1);
assert_eq!(
keys.previous[0].as_ref(),
"b".repeat(MIN_SECRET_LEN).as_bytes()
);
}
#[test]
fn resolved_keys_sign_and_verify_current() {
let keys = ResolvedSigningKeys::new(b"current-key-32-bytes-xxxxxxxxxx".to_vec(), vec![]);
let sig = keys.sign(b"test-message");
assert!(keys.verify(b"test-message", &sig));
}
#[test]
fn resolved_keys_verify_rejects_wrong_message() {
let keys = ResolvedSigningKeys::new(b"current-key-32-bytes-xxxxxxxxxx".to_vec(), vec![]);
let sig = keys.sign(b"message-a");
assert!(!keys.verify(b"message-b", &sig));
}
#[test]
fn resolved_keys_verify_previous_key_passes() {
let old_key = b"old-key-32-bytes-xxxxxxxxxxxx!x".to_vec();
let new_key = b"new-key-32-bytes-xxxxxxxxxxxx!x".to_vec();
let old_keys = ResolvedSigningKeys::new(old_key.clone(), vec![]);
let old_sig = old_keys.sign(b"session-id");
let new_keys = ResolvedSigningKeys::new(new_key, vec![old_key]);
assert!(new_keys.verify(b"session-id", &old_sig));
}
#[test]
fn resolved_keys_verify_wrong_key_fails() {
let keys_a = ResolvedSigningKeys::new(b"key-a-32-bytes-xxxxxxxxxxxxxxxx".to_vec(), vec![]);
let keys_b = ResolvedSigningKeys::new(b"key-b-32-bytes-xxxxxxxxxxxxxxxx".to_vec(), vec![]);
let sig = keys_a.sign(b"message");
assert!(!keys_b.verify(b"message", &sig));
}
#[test]
fn resolved_keys_sign_produces_64_char_hex() {
let keys = ResolvedSigningKeys::new(b"key".to_vec(), vec![]);
let sig = keys.sign(b"msg");
assert_eq!(sig.len(), 64, "HMAC-SHA256 hex is 64 chars");
assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
}
}