diff --git a/.env.example b/.env.example index ac76359c..d55d57b6 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 9b970952..1f3e6f35 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@
+
diff --git a/README_CN.md b/README_CN.md
index 7d3466ad..0ac5cc7b 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -26,6 +26,7 @@
+
diff --git a/crates/core/src/config/app.rs b/crates/core/src/config/app.rs
index 9ca4b696..a87067e4 100644
--- a/crates/core/src/config/app.rs
+++ b/crates/core/src/config/app.rs
@@ -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 {
diff --git a/crates/core/src/middleware/security_headers.rs b/crates/core/src/middleware/security_headers.rs
index e5a7d0d5..35c29308 100644
--- a/crates/core/src/middleware/security_headers.rs
+++ b/crates/core/src/middleware/security_headers.rs
@@ -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