Skip to content

Commit 2641fab

Browse files
rayketchamclaude
andcommitted
security: v0.5.8 — body-limit pre-buffer, rustls-webpki CVE fixes, demos
P1 hardening: multipart uploads now reject pre-buffer via `tower_http::limit::RequestBodyLimitLayer`. `Content-Length` exceeding `max_upload_size` returns 413 before any body bytes are read, closing a memory-exhaustion vector that existed when axum's multipart extractor streamed oversized bodies and mapped the resulting error to 500. CVEs: upgraded rustls-webpki 0.103.10 → 0.103.12 to address RUSTSEC-2026-0098 (URI name constraints incorrectly accepted) and RUSTSEC-2026-0099 (wildcard name constraints incorrectly accepted). Added four regression tests covering oversized sign/verify 413, Content-Length pre-buffer 413, and under-limit negative. Committed the interactive demos landing page (`docs/demo.html` + six asciinema casts) and bumped docs/README version badges to 0.5.8. CHANGELOG filled in the missing 0.5.6 / 0.5.7 entries and captures 0.5.8. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5627e09 commit 2641fab

14 files changed

Lines changed: 2663 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.5.8] - 2026-04-16
11+
12+
### Security
13+
- **P1 hardening: multipart body-limit pre-buffer enforcement** — added `tower_http::limit::RequestBodyLimitLayer` so `Content-Length` exceeding `max_upload_size` returns `413 Payload Too Large` before any body bytes are buffered. Prevents memory-exhaustion via lying/huge uploads at multipart-extractor edge.
14+
- **CVE: rustls-webpki name-constraint bypasses** (RUSTSEC-2026-0098, RUSTSEC-2026-0099) — bumped `rustls-webpki` from 0.103.10 to 0.103.12 via `cargo update`. URI-name and wildcard name-constraint validation is now correct.
15+
16+
### Added
17+
- Four body-limit regression tests in `web::handlers` covering sign/verify oversized-body 413, Content-Length pre-buffer 413, and under-limit negative assertion.
18+
- Interactive demos landing page at `docs/demo.html` with six asciinema scenarios.
19+
20+
## [0.5.7] - 2026-03-25
21+
22+
### Fixed
23+
- **CAB Authenticode interop** (#45) — reserve-header magic must be `00 00 10 00` (not `14 00 00 00`); hash covers selective fields `[0..4]+[8..34]+[56..60]+[60..sigOffset]` so osslsigncode/signtool accept output.
24+
- **MSI Authenticode interop** (#46) — hash includes root CLSID (16 bytes) after stream contents; stream order uses raw UTF-16LE byte sort.
25+
- CI test-coverage expansion for CAB/MSI interop.
26+
27+
## [0.5.6] - 2026-03-23
28+
29+
### Added
30+
- **P-521 ECDSA signing** end-to-end with web e2e tests.
31+
- **CIDR-aware reverse-proxy trust** for `X-Forwarded-For` / `X-Real-IP` — only trusted CIDRs may set `client_ip`.
32+
- **Rate limiting** middleware per-endpoint / per-IP.
33+
- **Cosign signing** support (keyless + key-pair) for OCI artifacts.
34+
- **Secret scanning** pre-commit hook and CI gate.
35+
- `client_ip` field on all audit-log records.
36+
- Unit tests for `config`, `audit`, `ldap` modules.
37+
38+
### Changed
39+
- **Error-handling refactor** — unified `AppError` variants across the web layer; consistent 4xx/5xx mapping.
40+
- All GitHub Actions pinned to commit SHAs.
41+
42+
### Security
43+
- Team-review fixes: dead-code removal, key `Zeroize` on drop paths, CI permission hardening.
44+
- CSP hardening and fail-closed auth middleware.
45+
- Assorted CVE patches (see commit `21879da`).
46+
1047
## [0.5.4] - 2026-03-20
1148

1249
### Added

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ resolver = "2"
33
members = ["crates/pki-sign"]
44

55
[workspace.package]
6-
version = "0.5.7"
6+
version = "0.5.8"
77
edition = "2021"
88
rust-version = "1.88"
99
license = "Apache-2.0"
@@ -32,7 +32,7 @@ tokio = { version = "1.44", features = ["full"] }
3232

3333
# HTTP server / client
3434
axum = "0.7"
35-
tower-http = { version = "0.5", features = ["trace", "cors", "fs"] }
35+
tower-http = { version = "0.5", features = ["trace", "cors", "fs", "limit"] }
3636
tracing = "0.1"
3737
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
3838
reqwest = { version = "0.12", features = ["json", "rustls-tls-native-roots", "rustls-tls-webpki-roots", "blocking"], default-features = false }

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ No OpenSSL. No `signtool.exe`. No external dependencies. One binary.
2222
[![OpenSSL](https://img.shields.io/badge/OpenSSL-not%20required-brightgreen?logo=openssl&logoColor=white)](https://github.com/rayketcham-lab/PKI-Signing-Service)
2323

2424
<!-- Project Info -->
25-
[![Version](https://img.shields.io/badge/version-0.5.7-blue?logo=semver&logoColor=white)](https://github.com/rayketcham-lab/PKI-Signing-Service/releases/latest)
25+
[![Version](https://img.shields.io/badge/version-0.5.8-blue?logo=semver&logoColor=white)](https://github.com/rayketcham-lab/PKI-Signing-Service/releases/latest)
2626
[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-green?logo=apache&logoColor=white)](LICENSE)
2727
[![Rust](https://img.shields.io/badge/language-Rust-orange?logo=rust&logoColor=white)](https://www.rust-lang.org/)
2828
[![MSRV](https://img.shields.io/badge/MSRV-1.88-orange?logo=rust&logoColor=white)](https://blog.rust-lang.org/)
@@ -338,7 +338,7 @@ Compatible with any RFC 3161 client --- `signtool.exe`, `openssl ts`, or this to
338338
## CLI Reference
339339

340340
```
341-
pki-sign 0.5.4
341+
pki-sign 0.5.8
342342
PKI Signing Service - Pure Rust Code Signing Engine
343343
344344
USAGE:

crates/pki-sign/src/web/handlers.rs

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,6 +1557,9 @@ mod tests {
15571557
}
15581558

15591559
/// Issue a POST request with an arbitrary body against the router.
1560+
///
1561+
/// Sets `Content-Length` from `body.len()` to match realistic clients —
1562+
/// without it, the body-limit layer cannot pre-reject oversized uploads.
15601563
async fn post_raw(
15611564
state: Arc<AppState>,
15621565
uri: &str,
@@ -1570,6 +1573,7 @@ mod tests {
15701573
.method("POST")
15711574
.uri(uri)
15721575
.header(axum::http::header::CONTENT_TYPE, content_type)
1576+
.header(axum::http::header::CONTENT_LENGTH, body.len())
15731577
.body(axum::body::Body::from(body))
15741578
.unwrap();
15751579
router.oneshot(request).await.unwrap()
@@ -1578,8 +1582,6 @@ mod tests {
15781582
/// Build a minimal multipart/form-data body with a single `file` field.
15791583
///
15801584
/// Returns `(content_type_header, body_bytes)`.
1581-
/// Scaffolded for upcoming multipart upload handler tests.
1582-
#[allow(dead_code)]
15831585
fn build_multipart_with_file(filename: &str, data: &[u8]) -> (String, Vec<u8>) {
15841586
let boundary = "testboundary1234567890";
15851587
let content_type = format!("multipart/form-data; boundary={boundary}");
@@ -1701,6 +1703,131 @@ mod tests {
17011703
assert_eq!(json["error"], "not_found");
17021704
}
17031705

1706+
// ─── Body-limit enforcement tests (P1 security fix) ─────────────────────
1707+
1708+
/// Build an [`AppState`] with a tiny `max_upload_size` so tests can exceed
1709+
/// the limit without allocating huge buffers.
1710+
fn make_test_state_with_max(max_upload_size: u64) -> Arc<AppState> {
1711+
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
1712+
let audit_path = tmp.path().to_path_buf();
1713+
std::mem::forget(tmp);
1714+
1715+
let config = SignConfig {
1716+
dev_mode: true,
1717+
require_timestamp: false,
1718+
audit_log: audit_path.clone(),
1719+
max_upload_size,
1720+
..SignConfig::default()
1721+
};
1722+
1723+
Arc::new(AppState {
1724+
config,
1725+
credentials: tokio::sync::RwLock::new(vec![]),
1726+
default_credential: tokio::sync::RwLock::new(0),
1727+
audit: crate::web::audit::AuditLogger::new(&audit_path).expect("audit logger"),
1728+
started_at: std::time::Instant::now(),
1729+
stats: crate::web::SigningStats::default(),
1730+
gh_reporter: None,
1731+
})
1732+
}
1733+
1734+
/// POST a multipart body larger than `max_upload_size` — must be rejected
1735+
/// at the axum layer with 413 Payload Too Large, NOT 500 Internal.
1736+
///
1737+
/// Without pre-buffer enforcement the oversized body is streamed through
1738+
/// the multipart extractor and the resulting error was mapped to 500,
1739+
/// letting attackers observe internal error messages and forcing the
1740+
/// server to read significant data before rejecting.
1741+
#[tokio::test]
1742+
async fn test_sign_oversized_body_rejected_with_413() {
1743+
let max = 1024u64;
1744+
let state = make_test_state_with_max(max);
1745+
// Build a multipart body whose payload exceeds `max`.
1746+
let oversized = vec![0xAAu8; (max as usize) * 4];
1747+
let (ct, body) = build_multipart_with_file("big.exe", &oversized);
1748+
assert!(body.len() as u64 > max);
1749+
1750+
let resp = post_raw(state, "/api/v1/sign", &ct, body).await;
1751+
assert_eq!(
1752+
resp.status(),
1753+
axum::http::StatusCode::PAYLOAD_TOO_LARGE,
1754+
"oversized upload must return 413, got {}",
1755+
resp.status()
1756+
);
1757+
}
1758+
1759+
/// Same for `/api/v1/verify` — every multipart endpoint must enforce the
1760+
/// limit uniformly.
1761+
#[tokio::test]
1762+
async fn test_verify_oversized_body_rejected_with_413() {
1763+
let max = 1024u64;
1764+
let state = make_test_state_with_max(max);
1765+
let oversized = vec![0xAAu8; (max as usize) * 4];
1766+
let (ct, body) = build_multipart_with_file("big.exe", &oversized);
1767+
1768+
let resp = post_raw(state, "/api/v1/verify", &ct, body).await;
1769+
assert_eq!(
1770+
resp.status(),
1771+
axum::http::StatusCode::PAYLOAD_TOO_LARGE,
1772+
"oversized verify upload must return 413, got {}",
1773+
resp.status()
1774+
);
1775+
}
1776+
1777+
/// A request whose declared `Content-Length` exceeds the limit must be
1778+
/// rejected BEFORE the body is read (pre-buffer enforcement). This is
1779+
/// the defining property of the P1 fix: no bytes of a lying client's
1780+
/// payload are buffered when the header already reveals the overflow.
1781+
#[tokio::test]
1782+
async fn test_content_length_header_exceeds_limit_rejected_with_413() {
1783+
use tower::ServiceExt as _;
1784+
1785+
let max = 1024u64;
1786+
let state = make_test_state_with_max(max);
1787+
let router = crate::web::build_router(state);
1788+
1789+
// Craft a tiny body but a Content-Length header that claims it's huge.
1790+
// axum will compare CL against the limit and reject with 413 before
1791+
// streaming. We test with a legitimately oversized body to avoid
1792+
// ambiguous framing; the point is Content-Length > max.
1793+
let body_bytes = vec![0u8; (max as usize) * 2];
1794+
let request = axum::http::Request::builder()
1795+
.method("POST")
1796+
.uri("/api/v1/sign")
1797+
.header(
1798+
axum::http::header::CONTENT_TYPE,
1799+
"multipart/form-data; boundary=xxxx",
1800+
)
1801+
.header(axum::http::header::CONTENT_LENGTH, body_bytes.len())
1802+
.body(axum::body::Body::from(body_bytes))
1803+
.unwrap();
1804+
1805+
let resp = router.oneshot(request).await.unwrap();
1806+
assert_eq!(
1807+
resp.status(),
1808+
axum::http::StatusCode::PAYLOAD_TOO_LARGE,
1809+
"Content-Length > max_upload_size must return 413 pre-buffer"
1810+
);
1811+
}
1812+
1813+
/// Requests within the limit must NOT be rejected by the body-limit
1814+
/// layer (regression guard: over-eager enforcement would 413 legit uploads).
1815+
#[tokio::test]
1816+
async fn test_under_limit_multipart_not_413() {
1817+
let max = 64 * 1024u64;
1818+
let state = make_test_state_with_max(max);
1819+
let small = vec![0u8; 256]; // well under max
1820+
let (ct, body) = build_multipart_with_file("small.exe", &small);
1821+
assert!((body.len() as u64) < max);
1822+
1823+
let resp = post_raw(state, "/api/v1/sign", &ct, body).await;
1824+
assert_ne!(
1825+
resp.status(),
1826+
axum::http::StatusCode::PAYLOAD_TOO_LARGE,
1827+
"under-limit request must not be rejected as too large"
1828+
);
1829+
}
1830+
17041831
fn make_user(groups: Vec<String>, is_admin: bool) -> UserInfo {
17051832
let config = LdapConfig {
17061833
cert_groups: [("server".into(), "cn=server-signers".into())]

crates/pki-sign/src/web/mod.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ use axum::extract::DefaultBodyLimit;
4242
use axum::routing::{get, post};
4343
use axum::{middleware as axum_middleware, Router};
4444
use tokio::sync::RwLock;
45+
use tower_http::limit::RequestBodyLimitLayer;
4546
use tower_http::services::ServeDir;
4647
use tower_http::trace::TraceLayer;
4748
use tracing::info;
@@ -181,7 +182,14 @@ pub fn build_router(state: Arc<AppState>) -> Router {
181182
)
182183
// Catch-all
183184
.fallback(handlers::fallback)
184-
// Layers (applied bottom-up: trace first, then security headers, then body limit)
185+
// Layers (applied bottom-up: trace first, then security headers, then body limit).
186+
//
187+
// `RequestBodyLimitLayer` returns 413 Payload Too Large when `Content-Length`
188+
// exceeds `max_upload_size`, rejecting the request BEFORE any body is
189+
// buffered — the P1 security fix. `DefaultBodyLimit::max` is also set so
190+
// axum-extra's `Multipart` extractor uses the same limit (instead of its
191+
// internal 2 MB default) for mid-stream (chunked/no-CL) overflow bounding.
192+
.layer(RequestBodyLimitLayer::new(max_body))
185193
.layer(DefaultBodyLimit::max(max_body))
186194
.layer(axum_middleware::from_fn(
187195
middleware::security_headers_middleware,

0 commit comments

Comments
 (0)