feat: update csp , readme

This commit is contained in:
zhongqin
2026-08-21 22:55:29 +08:00
parent 7ddabeada6
commit 240d00a1fc
5 changed files with 158 additions and 36 deletions
+9 -3
View File
@@ -74,9 +74,15 @@ CRON_LOG_RETENTION_DAYS=30
# CORS (set explicit origins in production; comma-separated)
# CORS_ORIGINS=http://localhost:3000
# CSP — full policy override (nginx-style single value; built-in default when unset)
# Example: allow Cloudflare Web Analytics + admin template gallery (templates.json & screenshots from raisfast.com)
# CSP=default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://raisfast.com; font-src 'self'; connect-src 'self' https://cloudflareinsights.com https://raisfast.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
# CSP extra origins — space-separated list appended to script/connect/img/style-src.
# Use this to allow third-party scripts (analytics, chat widgets, CDN).
# Example: Cloudflare Web Analytics beacon
# CSP_ALLOW="https://static.cloudflareinsights.com https://cloudflareinsights.com"
#
# CSP — full policy override for experts (verbatim header value, nginx-style).
# Wins over CSP_ALLOW. Values containing single quotes MUST be wrapped in double
# quotes or the whole .env file fails to parse.
# CSP="default-src 'self'; script-src 'self'; ..."
# Public site URL (how frontends reach this backend; defaults to APP_HOST:APP_PORT)
# BASE_URL=http://localhost:3000
+1
View File
@@ -26,6 +26,7 @@
<p align="center">
<a href="https://github.com/RaisFast/raisfast/stargazers"><img src="https://img.shields.io/github/stars/RaisFast/raisfast?style=social" alt="GitHub Stars" /></a>
<a href="https://github.com/RaisFast/raisfast/releases"><img src="https://img.shields.io/github/downloads/RaisFast/raisfast/total?color=blue" alt="Total Downloads" /></a>
<a href="https://github.com/RaisFast/raisfast/releases"><img src="https://img.shields.io/github/v/release/RaisFast/raisfast?color=blue" alt="Latest Release" /></a>
<a href="https://github.com/RaisFast/raisfast"><img src="https://img.shields.io/badge/Rust-edition%202024-orange" alt="Rust Edition 2024" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/github/license/RaisFast/raisfast?color=success" alt="License" /></a>
+1
View File
@@ -26,6 +26,7 @@
<p align="center">
<a href="https://github.com/RaisFast/raisfast/stargazers"><img src="https://img.shields.io/github/stars/RaisFast/raisfast?style=social" alt="GitHub Stars" /></a>
<a href="https://github.com/RaisFast/raisfast/releases"><img src="https://img.shields.io/github/downloads/RaisFast/raisfast/total?color=blue" alt="Total Downloads" /></a>
<a href="https://github.com/RaisFast/raisfast/releases"><img src="https://img.shields.io/github/v/release/RaisFast/raisfast?color=blue" alt="Latest Release" /></a>
<a href="https://github.com/RaisFast/raisfast"><img src="https://img.shields.io/badge/Rust-edition%202024-orange" alt="Rust Edition 2024" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/github/license/RaisFast/raisfast?color=success" alt="License" /></a>
+28 -6
View File
@@ -589,6 +589,28 @@ fn default_storage_root_dir() -> String {
"./storage".into()
}
/// Load a dotenv file, printing a warning when it exists but fails to parse.
///
/// dotenvy aborts the whole file on the first malformed line — most commonly a
/// value containing single quotes without an outer double-quote wrapper (e.g.
/// `CSP=default-src 'self'; ...`). Without this warning that failure is
/// completely silent: every variable in the file is skipped.
///
/// Uses `eprintln!` (not `tracing`) because this runs before the logging
/// subscriber is installed.
fn load_dotenv(path: &str) {
if let Err(e) = dotenvy::from_path(path) {
match &e {
dotenvy::Error::Io(io) if io.kind() == std::io::ErrorKind::NotFound => {}
_ => eprintln!(
"warning: failed to parse {path} ({e}); NONE of its variables were loaded. \
Hint: wrap values containing quotes in double quotes, e.g. \
KEY=\"value with 'single quotes'\""
),
}
}
}
fn default_backup_retention() -> usize {
10
}
@@ -1302,9 +1324,9 @@ impl AppConfig {
.or_else(|_| env::var("APP_ENV"))
.unwrap_or_else(|_| "development".into());
dotenvy::from_path(".env").ok();
dotenvy::from_path(format!(".env.{profile}")).ok();
dotenvy::from_path(".env.local").ok();
load_dotenv(".env");
load_dotenv(&format!(".env.{profile}"));
load_dotenv(".env.local");
let mut config = Self::from_env();
config.started_at = Some(std::time::Instant::now());
@@ -1342,9 +1364,9 @@ impl AppConfig {
///
/// Useful for CLI tools that need env vars but not a full `AppConfig`.
pub fn load_env(profile: &str) {
dotenvy::from_path(".env").ok();
dotenvy::from_path(format!(".env.{profile}")).ok();
dotenvy::from_path(".env.local").ok();
load_dotenv(".env");
load_dotenv(&format!(".env.{profile}"));
load_dotenv(".env.local");
}
fn generate_app_key() -> String {
+119 -27
View File
@@ -25,32 +25,79 @@ static STRICT_TRANSPORT_SECURITY: HeaderName = HeaderName::from_static("strict-t
static CONTENT_SECURITY_POLICY: HeaderName = HeaderName::from_static("content-security-policy");
/// Build the Content-Security-Policy value.
/// Domains of raisfast's own infrastructure (docs site & template gallery),
/// allowed by default because the shipped admin UI loads template data and
/// screenshots from them — first-party features must work out of the box.
const RAISFAST_ORIGINS: &str = "https://raisfast.com https://www.raisfast.com";
/// Build the Content-Security-Policy value. Precedence (simple → expert):
///
/// Set the `CSP` env var to override the built-in policy entirely (verbatim
/// header value, nginx-style). Typical additions: Cloudflare Insights beacon
/// (`script-src`/`connect-src`) and the admin template gallery (`connect-src`/
/// `img-src` for `https://raisfast.com`). Cached for the process lifetime.
/// 1. **Default (nothing set)** — strict policy; only raisfast's own domains
/// are additionally allowed (built-in admin template gallery).
/// 2. **`CSP_ALLOW`** — space-separated extra origins, appended to
/// `script-src`, `connect-src`, `img-src` and `style-src`. Covers most
/// third-party needs (analytics, chat widgets, CDN scripts), e.g.
/// `CSP_ALLOW="https://static.cloudflareinsights.com https://cloudflareinsights.com"`.
/// 3. **`CSP`** — full policy override (verbatim header value, nginx-style)
/// for complete control; wins over everything above.
///
/// Cached for the process lifetime.
fn csp_value() -> &'static HeaderValue {
static CSP: OnceLock<HeaderValue> = OnceLock::new();
CSP.get_or_init(|| match std::env::var("CSP") {
Ok(policy) if !policy.trim().is_empty() => match HeaderValue::from_str(policy.trim()) {
Ok(v) => v,
Err(e) => {
tracing::warn!("invalid CSP env var ({e}), using default policy");
default_csp()
}
},
_ => default_csp(),
CSP.get_or_init(|| {
resolve_csp(
std::env::var("CSP").ok().as_deref(),
std::env::var("CSP_ALLOW").ok().as_deref(),
)
})
}
fn default_csp() -> HeaderValue {
HeaderValue::from_static(
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
/// Pure resolution of the policy header from the `CSP` / `CSP_ALLOW` values.
/// Deterministic and independent of the process environment (testable).
fn resolve_csp(csp: Option<&str>, allow: Option<&str>) -> HeaderValue {
// 3. Full override (wins over CSP_ALLOW)
if let Some(policy) = csp.map(str::trim).filter(|p| !p.is_empty()) {
match HeaderValue::from_str(policy) {
Ok(v) => return v,
Err(e) => eprintln!(
"warning: invalid CSP env var ({e}); falling back to the default policy. \
Hint: wrap values containing quotes in double quotes"
),
}
}
// 2. Extra origins appended to the default policy, otherwise default.
let extra = allow.unwrap_or_default().trim();
HeaderValue::from_str(&build_csp(extra)).unwrap_or_else(|_| default_csp())
}
/// Compose the policy string from optional extra origins (pure; testable).
fn build_csp(extra: &str) -> String {
if extra.is_empty() {
return format!(
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; \
img-src 'self' data: blob: {RAISFAST_ORIGINS}; font-src 'self'; \
connect-src 'self' {RAISFAST_ORIGINS}; frame-ancestors 'none'; \
base-uri 'self'; form-action 'self'"
);
}
format!(
"default-src 'self'; script-src 'self' {extra}; style-src 'self' 'unsafe-inline' {extra}; \
img-src 'self' data: blob: {RAISFAST_ORIGINS} {extra}; font-src 'self'; \
connect-src 'self' {RAISFAST_ORIGINS} {extra}; frame-ancestors 'none'; \
base-uri 'self'; form-action 'self'"
)
}
fn default_csp() -> HeaderValue {
// No unwrap/expect: build_csp only interpolates RAISFAST_ORIGINS / extra,
// both of which must come from env; on invalid header chars fall back to
// the static baseline below.
HeaderValue::from_str(&build_csp("")).unwrap_or(HeaderValue::from_static(
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
))
}
/// Security response headers middleware.
pub async fn security_headers(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
@@ -92,19 +139,14 @@ mod tests {
use super::*;
#[test]
fn csp_value_returns_strict_default_without_env() {
// std::env::var reads the process env; the CSP var is unset in tests,
// so this exercises the default branch.
let default = default_csp();
let v = csp_value();
assert_eq!(v, default);
assert!(v.to_str().unwrap().contains("script-src 'self'"));
fn resolve_default_policy_without_any_env() {
let v = resolve_csp(None, None);
assert_eq!(v, default_csp());
}
#[test]
fn default_csp_is_valid_directive_list() {
let default = default_csp();
let s = default.to_str().unwrap();
fn default_policy_has_all_directives() {
let s = build_csp("");
for directive in [
"default-src",
"script-src",
@@ -119,4 +161,54 @@ mod tests {
assert!(s.contains(directive), "missing {directive}");
}
}
#[test]
fn default_policy_allows_raisfast_own_domains() {
// The shipped admin template gallery loads templates.json and
// screenshots from raisfast.com / www.raisfast.com — must be allowed
// out of the box (connect-src + img-src).
let s = build_csp("");
assert!(
s.contains("img-src 'self' data: blob: https://raisfast.com https://www.raisfast.com")
);
assert!(s.contains("connect-src 'self' https://raisfast.com https://www.raisfast.com"));
// First-party domains are NOT allowed to run scripts by default.
assert_eq!(
s.split("script-src").nth(1).unwrap().split(';').next(),
Some(" 'self'")
);
}
#[test]
fn csp_allow_appends_extra_origins_to_all_fetch_directives() {
let s = build_csp("https://analytics.example.com");
assert!(s.contains("script-src 'self' https://analytics.example.com"));
assert!(s.contains("style-src 'self' 'unsafe-inline' https://analytics.example.com"));
assert!(s.contains("connect-src 'self' https://raisfast.com https://www.raisfast.com https://analytics.example.com"));
let base = build_csp("");
assert!(!base.contains("example.com"));
}
#[test]
fn full_csp_override_wins_over_csp_allow() {
let v = resolve_csp(Some("default-src 'self'"), Some("https://x.example.com"));
assert_eq!(v.to_str().unwrap(), "default-src 'self'");
}
#[test]
fn empty_or_whitespace_csp_falls_back_to_allow_tier() {
let v = resolve_csp(Some(" "), Some("https://analytics.example.com"));
assert!(
v.to_str()
.unwrap()
.contains("script-src 'self' https://analytics.example.com")
);
}
#[test]
fn invalid_csp_override_falls_back_to_default_policy() {
// '\n' is not a legal header byte → override rejected, default used.
let v = resolve_csp(Some("default-src 'self';\nscript-src *"), None);
assert_eq!(v, default_csp());
}
}