Part of the SunLitSecurityLibraries dev-guide. Target audience: engineers and security agents adding
secure_boundaryto an Actix-web 4 service.
Three drop-in Actix-web 4 adapters that mirror the axum versions byte-for-byte:
| Adapter | What it does | Replaces |
|---|---|---|
SecureJson<T> |
Extracts a JSON body and runs the four-stage validation pipeline (content-type → body size → JSON nesting/field limits → serde parse → SecureValidate::validate_syntax → SecureValidate::validate_semantics). Rejection gives a safe, code-only JSON error body; no raw input is ever echoed. |
Hand-rolled web::Json<T> + ad-hoc checks in every handler. |
SecurityHeadersTransform |
Injects the OWASP-recommended header set (HSTS, CSP, X-Content-Type-Options, X-Frame-Options, Permissions-Policy, Cache-Control, COEP, COOP, CORP, X-DNS-Prefetch-Control, X-Permitted-Cross-Domain-Policies) on every response — including error responses. Optional per-request CSP nonces. | A dozen lines of HttpResponse::insert_header per endpoint. |
FetchMetadataTransform |
Blocks suspicious cross-site browser requests based on the Sec-Fetch-Site/Sec-Fetch-Mode/Sec-Fetch-Dest headers, preserving backward compatibility for older clients that don't send them. |
Hand-written cross-site request filtering that rots as browser behavior changes. |
All three are behavior-identical to their axum twins. A service on Actix with these three middlewares gets the same defense-in-depth posture as an axum service using SecureJson + SecurityHeadersLayer + FetchMetadataLayer.
[dependencies]
secure_boundary = { version = "0.1.2", default-features = false, features = ["actix-web"] }
actix-web = "4"default-features = falseturns off the (default)axumfeature so your build doesn't pull in axum.- If your workspace hosts services on both frameworks, you can enable both features at once:
features = ["axum", "actix-web"]. The crate composes. - If you prefer git-rev pinning during Era 2 of Sunlit Guardian, replace
version = "0.1.2"withgit = "https://github.com/kerberosmansour/SunLitSecurityLibraries", rev = "<sha>".
The complete runnable version lives at crates/secure_boundary/examples/actix_minimal.rs. Build and run it with:
cargo run --example actix_minimal -p secure_boundary --features actix-webTest it with:
curl -v -X POST http://127.0.0.1:8080/items \
-H "content-type: application/json" \
-d '{"name":"widget"}'You'll see every OWASP security header set on the response, and the CSP line contains a fresh nonce per request.
The shape of the code:
use actix_web::{web, App, HttpResponse, HttpServer};
use secure_boundary::actix::{FetchMetadataTransform, SecurityHeadersTransform};
use secure_boundary::validate::{SecureValidate, ValidationContext};
use secure_boundary::SecureJson;
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)] // Rejects extra/unknown fields at parse time.
struct CreateItem {
name: String,
}
impl SecureValidate for CreateItem {
fn validate_syntax(&self, _: &ValidationContext) -> Result<(), &'static str> {
if self.name.is_empty() { return Err("name_empty"); }
if self.name.len() > 64 { return Err("name_too_long"); }
Ok(())
}
fn validate_semantics(&self, _: &ValidationContext) -> Result<(), &'static str> {
Ok(()) // business-rule checks go here
}
}
async fn create_item(item: SecureJson<CreateItem>) -> HttpResponse {
HttpResponse::Ok().body(format!("created: {}", item.into_inner().name))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.wrap(SecurityHeadersTransform::new().with_csp_nonce())
.wrap(FetchMetadataTransform::new())
.route("/items", web::post().to(create_item))
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}Every line of this snippet is exercised by the runnable example and the E2E test suite at
crates/secure_smoke_service/tests/e2e_sg_gate_a_m1.rs. If this guide drifts out of sync with code, those tests fail.
Apply .wrap() in the order you want middlewares to wrap the inner service. Actix executes the outermost wrap first on request, last on response. Recommended:
wrap(SecurityHeadersTransform::new()) ← outermost: sets response headers on EVERY response, including error responses and short-circuits from inner middleware.
wrap(FetchMetadataTransform::new()) ← inner: short-circuits cross-site requests before they reach the handler (or `SecureJson`).
route("/items", web::post().to(h)) ← innermost: handler with SecureJson<T> extractor.
This ordering means a cross-site request still gets OWASP security headers on the 403 response, and a malformed JSON request still gets security headers on the 422 response. If you reverse the order, you may leak 403/422 responses without the full header set.
By default SecureJson<T> uses OWASP-recommended limits (1 MiB body, 10 nesting levels, 100 top-level fields). Override per-route via app_data:
use secure_boundary::limits::RequestLimits;
App::new()
.wrap(SecurityHeadersTransform::new())
.service(
web::scope("/upload")
.app_data(RequestLimits::new()
.with_max_body_bytes(4 * 1024 * 1024) // 4 MiB for this scope
.with_max_field_count(500))
.route("", web::post().to(upload_handler)),
)
.route("/items", web::post().to(create_item)) // inherits OWASP defaultsWith .with_csp_nonce() enabled, each request gets a fresh cryptographically-random nonce inserted into the SecurityHeadersTransform. Handlers can read it via actix_web::HttpMessage::extensions():
use actix_web::{HttpMessage, HttpRequest, HttpResponse};
use secure_boundary::headers::CspNonce;
async fn render(req: HttpRequest) -> HttpResponse {
let nonce_str = req
.extensions()
.get::<CspNonce>()
.map(|n| n.as_str().to_owned())
.unwrap_or_default();
HttpResponse::Ok().body(format!(
"<!doctype html><script nonce='{}'>console.log('hi')</script>",
nonce_str
))
}The same CspNonce value appears in the script-src 'nonce-...' directive on the response's CSP header, so browsers execute only scripts with the matching nonce.
secure_boundary = { version = "0.1.2", features = ["actix-web"] } # pulls axum tooThis compiles, but you'll pay for compiling axum + tower + tower-http you won't use. If that's acceptable (e.g., because a sibling crate in the workspace uses the axum path), it's fine. Otherwise:
secure_boundary = { version = "0.1.2", default-features = false, features = ["actix-web"] }If you wrap(FetchMetadataTransform) OUTSIDE wrap(SecurityHeadersTransform), a blocked cross-site request will return 403 without security headers. That weakens defense-in-depth. See the order recommendation above.
SecureJson<T> checks Content-Type starts with application/json — nothing more, nothing less. If a client sends application/json; charset=utf-8, that's fine. If they send text/plain, they get 415 Unsupported Media Type with the code invalid_content_type.
Always annotate your T with #[serde(deny_unknown_fields)]. Otherwise an attacker who can send JSON with extra fields may find new attack surface. The example above does this; so should yours.
Return &'static str reason codes from validate_syntax / validate_semantics. These strings are logged as part of the BoundaryViolation security event for variant analysis; changing them breaks historical queries. Treat them like error codes in an API contract.
If your workspace has both axum and Actix services and wants identical security posture across them:
| axum | actix-web 4 | |
|---|---|---|
| JSON extractor | SecureJson<T> as FromRequest |
SecureJson<T> as actix_web::FromRequest |
| Security headers | SecurityHeadersLayer (tower Layer) |
SecurityHeadersTransform (actix Transform) |
| Fetch-Metadata | FetchMetadataLayer (tower Layer) |
FetchMetadataTransform (actix Transform) |
| Rejection status codes | Same | Same — verified by cross-framework parity tests in sg_gate_a_parity_boundary.rs |
| Response header bytes | Same | Same — verified by the same parity suite |
The cross-framework parity tests run on every PR via the CI feature-matrix gate (M4). A diff between axum and Actix output causes CI to fail, so both paths stay in lockstep.
SecureJson<T>rustdoc — API reference and doctest examplesactixmodule rustdoc — adapter overviewexamples/actix_minimal.rs— full runnable example (what this guide is built on)SecurityHeadersLayerrustdoc — header value defaults and customisationFetchMetadataLayerrustdoc — allow/block semanticsdocs/dev-guide/README.md— dev-guide index