Private
Public Access
1
0

Compare commits

...

8 Commits

Author SHA1 Message Date
eac05ad1eb fix: remove dead min_tls_version config field, TLS 1.3 is only supported version (closes #16)
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 4s
CI/CD Pipeline / Clippy Lints (push) Successful in 44s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m24s
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m15s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m0s
CI/CD Pipeline / Build Debian Package (push) Failing after 4s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Failing after 4s
CI/CD Pipeline / Build RPM Package (push) Successful in 2m17s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m25s
CI/CD Pipeline / Build Alpine Package (push) Failing after 3m15s
Co-authored-by: git-echo <git-echo@moon-dragon.us>
2026-06-06 16:50:55 -05:00
df2f4c70c9 feat: add rate limiting and job queue depth cap (closes #15)
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 45s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m24s
CI/CD Pipeline / Security Audit (push) Successful in 4s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m14s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Failing after 4s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 1m0s
CI/CD Pipeline / Build Debian Package (push) Failing after 5s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m24s
CI/CD Pipeline / Build RPM Package (push) Successful in 2m15s
CI/CD Pipeline / Build Alpine Package (push) Failing after 3m19s
- Add custom RateLimitMiddleware using governor crate for per-IP rate limiting
- Two-tier rate limiting: destructive (20 req/min, burst 10) and read (120 req/min, burst 30)
- Health endpoints (/health, /api/v1/system/info) exempt from rate limiting
- Add max_queue_depth to JobManager (default: 100, configurable via config.yaml)
- Return 429 Too Many Requests with Retry-After header when queue is full
- Add RateLimitConfig to config.yaml with all rate limit settings
- Add 10 tests covering rate limiting, queue depth, and configuration defaults

Co-authored-by: git-echo <git-echo@moon-dragon.us>
2026-06-06 15:39:49 -05:00
6a4c4c95a4 fix: remove dead MtlsMiddleware, add security header middleware, document rustls as auth gate (closes #13)
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 42s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m11s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m13s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 58s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Failing after 8s
CI/CD Pipeline / Build Debian Package (push) Failing after 5s
CI/CD Pipeline / Build RPM Package (push) Successful in 2m5s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m16s
CI/CD Pipeline / Build Alpine Package (push) Failing after 3m5s
- Remove dead MtlsMiddleware struct, MtlsMiddlewareService, Transform/Service impls
- Remove validate_client_certificate() stub (returned Ok(()) unconditionally)
- Remove has_duplicate_critical_headers() from mtls.rs (moved to new module)
- Convert build_rustls_config() from method on MtlsMiddleware to free function
- Create SecurityHeadersMiddleware in src/auth/security_headers.rs for VULN-006
- Wire SecurityHeadersMiddleware into Actix-web pipeline in main.rs
- Add ADR documenting rustls as authoritative client-auth gate
- Preserve CrlAwareVerifier, MtlsConfig, MtlsError, ClientCertInfo, build_rustls_config
- Add integration tests for duplicate header detection
- Update HARDENING_REPORT.md and SECURITY_FINDINGS_REPORT.md with ADR

Co-authored-by: git-echo <git-echo@moon-dragon.us>
2026-06-06 13:58:01 -05:00
efaac33c47 fix: remove committed private keys and add runtime cert generation (closes #12)
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 43s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m12s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Failing after 4s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 57s
CI/CD Pipeline / Build Debian Package (push) Failing after 4s
CI/CD Pipeline / Build RPM Package (push) Successful in 2m12s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m18s
CI/CD Pipeline / Build Alpine Package (push) Failing after 3m7s
- Remove all private key files from git tracking (git rm --cached)
  - configs/certs/ca.key.pem, server.key.pem, client001.key.pem
  - tests/e2e/certs/client.key
  - Also remove public certs from configs/certs/ (generated at runtime)
- Add .gitignore patterns for *.key, *.key.pem, configs/certs/*.pem, *.srl
- Add scripts/generate-dev-certs.sh for runtime test cert generation
- Update Python e2e test to generate certs on demand (ensure_certs())
- Update test_wrong_cert_connection to generate wrong-CA certs at runtime
- Add gitleaks secret scanning job to CI workflow
- Update SECURITY_FINDINGS_REPORT.md with critical finding for Issue #12
- Update SECURITY_CONTROLS_MATRIX.md evidence references
- Add README.md to configs/certs/ and tests/e2e/certs/

Private keys were dev/test only - no production key rotation needed.
Git history purge with filter-repo will follow after PR merge.

Co-authored-by: git-echo <git-echo@moon-dragon.us>
2026-06-06 13:20:43 -05:00
d0c0790cbf fix: enforce IP whitelist middleware in request pipeline (closes #11)
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 41s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m9s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m11s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 56s
CI/CD Pipeline / Build Debian Package (push) Failing after 3s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Failing after 4s
CI/CD Pipeline / Build RPM Package (push) Successful in 2m13s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m17s
CI/CD Pipeline / Build Alpine Package (push) Failing after 3m13s
Co-authored-by: git-echo <git-echo@moon-dragon.us>
2026-06-06 12:47:24 -05:00
130206a3a3 fix: prevent argument injection RCE in package manager backends (closes #10)
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 4s
CI/CD Pipeline / Clippy Lints (push) Successful in 42s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m10s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m13s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Failing after 4s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 59s
CI/CD Pipeline / Build RPM Package (push) Successful in 2m13s
CI/CD Pipeline / Build Debian Package (push) Failing after 5s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m28s
CI/CD Pipeline / Build Alpine Package (push) Failing after 3m33s
P0-1: Replace weak validate_package_name() with strict allowlist validation
- Pattern: ^[a-zA-Z0-9][a-zA-Z0-9+._-]*$ (must start alphanumeric)
- Blocks shell metacharacters, path separators, whitespace, leading hyphens
- Add validate_version_string() for version fields (allows : and ~ for RPM epochs)
- Add validate_service_name() for service names (allows dots, @, hyphens)

P0-2: Add -- separator before user-supplied args in all 20 command sites
- APT: install_packages, update_package, remove_package, apply_patches
- APK: install_packages, update_package, remove_package, apply_patches
- DNF: install_packages, update_package, remove_package, apply_patches
- YUM: install_packages, update_package, remove_package, apply_patches
- Pacman: install_packages, update_package, remove_package, apply_patches

P0-3: Add validation to /patches/apply endpoint
- Validate all package names using validate_package_name()
- Return 400 Bad Request for invalid names

P1: Harden service name validation across all 5 backends
- Replace weak checks (empty + / + ..) with strict allowlist
- Add -- separator to systemctl show command

P2: Gate --force-yes option in APT
- Log warning when --force-yes is used (bypasses signature verification)

Add comprehensive unit tests for all validation functions.

Co-authored-by: git-echo <git-echo@moon-dragon.us>
2026-06-06 12:00:38 -05:00
913d7286e1 chore: bump version to 1.3.2
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 43s
CI/CD Pipeline / All Unit Tests (push) Successful in 2m48s
CI/CD Pipeline / Security Audit (push) Successful in 7s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m23s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Failing after 5s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 59s
CI/CD Pipeline / Build Debian Package (push) Failing after 4s
CI/CD Pipeline / Build RPM Package (push) Successful in 2m13s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m18s
CI/CD Pipeline / Build Alpine Package (push) Failing after 2m58s
* fix: extract DER from PEM-encoded CA cert before CRL signature verification

* chore: bump version to 1.3.2

---------

Co-authored-by: git-echo <git-echo@moon-dragon.us>
Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-06 08:44:34 -05:00
3c70b15831 fix: extract DER from PEM-encoded CA cert before CRL signature verification
Co-authored-by: git-echo <git-echo@moon-dragon.us>
2026-06-06 08:31:20 -05:00
45 changed files with 2189 additions and 624 deletions

View File

@ -61,6 +61,18 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- run: cargo install cargo-audit && cargo audit --ignore RUSTSEC-2025-0134 - run: cargo install cargo-audit && cargo audit --ignore RUSTSEC-2025-0134
gitleaks:
name: Secret scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
enrollment-tests: enrollment-tests:
name: Enrollment Tests name: Enrollment Tests
needs: [fmt, clippy] needs: [fmt, clippy]

7
.gitignore vendored
View File

@ -14,5 +14,12 @@ debian/linux-patch-api.substvars
*.buildinfo *.buildinfo
*.changes *.changes
# Private key material - NEVER commit
*.key
*.key.pem
configs/certs/*.pem
configs/certs/*.srl
tests/e2e/certs/*.key
# Agent Zero project data # Agent Zero project data
.a0proj/ .a0proj/

105
Cargo.lock generated
View File

@ -44,6 +44,18 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "actix-governor"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0954b0f27aabd8f56bb03f2a77b412ddf3f8c034a3c27b2086c1fc75415760df"
dependencies = [
"actix-http",
"actix-web",
"futures",
"governor",
]
[[package]] [[package]]
name = "actix-http" name = "actix-http"
version = "3.12.1" version = "3.12.1"
@ -968,6 +980,19 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "dashmap"
version = "5.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [
"cfg-if",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.11.0" version = "2.11.0"
@ -1314,6 +1339,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.32" version = "0.3.32"
@ -1382,6 +1413,26 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "governor"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b"
dependencies = [
"cfg-if",
"dashmap",
"futures",
"futures-timer",
"no-std-compat",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.8.6",
"smallvec",
"spinning_top",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.3.27" version = "0.3.27"
@ -1931,9 +1982,10 @@ dependencies = [
[[package]] [[package]]
name = "linux-patch-api" name = "linux-patch-api"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"actix", "actix",
"actix-governor",
"actix-rt", "actix-rt",
"actix-tls", "actix-tls",
"actix-web", "actix-web",
@ -2104,6 +2156,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@ -2114,6 +2172,12 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]] [[package]]
name = "notify" name = "notify"
version = "6.1.1" version = "6.1.1"
@ -2383,6 +2447,12 @@ dependencies = [
"plotters-backend", "plotters-backend",
] ]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@ -2441,6 +2511,21 @@ version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@ -2593,6 +2678,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.11.1",
]
[[package]] [[package]]
name = "rayon" name = "rayon"
version = "1.12.0" version = "1.12.0"
@ -3085,6 +3179,15 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "linux-patch-api" name = "linux-patch-api"
version = "1.3.1" version = "1.3.2"
edition = "2021" edition = "2021"
authors = ["Echo <echo@moon-dragon.us>"] authors = ["Echo <echo@moon-dragon.us>"]
description = "Secure remote package management API for Linux systems" description = "Secure remote package management API for Linux systems"
@ -16,6 +16,9 @@ actix-web-actors = "4"
actix = "0.13" actix = "0.13"
actix-tls = { version = "3", features = ["rustls-0_23"] } actix-tls = { version = "3", features = ["rustls-0_23"] }
# Rate limiting (actix-governor for per-IP rate limiting)
actix-governor = "0.6"
# Async runtime # Async runtime
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
@ -114,6 +117,14 @@ path = "tests/integration/enrollment_test.rs"
name = "enrollment_e2e" name = "enrollment_e2e"
path = "tests/e2e/test_enrollment_e2e.rs" path = "tests/e2e/test_enrollment_e2e.rs"
[[test]]
name = "auth_test"
path = "tests/integration/auth_test.rs"
[[test]]
name = "rate_limit_test"
path = "tests/unit/rate_limit_test.rs"
[[bench]] [[bench]]
name = "api_benchmarks" name = "api_benchmarks"
harness = false harness = false

View File

@ -181,7 +181,7 @@ tls:
ca_cert: "/etc/linux_patch_api/certs/ca.pem" ca_cert: "/etc/linux_patch_api/certs/ca.pem"
server_cert: "/etc/linux_patch_api/certs/server.pem" server_cert: "/etc/linux_patch_api/certs/server.pem"
server_key: "/etc/linux_patch_api/certs/server.key" server_key: "/etc/linux_patch_api/certs/server.key"
min_tls_version: "1.3" # TLS 1.3 is the only supported version (hardcoded, not configurable)
jobs: jobs:
max_concurrent: 5 max_concurrent: 5

View File

@ -20,7 +20,7 @@ This report documents the implementation of 6 security hardening fixes deferred
| VULN-003 | LOW | Input Validation | ✅ RESOLVED | src/api/handlers/packages.rs | | VULN-003 | LOW | Input Validation | ✅ RESOLVED | src/api/handlers/packages.rs |
| VULN-004 | MEDIUM | Header Security | ✅ RESOLVED | src/main.rs | | VULN-004 | MEDIUM | Header Security | ✅ RESOLVED | src/main.rs |
| VULN-005 | LOW | HTTP Protocol | ✅ RESOLVED | src/api/routes.rs | | VULN-005 | LOW | HTTP Protocol | ✅ RESOLVED | src/api/routes.rs |
| VULN-006 | LOW | Header Security | ✅ RESOLVED | src/auth/mtls.rs | | VULN-006 | LOW | Header Security | ✅ RESOLVED | src/auth/security_headers.rs |
--- ---
@ -176,20 +176,19 @@ web::scope("/api/v1")
**Finding:** Duplicate Content-Type headers were accepted. **Finding:** Duplicate Content-Type headers were accepted.
**Implementation:** **Implementation:**
- Added `has_duplicate_critical_headers()` function to check for duplicate headers - `has_duplicate_critical_headers()` function checks for duplicate headers on every request
- Monitors critical headers: `content-type`, `authorization`, `host` - Monitors critical headers: `content-type`, `authorization`, `host`
- Integrated into mTLS middleware `call()` method - Implemented as `SecurityHeadersMiddleware` — a dedicated Actix-web middleware
- Rejects requests with duplicate critical headers before further processing - Wired into the middleware pipeline in `main.rs` between WhitelistMiddleware and Logger
- Rejects requests with duplicate critical headers with HTTP 400 Bad Request
**Code Location:** `src/auth/mtls.rs` (lines 26-49, 203-212) **Code Location:** `src/auth/security_headers.rs`
```rust ```rust
fn has_duplicate_critical_headers(req: &ServiceRequest) -> bool { pub fn has_duplicate_critical_headers(headers: &HeaderMap) -> bool {
let critical_headers = ["content-type", "authorization", "host"]; for header_name in CRITICAL_HEADERS.iter() {
for header_name in critical_headers.iter() {
let mut count = 0; let mut count = 0;
for (name, _) in req.headers().iter() { for (name, _value) in headers.iter() {
if name.as_str().eq_ignore_ascii_case(header_name) { if name.as_str().eq_ignore_ascii_case(header_name) {
count += 1; count += 1;
if count > 1 { if count > 1 {
@ -202,7 +201,29 @@ fn has_duplicate_critical_headers(req: &ServiceRequest) -> bool {
} }
``` ```
**Response:** HTTP 400 Bad Request with message "Duplicate critical headers not allowed" **Response:** HTTP 400 Bad Request with error message "Duplicate critical headers not allowed"
**Architecture Note:** The duplicate-header check was originally in `MtlsMiddleware`, which was dead code (never wired into the pipeline). It has been extracted into `SecurityHeadersMiddleware`, which IS wired into the pipeline and runs on every request. Client certificate authentication is handled at the TLS handshake level by rustls via `CrlAwareVerifier` — no application-layer certificate middleware is needed. See `src/auth/mtls.rs` for the ADR documenting this decision.
---
## Architecture Decision Record: rustls as Authoritative Client-Auth Gate
**Decision:** Client certificate authentication is enforced at the TLS handshake level by rustls via `CrlAwareVerifier`, NOT by application-layer middleware.
**Context:** The original `MtlsMiddleware` was never wired into the Actix-web pipeline. It contained both a duplicate-header check (VULN-006) and a `validate_client_certificate()` stub that returned `Ok(())` unconditionally. Meanwhile, the actual client certificate verification was always performed by rustls at the TLS handshake level through `CrlAwareVerifier`, which wraps `WebPkiClientVerifier`.
**Rationale:**
- rustls provides battle-tested X.509 verification at the TLS handshake level
- Enforcing auth at the TLS layer eliminates bypass vulnerabilities (middleware ordering bugs, route-specific skips)
- CRL revocation checking is integrated into the same handshake path via `CrlAwareVerifier`
- Application-layer certificate validation is redundant when the TLS layer already rejects untrusted connections
**Consequences:**
- `MtlsMiddleware` (Transform/Service) and `validate_client_certificate()` have been removed as dead code
- `build_rustls_config()` is now a free function (no longer a method on `MtlsMiddleware`)
- `SecurityHeadersMiddleware` handles VULN-006 (duplicate critical header rejection) as a dedicated, wired middleware
- `ClientCertInfo` struct is preserved for potential future use in extracting certificate details from TLS sessions
--- ---

View File

@ -395,7 +395,7 @@ tls:
ca_cert: "/etc/linux_patch_api/certs/ca.pem" ca_cert: "/etc/linux_patch_api/certs/ca.pem"
server_cert: "/etc/linux_patch_api/certs/server.pem" server_cert: "/etc/linux_patch_api/certs/server.pem"
server_key: "/etc/linux_patch_api/certs/server.key" server_key: "/etc/linux_patch_api/certs/server.key"
min_tls_version: "1.3" # TLS 1.3 is the only supported version (hardcoded, not configurable)
# Job Configuration # Job Configuration
jobs: jobs:

View File

@ -41,7 +41,7 @@
| **SPEC.md Reference** | Lines 132-138 | | **SPEC.md Reference** | Lines 132-138 |
| **Requirement** | Internal self-hosted CA for certificate issuance | | **Requirement** | Internal self-hosted CA for certificate issuance |
| **Implementation** | OpenSSL CA infrastructure with 4096-bit RSA keys | | **Implementation** | OpenSSL CA infrastructure with 4096-bit RSA keys |
| **Evidence** | `configs/CA_SETUP.md`, `configs/certs/ca.pem`, `configs/certs/ca.key.pem` | | **Evidence** | `configs/CA_SETUP.md`, `scripts/generate-dev-certs.sh` (private keys generated at runtime, not committed) |
| **Test Result** | ✅ PASS - CA properly signs server and client certificates | | **Test Result** | ✅ PASS - CA properly signs server and client certificates |
| **Compliance Status** | ✅ COMPLIANT | | **Compliance Status** | ✅ COMPLIANT |
@ -52,7 +52,7 @@
| **SPEC.md Reference** | Line 136 | | **SPEC.md Reference** | Line 136 |
| **Requirement** | Unique certificate per client (no shared certs) | | **Requirement** | Unique certificate per client (no shared certs) |
| **Implementation** | Per-client certificate generation with unique CN | | **Implementation** | Per-client certificate generation with unique CN |
| **Evidence** | `configs/certs/client001.pem`, `SECURITY.md` line 65 | | **Evidence** | `scripts/generate-dev-certs.sh` (certificates generated at runtime, not committed) |
| **Test Result** | ✅ PASS - Each client has distinct certificate | | **Test Result** | ✅ PASS - Each client has distinct certificate |
| **Compliance Status** | ✅ COMPLIANT | | **Compliance Status** | ✅ COMPLIANT |
@ -63,7 +63,7 @@
| **SPEC.md Reference** | Line 135 | | **SPEC.md Reference** | Line 135 |
| **Requirement** | 1 year standard certificate expiration | | **Requirement** | 1 year standard certificate expiration |
| **Implementation** | Certificates generated with `-days 365` parameter | | **Implementation** | Certificates generated with `-days 365` parameter |
| **Evidence** | `configs/certs/` certificate files, `openssl x509 -in cert.pem -noout -dates` | | **Evidence** | `scripts/generate-dev-certs.sh` (certificates generated at runtime, not committed) |
| **Test Result** | ✅ PASS - Expired certificates properly rejected (FUZZ_TEST_REPORT.md Test 3.2) | | **Test Result** | ✅ PASS - Expired certificates properly rejected (FUZZ_TEST_REPORT.md Test 3.2) |
| **Compliance Status** | ✅ COMPLIANT | | **Compliance Status** | ✅ COMPLIANT |
@ -137,7 +137,7 @@
| **SPEC.md Reference** | Lines 86-89 | | **SPEC.md Reference** | Lines 86-89 |
| **Requirement** | Private key permissions 600 (owner read/write only) | | **Requirement** | Private key permissions 600 (owner read/write only) |
| **Implementation** | File permissions set during certificate deployment | | **Implementation** | File permissions set during certificate deployment |
| **Evidence** | `configs/certs/*.key.pem` (chmod 600), `DEPLOYMENT_SECURITY_GUIDE.md` Section 1 | | **Evidence** | Private keys generated at runtime with `chmod 600` by `scripts/generate-dev-certs.sh`, not committed to repository |
| **Test Result** | ✅ PASS - Key files properly protected | | **Test Result** | ✅ PASS - Key files properly protected |
| **Compliance Status** | ✅ COMPLIANT | | **Compliance Status** | ✅ COMPLIANT |

View File

@ -15,7 +15,7 @@
| **Total Tests** | 16 | | **Total Tests** | 16 |
| **Passed** | 16 | | **Passed** | 16 |
| **Failed** | 0 | | **Failed** | 0 |
| **Critical Findings** | 0 (Previously 1 - RESOLVED) | | **Critical Findings** | 1 (Issue #12 - Committed Private Keys - RESOLVED) |
| **High Findings** | 0 (Previously 2 - RESOLVED) | | **High Findings** | 0 (Previously 2 - RESOLVED) |
| **Medium Findings** | 3 (Unchanged) | | **Medium Findings** | 3 (Unchanged) |
| **Low Findings** | 4 (Unchanged) | | **Low Findings** | 4 (Unchanged) |
@ -150,6 +150,36 @@ Consider storing CA key on separate, more secure host.
--- ---
### 🔴 CRITICAL: Committed Private Key Material (Issue #12)
**Description:**
Private key files (`*.key`, `*.key.pem`) were committed to version control in:
- `configs/certs/ca.key.pem` — CA private key
- `configs/certs/server.key.pem` — Server private key
- `configs/certs/client001.key.pem` — Client private key
- `tests/e2e/certs/client.key` — E2E test client private key
Committed private keys are a critical security risk: anyone with repository access
(even read-only) can impersonate the server or clients, decrypt captured TLS traffic,
or forge certificates signed by the CA.
**Status:** ✅ RESOLVED
**Remediation Applied:**
1. Removed all private key files from git tracking (`git rm --cached`)
2. Added `*.key`, `*.key.pem`, `configs/certs/`, and `tests/e2e/certs/*.key` to `.gitignore`
3. Created `scripts/generate-dev-certs.sh` to generate test certificates at runtime
4. Updated e2e tests to generate certificates on demand instead of loading from disk
5. Added `gitleaks` secret scanning to CI pipeline
6. Git history will be purged with `git filter-repo` after PR merge
**Key Rotation:**
These keys were used for development/testing only. No production key rotation is needed.
All committed keys should be considered compromised and must not be used in any
production environment.
---
### 🟢 LOW: No Automated Security Scanning ### 🟢 LOW: No Automated Security Scanning
**Description:** **Description:**
@ -235,5 +265,39 @@ The Linux_Patch_API Phase 3 is now **SECURE FOR DEPLOYMENT** in an internal netw
--- ---
## Architecture Decision Record: rustls as Authoritative Client-Auth Gate
**Date:** 2026-06-06
**Status:** Accepted
**Context:** Issue #13
### Decision
Client certificate authentication is enforced at the TLS handshake level by rustls via `CrlAwareVerifier`, NOT by application-layer middleware.
### Context
The original `MtlsMiddleware` was never wired into the Actix-web pipeline (dead code). It contained:
1. A duplicate-header check (VULN-006) that never ran
2. A `validate_client_certificate()` stub that returned `Ok(())` unconditionally
Meanwhile, actual client certificate verification was always performed by rustls at the TLS handshake level through `CrlAwareVerifier` (which wraps `WebPkiClientVerifier`), with CRL revocation checking integrated into the same path.
### Changes Made
1. **Removed dead code:** `MtlsMiddleware`, `MtlsMiddlewareService`, `validate_client_certificate()`, and the Transform/Service impls
2. **Extracted VULN-006:** `has_duplicate_critical_headers()` moved to new `SecurityHeadersMiddleware` (wired into pipeline)
3. **Converted `build_rustls_config()`** from method on `MtlsMiddleware` to free function
4. **Preserved:** `CrlAwareVerifier`, `MtlsConfig`, `MtlsError`, `ClientCertInfo`, `build_rustls_config()`, and all CRL infrastructure
### Rationale
- rustls provides battle-tested X.509 verification at the TLS handshake level
- Enforcing auth at the TLS layer eliminates bypass vulnerabilities (middleware ordering bugs, route-specific skips)
- CRL revocation checking is integrated into the same handshake path
- Application-layer certificate validation is redundant when TLS already rejects untrusted connections
---
**Report Generated:** 2026-04-09T22:57:00Z **Report Generated:** 2026-04-09T22:57:00Z
**Verified By:** Security Verification Agent (Agent Zero) **Verified By:** Security Verification Agent (Agent Zero)

33
configs/certs/README.md Normal file
View File

@ -0,0 +1,33 @@
# Development Certificates
**⚠️ Private keys are NOT committed to version control.**
This directory is used for local development certificates only. Private key
files (`*.key`, `*.key.pem`) are excluded from git via `.gitignore`.
## Generating Development Certificates
Run the generation script from the repository root:
```bash
./scripts/generate-dev-certs.sh
```
This creates:
- `ca.pem` / `ca.key.pem` — Internal CA certificate and key
- `server.pem` / `server.key.pem` — Server certificate and key
- `client001.pem` / `client001.key.pem` — Client certificate and key
- `tests/e2e/certs/` — E2E test certificates
## Production Deployments
Production deployments should use certificates issued by the organisation's
internal CA. The `install.sh` script and systemd unit handle production
certificate paths at `/etc/linux_patch_api/certs/`.
## Security
- **Never commit private keys** (`*.key`, `*.key.pem`) to version control
- Private keys must have `0600` permissions in production
- The `gitleaks` CI check scans for accidentally committed secrets
- See `SECURITY_FINDINGS_REPORT.md` and `SECURITY.md` for full details

View File

@ -1,5 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg46Ewu04V/qVbFIaW
ll6hUNA1ocfdND68cRv6GiOBikyhRANCAARORR0UUR6G6ndxeefpKai+82eH58ud
sW5qox3Ed4I0WF12RcSwioAPrt5WNB+ptw0wvzx78wH8CdkqjyUb7Koc
-----END PRIVATE KEY-----

View File

@ -1,12 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBsTCCAVegAwIBAgIQVxQmz3/uqfSgf+8ukKa6GTAKBggqhkjOPQQDAjA4MR4w
HAYDVQQDDBVQYXRjaCBNYW5hZ2VyIFJvb3QgQ0ExFjAUBgNVBAoMDVBhdGNoIE1h
bmFnZXIwHhcNMjYwNTE4MTU1MjUxWhcNMzYwNTE1MTU1MjUxWjA4MR4wHAYDVQQD
DBVQYXRjaCBNYW5hZ2VyIFJvb3QgQ0ExFjAUBgNVBAoMDVBhdGNoIE1hbmFnZXIw
WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARORR0UUR6G6ndxeefpKai+82eH58ud
sW5qox3Ed4I0WF12RcSwioAPrt5WNB+ptw0wvzx78wH8CdkqjyUb7Koco0MwQTAP
BgNVHQ8BAf8EBQMDBwYAMB0GA1UdDgQWBBTcLRFILwfBbjqUm3fT8AzIAN5mQDAP
BgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gAMEUCIQDMHR7n6plBEz7tP9Si
Cs6Rk8m2gt9CL6qHlkeWiDJmtgIgVXrj2Lmqn1dEuKbVu9LaxPyvXU4/t2etWHgJ
lfK+SS8=
-----END CERTIFICATE-----

View File

@ -1 +0,0 @@
790CDB9FA2002BF59B3EE88AF326CB060353D113

View File

@ -1,8 +0,0 @@
-----BEGIN CERTIFICATE REQUEST-----
MIH7MIGhAgEAMD8xHTAbBgNVBAMMFHBhdGNoLW1hbmFnZXItY2xpZW50MREwDwYD
VQQKDAhJbnRlcm5hbDELMAkGA1UEBhMCVVMwWTATBgcqhkjOPQIBBggqhkjOPQMB
BwNCAAQauACwaR4SyVoHEPviQgV0I4fbyFuGoHiQExzpYf9Ta025dy88T/a6qG6G
TYJMtbRSjP/piLWfZ/2ze2AdbmczoAAwCgYIKoZIzj0EAwIDSQAwRgIhAJ/BBYsB
aIhKjdwRr0vTqtYPKeeyO2rzHuyRnSvKKkdOAiEA94zCvG0FzkFiqGKT1oHGCVf9
qZdkjkodRAUk6/4S2AU=
-----END CERTIFICATE REQUEST-----

View File

@ -1,5 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg0iNlJbfLqO8Y5sOh
1xRe2bPq8fF9M1ybEOnqmbSGpdGhRANCAAQauACwaR4SyVoHEPviQgV0I4fbyFuG
oHiQExzpYf9Ta025dy88T/a6qG6GTYJMtbRSjP/piLWfZ/2ze2Adbmcz
-----END PRIVATE KEY-----

View File

@ -1,12 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBuzCCAWGgAwIBAgIUeQzbn6IAK/WbPuiK8ybLBgNT0RMwCgYIKoZIzj0EAwIw
ODEeMBwGA1UEAwwVUGF0Y2ggTWFuYWdlciBSb290IENBMRYwFAYDVQQKDA1QYXRj
aCBNYW5hZ2VyMB4XDTI2MDUxODE2MDAwNloXDTI3MDUxODE2MDAwNlowPzEdMBsG
A1UEAwwUcGF0Y2gtbWFuYWdlci1jbGllbnQxETAPBgNVBAoMCEludGVybmFsMQsw
CQYDVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBq4ALBpHhLJWgcQ
++JCBXQjh9vIW4ageJATHOlh/1NrTbl3LzxP9rqoboZNgky1tFKM/+mItZ9n/bN7
YB1uZzOjQjBAMB0GA1UdDgQWBBQhTcmoHT0HqIuEUkL891TKMlWWjjAfBgNVHSME
GDAWgBTcLRFILwfBbjqUm3fT8AzIAN5mQDAKBggqhkjOPQQDAgNIADBFAiApQ6N8
qQR1vWLU3QNrcIwLxK8g2shV5ggypS/CKkfTgwIhAJdZd0silwqEpPo5ng0I5SJ9
MOd4Kx0dps2kY/wqgMSI
-----END CERTIFICATE-----

View File

@ -1,8 +0,0 @@
-----BEGIN CERTIFICATE REQUEST-----
MIH1MIGcAgEAMDoxGDAWBgNVBAMMD2xpbnV4LXBhdGNoLWFwaTERMA8GA1UECgwI
SW50ZXJuYWwxCzAJBgNVBAYTAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE
C32xU18H3OljGW+wQUesT1qSB+bp5cCkNW9rfpv7wjr79eHriZkQ8EgrdVAK9Zw0
fZJNdd4LyekDGXiQU/qAJ6AAMAoGCCqGSM49BAMCA0gAMEUCIQDf7FSy4YiZvWkj
G9BgdSPTcIq8VYSGm7nnXprD8u1ZTwIgO6/5jH72reiCaaMm62X1Vrpc+8SDMVtO
+dlP4dZ+BM8=
-----END CERTIFICATE REQUEST-----

View File

@ -1,5 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgKWrGjaMdvANVPz/d
LQPtDS4FmU8H0gg8zix2AvxaQp2hRANCAAQLfbFTXwfc6WMZb7BBR6xPWpIH5unl
wKQ1b2t+m/vCOvv14euJmRDwSCt1UAr1nDR9kk113gvJ6QMZeJBT+oAn
-----END PRIVATE KEY-----

View File

@ -1,12 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBtjCCAVygAwIBAgIUeQzbn6IAK/WbPuiK8ybLBgNT0RIwCgYIKoZIzj0EAwIw
ODEeMBwGA1UEAwwVUGF0Y2ggTWFuYWdlciBSb290IENBMRYwFAYDVQQKDA1QYXRj
aCBNYW5hZ2VyMB4XDTI2MDUxODE2MDAwNloXDTI3MDUxODE2MDAwNlowOjEYMBYG
A1UEAwwPbGludXgtcGF0Y2gtYXBpMREwDwYDVQQKDAhJbnRlcm5hbDELMAkGA1UE
BhMCVVMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLfbFTXwfc6WMZb7BBR6xP
WpIH5unlwKQ1b2t+m/vCOvv14euJmRDwSCt1UAr1nDR9kk113gvJ6QMZeJBT+oAn
o0IwQDAdBgNVHQ4EFgQUDTnKCjj1BJ0MdwJHPUGf0raJ6/kwHwYDVR0jBBgwFoAU
3C0RSC8HwW46lJt30/AMyADeZkAwCgYIKoZIzj0EAwIDSAAwRQIhAJ4jy8W2hbqK
kiTI9aYS+xwMJlxH6cFJaKplrA+a5Ay8AiANPJdJN9ucgCsq/N3Ai6kO89rcXy8Z
60kvNNc3Zg/Oog==
-----END CERTIFICATE-----

View File

@ -14,7 +14,7 @@ tls:
ca_cert: "/etc/linux_patch_api/certs/ca.pem" ca_cert: "/etc/linux_patch_api/certs/ca.pem"
server_cert: "/etc/linux_patch_api/certs/server.pem" server_cert: "/etc/linux_patch_api/certs/server.pem"
server_key: "/etc/linux_patch_api/certs/server.key" server_key: "/etc/linux_patch_api/certs/server.key"
min_tls_version: "1.3" # TLS 1.3 is the only supported version (hardcoded, not configurable)
# Job Configuration # Job Configuration
jobs: jobs:

82
scripts/generate-dev-certs.sh Executable file
View File

@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Generate development/test certificates for Linux Patch API.
#
# This script creates a self-signed CA, server certificate, and client
# certificate suitable for local development and testing. It is NOT
# intended for production use.
#
# Usage:
# ./scripts/generate-dev-certs.sh [OUTPUT_DIR]
#
# If OUTPUT_DIR is omitted, certificates are written to configs/certs/
# relative to the repository root. The e2e Python test certs are also
# regenerated under tests/e2e/certs/.
#
# Private keys (*.key, *.key.pem) are excluded from git via .gitignore
# and must NEVER be committed to version control.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUTPUT_DIR="${1:-$REPO_ROOT/configs/certs}"
E2E_DIR="$REPO_ROOT/tests/e2e/certs"
DAYS_CA=3650
DAYS_CERT=365
echo "Generating development certificates..."
echo " Output dir: $OUTPUT_DIR"
echo " E2E dir: $E2E_DIR"
mkdir -p "$OUTPUT_DIR"
mkdir -p "$E2E_DIR"
# CA
echo "[1/6] Generating CA key and certificate..."
openssl genrsa -out "$OUTPUT_DIR/ca.key.pem" 4096 2>/dev/null
chmod 600 "$OUTPUT_DIR/ca.key.pem"
openssl req -x509 -new -nodes -key "$OUTPUT_DIR/ca.key.pem" -sha256 -days "$DAYS_CA" -out "$OUTPUT_DIR/ca.pem" -subj "/CN=LinuxPatchAPI Dev CA/O=Internal/C=US"
# Server certificate
echo "[2/6] Generating server key and certificate..."
openssl genrsa -out "$OUTPUT_DIR/server.key.pem" 2048 2>/dev/null
chmod 600 "$OUTPUT_DIR/server.key.pem"
openssl req -new -key "$OUTPUT_DIR/server.key.pem" -out "$OUTPUT_DIR/server.csr.pem" -subj "/CN=localhost/O=Internal/C=US"
openssl x509 -req -in "$OUTPUT_DIR/server.csr.pem" -CA "$OUTPUT_DIR/ca.pem" -CAkey "$OUTPUT_DIR/ca.key.pem" -CAcreateserial -out "$OUTPUT_DIR/server.pem" -days "$DAYS_CERT" -sha256
# Client certificate
echo "[3/6] Generating client key and certificate..."
openssl genrsa -out "$OUTPUT_DIR/client001.key.pem" 2048 2>/dev/null
chmod 600 "$OUTPUT_DIR/client001.key.pem"
openssl req -new -key "$OUTPUT_DIR/client001.key.pem" -out "$OUTPUT_DIR/client001.csr.pem" -subj "/CN=client001/O=Internal/C=US"
openssl x509 -req -in "$OUTPUT_DIR/client001.csr.pem" -CA "$OUTPUT_DIR/ca.pem" -CAkey "$OUTPUT_DIR/ca.key.pem" -CAcreateserial -out "$OUTPUT_DIR/client001.pem" -days "$DAYS_CERT" -sha256
# E2E test certificates
echo "[4/6] Generating e2e test CA certificate..."
cp "$OUTPUT_DIR/ca.pem" "$E2E_DIR/ca.crt"
echo "[5/6] Generating e2e test client certificate..."
openssl genrsa -out "$E2E_DIR/client.key" 2048 2>/dev/null
chmod 600 "$E2E_DIR/client.key"
openssl req -new -key "$E2E_DIR/client.key" -out "$E2E_DIR/client.csr" -subj "/CN=e2e-test-client/O=Internal/C=US"
openssl x509 -req -in "$E2E_DIR/client.csr" -CA "$OUTPUT_DIR/ca.pem" -CAkey "$OUTPUT_DIR/ca.key.pem" -CAcreateserial -out "$E2E_DIR/client.crt" -days "$DAYS_CERT" -sha256
# Cleanup CSR files
echo "[6/6] Cleaning up CSR files..."
rm -f "$OUTPUT_DIR/server.csr.pem" "$OUTPUT_DIR/client001.csr.pem" "$E2E_DIR/client.csr"
echo
echo "Development certificates generated successfully."
echo " CA cert: $OUTPUT_DIR/ca.pem"
echo " Server cert: $OUTPUT_DIR/server.pem"
echo " Server key: $OUTPUT_DIR/server.key.pem"
echo " Client cert: $OUTPUT_DIR/client001.pem"
echo " Client key: $OUTPUT_DIR/client001.key.pem"
echo " E2E CA cert: $E2E_DIR/ca.crt"
echo " E2E client cert: $E2E_DIR/client.crt"
echo " E2E client key: $E2E_DIR/client.key"
echo
echo "⚠ WARNING: These are development-only certificates. Do NOT use in production."
echo "⚠ Private keys (*.key, *.key.pem) are excluded from git via .gitignore."

View File

@ -190,6 +190,19 @@ pub async fn rollback_job(
info!(request_id = %request_id, job_id = %job_id_str, "Initiating job rollback"); info!(request_id = %request_id, job_id = %job_id_str, "Initiating job rollback");
// Check job queue capacity
if !job_manager.can_accept_job().await {
let response = ApiResponse::<()>::error(
"QUEUE_FULL",
"Job queue is at capacity. Please retry later.",
None,
true,
);
return HttpResponse::TooManyRequests()
.insert_header(("Retry-After", "60"))
.json(response);
}
// Parse job ID // Parse job ID
let job_id = match Uuid::parse_str(&job_id_str) { let job_id = match Uuid::parse_str(&job_id_str) {
Ok(id) => id, Ok(id) => id,
@ -321,7 +334,7 @@ pub async fn delete_job(
} }
} }
/// Configure routes for job endpoints /// Configure all job routes
pub fn configure_routes(cfg: &mut web::ServiceConfig) { pub fn configure_routes(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
web::scope("/jobs") web::scope("/jobs")

View File

@ -14,29 +14,18 @@ use tracing::{error, info, warn};
use uuid::Uuid; use uuid::Uuid;
use crate::jobs::manager::{JobManager, JobOperation, JobStatus}; use crate::jobs::manager::{JobManager, JobOperation, JobStatus};
use crate::packages::{InstallOptions, Package, PackageManagerBackend, PackageSpec}; use crate::packages::{
validate_package_name, validate_version_string, InstallOptions, Package, PackageManagerBackend,
PackageSpec,
};
/// Maximum allowed length for package names /// Validate all package names and versions in a request
const MAX_PACKAGE_NAME_LENGTH: usize = 256;
/// Validate package name: must not be empty and must not exceed max length
fn validate_package_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Package name cannot be empty".to_string());
}
if name.len() > MAX_PACKAGE_NAME_LENGTH {
return Err(format!(
"Package name exceeds maximum length of {} characters",
MAX_PACKAGE_NAME_LENGTH
));
}
Ok(())
}
/// Validate all package names in a request
fn validate_package_names(packages: &[PackageSpec]) -> Result<(), String> { fn validate_package_names(packages: &[PackageSpec]) -> Result<(), String> {
for pkg in packages { for pkg in packages {
validate_package_name(&pkg.name)?; validate_package_name(&pkg.name)?;
if let Some(version) = &pkg.version {
validate_version_string(version)?;
}
} }
Ok(()) Ok(())
} }
@ -263,6 +252,19 @@ pub async fn install_packages(
info!(request_id = %request_id, packages = ?package_names, "Installing packages"); info!(request_id = %request_id, packages = ?package_names, "Installing packages");
// Check job queue capacity
if !job_manager.can_accept_job().await {
let response = ApiResponse::<()>::error(
"QUEUE_FULL",
"Job queue is at capacity. Please retry later.",
None,
true,
);
return HttpResponse::TooManyRequests()
.insert_header(("Retry-After", "60"))
.json(response);
}
// Create async job // Create async job
match job_manager match job_manager
.create_job(JobOperation::Install, package_names.clone()) .create_job(JobOperation::Install, package_names.clone())
@ -348,6 +350,19 @@ pub async fn update_package(
info!(request_id = %request_id, package = %package_name, "Updating package"); info!(request_id = %request_id, package = %package_name, "Updating package");
// Check job queue capacity
if !job_manager.can_accept_job().await {
let response = ApiResponse::<()>::error(
"QUEUE_FULL",
"Job queue is at capacity. Please retry later.",
None,
true,
);
return HttpResponse::TooManyRequests()
.insert_header(("Retry-After", "60"))
.json(response);
}
// Create async job // Create async job
match job_manager match job_manager
.create_job(JobOperation::Update, vec![package_name.clone()]) .create_job(JobOperation::Update, vec![package_name.clone()])
@ -431,6 +446,20 @@ pub async fn remove_package(
} }
info!(request_id = %request_id, package = %package_name, "Removing package"); info!(request_id = %request_id, package = %package_name, "Removing package");
// Check job queue capacity
if !job_manager.can_accept_job().await {
let response = ApiResponse::<()>::error(
"QUEUE_FULL",
"Job queue is at capacity. Please retry later.",
None,
true,
);
return HttpResponse::TooManyRequests()
.insert_header(("Retry-After", "60"))
.json(response);
}
match job_manager match job_manager
.create_job(JobOperation::Remove, vec![package_name.clone()]) .create_job(JobOperation::Remove, vec![package_name.clone()])
.await .await
@ -495,7 +524,7 @@ pub async fn remove_package(
} }
} }
/// Configure routes for package endpoints /// Configure all package routes
pub fn configure_routes(cfg: &mut web::ServiceConfig) { pub fn configure_routes(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
web::scope("/packages") web::scope("/packages")

View File

@ -11,7 +11,7 @@ use tracing::{error, info};
use uuid::Uuid; use uuid::Uuid;
use crate::jobs::manager::{JobManager, JobOperation, JobStatus}; use crate::jobs::manager::{JobManager, JobOperation, JobStatus};
use crate::packages::PackageManagerBackend; use crate::packages::{validate_package_name, PackageManagerBackend};
use super::packages::{ApiResponse, JobResponseData}; use super::packages::{ApiResponse, JobResponseData};
@ -88,6 +88,16 @@ pub async fn apply_patches(
let _timestamp = Utc::now().to_rfc3339(); let _timestamp = Utc::now().to_rfc3339();
let packages_count = body.packages.as_ref().map(|p| p.len()).unwrap_or(0); let packages_count = body.packages.as_ref().map(|p| p.len()).unwrap_or(0);
// SECURITY: Validate all package names in the request to prevent argument injection
if let Some(ref pkgs) = body.packages {
for pkg in pkgs {
if let Err(e) = validate_package_name(pkg) {
let response = ApiResponse::<()>::error("VALIDATION_ERROR", &e, None, false);
return HttpResponse::BadRequest().json(response);
}
}
}
info!( info!(
request_id = %request_id, request_id = %request_id,
packages = ?body.packages, packages = ?body.packages,
@ -95,6 +105,19 @@ pub async fn apply_patches(
"Applying patches" "Applying patches"
); );
// Check job queue capacity
if !job_manager.can_accept_job().await {
let response = ApiResponse::<()>::error(
"QUEUE_FULL",
"Job queue is at capacity. Please retry later.",
None,
true,
);
return HttpResponse::TooManyRequests()
.insert_header(("Retry-After", "60"))
.json(response);
}
// Create async job // Create async job
let package_list = body.packages.clone().unwrap_or_default(); let package_list = body.packages.clone().unwrap_or_default();
match job_manager match job_manager
@ -311,7 +334,7 @@ pub async fn apply_patches(
} }
} }
/// Configure routes for patch endpoints /// Configure all patch routes
pub fn configure_routes(cfg: &mut web::ServiceConfig) { pub fn configure_routes(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
web::scope("/patches") web::scope("/patches")

View File

@ -229,6 +229,19 @@ pub async fn reboot_system(
} }
} }
// Check job queue capacity
if !job_manager.can_accept_job().await {
let response = ApiResponse::<()>::error(
"QUEUE_FULL",
"Job queue is at capacity. Please retry later.",
None,
true,
);
return HttpResponse::TooManyRequests()
.insert_header(("Retry-After", "60"))
.json(response);
}
// Create async job for reboot // Create async job for reboot
match job_manager.create_job(JobOperation::Reboot, vec![]).await { match job_manager.create_job(JobOperation::Reboot, vec![]).await {
Ok(job_id) => { Ok(job_id) => {

View File

@ -8,6 +8,7 @@
//! - WebSocket endpoint for real-time job status streaming //! - WebSocket endpoint for real-time job status streaming
pub mod handlers; pub mod handlers;
pub mod rate_limit;
pub mod routes; pub mod routes;
// Re-export handlers for convenience // Re-export handlers for convenience

209
src/api/rate_limit.rs Normal file
View File

@ -0,0 +1,209 @@
//! Rate Limiting Middleware
//!
//! Custom Actix-web middleware that provides per-IP rate limiting with two tiers:
//! - **Destructive tier**: POST/PUT/DELETE methods (20 req/min, burst 10 by default)
//! - **Read tier**: GET methods (120 req/min, burst 30 by default)
//! - **Health exempt**: /health, /api/v1/system/info bypass rate limiting entirely
use actix_governor::governor::clock::{Clock, DefaultClock};
use actix_governor::governor::middleware::NoOpMiddleware;
use actix_governor::governor::state::keyed::DefaultKeyedStateStore;
use actix_governor::governor::{Quota, RateLimiter};
use actix_web::body::BoxBody;
use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::Method;
use actix_web::{HttpResponse, ResponseError};
use std::future::{ready, Ready};
use std::net::IpAddr;
use std::num::NonZeroU32;
use std::sync::Arc;
use tracing::info;
use crate::config::loader::RateLimitConfig;
/// Paths exempt from rate limiting
const EXEMPT_PATHS: &[&str] = &["/health", "/api/v1/system/info"];
/// Rate limiting middleware factory
pub struct RateLimitMiddleware {
config: RateLimitConfig,
}
impl RateLimitMiddleware {
pub fn new(config: RateLimitConfig) -> Self {
Self { config }
}
}
/// Error returned when rate limit is exceeded
#[derive(Debug)]
pub struct RateLimitError {
retry_after_secs: u64,
}
impl std::fmt::Display for RateLimitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Rate limit exceeded. Retry after {} seconds.",
self.retry_after_secs
)
}
}
impl ResponseError for RateLimitError {
fn status_code(&self) -> actix_web::http::StatusCode {
actix_web::http::StatusCode::TOO_MANY_REQUESTS
}
fn error_response(&self) -> HttpResponse {
HttpResponse::TooManyRequests()
.insert_header(("Retry-After", self.retry_after_secs.to_string()))
.content_type("text/plain; charset=utf-8")
.body(self.to_string())
}
}
/// Type alias for per-IP rate limiter
pub type KeyedRateLimiter =
RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock, NoOpMiddleware>;
/// Shared rate limiter state
#[derive(Clone)]
pub struct RateLimiters {
/// Rate limiter for destructive operations (POST/PUT/DELETE)
destructive: Arc<KeyedRateLimiter>,
/// Rate limiter for read operations (GET)
read: Arc<KeyedRateLimiter>,
/// Whether rate limiting is enabled
enabled: bool,
}
impl RateLimiters {
/// Build rate limiters from configuration
pub fn new(config: &RateLimitConfig) -> Self {
let destructive_quota =
Quota::per_minute(NonZeroU32::new(config.destructive_per_minute).unwrap())
.allow_burst(NonZeroU32::new(config.destructive_burst).unwrap());
let read_quota = Quota::per_minute(NonZeroU32::new(config.read_per_minute).unwrap())
.allow_burst(NonZeroU32::new(config.read_burst).unwrap());
let destructive = Arc::new(KeyedRateLimiter::keyed(destructive_quota));
let read = Arc::new(KeyedRateLimiter::keyed(read_quota));
info!(
enabled = config.enabled,
destructive_per_min = config.destructive_per_minute,
destructive_burst = config.destructive_burst,
read_per_min = config.read_per_minute,
read_burst = config.read_burst,
"Rate limiters configured"
);
Self {
destructive,
read,
enabled: config.enabled,
}
}
/// Check if a request should be rate limited
/// Returns Ok(()) if the request is allowed, Err(RateLimitError) if rate limited
pub fn check(
&self,
method: &Method,
path: &str,
peer_ip: IpAddr,
) -> Result<(), RateLimitError> {
if !self.enabled {
return Ok(());
}
// Exempt paths bypass rate limiting entirely
if EXEMPT_PATHS.contains(&path) {
return Ok(());
}
let limiter = match *method {
Method::POST | Method::PUT | Method::DELETE => &self.destructive,
Method::GET => &self.read,
_ => &self.read, // Default to read tier for other methods
};
match limiter.check_key(&peer_ip) {
Ok(()) => Ok(()),
Err(negative) => {
let retry_after = negative
.wait_time_from(DefaultClock::default().now())
.as_secs();
Err(RateLimitError {
retry_after_secs: retry_after.max(1),
})
}
}
}
}
impl<S> Transform<S, ServiceRequest> for RateLimitMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
S::Future: 'static,
{
type Response = ServiceResponse<BoxBody>;
type Error = actix_web::Error;
type Transform = RateLimitService<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(RateLimitService {
service,
limiters: RateLimiters::new(&self.config),
}))
}
}
/// Rate limiting service wrapper
pub struct RateLimitService<S> {
service: S,
limiters: RateLimiters,
}
impl<S> Service<ServiceRequest> for RateLimitService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<BoxBody>, Error = actix_web::Error>,
S::Future: 'static,
{
type Response = ServiceResponse<BoxBody>;
type Error = actix_web::Error;
type Future =
std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>>>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
// Extract peer IP
let peer_ip = req
.connection_info()
.peer_addr()
.and_then(|addr| addr.parse::<IpAddr>().ok());
// Check rate limiting
if let Some(ip) = peer_ip {
let method = req.method().clone();
let path = req.path().to_string();
if let Err(e) = self.limiters.check(&method, &path, ip) {
// Rate limited - return 429 response
let (http_req, _) = req.into_parts();
let response = e.error_response();
let srv_resp = ServiceResponse::new(http_req, response);
return Box::pin(ready(Ok(srv_resp)));
}
}
// Not rate limited - pass through to the inner service
Box::pin(self.service.call(req))
}
}

View File

@ -1,6 +1,11 @@
//! API Routes Configuration //! API Routes Configuration
//! //!
//! Aggregates all endpoint routes and configures the Actix-web application. //! Aggregates all endpoint routes and configures the Actix-web application.
//! Rate limiting is applied at the App level in main.rs using actix-governor
//! with method-based filtering:
//! - **Read tier** (120 req/min, burst 30): GET methods
//! - **Destructive tier** (20 req/min, burst 10): POST/PUT/DELETE methods
//! - **Health exempt**: /health, /api/v1/system/info (health-exempt routes)
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
use tracing::info; use tracing::info;
@ -17,6 +22,7 @@ async fn method_not_allowed() -> HttpResponse {
.insert_header(("Allow", "GET, POST, PUT, DELETE")) .insert_header(("Allow", "GET, POST, PUT, DELETE"))
.finish() .finish()
} }
/// Configure all API routes for the application /// Configure all API routes for the application
pub fn configure_api_routes( pub fn configure_api_routes(
cfg: &mut web::ServiceConfig, cfg: &mut web::ServiceConfig,
@ -26,6 +32,10 @@ pub fn configure_api_routes(
) { ) {
info!("Configuring API v1 routes"); info!("Configuring API v1 routes");
// Health-exempt endpoint: /api/v1/system/info is registered separately
// so it can bypass rate limiting applied at the App level
cfg.service(web::resource("/api/v1/system/info").route(web::get().to(system::get_system_info)));
cfg.app_data(job_manager) cfg.app_data(job_manager)
.app_data(backend) .app_data(backend)
.app_data(cache_state) .app_data(cache_state)
@ -33,15 +43,10 @@ pub fn configure_api_routes(
web::scope("/api/v1") web::scope("/api/v1")
// VULN-005: Default handler for unsupported methods returns 405 instead of 404 // VULN-005: Default handler for unsupported methods returns 405 instead of 404
.default_service(web::route().to(method_not_allowed)) .default_service(web::route().to(method_not_allowed))
// Package Management Endpoints
.configure(packages::configure_routes) .configure(packages::configure_routes)
// Patch Management Endpoints
.configure(patches::configure_routes) .configure(patches::configure_routes)
// System Management Endpoints
.configure(system::configure_routes) .configure(system::configure_routes)
// Job Management Endpoints
.configure(jobs::configure_routes) .configure(jobs::configure_routes)
// WebSocket Endpoint
.configure(websocket::configure_routes), .configure(websocket::configure_routes),
); );
} }

View File

@ -165,7 +165,16 @@ pub fn load_crl(crl_path: &Path, ca_cert_der: &[u8]) -> CrlState {
}; };
// Verify CRL signature against CA // Verify CRL signature against CA
let (_, ca_cert) = match x509_parser::parse_x509_certificate(ca_cert_der) { // Extract DER from PEM if the CA cert is PEM-encoded
let ca_der = match extract_pem_cert_der(ca_cert_der) {
Some(der) => der,
None => {
// Not PEM — assume it's already DER
ca_cert_der.to_vec()
}
};
let (_, ca_cert) = match x509_parser::parse_x509_certificate(&ca_der) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
error!(error = %e, "Failed to parse CA cert for CRL signature verification"); error!(error = %e, "Failed to parse CA cert for CRL signature verification");
@ -220,6 +229,29 @@ pub fn load_crl(crl_path: &Path, ca_cert_der: &[u8]) -> CrlState {
} }
} }
/// Extract DER bytes from a PEM-encoded certificate.
/// Looks for `-----BEGIN CERTIFICATE-----` / `-----END CERTIFICATE-----` markers
/// and base64-decodes the content between them.
pub fn extract_pem_cert_der(pem_bytes: &[u8]) -> Option<Vec<u8>> {
let pem_str = String::from_utf8_lossy(pem_bytes);
let begin_marker = "-----BEGIN CERTIFICATE-----";
let end_marker = "-----END CERTIFICATE-----";
let begin_idx = pem_str.find(begin_marker)?;
let after_begin = begin_idx + begin_marker.len();
let end_idx = pem_str[after_begin..].find(end_marker)?;
// Strip all whitespace (including newlines) from the base64 block
// before decoding, since PEM format wraps lines at 64 characters.
let b64_block: String = pem_str[after_begin..after_begin + end_idx]
.split_whitespace()
.collect();
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(&b64_block)
.ok()
}
/// Extract DER bytes from a PEM-encoded CRL. /// Extract DER bytes from a PEM-encoded CRL.
/// Looks for `-----BEGIN X509 CRL-----` / `-----END X509 CRL-----` blocks. /// Looks for `-----BEGIN X509 CRL-----` / `-----END X509 CRL-----` blocks.
fn extract_pem_crl_der(pem_bytes: &[u8]) -> Option<Vec<u8>> { fn extract_pem_crl_der(pem_bytes: &[u8]) -> Option<Vec<u8>> {
@ -688,4 +720,47 @@ mod tests {
"Invalid CRL should not match any serial" "Invalid CRL should not match any serial"
); );
} }
#[test]
fn test_extract_pem_cert_der_invalid() {
// Not PEM
assert!(extract_pem_cert_der(b"not pem").is_none());
// PEM but wrong type (CRL instead of CERTIFICATE)
assert!(
extract_pem_cert_der(b"-----BEGIN X509 CRL-----\nAA==\n-----END X509 CRL-----")
.is_none()
);
}
#[test]
fn test_extract_pem_cert_der_valid() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (_, ca_cert) = generate_test_ca();
let cert_pem = ca_cert.pem();
// Verify PEM extraction succeeds
let der = extract_pem_cert_der(cert_pem.as_bytes());
assert!(
der.is_some(),
"PEM extraction should succeed for valid certificate PEM"
);
// Verify the DER can be parsed as an X.509 certificate
let der_bytes = der.unwrap();
let parsed = x509_parser::parse_x509_certificate(&der_bytes);
assert!(
parsed.is_ok(),
"DER should parse as a valid X.509 certificate"
);
}
#[test]
fn test_extract_pem_cert_der_rejects_crl_pem() {
// CERTIFICATE extraction should reject CRL PEM
let crl_pem = "-----BEGIN X509 CRL-----\nAA==\n-----END X509 CRL-----";
assert!(
extract_pem_cert_der(crl_pem.as_bytes()).is_none(),
"CRL PEM should not extract as CERTIFICATE"
);
}
} }

View File

@ -1,18 +1,31 @@
//! Auth Module - mTLS and IP Whitelist Enforcement //! Auth Module - mTLS, IP Whitelist, and Security Headers
//! //!
//! This module provides security authentication and authorization: //! This module provides security authentication and authorization:
//! - mTLS (Mutual TLS) certificate-based authentication //! - mTLS (Mutual TLS) certificate-based authentication (enforced at TLS handshake by rustls)
//! - IP whitelist enforcement with CIDR subnet support //! - IP whitelist enforcement with CIDR subnet support
//! - Security header validation (VULN-006: duplicate critical header rejection)
//! - Silent drop for non-compliant connections //! - Silent drop for non-compliant connections
//! - Comprehensive audit logging //! - Comprehensive audit logging
//!
//! # Architecture Decision Record: rustls as Authoritative Client-Auth Gate
//!
//! Client certificate authentication is enforced at the TLS handshake level by
//! rustls via `CrlAwareVerifier`. No application-layer certificate validation
//! middleware is needed — rustls rejects connections that fail client-cert
//! verification before any HTTP request is processed. See `mtls.rs` for details.
pub mod crl; pub mod crl;
pub mod mtls; pub mod mtls;
pub mod security_headers;
pub mod whitelist; pub mod whitelist;
pub use crl::{new_shared_state, CrlState, CrlStatus, SharedCrlState}; pub use crl::{new_shared_state, CrlState, CrlStatus, SharedCrlState};
pub use mtls::{ClientCertInfo, MtlsConfig, MtlsError, MtlsMiddleware}; pub use mtls::{ClientCertInfo, MtlsConfig, MtlsError};
pub use whitelist::{WhitelistConfig, WhitelistEntry, WhitelistManager, WhitelistMiddleware}; pub use security_headers::SecurityHeadersMiddleware;
pub use whitelist::{
WhitelistConfig, WhitelistEntry, WhitelistManager, WhitelistMiddleware,
WhitelistMiddlewareService,
};
/// Combined authentication result /// Combined authentication result
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View File

@ -1,20 +1,33 @@
//! mTLS Authentication Module //! mTLS Configuration Module
//! //!
//! Provides mutual TLS authentication middleware for Actix-web. //! Provides rustls-based mutual TLS configuration for the API server.
//! Non-mTLS connections are silently dropped (no response). //!
//! Supports CRL-aware client certificate verification when CRL is available. //! # Architecture Decision Record: rustls as Authoritative Client-Auth Gate
//!
//! Client certificate authentication is enforced at the TLS handshake level by
//! rustls via `CrlAwareVerifier` (which wraps `WebPkiClientVerifier`). This means:
//!
//! - **rustls + CrlAwareVerifier IS the authoritative client-auth gate.**
//! - No application-layer certificate validation middleware is needed because
//! rustls rejects connections that fail client-cert verification before any
//! HTTP request is processed.
//! - `build_rustls_config()` configures the TLS listener to require client
//! certificates (`with_client_cert_verifier`), making mTLS enforcement
//! unavoidable at the transport layer.
//! - CRL revocation checking is integrated into the same handshake path via
//! `CrlAwareVerifier`, so revoked certificates are also rejected before any
//! HTTP handler runs.
//!
//! This design was chosen because rustls provides battle-tested X.509
//! verification, and enforcing auth at the TLS layer eliminates an entire
//! class of bypass vulnerabilities that application-layer checks are
//! susceptible to (e.g., middleware ordering bugs, route-specific skips).
use actix_web::{ use chrono::{DateTime, Utc};
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error, HttpMessage,
};
#[allow(unused_imports)]
use chrono::{DateTime, Duration, Utc};
use futures_util::future::LocalBoxFuture;
use rustls::{ use rustls::{
client::danger::HandshakeSignatureValid, client::danger::HandshakeSignatureValid,
crypto::aws_lc_rs, crypto::aws_lc_rs,
pki_types::{CertificateDer, UnixTime}, pki_types::CertificateDer,
server::{ server::{
danger::{ClientCertVerified, ClientCertVerifier}, danger::{ClientCertVerified, ClientCertVerifier},
ServerConfig, WebPkiClientVerifier, ServerConfig, WebPkiClientVerifier,
@ -24,35 +37,10 @@ use rustls::{
}; };
use rustls_pemfile::{certs, private_key}; use rustls_pemfile::{certs, private_key};
use std::{fs::File, io::BufReader, sync::Arc}; use std::{fs::File, io::BufReader, sync::Arc};
use tracing::{debug, error, info, warn}; use tracing::{error, info, warn};
use super::crl::{cert_serial_hex, SharedCrlState}; use super::crl::{cert_serial_hex, SharedCrlState};
/// Check for duplicate critical headers (VULN-006)
/// Returns true if duplicate headers are detected
fn has_duplicate_critical_headers(req: &ServiceRequest) -> bool {
let critical_headers = ["content-type", "authorization", "host"];
for header_name in critical_headers.iter() {
// Count occurrences of this header
let mut count = 0;
for (name, _) in req.headers().iter() {
if name.as_str().eq_ignore_ascii_case(header_name) {
count += 1;
if count > 1 {
warn!(
peer_addr = ?req.peer_addr(),
header = header_name,
"Duplicate critical header detected - rejecting request"
);
return true;
}
}
}
}
false
}
/// CRL-aware client certificate verifier. /// CRL-aware client certificate verifier.
/// ///
/// Wraps WebPkiClientVerifier for chain validation, then checks the /// Wraps WebPkiClientVerifier for chain validation, then checks the
@ -87,7 +75,7 @@ impl ClientCertVerifier for CrlAwareVerifier {
&self, &self,
end_entity: &CertificateDer<'_>, end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>], intermediates: &[CertificateDer<'_>],
now: UnixTime, now: rustls::pki_types::UnixTime,
) -> Result<ClientCertVerified, RustlsError> { ) -> Result<ClientCertVerified, RustlsError> {
// 1. Delegate chain validation to WebPKI // 1. Delegate chain validation to WebPKI
self.inner self.inner
@ -155,41 +143,31 @@ impl ClientCertVerifier for CrlAwareVerifier {
} }
/// mTLS Configuration /// mTLS Configuration
///
/// TLS 1.3 is the only supported protocol version — this is hardcoded
/// in `build_rustls_config()` and cannot be configured via this struct.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MtlsConfig { pub struct MtlsConfig {
pub ca_cert_path: String, pub ca_cert_path: String,
pub server_cert_path: String, pub server_cert_path: String,
pub server_key_path: String, pub server_key_path: String,
pub min_tls_version: String,
} }
/// mTLS Middleware for Actix-web /// Build a rustls ServerConfig with client certificate verification.
pub struct MtlsMiddleware { ///
config: Arc<MtlsConfig>, /// This is the authoritative mTLS gate — rustls enforces client certificate
cert_store: Arc<RootCertStore>, /// validation at the TLS handshake level, before any HTTP request is processed.
} ///
/// When `crl_state` is provided and the CRL is available, wraps the
impl MtlsMiddleware { /// WebPkiClientVerifier with CrlAwareVerifier for revocation checking.
/// Create a new mTLS middleware /// When CRL is missing/degraded, falls back to WebPKI-only verification.
pub fn new(config: MtlsConfig) -> Result<Self, MtlsError> { pub fn build_rustls_config(
config: &MtlsConfig,
crl_state: Option<SharedCrlState>,
) -> Result<Arc<ServerConfig>, MtlsError> {
let cert_store = load_ca_certs(&config.ca_cert_path)?; let cert_store = load_ca_certs(&config.ca_cert_path)?;
Ok(Self { let webpki_verifier = WebPkiClientVerifier::builder(cert_store.clone().into())
config: Arc::new(config),
cert_store: Arc::new(cert_store),
})
}
/// Build rustls server configuration with client certificate verification.
///
/// When `crl_state` is provided and the CRL is available, wraps the
/// WebPkiClientVerifier with CrlAwareVerifier for revocation checking.
/// When CRL is missing/degraded, falls back to WebPKI-only verification.
pub fn build_rustls_config(
&self,
crl_state: Option<SharedCrlState>,
) -> Result<Arc<ServerConfig>, MtlsError> {
let webpki_verifier = WebPkiClientVerifier::builder(self.cert_store.clone())
.build() .build()
.map_err(|e| MtlsError::ClientVerifierError(e.to_string()))?; .map_err(|e| MtlsError::ClientVerifierError(e.to_string()))?;
@ -204,10 +182,11 @@ impl MtlsMiddleware {
} }
}; };
let server_cert = load_certs(&self.config.server_cert_path)?; let server_cert = load_certs(&config.server_cert_path)?;
let server_key = load_private_key(&self.config.server_key_path)?; let server_key = load_private_key(&config.server_key_path)?;
let config = ServerConfig::builder_with_provider(Arc::new(aws_lc_rs::default_provider())) let server_config =
ServerConfig::builder_with_provider(Arc::new(aws_lc_rs::default_provider()))
.with_protocol_versions(&[&TLS13]) .with_protocol_versions(&[&TLS13])
.map_err(|e| { .map_err(|e| {
MtlsError::ServerConfigError(format!("Failed to set TLS 1.3 only: {}", e)) MtlsError::ServerConfigError(format!("Failed to set TLS 1.3 only: {}", e))
@ -216,8 +195,7 @@ impl MtlsMiddleware {
.with_single_cert(server_cert, server_key) .with_single_cert(server_cert, server_key)
.map_err(|e| MtlsError::ServerConfigError(e.to_string()))?; .map_err(|e| MtlsError::ServerConfigError(e.to_string()))?;
Ok(Arc::new(config)) Ok(Arc::new(server_config))
}
} }
/// Load CA certificates from PEM file /// Load CA certificates from PEM file
@ -268,6 +246,21 @@ fn load_private_key(path: &str) -> Result<rustls::pki_types::PrivateKeyDer<'stat
Ok(key) Ok(key)
} }
/// Certificate information extracted from client certificate.
///
/// NOTE: This struct is preserved for potential future use in extracting
/// client certificate details from the TLS session at the application layer.
/// Client authentication is enforced at the TLS handshake level by
/// CrlAwareVerifier — this struct is NOT used for validation.
#[derive(Debug, Clone)]
pub struct ClientCertInfo {
pub subject: String,
pub issuer: String,
pub serial: String,
pub not_before: DateTime<Utc>,
pub not_after: DateTime<Utc>,
}
/// mTLS Error types /// mTLS Error types
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum MtlsError { pub enum MtlsError {
@ -285,229 +278,16 @@ pub enum MtlsError {
ValidationError(String), ValidationError(String),
} }
impl<S, B> Transform<S, ServiceRequest> for MtlsMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = MtlsMiddlewareService<S>;
type Future = futures_util::future::Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
futures_util::future::ok(MtlsMiddlewareService {
service,
config: self.config.clone(),
cert_store: self.cert_store.clone(),
})
}
}
pub struct MtlsMiddlewareService<S> {
service: S,
#[allow(dead_code)]
config: Arc<MtlsConfig>,
cert_store: Arc<RootCertStore>,
}
impl<S, B> Service<ServiceRequest> for MtlsMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let cert_store = self.cert_store.clone();
let peer_addr = req.peer_addr();
// VULN-006: Check for duplicate critical headers before processing
if has_duplicate_critical_headers(&req) {
warn!(
peer_addr = ?peer_addr,
"Duplicate critical headers detected - rejecting request (VULN-006)"
);
return Box::pin(async move {
Err(actix_web::error::ErrorBadRequest(
"Duplicate critical headers not allowed",
))
});
}
// Check for client certificate in request extensions
// In a proper mTLS setup with Actix-web + rustls, the certificate
// would be extracted from the TLS connection before reaching this middleware
let has_client_cert = req.extensions().get::<ClientCertInfo>().is_some();
if !has_client_cert {
// No client certificate provided - silent drop
warn!(
peer_addr = ?peer_addr,
"No client certificate provided - dropping connection (mTLS required)"
);
// Return error immediately without calling service
return Box::pin(async move {
Err(actix_web::error::ErrorBadRequest(
"Client certificate required",
))
});
}
// Certificate present - validate it
let cert_info = req.extensions().get::<ClientCertInfo>().cloned();
if let Some(info) = cert_info {
// Validate certificate against CA store
match validate_client_certificate(&info, &cert_store) {
Ok(_) => {
info!(
subject = %info.subject,
issuer = %info.issuer,
peer_addr = ?peer_addr,
"mTLS client certificate validated successfully"
);
}
Err(e) => {
warn!(
error = %e,
peer_addr = ?peer_addr,
"mTLS client certificate validation failed - dropping connection"
);
return Box::pin(async move {
Err(actix_web::error::ErrorBadRequest(
"Certificate validation failed",
))
});
}
}
} else {
warn!(
peer_addr = ?peer_addr,
"No client certificate provided - dropping connection (mTLS required)"
);
return Box::pin(async move {
Err(actix_web::error::ErrorBadRequest(
"Client certificate required",
))
});
}
debug!("mTLS authentication passed for request");
// All checks passed - call the service
let fut = self.service.call(req);
Box::pin(fut)
}
}
/// Certificate information extracted from client certificate
#[derive(Debug, Clone)]
pub struct ClientCertInfo {
pub subject: String,
pub issuer: String,
pub serial: String,
pub not_before: DateTime<Utc>,
pub not_after: DateTime<Utc>,
}
/// Validate client certificate against CA store
fn validate_client_certificate(
cert_info: &ClientCertInfo,
_cert_store: &RootCertStore,
) -> Result<(), MtlsError> {
// Check certificate validity period
let now = Utc::now();
if now < cert_info.not_before {
return Err(MtlsError::ValidationError(
"Certificate is not yet valid".to_string(),
));
}
if now > cert_info.not_after {
return Err(MtlsError::ValidationError(
"Certificate has expired".to_string(),
));
}
// In production, would verify certificate chain against CA store
// For now, we trust certificates that were extracted from the TLS connection
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_mtls_config_creation() {
let config = MtlsConfig {
ca_cert_path: "/etc/linux_patch_api/certs/ca.pem".to_string(),
server_cert_path: "/etc/linux_patch_api/certs/server.pem".to_string(),
server_key_path: "/etc/linux_patch_api/certs/server.key".to_string(),
min_tls_version: "1.3".to_string(),
};
assert_eq!(config.ca_cert_path, "/etc/linux_patch_api/certs/ca.pem");
assert_eq!(config.min_tls_version, "1.3");
}
#[test]
fn test_client_cert_info() {
let info = ClientCertInfo {
subject: "CN=test-client".to_string(),
issuer: "CN=Test CA".to_string(),
serial: "12345".to_string(),
not_before: Utc::now() - Duration::days(1),
not_after: Utc::now() + Duration::days(365),
};
assert!(info.subject.contains("CN="));
assert!(info.issuer.contains("CN="));
// Test validation with valid cert
let cert_store = RootCertStore::empty();
assert!(validate_client_certificate(&info, &cert_store).is_ok());
}
#[test]
fn test_client_cert_expired() {
let info = ClientCertInfo {
subject: "CN=expired-client".to_string(),
issuer: "CN=Test CA".to_string(),
serial: "12345".to_string(),
not_before: Utc::now() - Duration::days(365),
not_after: Utc::now() - Duration::days(1),
};
let cert_store = RootCertStore::empty();
let result = validate_client_certificate(&info, &cert_store);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("expired"));
}
// -----------------------------------------------------------------------
// CrlAwareVerifier unit tests
// -----------------------------------------------------------------------
/// Test that CrlAwareVerifier can be constructed with a WebPKI verifier
/// and a SharedCrlState. This verifies the wiring is correct.
#[test]
fn crl_aware_verifier_construction() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
use super::super::crl::{new_shared_state, CrlState, CrlStatus};
use std::collections::HashSet; use std::collections::HashSet;
// Build a simple CA cert + key for the root store. fn init_crypto_provider() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
fn make_test_ca_and_root_store() -> (rcgen::KeyPair, RootCertStore) {
let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap();
let mut ca_params = rcgen::CertificateParams::default(); let mut ca_params = rcgen::CertificateParams::default();
ca_params.not_before = time::OffsetDateTime::now_utc(); ca_params.not_before = time::OffsetDateTime::now_utc();
@ -519,18 +299,26 @@ mod tests {
ca_params.distinguished_name = dn; ca_params.distinguished_name = dn;
let ca_cert = ca_params.self_signed(&ca_key).unwrap(); let ca_cert = ca_params.self_signed(&ca_key).unwrap();
// Build root cert store with the CA.
let mut root_store = RootCertStore::empty(); let mut root_store = RootCertStore::empty();
root_store.add(ca_cert.der().to_owned()).unwrap(); root_store.add(ca_cert.der().to_owned()).unwrap();
// Build WebPKI verifier — build() returns Arc<WebPkiClientVerifier> (ca_key, root_store)
// which coerces to Arc<dyn ClientCertVerifier>. }
/// Test that CrlAwareVerifier can be constructed with a WebPKI verifier
/// and a SharedCrlState. This verifies the wiring is correct.
#[test]
fn crl_aware_verifier_construction() {
init_crypto_provider();
use super::super::crl::{new_shared_state, CrlState, CrlStatus};
let (_ca_key, root_store) = make_test_ca_and_root_store();
let webpki_verifier: Arc<dyn ClientCertVerifier> = let webpki_verifier: Arc<dyn ClientCertVerifier> =
WebPkiClientVerifier::builder(root_store.into()) WebPkiClientVerifier::builder(root_store.into())
.build() .build()
.unwrap(); .unwrap();
// Build CRL state in Valid status.
let crl_state = new_shared_state(); let crl_state = new_shared_state();
let valid_state = CrlState { let valid_state = CrlState {
status: CrlStatus::Valid, status: CrlStatus::Valid,
@ -540,31 +328,16 @@ mod tests {
}; };
crl_state.store(Arc::new(valid_state)); crl_state.store(Arc::new(valid_state));
// Construct CrlAwareVerifier — should succeed.
let _verifier = CrlAwareVerifier::new(webpki_verifier, crl_state); let _verifier = CrlAwareVerifier::new(webpki_verifier, crl_state);
// If we reach here without panic, construction succeeded.
} }
/// Test that CrlAwareVerifier with Missing CRL state can be constructed. /// Test that CrlAwareVerifier with Missing CRL state can be constructed.
/// Missing CRL means the verifier falls back to WebPKI-only.
#[test] #[test]
fn crl_aware_verifier_with_missing_crl() { fn crl_aware_verifier_with_missing_crl() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); init_crypto_provider();
use super::super::crl::new_shared_state; use super::super::crl::new_shared_state;
let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); let (_ca_key, root_store) = make_test_ca_and_root_store();
let mut ca_params = rcgen::CertificateParams::default();
ca_params.not_before = time::OffsetDateTime::now_utc();
ca_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(365);
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
ca_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign];
let mut dn = rcgen::DistinguishedName::new();
dn.push(rcgen::DnType::CommonName, "Test CA for Verifier");
ca_params.distinguished_name = dn;
let ca_cert = ca_params.self_signed(&ca_key).unwrap();
let mut root_store = RootCertStore::empty();
root_store.add(ca_cert.der().to_owned()).unwrap();
let webpki_verifier: Arc<dyn ClientCertVerifier> = let webpki_verifier: Arc<dyn ClientCertVerifier> =
WebPkiClientVerifier::builder(root_store.into()) WebPkiClientVerifier::builder(root_store.into())
@ -577,26 +350,12 @@ mod tests {
} }
/// Test that CrlAwareVerifier with Invalid CRL state can be constructed. /// Test that CrlAwareVerifier with Invalid CRL state can be constructed.
/// Invalid CRL means the verifier should reject ALL client certificates.
#[test] #[test]
fn crl_aware_verifier_with_invalid_crl() { fn crl_aware_verifier_with_invalid_crl() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); init_crypto_provider();
use super::super::crl::{new_shared_state, CrlState, CrlStatus}; use super::super::crl::{new_shared_state, CrlState, CrlStatus};
use std::collections::HashSet;
let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); let (_ca_key, root_store) = make_test_ca_and_root_store();
let mut ca_params = rcgen::CertificateParams::default();
ca_params.not_before = time::OffsetDateTime::now_utc();
ca_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(365);
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
ca_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign];
let mut dn = rcgen::DistinguishedName::new();
dn.push(rcgen::DnType::CommonName, "Test CA for Verifier");
ca_params.distinguished_name = dn;
let ca_cert = ca_params.self_signed(&ca_key).unwrap();
let mut root_store = RootCertStore::empty();
root_store.add(ca_cert.der().to_owned()).unwrap();
let webpki_verifier: Arc<dyn ClientCertVerifier> = let webpki_verifier: Arc<dyn ClientCertVerifier> =
WebPkiClientVerifier::builder(root_store.into()) WebPkiClientVerifier::builder(root_store.into())
@ -616,27 +375,13 @@ mod tests {
} }
/// Test that CrlAwareVerifier with a revoked serial in Valid CRL state /// Test that CrlAwareVerifier with a revoked serial in Valid CRL state
/// can be constructed. The actual verification logic is tested through /// can be constructed.
/// integration tests since it requires a full TLS handshake.
#[test] #[test]
fn crl_aware_verifier_with_revoked_serial() { fn crl_aware_verifier_with_revoked_serial() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); init_crypto_provider();
use super::super::crl::{new_shared_state, CrlState, CrlStatus}; use super::super::crl::{new_shared_state, CrlState, CrlStatus};
use std::collections::HashSet;
let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); let (_ca_key, root_store) = make_test_ca_and_root_store();
let mut ca_params = rcgen::CertificateParams::default();
ca_params.not_before = time::OffsetDateTime::now_utc();
ca_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(365);
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
ca_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign];
let mut dn = rcgen::DistinguishedName::new();
dn.push(rcgen::DnType::CommonName, "Test CA for Verifier");
ca_params.distinguished_name = dn;
let ca_cert = ca_params.self_signed(&ca_key).unwrap();
let mut root_store = RootCertStore::empty();
root_store.add(ca_cert.der().to_owned()).unwrap();
let webpki_verifier: Arc<dyn ClientCertVerifier> = let webpki_verifier: Arc<dyn ClientCertVerifier> =
WebPkiClientVerifier::builder(root_store.into()) WebPkiClientVerifier::builder(root_store.into())
@ -655,6 +400,5 @@ mod tests {
crl_state.store(Arc::new(valid_with_revoked)); crl_state.store(Arc::new(valid_with_revoked));
let _verifier = CrlAwareVerifier::new(webpki_verifier, crl_state); let _verifier = CrlAwareVerifier::new(webpki_verifier, crl_state);
// Construction succeeded — the verifier is ready to reject revoked certs.
} }
} }

View File

@ -0,0 +1,166 @@
//! Security Headers Middleware Module
//!
//! Provides request-level security header validation for Actix-web.
//! Enforces VULN-006: rejects requests with duplicate critical headers
//! (content-type, authorization, host) to prevent HTTP request smuggling
//! and response-splitting attacks.
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error,
};
use futures_util::future::LocalBoxFuture;
use tracing::warn;
/// Critical headers that MUST NOT appear more than once in a request.
/// Duplicate values for these headers can enable request smuggling,
/// response splitting, and other HTTP parsing ambiguities.
const CRITICAL_HEADERS: &[&str] = &["content-type", "authorization", "host"];
/// Security headers middleware for Actix-web.
///
/// Checks every incoming request for duplicate critical headers (VULN-006)
/// and rejects malformed requests with HTTP 400 Bad Request.
pub struct SecurityHeadersMiddleware;
impl SecurityHeadersMiddleware {
/// Create a new security headers middleware instance.
pub fn new() -> Self {
Self
}
}
impl Default for SecurityHeadersMiddleware {
fn default() -> Self {
Self::new()
}
}
/// Actix-web Transform implementation — wraps SecurityHeadersMiddleware as middleware
impl<S, B> Transform<S, ServiceRequest> for SecurityHeadersMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = SecurityHeadersMiddlewareService<S>;
type Future = futures_util::future::Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
futures_util::future::ok(SecurityHeadersMiddlewareService { service })
}
}
/// Security headers middleware service — performs per-request duplicate header checks
pub struct SecurityHeadersMiddlewareService<S> {
service: S,
}
impl<S, B> Service<ServiceRequest> for SecurityHeadersMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
// VULN-006: Check for duplicate critical headers before processing
if has_duplicate_critical_headers(req.headers()) {
let peer_addr = req.peer_addr();
warn!(
peer_addr = ?peer_addr,
"Duplicate critical headers detected - rejecting request (VULN-006)"
);
return Box::pin(async move {
Err(actix_web::error::ErrorBadRequest(
"Duplicate critical headers not allowed",
))
});
}
// All checks passed — call the service
let fut = self.service.call(req);
Box::pin(fut)
}
}
/// Check for duplicate critical headers (VULN-006).
/// Returns true if any critical header appears more than once.
///
/// This function is public for testing purposes.
pub fn has_duplicate_critical_headers(headers: &actix_web::http::header::HeaderMap) -> bool {
for header_name in CRITICAL_HEADERS.iter() {
let mut count = 0;
for (name, _value) in headers.iter() {
if name.as_str().eq_ignore_ascii_case(header_name) {
count += 1;
if count > 1 {
return true;
}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::http::header;
#[test]
fn test_no_duplicate_headers_passes() {
let mut headers = header::HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
headers.insert(header::AUTHORIZATION, "Bearer test".parse().unwrap());
headers.insert(header::HOST, "localhost".parse().unwrap());
assert!(!has_duplicate_critical_headers(&headers));
}
#[test]
fn test_duplicate_content_type_rejected() {
let mut headers = header::HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
headers.append(header::CONTENT_TYPE, "text/plain".parse().unwrap());
assert!(has_duplicate_critical_headers(&headers));
}
#[test]
fn test_duplicate_authorization_rejected() {
let mut headers = header::HeaderMap::new();
headers.insert(header::AUTHORIZATION, "Bearer test1".parse().unwrap());
headers.append(header::AUTHORIZATION, "Bearer test2".parse().unwrap());
assert!(has_duplicate_critical_headers(&headers));
}
#[test]
fn test_duplicate_host_rejected() {
let mut headers = header::HeaderMap::new();
headers.insert(header::HOST, "localhost".parse().unwrap());
headers.append(header::HOST, "evil.com".parse().unwrap());
assert!(has_duplicate_critical_headers(&headers));
}
#[test]
fn test_non_critical_duplicate_headers_allowed() {
// Duplicate non-critical headers should be allowed
let mut headers = header::HeaderMap::new();
headers.insert(header::ACCEPT, "text/html".parse().unwrap());
headers.append(header::ACCEPT, "application/json".parse().unwrap());
assert!(!has_duplicate_critical_headers(&headers));
}
#[test]
fn test_empty_headers_passes() {
let headers = header::HeaderMap::new();
assert!(!has_duplicate_critical_headers(&headers));
}
}

View File

@ -17,6 +17,12 @@ use std::sync::{Arc, RwLock};
use std::time::Duration; use std::time::Duration;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error,
};
use futures_util::future::LocalBoxFuture;
/// Whitelist entry types /// Whitelist entry types
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum WhitelistEntry { pub enum WhitelistEntry {
@ -282,6 +288,18 @@ impl WhitelistManager {
} }
} }
/// Create a deny-all whitelist manager (fail-closed fallback).
///
/// Used when the whitelist file cannot be loaded — all IPs are denied
/// except health endpoints (handled at middleware level).
pub fn new_deny_all() -> Self {
Self {
entries: Arc::new(RwLock::new(HashSet::new())),
config_path: String::new(),
watcher: None,
}
}
/// Get the number of entries in the whitelist /// Get the number of entries in the whitelist
pub fn entry_count(&self) -> usize { pub fn entry_count(&self) -> usize {
self.entries.read().unwrap().len() self.entries.read().unwrap().len()
@ -426,11 +444,9 @@ pub struct WhitelistMiddleware {
} }
impl WhitelistMiddleware { impl WhitelistMiddleware {
/// Create a new whitelist middleware /// Create a new whitelist middleware from an Arc<WhitelistManager>
pub fn new(manager: WhitelistManager) -> Self { pub fn new(manager: Arc<WhitelistManager>) -> Self {
Self { Self { manager }
manager: Arc::new(manager),
}
} }
/// Get the whitelist manager reference /// Get the whitelist manager reference
@ -439,6 +455,99 @@ impl WhitelistMiddleware {
} }
} }
/// Actix-web Transform implementation — wraps WhitelistMiddleware as middleware
impl<S, B> Transform<S, ServiceRequest> for WhitelistMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = WhitelistMiddlewareService<S>;
type Future = futures_util::future::Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
futures_util::future::ok(WhitelistMiddlewareService {
service,
manager: self.manager.clone(),
})
}
}
/// Whitelist middleware service — performs per-request IP checks
pub struct WhitelistMiddlewareService<S> {
service: S,
manager: Arc<WhitelistManager>,
}
/// Health/system endpoint paths exempt from IP whitelist enforcement
const WHITELIST_EXEMPT_PATHS: &[&str] = &["/health", "/api/v1/system/info"];
impl<S, B> Service<ServiceRequest> for WhitelistMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let path = req.path().to_owned();
// Exempt health and system info endpoints from IP whitelist
if WHITELIST_EXEMPT_PATHS.iter().any(|p| path == *p) {
debug!(path = %path, "Path exempt from IP whitelist");
let fut = self.service.call(req);
return Box::pin(fut);
}
// Get peer address — fail-closed if unavailable
let peer_addr = req.peer_addr();
match peer_addr {
Some(addr) => {
if self.manager.is_socket_allowed(&addr) {
debug!(
peer_addr = %addr,
path = %path,
"IP whitelist check passed"
);
let fut = self.service.call(req);
Box::pin(fut)
} else {
warn!(
peer_addr = %addr,
path = %path,
"IP whitelist denied - connection rejected"
);
Box::pin(async move {
Err(actix_web::error::ErrorForbidden(
"IP address not in whitelist",
))
})
}
}
None => {
// No peer address — fail-closed (deny by default)
warn!(
path = %path,
"No peer address available - denying request (fail-closed)"
);
Box::pin(async move {
Err(actix_web::error::ErrorForbidden(
"IP address not available - denied by policy",
))
})
}
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -511,4 +620,33 @@ mod tests {
let ip_outside: Ipv4Addr = "192.168.2.100".parse().unwrap(); let ip_outside: Ipv4Addr = "192.168.2.100".parse().unwrap();
assert!(!manager.is_allowed(&ip_outside)); assert!(!manager.is_allowed(&ip_outside));
} }
#[test]
fn test_new_deny_all_blocks_everything() {
let manager = WhitelistManager::new_deny_all();
// No IPs should be allowed in deny-all mode
let ip: Ipv4Addr = "192.168.1.1".parse().unwrap();
assert!(!manager.is_allowed(&ip));
let ip2: Ipv4Addr = "10.0.0.1".parse().unwrap();
assert!(!manager.is_allowed(&ip2));
// Entry count should be 0
assert_eq!(manager.entry_count(), 0);
}
#[test]
fn test_is_socket_allowed_ipv6_denied() {
let manager = WhitelistManager::new_deny_all();
// IPv6 should be denied even with a populated whitelist
let socket_v6: SocketAddr = "[::1]:12345".parse().unwrap();
assert!(!manager.is_socket_allowed(&socket_v6));
}
#[test]
fn test_exempt_paths_constant() {
// Verify the exempt paths include health and system info
assert!(WHITELIST_EXEMPT_PATHS.contains(&"/health"));
assert!(WHITELIST_EXEMPT_PATHS.contains(&"/api/v1/system/info"));
}
} }

View File

@ -33,8 +33,6 @@ pub struct TlsConfig {
pub ca_cert: String, pub ca_cert: String,
pub server_cert: String, pub server_cert: String,
pub server_key: String, pub server_key: String,
#[serde(default = "default_tls_version")]
pub min_tls_version: String,
/// Path to persist the CRL fetched from the manager. /// Path to persist the CRL fetched from the manager.
/// Defaults to /etc/linux_patch_api/certs/crl.pem /// Defaults to /etc/linux_patch_api/certs/crl.pem
#[serde(default = "default_crl_path")] #[serde(default = "default_crl_path")]
@ -49,10 +47,6 @@ fn default_true() -> bool {
true true
} }
fn default_tls_version() -> String {
"1.3".to_string()
}
/// Jobs configuration /// Jobs configuration
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct JobsConfig { pub struct JobsConfig {
@ -60,12 +54,58 @@ pub struct JobsConfig {
pub timeout_minutes: u64, pub timeout_minutes: u64,
#[serde(default = "default_storage_path")] #[serde(default = "default_storage_path")]
pub storage_path: String, pub storage_path: String,
#[serde(default = "default_max_queue_depth")]
pub max_queue_depth: usize,
} }
fn default_storage_path() -> String { fn default_storage_path() -> String {
"/var/lib/linux_patch_api/jobs".to_string() "/var/lib/linux_patch_api/jobs".to_string()
} }
fn default_max_queue_depth() -> usize {
100
}
/// Rate limiting configuration
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct RateLimitConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_destructive_per_minute")]
pub destructive_per_minute: u32,
#[serde(default = "default_destructive_burst")]
pub destructive_burst: u32,
#[serde(default = "default_read_per_minute")]
pub read_per_minute: u32,
#[serde(default = "default_read_burst")]
pub read_burst: u32,
}
fn default_destructive_per_minute() -> u32 {
20
}
fn default_destructive_burst() -> u32 {
10
}
fn default_read_per_minute() -> u32 {
120
}
fn default_read_burst() -> u32 {
30
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: true,
destructive_per_minute: default_destructive_per_minute(),
destructive_burst: default_destructive_burst(),
read_per_minute: default_read_per_minute(),
read_burst: default_read_burst(),
}
}
}
/// Logging configuration /// Logging configuration
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LoggingConfig { pub struct LoggingConfig {
@ -445,6 +485,8 @@ pub struct AppConfig {
pub package_manager: Option<PackageManagerConfig>, pub package_manager: Option<PackageManagerConfig>,
#[serde(default)] #[serde(default)]
pub enrollment: Option<EnrollmentConfig>, pub enrollment: Option<EnrollmentConfig>,
#[serde(default)]
pub rate_limit: RateLimitConfig,
} }
impl AppConfig { impl AppConfig {
@ -453,6 +495,19 @@ impl AppConfig {
let content = std::fs::read_to_string(path) let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path))?; .with_context(|| format!("Failed to read config file: {}", path))?;
// Check for deprecated fields before typed parsing
if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(&content) {
if let Some(tls) = value.get("tls") {
if tls.get("min_tls_version").is_some() {
tracing::warn!(
"Config contains deprecated 'tls.min_tls_version' field. \
This field is ignored — TLS 1.3 is the only supported version. \
Remove it from your config to silence this warning."
);
}
}
}
let config: AppConfig = serde_yaml::from_str(&content) let config: AppConfig = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {}", path))?; .with_context(|| format!("Failed to parse config file: {}", path))?;

View File

@ -6,6 +6,7 @@
//! - Auto-reload on file change via notify watcher //! - Auto-reload on file change via notify watcher
pub mod loader; pub mod loader;
pub use loader::{validate_certs, AppConfig, CertStatus, EnrollmentConfig}; pub use loader::{validate_certs, AppConfig, CertStatus, EnrollmentConfig, RateLimitConfig};
pub mod validator; pub mod validator;
pub use validator::validate_config_warnings;
pub mod watcher; pub mod watcher;

View File

@ -1,3 +1,25 @@
//! Configuration Validator //! Configuration Validator
//! //!
//! Placeholder - implementation in future phases //! Validates configuration values and warns about deprecated fields.
use tracing::warn;
/// Validate configuration for deprecated or unknown fields.
///
/// This is called after config loading to emit warnings for fields
/// that are no longer functional but may still be present in operator
/// config files.
pub fn validate_config_warnings(config_yaml: &str) {
// Check for deprecated tls.min_tls_version field
if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(config_yaml) {
if let Some(tls) = value.get("tls") {
if tls.get("min_tls_version").is_some() {
warn!(
"Config contains deprecated 'tls.min_tls_version' field. \
This field is ignored — TLS 1.3 is the only supported version. \
Remove it from your config to silence this warning."
);
}
}
}
}

View File

@ -140,6 +140,7 @@ pub struct JobStatusEvent {
pub struct JobManager { pub struct JobManager {
max_concurrent: usize, max_concurrent: usize,
timeout_minutes: u64, timeout_minutes: u64,
max_queue_depth: usize,
jobs: Arc<RwLock<HashMap<Uuid, Job>>>, jobs: Arc<RwLock<HashMap<Uuid, Job>>>,
/// Broadcast sender for job status events /// Broadcast sender for job status events
event_sender: broadcast::Sender<JobStatusEvent>, event_sender: broadcast::Sender<JobStatusEvent>,
@ -147,11 +148,16 @@ pub struct JobManager {
impl JobManager { impl JobManager {
/// Create a new job manager /// Create a new job manager
pub fn new(max_concurrent: usize, timeout_minutes: u64) -> Result<Self> { pub fn new(
max_concurrent: usize,
timeout_minutes: u64,
max_queue_depth: usize,
) -> Result<Self> {
let (event_sender, _) = broadcast::channel(256); let (event_sender, _) = broadcast::channel(256);
Ok(Self { Ok(Self {
max_concurrent, max_concurrent,
timeout_minutes, timeout_minutes,
max_queue_depth,
jobs: Arc::new(RwLock::new(HashMap::new())), jobs: Arc::new(RwLock::new(HashMap::new())),
event_sender, event_sender,
}) })
@ -167,6 +173,11 @@ impl JobManager {
self.max_concurrent self.max_concurrent
} }
/// Get max queue depth
pub fn max_queue_depth(&self) -> usize {
self.max_queue_depth
}
/// Subscribe to job status events /// Subscribe to job status events
/// Returns a broadcast receiver that will receive JobStatusEvent messages /// Returns a broadcast receiver that will receive JobStatusEvent messages
pub fn subscribe(&self) -> broadcast::Receiver<JobStatusEvent> { pub fn subscribe(&self) -> broadcast::Receiver<JobStatusEvent> {
@ -335,9 +346,17 @@ impl JobManager {
.count() .count()
} }
/// Check if can accept new job (respecting max_concurrent) /// Check if can accept new job (respecting max_queue_depth)
/// Returns false when the total number of pending + running jobs
/// equals or exceeds the configured queue depth cap.
pub async fn can_accept_job(&self) -> bool { pub async fn can_accept_job(&self) -> bool {
self.running_count().await < self.max_concurrent let jobs = self.jobs.read().await;
let active_count = jobs
.values()
.filter(|j| j.status == JobStatus::Running || j.status == JobStatus::Pending)
.count();
drop(jobs);
active_count < self.max_queue_depth
} }
/// Delete a completed/failed job from history /// Delete a completed/failed job from history
@ -401,6 +420,7 @@ impl Clone for JobManager {
Self { Self {
max_concurrent: self.max_concurrent, max_concurrent: self.max_concurrent,
timeout_minutes: self.timeout_minutes, timeout_minutes: self.timeout_minutes,
max_queue_depth: self.max_queue_depth,
jobs: self.jobs.clone(), jobs: self.jobs.clone(),
event_sender: self.event_sender.clone(), event_sender: self.event_sender.clone(),
} }

View File

@ -28,7 +28,9 @@ use tracing::{error, info, warn};
use linux_patch_api::api::{configure_api_routes, configure_health_route}; use linux_patch_api::api::{configure_api_routes, configure_health_route};
use linux_patch_api::auth::crl::{self, CrlStatus}; use linux_patch_api::auth::crl::{self, CrlStatus};
use linux_patch_api::auth::{mtls, MtlsMiddleware, WhitelistManager}; use linux_patch_api::auth::{
mtls, SecurityHeadersMiddleware, WhitelistManager, WhitelistMiddleware,
};
use linux_patch_api::config::loader::{validate_certs, CertStatus}; use linux_patch_api::config::loader::{validate_certs, CertStatus};
use linux_patch_api::enroll; use linux_patch_api::enroll;
use linux_patch_api::packages::cache::PackageCacheState; use linux_patch_api::packages::cache::PackageCacheState;
@ -250,10 +252,15 @@ async fn main() -> Result<()> {
} }
// Initialize job manager // Initialize job manager
let job_manager = JobManager::new(config.jobs.max_concurrent, config.jobs.timeout_minutes)?; let job_manager = JobManager::new(
config.jobs.max_concurrent,
config.jobs.timeout_minutes,
config.jobs.max_queue_depth,
)?;
info!( info!(
max_jobs = config.jobs.max_concurrent, max_jobs = config.jobs.max_concurrent,
timeout_minutes = config.jobs.timeout_minutes, timeout_minutes = config.jobs.timeout_minutes,
max_queue_depth = config.jobs.max_queue_depth,
"Job manager initialized" "Job manager initialized"
); );
@ -282,11 +289,12 @@ async fn main() -> Result<()> {
entries = manager.entry_count(), entries = manager.entry_count(),
"Whitelist manager initialized" "Whitelist manager initialized"
); );
Some(Arc::new(manager)) Arc::new(manager)
} }
Err(e) => { Err(e) => {
warn!(error = %e, "Failed to load whitelist - continuing with empty whitelist (all denied)"); // Fail-closed: deny all IPs when whitelist cannot be loaded
None warn!(error = %e, "Failed to load whitelist - using deny-all mode (fail-closed)");
Arc::new(WhitelistManager::new_deny_all())
} }
}; };
@ -305,29 +313,39 @@ async fn main() -> Result<()> {
// Configure bind address // Configure bind address
let bind_address = format!("{}:{}", config.server.bind, config.server.port); let bind_address = format!("{}:{}", config.server.bind, config.server.port);
// Clone whitelist manager for use inside the HttpServer closure
let wl = whitelist_manager.clone();
// Clone rate limit config for use inside the HttpServer closure
let rate_limit_config = config.rate_limit.clone();
// Create server builder // Create server builder
// Security middleware stack (order matters):
// 1. WhitelistMiddleware — IP-based access control (deny-by-default)
// 2. SecurityHeadersMiddleware — VULN-006: reject duplicate critical headers
// 3. RateLimitMiddleware — per-IP rate limiting (read + destructive tiers)
// 4. Logger — request logging (after auth decisions)
let server_builder = HttpServer::new(move || { let server_builder = HttpServer::new(move || {
let mut app = App::new() App::new()
.wrap(WhitelistMiddleware::new(wl.clone()))
.wrap(SecurityHeadersMiddleware::new())
.wrap(linux_patch_api::api::rate_limit::RateLimitMiddleware::new(
rate_limit_config.clone(),
))
.wrap(Logger::default()) .wrap(Logger::default())
.app_data(job_manager_data.clone()) .app_data(job_manager_data.clone())
.app_data(backend_data.clone()) .app_data(backend_data.clone())
.app_data(cache_state.clone()) .app_data(cache_state.clone())
.app_data(crl_state_data.clone()); .app_data(crl_state_data.clone())
.configure(|cfg| {
// Configure API routes
app = app.configure(|cfg| {
configure_api_routes( configure_api_routes(
cfg, cfg,
job_manager_data.clone(), job_manager_data.clone(),
backend_data.clone(), backend_data.clone(),
cache_state.clone(), cache_state.clone(),
); );
}); })
.configure(configure_health_route)
// Configure health route (outside API scope)
app = app.configure(configure_health_route);
app
}) })
.workers(4) .workers(4)
// VULN-004: Configure header size limit to 8KB to prevent DoS via oversized headers // VULN-004: Configure header size limit to 8KB to prevent DoS via oversized headers
@ -338,8 +356,8 @@ async fn main() -> Result<()> {
.max_connection_rate(1000); .max_connection_rate(1000);
info!( info!(
mtls_enabled = config.tls_config().is_some(), mtls_enabled = config.tls_config().is_some(),
whitelist_enabled = whitelist_manager.is_some(), whitelist_entries = whitelist_manager.entry_count(),
"Security layer status" "Security layer status (IP whitelist enforced)"
); );
info!("Linux Patch API initialized successfully"); info!("Linux Patch API initialized successfully");
@ -350,16 +368,15 @@ async fn main() -> Result<()> {
ca_cert = %tls_config.ca_cert, ca_cert = %tls_config.ca_cert,
server_cert = %tls_config.server_cert, server_cert = %tls_config.server_cert,
server_key = %tls_config.server_key, server_key = %tls_config.server_key,
min_tls_version = %tls_config.min_tls_version,
crl_path = %tls_config.crl_path, crl_path = %tls_config.crl_path,
"Initializing mTLS authentication with TLS binding" "Initializing mTLS authentication with TLS 1.3 binding"
); );
// TLS 1.3 is the only supported version — hardcoded in build_rustls_config()
let mtls_config = mtls::MtlsConfig { let mtls_config = mtls::MtlsConfig {
ca_cert_path: tls_config.ca_cert.clone(), ca_cert_path: tls_config.ca_cert.clone(),
server_cert_path: tls_config.server_cert.clone(), server_cert_path: tls_config.server_cert.clone(),
server_key_path: tls_config.server_key.clone(), server_key_path: tls_config.server_key.clone(),
min_tls_version: tls_config.min_tls_version.clone(),
}; };
// Load CRL from disk into the shared CRL state // Load CRL from disk into the shared CRL state
@ -401,14 +418,17 @@ async fn main() -> Result<()> {
info!("No manager URL configured -- CRL auto-refresh disabled"); info!("No manager URL configured -- CRL auto-refresh disabled");
} }
match MtlsMiddleware::new(mtls_config.clone()) { // ADR: rustls is the authoritative client-auth gate.
Ok(middleware) => { // Client certificate verification happens at the TLS handshake level
// Build rustls server configuration with CRL-aware verifier // via CrlAwareVerifier (which wraps WebPkiClientVerifier). No
let rustls_config = middleware // application-layer certificate validation middleware is needed.
.build_rustls_config(Some(shared_crl_state.clone())) // See src/auth/mtls.rs for the full ADR.
let rustls_config = mtls::build_rustls_config(&mtls_config, Some(shared_crl_state.clone()))
.map_err(|e| anyhow::anyhow!("Failed to build rustls config: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to build rustls config: {}", e))?;
info!("mTLS middleware and rustls config initialized successfully"); info!(
"mTLS rustls config initialized successfully (client auth enforced at TLS handshake)"
);
// Create TCP listener with SO_REUSEADDR using socket2 // Create TCP listener with SO_REUSEADDR using socket2
// This prevents "Address already in use" errors when restarting after a crash // This prevents "Address already in use" errors when restarting after a crash
@ -423,15 +443,13 @@ async fn main() -> Result<()> {
.set_reuse_address(true) .set_reuse_address(true)
.map_err(|e| anyhow::anyhow!("Failed to set SO_REUSEADDR: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to set SO_REUSEADDR: {}", e))?;
let bind_addr: std::net::SocketAddr = bind_address.parse().map_err(|e| { let bind_addr: std::net::SocketAddr = bind_address
anyhow::anyhow!("Invalid bind address '{}': {}", bind_address, e) .parse()
})?; .map_err(|e| anyhow::anyhow!("Invalid bind address '{}': {}", bind_address, e))?;
socket socket
.bind(&socket2::SockAddr::from(bind_addr)) .bind(&socket2::SockAddr::from(bind_addr))
.map_err(|e| { .map_err(|e| anyhow::anyhow!("Failed to bind socket to {}: {}", bind_address, e))?;
anyhow::anyhow!("Failed to bind socket to {}: {}", bind_address, e)
})?;
socket socket
.listen(128) .listen(128)
@ -452,12 +470,6 @@ async fn main() -> Result<()> {
.listen_rustls_0_23(tcp_listener, server_config)? .listen_rustls_0_23(tcp_listener, server_config)?
.run() .run()
.await?; .await?;
}
Err(e) => {
error!(error = %e, "Failed to initialize mTLS middleware");
return Err(anyhow::anyhow!("mTLS initialization failed: {}", e));
}
}
} else { } else {
// Create TCP listener with SO_REUSEADDR for non-TLS mode // Create TCP listener with SO_REUSEADDR for non-TLS mode
let socket = socket2::Socket::new( let socket = socket2::Socket::new(

View File

@ -12,6 +12,112 @@ use serde::{Deserialize, Serialize};
use std::process::Command; use std::process::Command;
use tracing::info; use tracing::info;
/// Maximum allowed length for package names and version strings
pub const MAX_NAME_LENGTH: usize = 256;
/// Validate a package name against a strict allowlist pattern.
/// Prevents argument injection by blocking shell metacharacters,
/// path separators, whitespace, and leading hyphens.
/// Pattern: ^[a-zA-Z0-9][a-zA-Z0-9+._-]*$
pub fn validate_package_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Package name cannot be empty".to_string());
}
if name.len() > MAX_NAME_LENGTH {
return Err(format!(
"Package name exceeds maximum length of {} characters",
MAX_NAME_LENGTH
));
}
let bytes = name.as_bytes();
if !bytes[0].is_ascii_alphanumeric() {
return Err(format!(
"Package name must start with an alphanumeric character: '{}'",
name
));
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '_' || c == '-')
{
return Err(format!(
"Package name contains invalid characters: '{}'. Only alphanumeric, plus, dot, underscore, and hyphen are allowed",
name
));
}
Ok(())
}
/// Validate a version string against a strict allowlist pattern.
/// Allows characters commonly found in package versions (colons for RPM epochs,
/// tildes for version ordering) while blocking shell metacharacters and path separators.
/// Pattern: ^[a-zA-Z0-9][a-zA-Z0-9+.:~_-]*$
pub fn validate_version_string(version: &str) -> Result<(), String> {
if version.is_empty() {
return Err("Version string cannot be empty".to_string());
}
if version.len() > MAX_NAME_LENGTH {
return Err(format!(
"Version string exceeds maximum length of {} characters",
MAX_NAME_LENGTH
));
}
let bytes = version.as_bytes();
if !bytes[0].is_ascii_alphanumeric() {
return Err(format!(
"Version string must start with an alphanumeric character: '{}'",
version
));
}
if !version.chars().all(|c| {
c.is_ascii_alphanumeric()
|| c == '+'
|| c == '.'
|| c == '_'
|| c == '-'
|| c == ':'
|| c == '~'
}) {
return Err(format!(
"Version string contains invalid characters: '{}'. Only alphanumeric, plus, dot, underscore, hyphen, colon, and tilde are allowed",
version
));
}
Ok(())
}
/// Validate a service name against a strict allowlist pattern.
/// Prevents shell injection and argument injection in systemctl/rc-service commands.
/// Allows hyphens (common in systemd unit names), dots for unit suffixes, and @ for template instances.
/// Pattern: ^[a-zA-Z0-9][a-zA-Z0-9_.@+-]*$
pub fn validate_service_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Service name cannot be empty".to_string());
}
if name.len() > MAX_NAME_LENGTH {
return Err(format!(
"Service name exceeds maximum length of {} characters",
MAX_NAME_LENGTH
));
}
let bytes = name.as_bytes();
if !bytes[0].is_ascii_alphanumeric() {
return Err(format!(
"Service name must start with an alphanumeric character: '{}'",
name
));
}
if !name.chars().all(|c| {
c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '@' || c == '+' || c == '-'
}) {
return Err(format!(
"Service name contains invalid characters: '{}'. Only alphanumeric, underscore, dot, at-sign, plus, and hyphen are allowed",
name
));
}
Ok(())
}
/// Package status /// Package status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum PackageStatus { pub enum PackageStatus {
@ -311,10 +417,20 @@ impl PackageManagerBackend for AptBackend {
args.push("--no-install-recommends".to_string()); args.push("--no-install-recommends".to_string());
} }
// SECURITY: --force-yes bypasses GPG signature verification and is a security risk.
// Only allow when explicitly requested; log a warning.
if options.force { if options.force {
tracing::warn!(
"--force-yes requested: package signature verification will be bypassed"
);
args.push("--force-yes".to_string()); args.push("--force-yes".to_string());
} }
// SECURITY: Insert -- separator before user-supplied package names to prevent
// argument injection. Without this, a package name like "--allow-unauthenticated"
// would be interpreted as an apt option rather than a package name.
args.push("--".to_string());
for pkg in packages { for pkg in packages {
let pkg_arg = if let Some(version) = &pkg.version { let pkg_arg = if let Some(version) = &pkg.version {
format!("{}={}", pkg.name, version) format!("{}={}", pkg.name, version)
@ -334,16 +450,18 @@ impl PackageManagerBackend for AptBackend {
} }
fn update_package(&self, name: &str) -> Result<()> { fn update_package(&self, name: &str) -> Result<()> {
self.run_apt(&["install", "-y", "--only-upgrade", name])?; // SECURITY: -- separator prevents argument injection via package name
self.run_apt(&["install", "-y", "--only-upgrade", "--", name])?;
info!("Updated package: {}", name); info!("Updated package: {}", name);
Ok(()) Ok(())
} }
fn remove_package(&self, name: &str, purge: bool) -> Result<()> { fn remove_package(&self, name: &str, purge: bool) -> Result<()> {
// SECURITY: -- separator prevents argument injection via package name
let args = if purge { let args = if purge {
vec!["purge", "-y", name] vec!["purge", "-y", "--", name]
} else { } else {
vec!["remove", "-y", name] vec!["remove", "-y", "--", name]
}; };
self.run_apt(&args)?; self.run_apt(&args)?;
@ -392,7 +510,8 @@ impl PackageManagerBackend for AptBackend {
fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> { fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> {
let args = match packages { let args = match packages {
Some(pkgs) => { Some(pkgs) => {
let mut a = vec!["install", "-y"]; // SECURITY: -- separator prevents argument injection via package names
let mut a: Vec<&str> = vec!["install", "-y", "--"];
for pkg in pkgs { for pkg in pkgs {
a.push(pkg); a.push(pkg);
} }
@ -506,10 +625,8 @@ impl PackageManagerBackend for AptBackend {
} }
fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> { fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> {
// Validate service name to prevent shell injection // SECURITY: Strict allowlist validation to prevent argument/shell injection
if name.is_empty() || name.contains('/') || name.contains("..") { validate_service_name(name).map_err(|e| anyhow::anyhow!("{}", e))?;
return Err(anyhow::anyhow!("Invalid service name: {}", name));
}
// Determine init system and query accordingly // Determine init system and query accordingly
let is_systemd = std::path::Path::new("/run/systemd/system").exists(); let is_systemd = std::path::Path::new("/run/systemd/system").exists();
@ -549,9 +666,11 @@ impl PackageManagerBackend for AptBackend {
/// Query systemd service status via systemctl /// Query systemd service status via systemctl
fn get_systemd_service_status(name: &str) -> Result<Option<ServiceStatus>> { fn get_systemd_service_status(name: &str) -> Result<Option<ServiceStatus>> {
// SECURITY: -- separator prevents argument injection via service name
let output = Command::new("systemctl") let output = Command::new("systemctl")
.args([ .args([
"show", "show",
"--",
name, name,
"--property=Id,Description,ActiveState,SubState,LoadState,UnitFileState,MainPID", "--property=Id,Description,ActiveState,SubState,LoadState,UnitFileState,MainPID",
"--no-pager", "--no-pager",
@ -978,6 +1097,9 @@ impl PackageManagerBackend for ApkBackend {
args.push("--force".to_string()); args.push("--force".to_string());
} }
// SECURITY: -- separator prevents argument injection via package names
args.push("--".to_string());
for pkg in packages { for pkg in packages {
let pkg_arg = if let Some(version) = &pkg.version { let pkg_arg = if let Some(version) = &pkg.version {
format!("{}={}", pkg.name, version) format!("{}={}", pkg.name, version)
@ -997,14 +1119,16 @@ impl PackageManagerBackend for ApkBackend {
} }
fn update_package(&self, name: &str) -> Result<()> { fn update_package(&self, name: &str) -> Result<()> {
self.run_apk(&["upgrade", name])?; // SECURITY: -- separator prevents argument injection via package name
self.run_apk(&["upgrade", "--", name])?;
info!("Updated package: {}", name); info!("Updated package: {}", name);
Ok(()) Ok(())
} }
fn remove_package(&self, name: &str, _purge: bool) -> Result<()> { fn remove_package(&self, name: &str, _purge: bool) -> Result<()> {
// APK doesn't have a purge concept - just remove the package // APK doesn't have a purge concept - just remove the package
self.run_apk(&["del", name])?; // SECURITY: -- separator prevents argument injection via package name
self.run_apk(&["del", "--", name])?;
info!("Removed package: {}", name); info!("Removed package: {}", name);
Ok(()) Ok(())
} }
@ -1066,7 +1190,8 @@ impl PackageManagerBackend for ApkBackend {
fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> { fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> {
match packages { match packages {
Some(pkgs) => { Some(pkgs) => {
let mut args: Vec<&str> = vec!["upgrade"]; // SECURITY: -- separator prevents argument injection via package names
let mut args: Vec<&str> = vec!["upgrade", "--"];
for pkg in pkgs { for pkg in pkgs {
args.push(pkg); args.push(pkg);
} }
@ -1186,10 +1311,8 @@ impl PackageManagerBackend for ApkBackend {
} }
fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> { fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> {
// Validate service name to prevent shell injection // SECURITY: Strict allowlist validation to prevent argument/shell injection
if name.is_empty() || name.contains('/') || name.contains("..") { validate_service_name(name).map_err(|e| anyhow::anyhow!("{}", e))?;
return Err(anyhow::anyhow!("Invalid service name: {}", name));
}
// Alpine uses OpenRC for service management // Alpine uses OpenRC for service management
get_openrc_service_status(name) get_openrc_service_status(name)
@ -1533,6 +1656,9 @@ impl PackageManagerBackend for DnfBackend {
args.push("--allowerasing".to_string()); args.push("--allowerasing".to_string());
} }
// SECURITY: -- separator prevents argument injection via package names
args.push("--".to_string());
for pkg in packages { for pkg in packages {
let pkg_arg = if let Some(version) = &pkg.version { let pkg_arg = if let Some(version) = &pkg.version {
format!("{}-{}", pkg.name, version) format!("{}-{}", pkg.name, version)
@ -1552,16 +1678,18 @@ impl PackageManagerBackend for DnfBackend {
} }
fn update_package(&self, name: &str) -> Result<()> { fn update_package(&self, name: &str) -> Result<()> {
self.run_dnf(&["upgrade", "-y", name])?; // SECURITY: -- separator prevents argument injection via package name
self.run_dnf(&["upgrade", "-y", "--", name])?;
info!("Updated package: {}", name); info!("Updated package: {}", name);
Ok(()) Ok(())
} }
fn remove_package(&self, name: &str, purge: bool) -> Result<()> { fn remove_package(&self, name: &str, purge: bool) -> Result<()> {
// SECURITY: -- separator prevents argument injection via package name
let args = if purge { let args = if purge {
vec!["remove", "-y", "--noautoremove", name] vec!["remove", "-y", "--noautoremove", "--", name]
} else { } else {
vec!["remove", "-y", name] vec!["remove", "-y", "--", name]
}; };
self.run_dnf(&args)?; self.run_dnf(&args)?;
info!("Removed package: {} (purge={})", name, purge); info!("Removed package: {} (purge={})", name, purge);
@ -1641,7 +1769,8 @@ impl PackageManagerBackend for DnfBackend {
fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> { fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> {
match packages { match packages {
Some(pkgs) => { Some(pkgs) => {
let mut args: Vec<&str> = vec!["upgrade", "-y"]; // SECURITY: -- separator prevents argument injection via package names
let mut args: Vec<&str> = vec!["upgrade", "-y", "--"];
for pkg in pkgs { for pkg in pkgs {
args.push(pkg); args.push(pkg);
} }
@ -1758,10 +1887,8 @@ impl PackageManagerBackend for DnfBackend {
} }
fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> { fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> {
// Validate service name to prevent shell injection // SECURITY: Strict allowlist validation to prevent argument/shell injection
if name.is_empty() || name.contains('/') || name.contains("..") { validate_service_name(name).map_err(|e| anyhow::anyhow!("{}", e))?;
return Err(anyhow::anyhow!("Invalid service name: {}", name));
}
// Fedora/RHEL use systemd for service management // Fedora/RHEL use systemd for service management
get_systemd_service_status(name) get_systemd_service_status(name)
@ -2087,6 +2214,9 @@ impl PackageManagerBackend for YumBackend {
// yum doesn't have --allowerasing, skip force option // yum doesn't have --allowerasing, skip force option
// SECURITY: -- separator prevents argument injection via package names
args.push("--".to_string());
for pkg in packages { for pkg in packages {
let pkg_arg = if let Some(version) = &pkg.version { let pkg_arg = if let Some(version) = &pkg.version {
format!("{}-{}", pkg.name, version) format!("{}-{}", pkg.name, version)
@ -2106,7 +2236,8 @@ impl PackageManagerBackend for YumBackend {
} }
fn update_package(&self, name: &str) -> Result<()> { fn update_package(&self, name: &str) -> Result<()> {
self.run_yum(&["update", "-y", name])?; // SECURITY: -- separator prevents argument injection via package name
self.run_yum(&["update", "-y", "--", name])?;
info!("Updated package: {}", name); info!("Updated package: {}", name);
Ok(()) Ok(())
} }
@ -2114,7 +2245,8 @@ impl PackageManagerBackend for YumBackend {
fn remove_package(&self, name: &str, purge: bool) -> Result<()> { fn remove_package(&self, name: &str, purge: bool) -> Result<()> {
// yum doesn't distinguish between remove and purge // yum doesn't distinguish between remove and purge
let _ = purge; let _ = purge;
self.run_yum(&["remove", "-y", name])?; // SECURITY: -- separator prevents argument injection via package name
self.run_yum(&["remove", "-y", "--", name])?;
info!("Removed package: {} (purge={})", name, purge); info!("Removed package: {} (purge={})", name, purge);
Ok(()) Ok(())
} }
@ -2185,7 +2317,8 @@ impl PackageManagerBackend for YumBackend {
fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> { fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> {
match packages { match packages {
Some(pkgs) => { Some(pkgs) => {
let mut args: Vec<&str> = vec!["update", "-y"]; // SECURITY: -- separator prevents argument injection via package names
let mut args: Vec<&str> = vec!["update", "-y", "--"];
for pkg in pkgs { for pkg in pkgs {
args.push(pkg); args.push(pkg);
} }
@ -2301,10 +2434,8 @@ impl PackageManagerBackend for YumBackend {
} }
fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> { fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> {
// Validate service name to prevent shell injection // SECURITY: Strict allowlist validation to prevent argument/shell injection
if name.is_empty() || name.contains('/') || name.contains("..") { validate_service_name(name).map_err(|e| anyhow::anyhow!("{}", e))?;
return Err(anyhow::anyhow!("Invalid service name: {}", name));
}
// CentOS 7 uses systemd for service management // CentOS 7 uses systemd for service management
get_systemd_service_status(name) get_systemd_service_status(name)
@ -2557,6 +2688,9 @@ impl PackageManagerBackend for PacmanBackend {
args.push("'*'".to_string()); args.push("'*'".to_string());
} }
// SECURITY: -- separator prevents argument injection via package names
args.push("--".to_string());
for pkg in packages { for pkg in packages {
args.push(pkg.name.clone()); args.push(pkg.name.clone());
} }
@ -2571,14 +2705,16 @@ impl PackageManagerBackend for PacmanBackend {
} }
fn update_package(&self, name: &str) -> Result<()> { fn update_package(&self, name: &str) -> Result<()> {
self.run_pacman(&["-S", "--noconfirm", name])?; // SECURITY: -- separator prevents argument injection via package name
self.run_pacman(&["-S", "--noconfirm", "--", name])?;
info!("Updated package: {}", name); info!("Updated package: {}", name);
Ok(()) Ok(())
} }
fn remove_package(&self, name: &str, _purge: bool) -> Result<()> { fn remove_package(&self, name: &str, _purge: bool) -> Result<()> {
// pacman doesn't have a purge concept - just remove the package // pacman doesn't have a purge concept - just remove the package
self.run_pacman(&["-R", "--noconfirm", name])?; // SECURITY: -- separator prevents argument injection via package name
self.run_pacman(&["-R", "--noconfirm", "--", name])?;
info!("Removed package: {} (purge={})", name, _purge); info!("Removed package: {} (purge={})", name, _purge);
Ok(()) Ok(())
} }
@ -2629,7 +2765,8 @@ impl PackageManagerBackend for PacmanBackend {
fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> { fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> {
match packages { match packages {
Some(pkgs) => { Some(pkgs) => {
let mut args: Vec<&str> = vec!["-S", "--noconfirm", "--needed"]; // SECURITY: -- separator prevents argument injection via package names
let mut args: Vec<&str> = vec!["-S", "--noconfirm", "--needed", "--"];
for pkg in pkgs { for pkg in pkgs {
args.push(pkg); args.push(pkg);
} }
@ -2746,10 +2883,8 @@ impl PackageManagerBackend for PacmanBackend {
} }
fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> { fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> {
// Validate service name to prevent shell injection // SECURITY: Strict allowlist validation to prevent argument/shell injection
if name.is_empty() || name.contains('/') || name.contains("..") { validate_service_name(name).map_err(|e| anyhow::anyhow!("{}", e))?;
return Err(anyhow::anyhow!("Invalid service name: {}", name));
}
// Arch Linux uses systemd for service management // Arch Linux uses systemd for service management
get_systemd_service_status(name) get_systemd_service_status(name)
@ -2998,4 +3133,160 @@ mod tests {
assert_eq!(pkg.dependencies.len(), 3); assert_eq!(pkg.dependencies.len(), 3);
assert!(pkg.dependencies.contains(&"readline".to_string())); assert!(pkg.dependencies.contains(&"readline".to_string()));
} }
// --- Validation function tests (Issue #10: Argument injection RCE prevention) ---
#[test]
fn test_validate_package_name_valid() {
assert!(validate_package_name("bash").is_ok());
assert!(validate_package_name("libssl1.1").is_ok());
assert!(validate_package_name("python3-pip").is_ok());
assert!(validate_package_name("g++-11").is_ok());
assert!(validate_package_name("nginx-common").is_ok());
assert!(validate_package_name("curl").is_ok());
assert!(validate_package_name("lib32-glibc").is_ok());
assert!(validate_package_name("a").is_ok());
}
#[test]
fn test_validate_package_name_empty() {
assert!(validate_package_name("").is_err());
}
#[test]
fn test_validate_package_name_too_long() {
let long_name = "a".repeat(257);
assert!(validate_package_name(&long_name).is_err());
// Exactly 256 chars should be fine
let max_name = "a".repeat(256);
assert!(validate_package_name(&max_name).is_ok());
}
#[test]
fn test_validate_package_name_leading_hyphen() {
// Leading hyphen could be interpreted as a command-line option
assert!(validate_package_name("-evil").is_err());
assert!(validate_package_name("--allow-unauthenticated").is_err());
}
#[test]
fn test_validate_package_name_shell_metacharacters() {
// Shell metacharacters that could enable injection
assert!(validate_package_name("pkg;rm -rf").is_err());
assert!(validate_package_name("pkg$(cmd)").is_err());
assert!(validate_package_name("pkg`cmd`").is_err());
assert!(validate_package_name("pkg|other").is_err());
assert!(validate_package_name("pkg&other").is_err());
assert!(validate_package_name("pkg>file").is_err());
assert!(validate_package_name("pkg<file").is_err());
assert!(validate_package_name("pkg'other").is_err());
assert!(validate_package_name("pkg\"other").is_err());
assert!(validate_package_name("pkg!other").is_err());
}
#[test]
fn test_validate_package_name_path_separators() {
assert!(validate_package_name("/usr/bin/evil").is_err());
assert!(validate_package_name("..\\..\\evil").is_err());
assert!(validate_package_name("../evil").is_err());
}
#[test]
fn test_validate_package_name_whitespace() {
assert!(validate_package_name("pkg name").is_err());
assert!(validate_package_name("pkg\tname").is_err());
assert!(validate_package_name("pkg\nname").is_err());
}
#[test]
fn test_validate_package_name_leading_digit() {
// Digits are valid start characters
assert!(validate_package_name("3ddesktop").is_ok());
assert!(validate_package_name("0ad").is_ok());
}
#[test]
fn test_validate_version_string_valid() {
assert!(validate_version_string("1.2.3").is_ok());
assert!(validate_version_string("1.2.3-r0").is_ok());
assert!(validate_version_string("5.2.21-1.fc43").is_ok());
assert!(validate_version_string("2:1.0-1").is_ok()); // RPM epoch
assert!(validate_version_string("1.0~beta1").is_ok()); // Debian tilde
assert!(validate_version_string("1.0+dfsg-1").is_ok()); // Debian suffix
}
#[test]
fn test_validate_version_string_empty() {
assert!(validate_version_string("").is_err());
}
#[test]
fn test_validate_version_string_leading_hyphen() {
assert!(validate_version_string("-1.0").is_err());
}
#[test]
fn test_validate_version_string_shell_metacharacters() {
assert!(validate_version_string("1.0;cmd").is_err());
assert!(validate_version_string("1.0$(cmd)").is_err());
assert!(validate_version_string("1.0`cmd`").is_err());
assert!(validate_version_string("1.0|cmd").is_err());
assert!(validate_version_string("1.0&cmd").is_err());
assert!(validate_version_string("1.0 cmd").is_err());
}
#[test]
fn test_validate_version_string_path_separators() {
assert!(validate_version_string("1.0/evil").is_err());
assert!(validate_version_string("1.0\\evil").is_err());
}
#[test]
fn test_validate_service_name_valid() {
assert!(validate_service_name("sshd").is_ok());
assert!(validate_service_name("nginx.service").is_ok());
assert!(validate_service_name("ssh@host").is_ok());
assert!(validate_service_name("docker").is_ok());
assert!(validate_service_name("networkd-dispatcher").is_ok());
}
#[test]
fn test_validate_service_name_empty() {
assert!(validate_service_name("").is_err());
}
#[test]
fn test_validate_service_name_leading_hyphen() {
assert!(validate_service_name("-evil").is_err());
assert!(validate_service_name("--help").is_err());
}
#[test]
fn test_validate_service_name_path_separators() {
assert!(validate_service_name("/usr/bin/evil").is_err());
assert!(validate_service_name("../evil").is_err());
}
#[test]
fn test_validate_service_name_shell_metacharacters() {
assert!(validate_service_name("svc;rm").is_err());
assert!(validate_service_name("svc$(cmd)").is_err());
assert!(validate_service_name("svc|other").is_err());
assert!(validate_service_name("svc&other").is_err());
assert!(validate_service_name("svc name").is_err());
}
#[test]
fn test_validate_service_name_with_hyphen() {
// Hyphens ARE allowed in service names (common in systemd unit names like networkd-dispatcher)
assert!(validate_service_name("networkd-dispatcher").is_ok());
// But leading hyphens are NOT allowed (would be interpreted as command flags)
assert!(validate_service_name("-evil").is_err());
}
#[test]
fn test_validate_service_name_with_plus() {
// Plus is allowed in service names
assert!(validate_service_name("cups+daemon").is_ok());
}
} }

24
tests/e2e/certs/README.md Normal file
View File

@ -0,0 +1,24 @@
# E2E Test Certificates
**⚠️ Private keys are NOT committed to version control.**
This directory holds mTLS certificates used by the Python E2E test suite.
The `client.key` private key is excluded from git via `.gitignore`.
## Generating Test Certificates
Certificates are generated automatically by the E2E test runner, or manually:
```bash
./scripts/generate-dev-certs.sh
```
This script creates `ca.crt`, `client.crt`, and `client.key` in this directory.
## Files
| File | Committed | Description |
|------|-----------|-------------|
| `ca.crt` | ✅ Yes | CA certificate (public) |
| `client.crt` | ✅ Yes | Client certificate (public) |
| `client.key` | ❌ No | Client private key (gitignored) |

View File

@ -1,5 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg5dkQDY44tZkcnQ6M
lGDNFyFrEvcOlnDoKfA/uTvBCtehRANCAAT8X1WUWE52l/i2I3MmlSiPgrESEJ2R
I6CJvV2hHKirY+wJbanH39b1ebW8b+W3fuhEHPaFFcpPFEnPriA+xWvT
-----END PRIVATE KEY-----

View File

@ -16,6 +16,7 @@ Usage:
import argparse import argparse
import json import json
import subprocess
import sys import sys
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -37,6 +38,19 @@ CA_CERT = CERTS_DIR / "ca.crt"
CLIENT_CERT = CERTS_DIR / "client.crt" CLIENT_CERT = CERTS_DIR / "client.crt"
CLIENT_KEY = CERTS_DIR / "client.key" CLIENT_KEY = CERTS_DIR / "client.key"
def ensure_certs() -> None:
"""Generate e2e test certificates at runtime if they do not exist."""
if CLIENT_KEY.exists() and CLIENT_CERT.exists() and CA_CERT.exists():
return
script = Path(__file__).resolve().parent.parent.parent / "scripts" / "generate-dev-certs.sh"
if not script.exists():
raise FileNotFoundError(
f"Certificate generation script not found: {script}. "
"Run ./scripts/generate-dev-certs.sh manually."
)
subprocess.check_call([str(script)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
TARGETS = { TARGETS = {
"dev": { "dev": {
"name": "linux-patch-manager-dev", "name": "linux-patch-manager-dev",
@ -537,14 +551,51 @@ def test_wrong_cert_connection(client: PatchAPIClient) -> str:
"""Verify that connections with wrong cert are rejected. """Verify that connections with wrong cert are rejected.
Per spec: invalid/expired certificates should be silently dropped. Per spec: invalid/expired certificates should be silently dropped.
Uses project test certs (different CA) which should be rejected. Generates a separate "wrong CA" certificate at runtime to test that
certificates signed by an untrusted CA are rejected.
""" """
project_ca = "/a0/usr/projects/linux_patch_api/configs/certs/ca.pem" import tempfile
project_cert = "/a0/usr/projects/linux_patch_api/configs/certs/client001.pem" wrong_ca_dir = Path(tempfile.mkdtemp(prefix="lpa-wrong-ca-"))
project_key = "/a0/usr/projects/linux_patch_api/configs/certs/client001.key.pem" try:
# Generate a completely separate CA + client cert (wrong CA)
wrong_ca_key = wrong_ca_dir / "ca.key"
wrong_ca_cert = wrong_ca_dir / "ca.crt"
wrong_client_key = wrong_ca_dir / "client.key"
wrong_client_csr = wrong_ca_dir / "client.csr"
wrong_client_cert = wrong_ca_dir / "client.crt"
if not Path(project_ca).exists(): subprocess.run(
return "SKIPPED: Project test certs not available" ["openssl", "genrsa", "-out", str(wrong_ca_key), "2048"],
check=True, capture_output=True,
)
subprocess.run(
["openssl", "req", "-x509", "-new", "-nodes", "-key", str(wrong_ca_key),
"-sha256", "-days", "1", "-out", str(wrong_ca_cert),
"-subj", "/CN=Wrong CA/O=Attacker/C=US"],
check=True, capture_output=True,
)
subprocess.run(
["openssl", "genrsa", "-out", str(wrong_client_key), "2048"],
check=True, capture_output=True,
)
subprocess.run(
["openssl", "req", "-new", "-key", str(wrong_client_key),
"-out", str(wrong_client_csr),
"-subj", "/CN=wrong-client/O=Attacker/C=US"],
check=True, capture_output=True,
)
subprocess.run(
["openssl", "x509", "-req", "-in", str(wrong_client_csr),
"-CA", str(wrong_ca_cert), "-CAkey", str(wrong_ca_key),
"-CAcreateserial", "-out", str(wrong_client_cert),
"-days", "1", "-sha256"],
check=True, capture_output=True,
)
project_cert = str(wrong_client_cert)
project_key = str(wrong_client_key)
except (subprocess.CalledProcessError, FileNotFoundError):
return "SKIPPED: Could not generate wrong-CA test certificates"
session = requests.Session() session = requests.Session()
session.cert = (project_cert, project_key) session.cert = (project_cert, project_key)
@ -559,6 +610,8 @@ def test_wrong_cert_connection(client: PatchAPIClient) -> str:
return "Correctly rejected connection with untrusted client certificate" return "Correctly rejected connection with untrusted client certificate"
finally: finally:
session.close() session.close()
import shutil
shutil.rmtree(wrong_ca_dir, ignore_errors=True)
def test_job_lifecycle(client: PatchAPIClient) -> str: def test_job_lifecycle(client: PatchAPIClient) -> str:
@ -776,7 +829,10 @@ def main():
) )
args = parser.parse_args() args = parser.parse_args()
# Verify certs exist # Generate certs at runtime if missing (private keys are not committed)
ensure_certs()
# Verify certs exist after generation attempt
if not CA_CERT.exists(): if not CA_CERT.exists():
print(f"ERROR: CA cert not found: {CA_CERT}") print(f"ERROR: CA cert not found: {CA_CERT}")
sys.exit(1) sys.exit(1)

View File

@ -77,7 +77,6 @@ fn build_tls_config(cert_dir: &std::path::Path) -> TlsConfig {
.join("server.key.pem") .join("server.key.pem")
.to_string_lossy() .to_string_lossy()
.to_string(), .to_string(),
min_tls_version: "1.3".to_string(),
crl_path: String::new(), // No CRL in E2E tests crl_path: String::new(), // No CRL in E2E tests
} }
} }

View File

@ -15,13 +15,17 @@ mod mtls_tests {
ca_cert_path: "/etc/linux_patch_api/certs/ca.pem".to_string(), ca_cert_path: "/etc/linux_patch_api/certs/ca.pem".to_string(),
server_cert_path: "/etc/linux_patch_api/certs/server.pem".to_string(), server_cert_path: "/etc/linux_patch_api/certs/server.pem".to_string(),
server_key_path: "/etc/linux_patch_api/certs/server.key".to_string(), server_key_path: "/etc/linux_patch_api/certs/server.key".to_string(),
min_tls_version: "1.3".to_string(),
}; };
assert_eq!(config.ca_cert_path, "/etc/linux_patch_api/certs/ca.pem"); assert_eq!(config.ca_cert_path, "/etc/linux_patch_api/certs/ca.pem");
assert_eq!(config.server_cert_path, "/etc/linux_patch_api/certs/server.pem"); assert_eq!(
assert_eq!(config.server_key_path, "/etc/linux_patch_api/certs/server.key"); config.server_cert_path,
assert_eq!(config.min_tls_version, "1.3"); "/etc/linux_patch_api/certs/server.pem"
);
assert_eq!(
config.server_key_path,
"/etc/linux_patch_api/certs/server.key"
);
} }
#[test] #[test]
@ -232,9 +236,61 @@ mod auth_result_tests {
assert!(result.is_authenticated()); assert!(result.is_authenticated());
assert!(result.cert_info.is_some()); assert!(result.cert_info.is_some());
assert_eq!( assert_eq!(result.cert_info.unwrap().subject, "CN=client001");
result.cert_info.unwrap().subject, }
"CN=client001" }
);
/// Integration tests for SecurityHeadersMiddleware (VULN-006)
#[cfg(test)]
mod security_headers_tests {
use actix_web::http::header;
use linux_patch_api::auth::security_headers::has_duplicate_critical_headers;
#[test]
fn test_no_duplicate_headers_passes() {
let mut headers = header::HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
headers.insert(header::AUTHORIZATION, "Bearer test".parse().unwrap());
headers.insert(header::HOST, "localhost".parse().unwrap());
assert!(!has_duplicate_critical_headers(&headers));
}
#[test]
fn test_duplicate_content_type_detected() {
let mut headers = header::HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
headers.append(header::CONTENT_TYPE, "text/plain".parse().unwrap());
assert!(has_duplicate_critical_headers(&headers));
}
#[test]
fn test_duplicate_authorization_detected() {
let mut headers = header::HeaderMap::new();
headers.insert(header::AUTHORIZATION, "Bearer test1".parse().unwrap());
headers.append(header::AUTHORIZATION, "Bearer test2".parse().unwrap());
assert!(has_duplicate_critical_headers(&headers));
}
#[test]
fn test_duplicate_host_detected() {
let mut headers = header::HeaderMap::new();
headers.insert(header::HOST, "localhost".parse().unwrap());
headers.append(header::HOST, "evil.com".parse().unwrap());
assert!(has_duplicate_critical_headers(&headers));
}
#[test]
fn test_non_critical_duplicates_allowed() {
// Duplicate Accept headers should be fine
let mut headers = header::HeaderMap::new();
headers.insert(header::ACCEPT, "text/html".parse().unwrap());
headers.append(header::ACCEPT, "application/json".parse().unwrap());
assert!(!has_duplicate_critical_headers(&headers));
}
#[test]
fn test_empty_headers_passes() {
let headers = header::HeaderMap::new();
assert!(!has_duplicate_critical_headers(&headers));
} }
} }

View File

@ -0,0 +1,340 @@
//! Rate Limiting and Job Queue Depth Tests
//!
//! Tests for:
//! - HTTP rate limiting (429 when exceeded)
//! - Health endpoint exemption from rate limiting
//! - Job queue depth cap (429 when full)
//! - Configurable queue depth
use actix_web::{test, web, App};
use linux_patch_api::api::rate_limit::RateLimitMiddleware;
use linux_patch_api::api::routes::{configure_api_routes, configure_health_route};
use linux_patch_api::auth::crl;
use linux_patch_api::config::loader::RateLimitConfig;
use linux_patch_api::jobs::manager::{JobManager, JobOperation};
use linux_patch_api::packages::cache::PackageCacheState;
use std::net::SocketAddr;
/// Helper to build a test request with a peer IP address (required for rate limiting)
fn test_request(method: actix_web::http::Method, uri: &str) -> test::TestRequest {
test::TestRequest::with_uri(uri)
.method(method)
.peer_addr(SocketAddr::from(([127, 0, 0, 1], 12345)))
}
#[actix_web::test]
async fn test_health_endpoint_exempt_from_rate_limiting() {
let job_manager = web::Data::new(JobManager::new(5, 30, 100).unwrap());
let backend = web::Data::new(linux_patch_api::packages::create_backend().unwrap());
let cache_state = web::Data::new(PackageCacheState::new());
let shared_crl_state = web::Data::new(crl::new_shared_state());
let rl_cfg = RateLimitConfig::default();
let app = test::init_service(
App::new()
.wrap(RateLimitMiddleware::new(rl_cfg))
.app_data(job_manager.clone())
.app_data(backend.clone())
.app_data(cache_state.clone())
.app_data(shared_crl_state.clone())
.configure(|cfg| {
configure_api_routes(
cfg,
job_manager.clone(),
backend.clone(),
cache_state.clone(),
);
})
.configure(configure_health_route),
)
.await;
// Health endpoint should always respond 200 regardless of rate limiting
for _ in 0..50 {
let req = test_request(actix_web::http::Method::GET, "/health").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(
resp.status(),
200,
"Health endpoint should be exempt from rate limiting"
);
}
}
#[actix_web::test]
async fn test_system_info_exempt_from_rate_limiting() {
let job_manager = web::Data::new(JobManager::new(5, 30, 100).unwrap());
let backend = web::Data::new(linux_patch_api::packages::create_backend().unwrap());
let cache_state = web::Data::new(PackageCacheState::new());
let shared_crl_state = web::Data::new(crl::new_shared_state());
let rl_cfg = RateLimitConfig::default();
let app = test::init_service(
App::new()
.wrap(RateLimitMiddleware::new(rl_cfg))
.app_data(job_manager.clone())
.app_data(backend.clone())
.app_data(cache_state.clone())
.app_data(shared_crl_state.clone())
.configure(|cfg| {
configure_api_routes(
cfg,
job_manager.clone(),
backend.clone(),
cache_state.clone(),
);
})
.configure(configure_health_route),
)
.await;
// /api/v1/system/info should be exempt from rate limiting
for _ in 0..50 {
let req = test_request(actix_web::http::Method::GET, "/api/v1/system/info").to_request();
let resp = test::call_service(&app, req).await;
// May return 200 or 500 depending on system, but should NOT be 429
assert_ne!(
resp.status(),
429,
"System info endpoint should be exempt from rate limiting"
);
}
}
#[actix_web::test]
async fn test_read_rate_limiting_returns_429() {
let job_manager = web::Data::new(JobManager::new(5, 30, 100).unwrap());
let backend = web::Data::new(linux_patch_api::packages::create_backend().unwrap());
let cache_state = web::Data::new(PackageCacheState::new());
let shared_crl_state = web::Data::new(crl::new_shared_state());
// Use very low limits so sequential test requests can reliably trigger 429
let rl_cfg = RateLimitConfig {
enabled: true,
destructive_per_minute: 20,
destructive_burst: 10,
read_per_minute: 5,
read_burst: 3,
};
let app = test::init_service(
App::new()
.wrap(RateLimitMiddleware::new(rl_cfg))
.app_data(job_manager.clone())
.app_data(backend.clone())
.app_data(cache_state.clone())
.app_data(shared_crl_state.clone())
.configure(|cfg| {
configure_api_routes(
cfg,
job_manager.clone(),
backend.clone(),
cache_state.clone(),
);
})
.configure(configure_health_route),
)
.await;
// Read tier: 120 req/min, burst 30
// Send more than burst_size requests to trigger rate limiting
let mut rate_limited = false;
for _ in 0..50 {
let req = test_request(actix_web::http::Method::GET, "/api/v1/packages").to_request();
let resp = test::call_service(&app, req).await;
if resp.status() == 429 {
rate_limited = true;
break;
}
}
assert!(
rate_limited,
"Read endpoint should return 429 after exceeding burst limit"
);
}
#[actix_web::test]
async fn test_destructive_rate_limiting_returns_429() {
let job_manager = web::Data::new(JobManager::new(5, 30, 100).unwrap());
let backend = web::Data::new(linux_patch_api::packages::create_backend().unwrap());
let cache_state = web::Data::new(PackageCacheState::new());
let shared_crl_state = web::Data::new(crl::new_shared_state());
// Use very low limits so sequential test requests can reliably trigger 429
let rl_cfg = RateLimitConfig {
enabled: true,
destructive_per_minute: 5,
destructive_burst: 3,
read_per_minute: 120,
read_burst: 30,
};
let app = test::init_service(
App::new()
.wrap(RateLimitMiddleware::new(rl_cfg))
.app_data(job_manager.clone())
.app_data(backend.clone())
.app_data(cache_state.clone())
.app_data(shared_crl_state.clone())
.configure(|cfg| {
configure_api_routes(
cfg,
job_manager.clone(),
backend.clone(),
cache_state.clone(),
);
})
.configure(configure_health_route),
)
.await;
// Destructive tier: 20 req/min, burst 10
// Send more than burst_size requests to trigger rate limiting
let mut rate_limited = false;
for _ in 0..15 {
let req = test_request(actix_web::http::Method::POST, "/api/v1/packages")
.set_json(serde_json::json!({
"packages": [{"name": "test-pkg"}],
"options": {}
}))
.to_request();
let resp = test::call_service(&app, req).await;
if resp.status() == 429 {
rate_limited = true;
break;
}
}
assert!(
rate_limited,
"Destructive endpoint should return 429 after exceeding burst limit"
);
}
#[actix_web::test]
async fn test_rate_limiting_disabled() {
let job_manager = web::Data::new(JobManager::new(5, 30, 100).unwrap());
let backend = web::Data::new(linux_patch_api::packages::create_backend().unwrap());
let cache_state = web::Data::new(PackageCacheState::new());
let shared_crl_state = web::Data::new(crl::new_shared_state());
let rl_cfg = RateLimitConfig {
enabled: false,
..RateLimitConfig::default()
};
let app = test::init_service(
App::new()
.wrap(RateLimitMiddleware::new(rl_cfg))
.app_data(job_manager.clone())
.app_data(backend.clone())
.app_data(cache_state.clone())
.app_data(shared_crl_state.clone())
.configure(|cfg| {
configure_api_routes(
cfg,
job_manager.clone(),
backend.clone(),
cache_state.clone(),
);
})
.configure(configure_health_route),
)
.await;
// With rate limiting disabled, even excessive requests should not get 429
let mut got_429 = false;
for _ in 0..50 {
let req = test_request(actix_web::http::Method::GET, "/api/v1/packages").to_request();
let resp = test::call_service(&app, req).await;
if resp.status() == 429 {
got_429 = true;
break;
}
}
assert!(
!got_429,
"Should not get 429 when rate limiting is disabled"
);
}
#[actix_web::test]
async fn test_job_queue_depth_cap() {
// Create JobManager with very small queue depth (2)
let job_manager = JobManager::new(5, 30, 2).unwrap();
// Fill the queue with pending jobs
job_manager
.create_job(JobOperation::Install, vec!["pkg1".to_string()])
.await
.unwrap();
job_manager
.create_job(JobOperation::Install, vec!["pkg2".to_string()])
.await
.unwrap();
// Queue should now be at capacity
assert!(
!job_manager.can_accept_job().await,
"Queue should be at capacity after filling to max_queue_depth"
);
}
#[actix_web::test]
async fn test_job_queue_depth_default() {
// Default max_queue_depth should be 100
let job_manager = JobManager::new(5, 30, 100).unwrap();
assert_eq!(
job_manager.max_queue_depth(),
100,
"Default max_queue_depth should be 100"
);
}
#[actix_web::test]
async fn test_job_queue_depth_configurable() {
// Verify queue depth is configurable
let job_manager = JobManager::new(5, 30, 50).unwrap();
assert_eq!(
job_manager.max_queue_depth(),
50,
"max_queue_depth should be configurable"
);
}
#[actix_web::test]
async fn test_can_accept_job_respects_queue_depth() {
let job_manager = JobManager::new(5, 30, 3).unwrap();
// Should accept when queue is empty
assert!(
job_manager.can_accept_job().await,
"Should accept job when queue is empty"
);
// Fill to capacity
job_manager
.create_job(JobOperation::Install, vec!["a".to_string()])
.await
.unwrap();
job_manager
.create_job(JobOperation::Install, vec!["b".to_string()])
.await
.unwrap();
job_manager
.create_job(JobOperation::Install, vec!["c".to_string()])
.await
.unwrap();
// Should reject when at capacity
assert!(
!job_manager.can_accept_job().await,
"Should reject job when queue is at capacity"
);
}
#[actix_web::test]
async fn test_rate_limit_config_defaults() {
let config = RateLimitConfig::default();
assert!(config.enabled, "Rate limiting should be enabled by default");
assert_eq!(config.destructive_per_minute, 20);
assert_eq!(config.destructive_burst, 10);
assert_eq!(config.read_per_minute, 120);
assert_eq!(config.read_burst, 30);
}