Single-commit clean baseline after security scrub of niche-tells, project codenames, internal jargon, and contributor-email leaks. Contents: - 100 Rust crates (_primitives/_rust/) - 37 agent manifests (_manifests/) + generated specs (_generated/) - 67 user-invocable skills (skills/) - 33 hooks (hooks/) - Composition blocks (_blocks/) - Documentation (docs/, README.md) - TS adapter packages (_ts_packages/) - Assembler (_assembler/) - Roles (_roles/) - Templates (_templates/) - Forgejo CI (.forgejo/) Author: Denis Parfionovich <info@greendragon.info> License: see LICENSE.
117 lines
3.7 KiB
Rust
117 lines
3.7 KiB
Rust
//! HTTP middleware — defence against cross-origin / DNS-rebinding attacks.
|
|
//!
|
|
//! Two layers:
|
|
//! - [`require_local_host`] — rejects requests whose `Host:` header is not
|
|
//! exactly `localhost:8747` or `127.0.0.1:8747`. Blocks DNS-rebinding
|
|
//! (attacker points `a.evil.com` → 127.0.0.1 while browser still trusts
|
|
//! the evil.com origin for Same-Origin-Policy purposes).
|
|
//! - [`require_json_content_type`] — rejects `POST /forge` unless body is
|
|
//! `application/json`. Blocks CSRF via `<form>` submissions: urlencoded
|
|
//! POSTs are SOP-safe (no preflight), but JSON bodies trigger CORS
|
|
//! preflight so SOP engages.
|
|
//!
|
|
//! Both are advisory: they compose via `axum::middleware::from_fn` and
|
|
//! never touch application state.
|
|
|
|
use axum::{
|
|
extract::Request,
|
|
http::{header, Method, StatusCode},
|
|
middleware::Next,
|
|
response::Response,
|
|
};
|
|
|
|
const ALLOWED_HOSTS: &[&str] = &["localhost:8747", "127.0.0.1:8747"];
|
|
|
|
/// Reject requests whose `Host:` is not an exact allow-list match.
|
|
///
|
|
/// Returns 421 Misdirected Request on mismatch (RFC 7540 §9.1.2).
|
|
pub async fn require_local_host(req: Request, next: Next) -> Result<Response, StatusCode> {
|
|
let host = req
|
|
.headers()
|
|
.get(header::HOST)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
if ALLOWED_HOSTS.iter().any(|&h| h == host) {
|
|
Ok(next.run(req).await)
|
|
} else {
|
|
Err(StatusCode::MISDIRECTED_REQUEST)
|
|
}
|
|
}
|
|
|
|
/// Reject POSTs whose `Content-Type` is not `application/json`.
|
|
///
|
|
/// GET and other methods pass through unchanged. Returns 415 Unsupported
|
|
/// Media Type on mismatch.
|
|
pub async fn require_json_content_type(
|
|
req: Request,
|
|
next: Next,
|
|
) -> Result<Response, StatusCode> {
|
|
if req.method() != Method::POST {
|
|
return Ok(next.run(req).await);
|
|
}
|
|
let ct = req
|
|
.headers()
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
// Match only the media type, ignoring optional `; charset=…`.
|
|
let base = ct.split(';').next().unwrap_or("").trim();
|
|
if base.eq_ignore_ascii_case("application/json") {
|
|
Ok(next.run(req).await)
|
|
} else {
|
|
Err(StatusCode::UNSUPPORTED_MEDIA_TYPE)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::body::Body;
|
|
use axum::http::Request as HttpRequest;
|
|
use axum::middleware::from_fn;
|
|
use axum::routing::{get, post};
|
|
use axum::Router;
|
|
use tower::ServiceExt;
|
|
|
|
fn test_app() -> Router {
|
|
Router::new()
|
|
.route("/", get(|| async { "ok" }))
|
|
.route("/forge", post(|| async { "ok" }))
|
|
.layer(from_fn(require_json_content_type))
|
|
.layer(from_fn(require_local_host))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn blocks_evil_host() {
|
|
let app = test_app();
|
|
let resp = app
|
|
.oneshot(
|
|
HttpRequest::builder()
|
|
.uri("/")
|
|
.header("host", "evil.com")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::MISDIRECTED_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn blocks_urlencoded_post() {
|
|
let app = test_app();
|
|
let resp = app
|
|
.oneshot(
|
|
HttpRequest::builder()
|
|
.method("POST")
|
|
.uri("/forge")
|
|
.header("host", "127.0.0.1:8747")
|
|
.header("content-type", "application/x-www-form-urlencoded")
|
|
.body(Body::from("x=1"))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
|
}
|
|
}
|