KeiSeiKit-1.0/_primitives/_rust/kei-forge/tests/smoke.rs
Parfii-bot f7982f0415 fix(substrate): E2 — kei-forge security hardening (DNS rebind + CSRF + injection)
Three HIGH security findings resolved in _primitives/_rust/kei-forge/:

- F-1: DNS rebinding — require_local_host middleware returns 421 on
  non-localhost Host headers
- F-2: CSRF via urlencoded — require_json_content_type middleware
  returns 415 on non-JSON; form HTML now POSTs JSON via fetch()
- crit#1/SA F-7: description sed injection — whitelist validator rejects
  newline/CR/tab/NUL/backtick/$/length>200, blocks the shell-script attack
  at the Rust layer
- crit#11: missing security headers — CSP, X-Frame-Options DENY,
  X-Content-Type-Options nosniff, Referrer-Policy no-referrer on GET /

Zero new deps (axum 0.7 middleware::from_fn + HeaderMap native).
Constructor Pattern compliant — 6 Cube files, largest 231 LOC including tests.

Tests: 29/29 (was 12/12; +17 new). Includes 4 adversarial integration
tests for each defence layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:49:49 +08:00

190 lines
6.5 KiB
Rust

//! Integration smoke test for kei-forge.
//!
//! Exercises GET / and POST /forge via `tower::ServiceExt::oneshot` on
//! the Router — no real socket, no real shell-out. The `mock-generate`
//! feature makes `generate::forge` return a synthesized success payload
//! so the test never touches the filesystem.
//!
//! Run with: `cargo test -p kei-forge --features mock-generate`
use axum::{
body::{to_bytes, Body},
http::{header, Request, StatusCode},
};
use kei_forge::server;
use serde_json::Value;
use tower::ServiceExt;
const LOCAL_HOST: &str = "127.0.0.1:8747";
fn get(uri: &str) -> Request<Body> {
Request::builder()
.uri(uri)
.header("host", LOCAL_HOST)
.body(Body::empty())
.unwrap()
}
fn post_json(uri: &str, body: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header("host", LOCAL_HOST)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}
#[tokio::test]
async fn get_root_serves_form() {
let app = server::app();
let resp = app.oneshot(get("/")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
let html = std::str::from_utf8(&body).unwrap();
assert!(html.contains("kei-forge"));
assert!(html.contains("<form"));
assert!(html.contains("name=\"verb\""));
assert!(html.contains("name=\"kind\""));
}
#[tokio::test]
async fn post_forge_returns_json_shape() {
let app = server::app();
let body = r#"{"crate":"kei-task","verb":"add-dependency","kind":"command","description":"test desc"}"#;
let resp = app.oneshot(post_json("/forge", body)).await.unwrap();
let status = resp.status();
let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
let json: Value = serde_json::from_slice(&bytes).expect("response is JSON");
assert!(json.get("success").is_some(), "missing success field");
assert!(json.get("files").is_some(), "missing files field");
assert!(json.get("errors").is_some(), "missing errors field");
assert!(
status == StatusCode::OK
|| status == StatusCode::UNPROCESSABLE_ENTITY
|| status == StatusCode::BAD_REQUEST,
"unexpected status {status}"
);
}
#[tokio::test]
async fn post_forge_rejects_bad_kind() {
let app = server::app();
let body = r#"{"crate":"kei-task","verb":"x","kind":"saga","description":"y"}"#;
let resp = app.oneshot(post_json("/forge", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["success"], Value::Bool(false));
let errs = json["errors"].as_array().unwrap();
assert!(!errs.is_empty());
}
// ---------------------------------------------------------------------
// Security hardening — the four new tests required by the fix contract.
// ---------------------------------------------------------------------
/// FIX A (DNS rebinding): a POST whose `Host:` header names an attacker
/// domain — even when the underlying socket is 127.0.0.1 — MUST be
/// rejected before the handler sees it.
#[tokio::test]
async fn post_with_evil_host_is_rejected() {
let app = server::app();
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/forge")
.header("host", "evil.com")
.header("content-type", "application/json")
.body(Body::from(
r#"{"crate":"kei-task","verb":"x","kind":"command","description":"y"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_ne!(resp.status(), StatusCode::OK);
assert!(
resp.status() == StatusCode::MISDIRECTED_REQUEST
|| resp.status() == StatusCode::FORBIDDEN,
"expected 403 or 421, got {}",
resp.status()
);
}
/// FIX B (CSRF): `application/x-www-form-urlencoded` is SOP-safe, so a
/// malicious `<form>` on any site could POST to us without preflight.
/// Must be rejected with 415 Unsupported Media Type.
#[tokio::test]
async fn post_urlencoded_is_rejected() {
let app = server::app();
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/forge")
.header("host", LOCAL_HOST)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"crate=kei-task&verb=x&kind=command&description=y",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
/// FIX C (description injection): a newline in `description` could
/// escape the `sed` substitution inside `scripts/new-atom.sh` and
/// append a hostile `-e` expression. Must fail validation with 400.
#[tokio::test]
async fn post_description_with_newline_is_rejected() {
let app = server::app();
// JSON escape for newline is \n literal in the string.
let body = r#"{"crate":"kei-task","verb":"noop","kind":"command","description":"foo\nevil"}"#;
let resp = app.oneshot(post_json("/forge", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["success"], Value::Bool(false));
let err = json["errors"][0].as_str().unwrap();
assert!(
err.contains("description"),
"expected description error, got: {err}"
);
}
/// FIX (defence-in-depth): GET / must carry the four hardening
/// headers so an iframe / reflected-XSS pivot cannot escalate.
#[tokio::test]
async fn get_root_has_security_headers() {
let app = server::app();
let resp = app.oneshot(get("/")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let h = resp.headers();
assert!(
h.contains_key(header::CONTENT_SECURITY_POLICY),
"missing CSP header"
);
assert!(
h.contains_key(header::X_CONTENT_TYPE_OPTIONS),
"missing X-Content-Type-Options"
);
assert!(
h.contains_key(header::X_FRAME_OPTIONS),
"missing X-Frame-Options"
);
assert!(
h.contains_key(header::REFERRER_POLICY),
"missing Referrer-Policy"
);
}