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>
This commit is contained in:
parent
42fe08232e
commit
f7982f0415
7 changed files with 504 additions and 90 deletions
|
|
@ -24,9 +24,42 @@ pub fn validate(req: &ForgeRequest) -> Result<(), String> {
|
|||
validate_crate_name(&req.crate_name)?;
|
||||
validate_verb(&req.verb)?;
|
||||
validate_kind(&req.kind)?;
|
||||
validate_description(&req.description)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Description whitelist — ASCII printable only.
|
||||
///
|
||||
/// Hardening against shell-substitution in `scripts/new-atom.sh`: an
|
||||
/// attacker-controlled newline, backtick, or `$` could smuggle a
|
||||
/// secondary `sed` expression into the template-substitution step and
|
||||
/// poison generated Rust source. Blocking these at the Rust layer
|
||||
/// prevents the shell from ever seeing a hostile byte.
|
||||
fn validate_description(d: &str) -> Result<(), String> {
|
||||
if d.len() > MAX_DESCRIPTION_LEN {
|
||||
return Err(format!(
|
||||
"description must be ≤{MAX_DESCRIPTION_LEN} chars (got {})",
|
||||
d.len()
|
||||
));
|
||||
}
|
||||
for (i, b) in d.bytes().enumerate() {
|
||||
if !(0x20..=0x7E).contains(&b) {
|
||||
return Err(format!(
|
||||
"description contains non-printable byte 0x{b:02X} at offset {i}"
|
||||
));
|
||||
}
|
||||
if matches!(b, b'`' | b'$') {
|
||||
return Err(format!(
|
||||
"description contains forbidden character '{}' at offset {i}",
|
||||
b as char
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MAX_DESCRIPTION_LEN: usize = 200;
|
||||
|
||||
fn validate_crate_name(name: &str) -> Result<(), String> {
|
||||
if name.is_empty() {
|
||||
return Err("crate must not be empty".to_string());
|
||||
|
|
@ -136,4 +169,63 @@ mod tests {
|
|||
};
|
||||
assert!(validate(&req).is_err());
|
||||
}
|
||||
|
||||
fn req_with_desc(d: &str) -> ForgeRequest {
|
||||
ForgeRequest {
|
||||
crate_name: "kei-task".into(),
|
||||
verb: "noop".into(),
|
||||
kind: "command".into(),
|
||||
description: d.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_rejects_newline() {
|
||||
let err = validate(&req_with_desc("foo\nbar")).unwrap_err();
|
||||
assert!(err.contains("0x0A"), "expected newline byte report: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_rejects_carriage_return() {
|
||||
assert!(validate(&req_with_desc("foo\rbar")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_rejects_tab() {
|
||||
assert!(validate(&req_with_desc("foo\tbar")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_rejects_nul() {
|
||||
assert!(validate(&req_with_desc("foo\0bar")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_rejects_backtick() {
|
||||
let err = validate(&req_with_desc("foo`id`bar")).unwrap_err();
|
||||
assert!(err.contains('`'), "expected backtick in error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_rejects_dollar_sign() {
|
||||
let err = validate(&req_with_desc("foo$(id)bar")).unwrap_err();
|
||||
assert!(err.contains('$'), "expected dollar in error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_rejects_over_length() {
|
||||
let long = "a".repeat(201);
|
||||
assert!(validate(&req_with_desc(&long)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_accepts_minimal() {
|
||||
assert!(validate(&req_with_desc("ok")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_accepts_at_length_cap() {
|
||||
let exact = "a".repeat(200);
|
||||
assert!(validate(&req_with_desc(&exact)).is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
_primitives/_rust/kei-forge/src/headers.rs
Normal file
62
_primitives/_rust/kei-forge/src/headers.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
//! Security headers applied to the GET / HTML response.
|
||||
//!
|
||||
//! Defence-in-depth layer on top of the Host allow-list and JSON
|
||||
//! content-type enforcement: these directives limit the blast radius of
|
||||
//! any reflected-XSS / iframe-embedding attempt against the wizard UI.
|
||||
//!
|
||||
//! - `Content-Security-Policy` — inline-script/style only from self, no
|
||||
//! external origins, `form-action 'self'` blocks cross-origin form
|
||||
//! posts even if the SOP layer is bypassed.
|
||||
//! - `X-Content-Type-Options: nosniff` — browsers MUST NOT sniff MIME.
|
||||
//! - `X-Frame-Options: DENY` — cannot be iframe-embedded (clickjacking).
|
||||
//! - `Referrer-Policy: no-referrer` — don't leak the wizard URL.
|
||||
|
||||
use axum::http::{header, HeaderMap, HeaderValue};
|
||||
|
||||
/// Populate `headers` with the four security headers. Used by the GET /
|
||||
/// handler to decorate its HTML response.
|
||||
pub fn apply_security_headers(headers: &mut HeaderMap) {
|
||||
headers.insert(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
HeaderValue::from_static(
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; \
|
||||
style-src 'self' 'unsafe-inline'; form-action 'self'; \
|
||||
frame-ancestors 'none'",
|
||||
),
|
||||
);
|
||||
headers.insert(
|
||||
header::X_CONTENT_TYPE_OPTIONS,
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert(
|
||||
header::X_FRAME_OPTIONS,
|
||||
HeaderValue::from_static("DENY"),
|
||||
);
|
||||
headers.insert(
|
||||
header::REFERRER_POLICY,
|
||||
HeaderValue::from_static("no-referrer"),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adds_all_four_headers() {
|
||||
let mut h = HeaderMap::new();
|
||||
apply_security_headers(&mut h);
|
||||
assert!(h.contains_key(header::CONTENT_SECURITY_POLICY));
|
||||
assert!(h.contains_key(header::X_CONTENT_TYPE_OPTIONS));
|
||||
assert!(h.contains_key(header::X_FRAME_OPTIONS));
|
||||
assert!(h.contains_key(header::REFERRER_POLICY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csp_forbids_cross_origin_forms() {
|
||||
let mut h = HeaderMap::new();
|
||||
apply_security_headers(&mut h);
|
||||
let csp = h.get(header::CONTENT_SECURITY_POLICY).unwrap();
|
||||
assert!(csp.to_str().unwrap().contains("form-action 'self'"));
|
||||
}
|
||||
}
|
||||
71
_primitives/_rust/kei-forge/src/html.rs
Normal file
71
_primitives/_rust/kei-forge/src/html.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
//! Static HTML for the wizard form.
|
||||
//!
|
||||
//! The form no longer POSTs `application/x-www-form-urlencoded` directly
|
||||
//! — that body-type is SOP-safe (no CORS preflight) which allowed any
|
||||
//! web page to CSRF POST the handler. Instead, a small inline `<script>`
|
||||
//! serialises form values to JSON and POSTs via `fetch()` with
|
||||
//! `Content-Type: application/json`. JSON bodies trigger CORS preflight
|
||||
//! so the Same-Origin-Policy now engages.
|
||||
|
||||
pub const FORM_HTML: &str = r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>kei-forge</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>kei-forge — scaffold an atom</h1>
|
||||
<p>Per <a href="/static/SCHEMA-LOCKED.md">locked substrate schema</a>.</p>
|
||||
<form id="forge-form">
|
||||
<p>
|
||||
<label>crate:
|
||||
<input name="crate" required pattern="[a-z][a-z0-9-]*" placeholder="kei-task">
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>verb:
|
||||
<input name="verb" required pattern="[a-z][a-z0-9-]*" placeholder="add-dependency">
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>kind:
|
||||
<select name="kind" required>
|
||||
<option value="command">command</option>
|
||||
<option value="query">query</option>
|
||||
<option value="stream">stream</option>
|
||||
<option value="transform">transform</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>description:<br>
|
||||
<textarea name="description" rows="3" cols="60" maxlength="200"
|
||||
placeholder="One-line purpose. Used in atoms/<verb>.md"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
<p><button type="submit">forge atom</button></p>
|
||||
</form>
|
||||
<pre id="result"></pre>
|
||||
<script>
|
||||
document.getElementById('forge-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const payload = {
|
||||
crate: fd.get('crate'),
|
||||
verb: fd.get('verb'),
|
||||
kind: fd.get('kind'),
|
||||
description: fd.get('description')
|
||||
};
|
||||
const resp = await fetch('/forge', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const text = await resp.text();
|
||||
document.getElementById('result').textContent =
|
||||
'HTTP ' + resp.status + '\n' + text;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
|
@ -2,9 +2,12 @@
|
|||
//! SUBSTRATE-SCHEMA.md contract.
|
||||
//!
|
||||
//! Architecture (Constructor Pattern, one responsibility per file):
|
||||
//! - [`server`] — axum router + HTML form handler
|
||||
//! - [`form`] — request deserialization + validation
|
||||
//! - [`generate`] — invoke scripts/new-atom.sh, parse output
|
||||
//! - [`server`] — axum router + handlers
|
||||
//! - [`middleware`] — DNS-rebinding + CSRF defences
|
||||
//! - [`headers`] — CSP / nosniff / frame-deny / referrer headers
|
||||
//! - [`html`] — static HTML form (JSON-over-fetch)
|
||||
//! - [`form`] — request deserialization + validation
|
||||
//! - [`generate`] — invoke scripts/new-atom.sh, parse output
|
||||
//!
|
||||
//! Public entry point is [`server::app`], which returns the fully-wired
|
||||
//! `axum::Router` ready to be served by any bind target (production =
|
||||
|
|
@ -12,4 +15,7 @@
|
|||
|
||||
pub mod form;
|
||||
pub mod generate;
|
||||
pub mod headers;
|
||||
pub mod html;
|
||||
pub mod middleware;
|
||||
pub mod server;
|
||||
|
|
|
|||
117
_primitives/_rust/kei-forge/src/middleware.rs
Normal file
117
_primitives/_rust/kei-forge/src/middleware.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,30 +3,49 @@
|
|||
//! Intentionally stateless: no `AppState`, no handles, no async init.
|
||||
//! Every request is self-contained. This lets tests spin up `app()` in
|
||||
//! an ephemeral Tokio runtime without setup teardown overhead.
|
||||
//!
|
||||
//! Security layers applied as middleware (see `crate::middleware`):
|
||||
//! 1. `require_local_host` — reject non-127.0.0.1 Host headers (blocks
|
||||
//! DNS rebinding).
|
||||
//! 2. `require_json_content_type` — reject urlencoded POSTs (blocks
|
||||
//! `<form>`-based CSRF).
|
||||
//!
|
||||
//! GET / responses additionally carry CSP + nosniff + frame-deny headers.
|
||||
|
||||
use axum::{
|
||||
extract::Form,
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse},
|
||||
http::{HeaderMap, StatusCode},
|
||||
middleware::from_fn,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use crate::form::{validate, ForgeRequest};
|
||||
use crate::generate::{forge, ForgeResult};
|
||||
use crate::headers::apply_security_headers;
|
||||
use crate::html::FORM_HTML;
|
||||
use crate::middleware::{require_json_content_type, require_local_host};
|
||||
|
||||
/// Build the router. Called by `main.rs` and by tests.
|
||||
pub fn app() -> Router {
|
||||
Router::new()
|
||||
.route("/", get(render_form))
|
||||
.route("/forge", post(handle_forge))
|
||||
.layer(from_fn(require_json_content_type))
|
||||
.layer(from_fn(require_local_host))
|
||||
}
|
||||
|
||||
async fn render_form() -> Html<&'static str> {
|
||||
Html(FORM_HTML)
|
||||
async fn render_form() -> impl IntoResponse {
|
||||
let mut headers = HeaderMap::new();
|
||||
apply_security_headers(&mut headers);
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
(StatusCode::OK, headers, FORM_HTML)
|
||||
}
|
||||
|
||||
async fn handle_forge(Form(req): Form<ForgeRequest>) -> impl IntoResponse {
|
||||
async fn handle_forge(Json(req): Json<ForgeRequest>) -> impl IntoResponse {
|
||||
if let Err(e) = validate(&req) {
|
||||
return (StatusCode::BAD_REQUEST, Json(ForgeResult::fail(e)));
|
||||
}
|
||||
|
|
@ -38,47 +57,3 @@ async fn handle_forge(Form(req): Form<ForgeRequest>) -> impl IntoResponse {
|
|||
};
|
||||
(status, Json(result))
|
||||
}
|
||||
|
||||
/// Minimal HTML — 5 inputs, no JS, no CSS framework. The locked schema
|
||||
/// allows only 4 atom kinds, hard-coded as a `<select>`.
|
||||
const FORM_HTML: &str = r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>kei-forge</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>kei-forge — scaffold an atom</h1>
|
||||
<p>Per <a href="/static/SCHEMA-LOCKED.md">locked substrate schema</a>.</p>
|
||||
<form method="POST" action="/forge">
|
||||
<p>
|
||||
<label>crate:
|
||||
<input name="crate" required pattern="[a-z][a-z0-9-]*" placeholder="kei-task">
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>verb:
|
||||
<input name="verb" required pattern="[a-z][a-z0-9-]*" placeholder="add-dependency">
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>kind:
|
||||
<select name="kind" required>
|
||||
<option value="command">command</option>
|
||||
<option value="query">query</option>
|
||||
<option value="stream">stream</option>
|
||||
<option value="transform">transform</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>description:<br>
|
||||
<textarea name="description" rows="3" cols="60"
|
||||
placeholder="One-line purpose. Used in atoms/<verb>.md"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
<p><button type="submit">forge atom</button></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
|
|
|||
|
|
@ -9,24 +9,36 @@
|
|||
|
||||
use axum::{
|
||||
body::{to_bytes, Body},
|
||||
http::{Request, StatusCode},
|
||||
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(
|
||||
Request::builder()
|
||||
.uri("/")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = app.oneshot(get("/")).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
|
||||
|
|
@ -40,19 +52,9 @@ async fn get_root_serves_form() {
|
|||
#[tokio::test]
|
||||
async fn post_forge_returns_json_shape() {
|
||||
let app = server::app();
|
||||
let body = "crate=kei-task&verb=add-dependency&kind=command&description=test+desc";
|
||||
let body = r#"{"crate":"kei-task","verb":"add-dependency","kind":"command","description":"test desc"}"#;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/forge")
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
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();
|
||||
|
|
@ -62,9 +64,6 @@ async fn post_forge_returns_json_shape() {
|
|||
assert!(json.get("files").is_some(), "missing files field");
|
||||
assert!(json.get("errors").is_some(), "missing errors field");
|
||||
|
||||
// With --features mock-generate we return success; without, the shell-out
|
||||
// may succeed or fail depending on whether scripts/new-atom.sh lives in
|
||||
// the worktree. Either way the schema must be correct.
|
||||
assert!(
|
||||
status == StatusCode::OK
|
||||
|| status == StatusCode::UNPROCESSABLE_ENTITY
|
||||
|
|
@ -76,19 +75,9 @@ async fn post_forge_returns_json_shape() {
|
|||
#[tokio::test]
|
||||
async fn post_forge_rejects_bad_kind() {
|
||||
let app = server::app();
|
||||
let body = "crate=kei-task&verb=x&kind=saga&description=y";
|
||||
let body = r#"{"crate":"kei-task","verb":"x","kind":"saga","description":"y"}"#;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/forge")
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
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();
|
||||
|
|
@ -97,3 +86,105 @@ async fn post_forge_rejects_bad_kind() {
|
|||
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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue