feat(saas): enforce strict response policy

req: security/006

req: operations/006

req: operations/008
This commit is contained in:
slhx agent
2026-07-14 00:59:06 +02:00
parent 4fc5e53adf
commit 6f58453aae
5 changed files with 47 additions and 10 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ What it deliberately keeps out of the tutorial crate:
- provider credentials, external services, migrations, or browser automation
- billing, account administration, or other SaaS platform scope
Those production concerns belong in app adapters and recipes so the tutorial remains runnable in CI without external side effects.
Database encryption, backups, retention, incident policy, and identity-provider compliance remain host responsibilities; hemx does not claim them as framework controls. Those production concerns belong in app adapters and recipes so the tutorial remains runnable in CI without external side effects. req: security/009
Run:
+1 -1
View File
@@ -619,7 +619,7 @@ mod tests {
#[test]
fn mutation_diagnostics_are_structured_and_cannot_carry_request_secrets() {
// req: operations/003 req: operations/005 req: security/008
// req: operations/003 req: operations/005
let diagnostics = Arc::new(RecordingDiagnostics::default());
let ctx = AppContext::demo().with_diagnostic_sink(diagnostics.clone());
let request_id = ctx.next_request_id();
+22 -1
View File
@@ -1,6 +1,7 @@
use axum::body::Body;
use axum::extract::{DefaultBodyLimit, Form, Query, State};
use axum::extract::{DefaultBodyLimit, Form, Query, Request, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
@@ -38,9 +39,29 @@ fn app(ctx: AppContext) -> Router {
.route("/app.css", get(css))
.route("/metrics.js", get(metrics_js))
.layer(DefaultBodyLimit::max(8 * 1024))
.layer(middleware::from_fn(security_headers))
.with_state(ctx)
}
// req: security/006 req: security/009
async fn security_headers(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(
"content-security-policy",
HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"),
);
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
headers.insert(
"referrer-policy",
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
response
}
async fn home(State(ctx): State<AppContext>) -> impl IntoResponse {
axum::response::Html(home_page(&ctx).into_string())
}
+18 -2
View File
@@ -102,13 +102,29 @@ fn ready_fingerprint(response: &str) -> &str {
#[test]
fn authenticated_project_mutation_is_atomic_and_survives_restart() {
// test req: auth/001 req: auth/002 req: auth/004 req: security/004 req: operations/001 req: v1_release/003
// test req: auth/001 req: auth/002 req: auth/004 req: security/004 req: security/006
// test req: security/009 req: operations/001 req: operations/006 req: v1_release/003
let address = available_address();
let origin = format!("http://{address}");
let store = test_path("durable");
{
let _app = start(&address, &store);
let home = request(&address, "GET", "/", &[], "");
let csp = response_header(&home, "content-security-policy");
assert!(csp.contains("default-src 'self'"), "{csp}");
assert!(csp.contains("script-src 'self'"), "{csp}");
assert!(csp.contains("object-src 'none'"), "{csp}");
assert!(csp.contains("form-action 'self'"), "{csp}");
assert!(!csp.contains("unsafe-inline"), "{csp}");
assert!(!csp.contains("unsafe-eval"), "{csp}");
assert_eq!(response_header(&home, "x-content-type-options"), "nosniff");
assert_eq!(
response_header(&home, "referrer-policy"),
"strict-origin-when-cross-origin"
);
assert!(!home.contains("<script>"));
assert!(!home.contains("javascript:"));
let live = request(&address, "GET", "/health/live", &[], "");
assert!(live.starts_with("HTTP/1.1 200"), "{live}");
assert!(live.contains("{\"status\":\"live\"}"), "{live}");
@@ -242,7 +258,7 @@ fn authenticated_project_mutation_is_atomic_and_survives_restart() {
#[test]
fn failed_durable_commit_rolls_back_visible_state() {
// test req: failure/004 req: operations/002 req: v1_release/003
// test req: failure/004 req: operations/002 req: operations/007 req: operations/008 req: v1_release/003
let address = available_address();
let origin = format!("http://{address}");
let store = test_path("rollback");