mirror of
https://github.com/RaisFast/raisfast.git
synced 2026-10-08 08:02:46 +00:00
feat: update csp , readme
This commit is contained in:
+9
-3
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user