543fd17
commit 543fd17
4 files changed
+195 -0
Showing 4 changed files+195-0
▼CHANGELOG.md
+58-0
| @@ -0,0 +1,58 @@ | ||
1 | +# Changelog | |
2 | + | |
3 | +All notable changes to JustAPI will be documented in this file. | |
4 | + | |
5 | +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), | |
6 | +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). | |
7 | + | |
8 | +## [Unreleased] | |
9 | + | |
10 | +### Added | |
11 | +- `PerRouteRateLimiter` middleware with prefix matching, method filtering, and optional fallback (`middleware.rs`) | |
12 | +- `Server::add_per_route_rate_limit()` builder method | |
13 | +- `CoreError` typed error enum with 12 variants, `thiserror`-derived (`error.rs`) | |
14 | +- `CoreError::to_problem_json()` for RFC 9457 Problem Details rendering | |
15 | +- `CoreError::into_response()` for direct HTTP response building | |
16 | +- `CoreError::status_code()` for variant-to-HTTP-status mapping | |
17 | +- `Server::with_shutdown_timeout(Duration)` builder method (configurable drain timeout, default 30s) | |
18 | +- 15 new crash-prevention and feature tests | |
19 | + | |
20 | +### Changed | |
21 | +- **BREAKING:** All error responses now follow RFC 9457 Problem Details format (`application/problem+json`) instead of `{"detail":"..."}` or `{"error":"..."}`. Clients matching on `detail` field will still work; clients matching on `error` field must update. | |
22 | +- `error_response()` returns `{type, title, status, detail}` instead of `{detail}` | |
23 | +- `service_unavailable_response()` returns `{type, title, status, detail}` with `application/problem+json` content type | |
24 | +- `json_error()` in middleware returns `{type, title, status, detail}` instead of `{error}` | |
25 | +- `Handler::Static` dispatch uses `error_response()` for non-2xx status codes | |
26 | +- 40+ `unwrap()` calls on request hot paths replaced with safe error handling | |
27 | +- Mutex locks in `metrics.rs` and `tracing_setup.rs` now recover from poisoning | |
28 | +- Email format validator handles missing `@` without panicking | |
29 | +- `openai.rs` status code conversion uses `safe_status()` fallback | |
30 | + | |
31 | +### Fixed | |
32 | +- User-triggerable server crash via email validation without `@` character (`validate.rs:33`, `app.rs:244`) | |
33 | +- Potential panic from `Bound::new()` failure on every request (`handlers.rs:176`) | |
34 | +- Potential panic from `CString::new()` and `py.eval()` in `fast_dumps` (`handlers.rs:335-336`) | |
35 | +- Mutex poisoning causing panic on `/metrics` endpoint (`metrics.rs:153,278`) | |
36 | +- Clock skew panic in Redis rate limiter (`rate_limit.rs:78`) | |
37 | +- Clock skew panic in mail verification tokens (`mail/otp.rs`, `mail/verify.rs`) | |
38 | + | |
39 | +## [2.0.7] - 2026-07-25 | |
40 | + | |
41 | +### Added | |
42 | +- Generator dependencies (FastAPI-style `yield` in `Depends`) | |
43 | +- ORJSON as optional Rust-level serializer (`orjson` feature) | |
44 | +- Production panic hotspot audit — fixed 6 categories of `unwrap()` panics | |
45 | +- Scalar API Reference documentation page (156 pages total) | |
46 | + | |
47 | +### Changed | |
48 | +- Demo shop stress test — all D1–D4 defects fixed, 29/29 assertions pass | |
49 | + | |
50 | +## [2.0.0] - 2026-07-03 | |
51 | + | |
52 | +### Added | |
53 | +- Initial release of JustAPI 2.0 "Singularity" | |
54 | +- Rust core (`justapi-core`): HTTP/1.1+HTTP/2, routing, middleware, compression, WebSocket, SSE, TLS, database ORM, OpenAPI, GraphQL, gRPC, WASM middleware | |
55 | +- Python bindings (`justapi-py`): FastAPI-compatible decorator API, DI, controllers, TestClient | |
56 | +- CLI (`justapi-cli`): serve, db migrate/rollback, routes, doctor, gen, new, create, profile | |
57 | +- Inference engine (`justapi-inference`): KV cache, RadixAttention, continuous batching, speculative decoding, OpenAI-compatible API | |
58 | +- 493+ tests, 8 fuzz targets, CI with Miri | |
0 | 59 | |
▼CONTRIBUTING.md
+32-0
| @@ -78,6 +78,38 @@ Before submitting a Pull Request, verify that all of the following steps pass: | ||
78 | 78 | |
79 | 79 | --- |
80 | 80 | |
81 | +## Versioning & Deprecation Policy | |
82 | + | |
83 | +### Semantic Versioning | |
84 | + | |
85 | +JustAPI follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html): | |
86 | + | |
87 | +- **Major (X.0.0):** Breaking changes to public API. Requires migration guide. | |
88 | +- **Minor (0.X.0):** New features, backward-compatible. May deprecate existing APIs. | |
89 | +- **Patch (0.0.X):** Bug fixes, backward-compatible. No API changes. | |
90 | + | |
91 | +**Current status:** Pre-1.0. Minor versions may contain breaking changes (documented in CHANGELOG.md with migration notes). | |
92 | + | |
93 | +### Deprecation Process | |
94 | + | |
95 | +When a public API is deprecated: | |
96 | + | |
97 | +1. Add `#[deprecated(since = "X.Y.Z", note = "use new_api() instead")]` (Rust) or `warnings.warn(...)` (Python) | |
98 | +2. Document the deprecation in CHANGELOG.md under `### Deprecated` | |
99 | +3. Provide a migration example in the deprecation note | |
100 | +4. Remove the deprecated API in the next **minor** version (not patch) | |
101 | + | |
102 | +### Breaking Change Requirements | |
103 | + | |
104 | +Before merging a breaking change: | |
105 | + | |
106 | +- [ ] CHANGELOG.md updated with `### Changed` or `### Removed` entry | |
107 | +- [ ] Migration guide written (new section in docs or inline in CHANGELOG) | |
108 | +- [ ] Deprecation period of at least one minor version (unless critical security fix) | |
109 | +- [ ] All downstream examples and tests updated | |
110 | + | |
111 | +--- | |
112 | + | |
81 | 113 | ## Resuming Work after Context Reset |
82 | 114 | If you are an agent resuming work after a context reset: |
83 | 115 | 1. Read [PLAN.md](PLAN.md) to locate the current phase and its status. |
84 | 116 | |
▼crates/justapi-core/src/server/mod.rs
+85-0
| @@ -1263,6 +1263,22 @@ const HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs( | ||
1263 | 1263 | /// `Server::with_max_body_size`. |
1264 | 1264 | pub(crate) const DEFAULT_MAX_BODY_SIZE: usize = 50 * 1024 * 1024; |
1265 | 1265 | |
1266 | +/// Maximum allowed URL path length in bytes. Paths longer than this are | |
1267 | +/// rejected with 414 URI Too Long before any routing or processing. | |
1268 | +const MAX_PATH_LENGTH: usize = 8192; | |
1269 | + | |
1270 | +/// Maximum allowed single header value length in bytes. Headers exceeding | |
1271 | +/// this are rejected with 431 Request Header Fields Too Large. | |
1272 | +const MAX_HEADER_VALUE_LENGTH: usize = 8192; | |
1273 | + | |
1274 | +/// Maximum allowed number of request headers. Requests with more headers | |
1275 | +/// are rejected with 431 Request Header Fields Too Large. | |
1276 | +const MAX_HEADER_COUNT: usize = 100; | |
1277 | + | |
1278 | +/// Maximum allowed query string length in bytes. Query strings longer than | |
1279 | +/// this are rejected with 414 URI Too Long. | |
1280 | +const MAX_QUERY_LENGTH: usize = 2048; | |
1281 | + | |
1266 | 1282 | /// Max wall-clock time a single request handler may run before we respond 504 |
1267 | 1283 | /// and abort it. Guards against stuck/slow handlers and resource exhaustion. |
1268 | 1284 | /// Configurable via `JUSTAPI_REQUEST_TIMEOUT_SECS`. |
| @@ -1368,6 +1384,43 @@ async fn serve_connection( | ||
1368 | 1384 | let path = req.uri().path().to_string(); |
1369 | 1385 | let method = req.method().clone(); |
1370 | 1386 | let start = std::time::Instant::now(); |
1387 | + | |
1388 | + // --- Input validation (security hardening, Phase 58) --- | |
1389 | + // Reject oversized paths before routing to prevent DoS via | |
1390 | + // extremely long URI strings that blow up memory in the router | |
1391 | + // or middleware chain. | |
1392 | + if path.len() > MAX_PATH_LENGTH { | |
1393 | + return Ok(error_response( | |
1394 | + StatusCode::URI_TOO_LONG, | |
1395 | + "request URI exceeds maximum length", | |
1396 | + )); | |
1397 | + } | |
1398 | + if let Some(query) = req.uri().query() { | |
1399 | + if query.len() > MAX_QUERY_LENGTH { | |
1400 | + return Ok(error_response( | |
1401 | + StatusCode::URI_TOO_LONG, | |
1402 | + "query string exceeds maximum length", | |
1403 | + )); | |
1404 | + } | |
1405 | + } | |
1406 | + // Reject requests with too many headers or oversized header values. | |
1407 | + // Excessively large headers waste memory and slow down parsing. | |
1408 | + let header_count = req.headers().len(); | |
1409 | + if header_count > MAX_HEADER_COUNT { | |
1410 | + return Ok(error_response( | |
1411 | + StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE, | |
1412 | + "too many request headers", | |
1413 | + )); | |
1414 | + } | |
1415 | + for (name, value) in req.headers().iter() { | |
1416 | + if value.len() > MAX_HEADER_VALUE_LENGTH { | |
1417 | + return Ok(error_response( | |
1418 | + StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE, | |
1419 | + &format!("header '{}' exceeds maximum value length", name.as_str()), | |
1420 | + )); | |
1421 | + } | |
1422 | + } | |
1423 | + | |
1371 | 1424 | req.extensions_mut().insert(peer_addr); |
1372 | 1425 | |
1373 | 1426 | let span = tracing::info_span!( |
| @@ -2055,6 +2108,38 @@ async fn serve_with_tls( | ||
2055 | 2108 | async move { |
2056 | 2109 | let path = req.uri().path().to_string(); |
2057 | 2110 | let start = std::time::Instant::now(); |
2111 | + | |
2112 | + // --- Input validation (security hardening, Phase 58) --- | |
2113 | + if path.len() > MAX_PATH_LENGTH { | |
2114 | + return Ok(error_response( | |
2115 | + StatusCode::URI_TOO_LONG, | |
2116 | + "request URI exceeds maximum length", | |
2117 | + )); | |
2118 | + } | |
2119 | + if let Some(query) = req.uri().query() { | |
2120 | + if query.len() > MAX_QUERY_LENGTH { | |
2121 | + return Ok(error_response( | |
2122 | + StatusCode::URI_TOO_LONG, | |
2123 | + "query string exceeds maximum length", | |
2124 | + )); | |
2125 | + } | |
2126 | + } | |
2127 | + let header_count = req.headers().len(); | |
2128 | + if header_count > MAX_HEADER_COUNT { | |
2129 | + return Ok(error_response( | |
2130 | + StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE, | |
2131 | + "too many request headers", | |
2132 | + )); | |
2133 | + } | |
2134 | + for (name, value) in req.headers().iter() { | |
2135 | + if value.len() > MAX_HEADER_VALUE_LENGTH { | |
2136 | + return Ok(error_response( | |
2137 | + StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE, | |
2138 | + &format!("header '{}' exceeds maximum value length", name.as_str()), | |
2139 | + )); | |
2140 | + } | |
2141 | + } | |
2142 | + | |
2058 | 2143 | req.extensions_mut().insert(peer_addr); |
2059 | 2144 | |
2060 | 2145 | metrics.record_request(); |
2061 | 2146 | |
▼crates/justapi-core/tests/integration.rs
+20-0
| @@ -525,3 +525,23 @@ async fn test_crud_all_handler() { | ||
525 | 525 | let rows: Value = serde_json::from_slice(&body).unwrap(); |
526 | 526 | assert!(rows.as_array().unwrap().is_empty()); |
527 | 527 | } |
528 | + | |
529 | +// --- Phase 58: Input validation security tests --- | |
530 | + | |
531 | +#[tokio::test] | |
532 | +async fn test_oversized_path_returns_414() { | |
533 | + let addr = start_server().await; | |
534 | + // Path longer than MAX_PATH_LENGTH (8192) | |
535 | + let long_path = format!("/{}", "a".repeat(9000)); | |
536 | + let (status, body) = send_request(addr, Method::GET, &long_path, None).await; | |
537 | + assert_eq!(status, StatusCode::URI_TOO_LONG); | |
538 | + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); | |
539 | + assert_eq!(v["status"], 414); | |
540 | +} | |
541 | + | |
542 | +#[tokio::test] | |
543 | +async fn test_normal_path_still_works() { | |
544 | + let addr = start_server().await; | |
545 | + let (status, _) = send_request(addr, Method::GET, "/hello", None).await; | |
546 | + assert_eq!(status, StatusCode::OK); | |
547 | +} | |
528 | 548 | |