Merge pull request #783 from rager306/fix/csp-nonce

fix: replace unsafe-inline CSP with per-request nonce
This commit is contained in:
Jaber Jaber
2026-03-27 05:36:21 +03:00
committed by GitHub
2 changed files with 44 additions and 18 deletions
+10 -7
View File
@@ -236,13 +236,16 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
headers.insert("x-content-type-options", "nosniff".parse().unwrap());
headers.insert("x-frame-options", "DENY".parse().unwrap());
headers.insert("x-xss-protection", "1; mode=block".parse().unwrap());
// All JS/CSS is bundled inline — only external resource is Google Fonts.
headers.insert(
"content-security-policy",
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; font-src 'self' https://fonts.gstatic.com; media-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
.parse()
.unwrap(),
);
// The dashboard handler (webchat_page) sets its own nonce-based CSP.
// For all other responses (API endpoints), apply a strict default.
if !headers.contains_key("content-security-policy") {
headers.insert(
"content-security-policy",
"default-src 'none'; frame-ancestors 'none'"
.parse()
.unwrap(),
);
}
headers.insert(
"referrer-policy",
"strict-origin-when-cross-origin".parse().unwrap(),
+34 -11
View File
@@ -15,7 +15,13 @@
use axum::http::header;
use axum::response::IntoResponse;
/// Nonce placeholder in compile-time HTML, replaced at request time.
const NONCE_PLACEHOLDER: &str = "__NONCE__";
/// Compile-time ETag based on the crate version.
/// Not used for the dashboard page (nonce prevents caching) but retained
/// for potential future use by static asset handlers.
#[allow(dead_code)]
const ETAG: &str = concat!("\"openfang-", env!("CARGO_PKG_VERSION"), "\"");
/// Embedded logo PNG for single-binary deployment.
@@ -76,18 +82,35 @@ pub async fn sw_js() -> impl IntoResponse {
/// GET / — Serve the OpenFang Dashboard single-page application.
///
/// Returns the full SPA with ETag header based on package version for caching.
/// Generates a unique CSP nonce on every request and injects it into both
/// the `<script>` tags and the `Content-Security-Policy` header. This
/// replaces `'unsafe-inline'` so only our own scripts execute.
pub async fn webchat_page() -> impl IntoResponse {
let nonce = uuid::Uuid::new_v4().to_string();
let html = WEBCHAT_HTML.replace(NONCE_PLACEHOLDER, &nonce);
let csp = format!(
"default-src 'self'; \
script-src 'self' 'nonce-{nonce}' 'unsafe-eval'; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; \
img-src 'self' data: blob:; \
connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; \
font-src 'self' https://fonts.gstatic.com; \
media-src 'self' blob:; \
frame-src 'self' blob:; \
object-src 'none'; \
base-uri 'self'; \
form-action 'self'"
);
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(header::ETAG, ETAG),
(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
(
header::CACHE_CONTROL,
"public, max-age=3600, must-revalidate",
header::HeaderName::from_static("content-security-policy"),
csp,
),
(header::CACHE_CONTROL, "no-store".to_string()),
],
WEBCHAT_HTML,
html,
)
}
@@ -110,17 +133,17 @@ const WEBCHAT_HTML: &str = concat!(
"\n</style>\n",
include_str!("../static/index_body.html"),
// Vendor libs: marked + highlight first (used by app.js), then Chart.js
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/marked.min.js"),
"\n</script>\n",
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/highlight.min.js"),
"\n</script>\n",
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/chart.umd.min.js"),
"\n</script>\n",
// App code
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/js/api.js"),
"\n",
include_str!("../static/js/app.js"),
@@ -162,7 +185,7 @@ const WEBCHAT_HTML: &str = concat!(
include_str!("../static/js/pages/runtime.js"),
"\n</script>\n",
// Alpine.js MUST be last — it processes x-data and fires alpine:init
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/alpine.min.js"),
"\n</script>\n",
"</body></html>"