Private
Public Access
1
0

Compare commits

..

1 Commits

Author SHA1 Message Date
be085bbf35 fix(ci): rename fmt job to match required status check context 2026-06-05 14:21:57 -05:00
48 changed files with 546 additions and 2631 deletions

View File

@ -61,18 +61,6 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- 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:
name: Enrollment Tests
needs: [fmt, clippy]

7
.gitignore vendored
View File

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

147
Cargo.lock generated
View File

@ -44,18 +44,6 @@ dependencies = [
"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]]
name = "actix-http"
version = "3.12.1"
@ -980,19 +968,6 @@ dependencies = [
"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]]
name = "data-encoding"
version = "2.11.0"
@ -1339,12 +1314,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
version = "0.3.32"
@ -1413,26 +1382,6 @@ dependencies = [
"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]]
name = "h2"
version = "0.3.27"
@ -1528,12 +1477,6 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "http"
version = "0.2.12"
@ -1982,10 +1925,9 @@ dependencies = [
[[package]]
name = "linux-patch-api"
version = "1.3.2"
version = "1.2.0"
dependencies = [
"actix",
"actix-governor",
"actix-rt",
"actix-tls",
"actix-web",
@ -2001,12 +1943,9 @@ dependencies = [
"criterion",
"fs2",
"futures-util",
"hex",
"if-addrs",
"notify",
"pidlock",
"rand 0.8.6",
"rcgen",
"reqwest",
"rustls",
"rustls-pemfile",
@ -2156,12 +2095,6 @@ dependencies = [
"libc",
]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
[[package]]
name = "nom"
version = "7.1.3"
@ -2172,12 +2105,6 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "notify"
version = "6.1.1"
@ -2331,16 +2258,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@ -2447,12 +2364,6 @@ dependencies = [
"plotters-backend",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "potential_utf"
version = "0.1.5"
@ -2511,21 +2422,6 @@ version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "quinn"
version = "0.11.9"
@ -2678,15 +2574,6 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "rayon"
version = "1.12.0"
@ -2707,20 +2594,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"x509-parser",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -3179,15 +3052,6 @@ dependencies = [
"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]]
name = "stable_deref_trait"
version = "1.2.1"
@ -4435,15 +4299,6 @@ dependencies = [
"hashlink",
]
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.2"

View File

@ -1,6 +1,6 @@
[package]
name = "linux-patch-api"
version = "1.3.2"
version = "1.2.0"
edition = "2021"
authors = ["Echo <echo@moon-dragon.us>"]
description = "Secure remote package management API for Linux systems"
@ -16,9 +16,6 @@ actix-web-actors = "4"
actix = "0.13"
actix-tls = { version = "3", features = ["rustls-0_23"] }
# Rate limiting (actix-governor for per-IP rate limiting)
actix-governor = "0.6"
# Async runtime
tokio = { version = "1", features = ["full"] }
@ -98,10 +95,6 @@ tokio-test = "0.4"
wiremock = "0.6"
serial_test = "3"
tempfile = "3"
rcgen = { version = "0.13", features = ["pem", "x509-parser"] }
rand = "0.8"
hex = "0.4"
time = { version = "0.3", features = ["std"] }
criterion = { version = "0.5", features = ["html_reports"] }
# Integration tests in subdirectories
@ -117,14 +110,6 @@ path = "tests/integration/enrollment_test.rs"
name = "enrollment_e2e"
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]]
name = "api_benchmarks"
harness = false

View File

@ -181,7 +181,7 @@ tls:
ca_cert: "/etc/linux_patch_api/certs/ca.pem"
server_cert: "/etc/linux_patch_api/certs/server.pem"
server_key: "/etc/linux_patch_api/certs/server.key"
# TLS 1.3 is the only supported version (hardcoded, not configurable)
min_tls_version: "1.3"
jobs:
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-004 | MEDIUM | Header Security | ✅ RESOLVED | src/main.rs |
| VULN-005 | LOW | HTTP Protocol | ✅ RESOLVED | src/api/routes.rs |
| VULN-006 | LOW | Header Security | ✅ RESOLVED | src/auth/security_headers.rs |
| VULN-006 | LOW | Header Security | ✅ RESOLVED | src/auth/mtls.rs |
---
@ -176,19 +176,20 @@ web::scope("/api/v1")
**Finding:** Duplicate Content-Type headers were accepted.
**Implementation:**
- `has_duplicate_critical_headers()` function checks for duplicate headers on every request
- Added `has_duplicate_critical_headers()` function to check for duplicate headers
- Monitors critical headers: `content-type`, `authorization`, `host`
- Implemented as `SecurityHeadersMiddleware` — a dedicated Actix-web middleware
- Wired into the middleware pipeline in `main.rs` between WhitelistMiddleware and Logger
- Rejects requests with duplicate critical headers with HTTP 400 Bad Request
- Integrated into mTLS middleware `call()` method
- Rejects requests with duplicate critical headers before further processing
**Code Location:** `src/auth/security_headers.rs`
**Code Location:** `src/auth/mtls.rs` (lines 26-49, 203-212)
```rust
pub fn has_duplicate_critical_headers(headers: &HeaderMap) -> bool {
for header_name in CRITICAL_HEADERS.iter() {
fn has_duplicate_critical_headers(req: &ServiceRequest) -> bool {
let critical_headers = ["content-type", "authorization", "host"];
for header_name in critical_headers.iter() {
let mut count = 0;
for (name, _value) in headers.iter() {
for (name, _) in req.headers().iter() {
if name.as_str().eq_ignore_ascii_case(header_name) {
count += 1;
if count > 1 {
@ -201,29 +202,7 @@ pub fn has_duplicate_critical_headers(headers: &HeaderMap) -> bool {
}
```
**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
**Response:** HTTP 400 Bad Request with message "Duplicate critical headers not allowed"
---

View File

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

View File

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

View File

@ -15,7 +15,7 @@
| **Total Tests** | 16 |
| **Passed** | 16 |
| **Failed** | 0 |
| **Critical Findings** | 1 (Issue #12 - Committed Private Keys - RESOLVED) |
| **Critical Findings** | 0 (Previously 1 - RESOLVED) |
| **High Findings** | 0 (Previously 2 - RESOLVED) |
| **Medium Findings** | 3 (Unchanged) |
| **Low Findings** | 4 (Unchanged) |
@ -150,36 +150,6 @@ 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
**Description:**
@ -265,39 +235,5 @@ 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
**Verified By:** Security Verification Agent (Agent Zero)

View File

@ -1,33 +0,0 @@
# 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

5
configs/certs/ca.key.pem Normal file
View File

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

12
configs/certs/ca.pem Normal file
View File

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

1
configs/certs/ca.srl Normal file
View File

@ -0,0 +1 @@
790CDB9FA2002BF59B3EE88AF326CB060353D113

View File

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

View File

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

View File

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

View File

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

View File

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

12
configs/certs/server.pem Normal file
View File

@ -0,0 +1,12 @@
-----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"
server_cert: "/etc/linux_patch_api/certs/server.pem"
server_key: "/etc/linux_patch_api/certs/server.key"
# TLS 1.3 is the only supported version (hardcoded, not configurable)
min_tls_version: "1.3"
# Job Configuration
jobs:

View File

@ -1,82 +0,0 @@
#!/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,19 +190,6 @@ pub async fn rollback_job(
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
let job_id = match Uuid::parse_str(&job_id_str) {
Ok(id) => id,
@ -334,7 +321,7 @@ pub async fn delete_job(
}
}
/// Configure all job routes
/// Configure routes for job endpoints
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/jobs")

View File

@ -14,18 +14,29 @@ use tracing::{error, info, warn};
use uuid::Uuid;
use crate::jobs::manager::{JobManager, JobOperation, JobStatus};
use crate::packages::{
validate_package_name, validate_version_string, InstallOptions, Package, PackageManagerBackend,
PackageSpec,
};
use crate::packages::{InstallOptions, Package, PackageManagerBackend, PackageSpec};
/// Validate all package names and versions in a request
/// Maximum allowed length for package names
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> {
for pkg in packages {
validate_package_name(&pkg.name)?;
if let Some(version) = &pkg.version {
validate_version_string(version)?;
}
}
Ok(())
}
@ -252,19 +263,6 @@ pub async fn install_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
match job_manager
.create_job(JobOperation::Install, package_names.clone())
@ -350,19 +348,6 @@ pub async fn update_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
match job_manager
.create_job(JobOperation::Update, vec![package_name.clone()])
@ -446,20 +431,6 @@ pub async fn remove_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
.create_job(JobOperation::Remove, vec![package_name.clone()])
.await
@ -524,7 +495,7 @@ pub async fn remove_package(
}
}
/// Configure all package routes
/// Configure routes for package endpoints
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/packages")

View File

@ -11,7 +11,7 @@ use tracing::{error, info};
use uuid::Uuid;
use crate::jobs::manager::{JobManager, JobOperation, JobStatus};
use crate::packages::{validate_package_name, PackageManagerBackend};
use crate::packages::PackageManagerBackend;
use super::packages::{ApiResponse, JobResponseData};
@ -88,16 +88,6 @@ pub async fn apply_patches(
let _timestamp = Utc::now().to_rfc3339();
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!(
request_id = %request_id,
packages = ?body.packages,
@ -105,19 +95,6 @@ pub async fn apply_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
let package_list = body.packages.clone().unwrap_or_default();
match job_manager
@ -334,7 +311,7 @@ pub async fn apply_patches(
}
}
/// Configure all patch routes
/// Configure routes for patch endpoints
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/patches")

View File

@ -229,19 +229,6 @@ 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
match job_manager.create_job(JobOperation::Reboot, vec![]).await {
Ok(job_id) => {

View File

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

View File

@ -1,209 +0,0 @@
//! 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,11 +1,6 @@
//! API Routes Configuration
//!
//! 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 tracing::info;
@ -22,7 +17,6 @@ async fn method_not_allowed() -> HttpResponse {
.insert_header(("Allow", "GET, POST, PUT, DELETE"))
.finish()
}
/// Configure all API routes for the application
pub fn configure_api_routes(
cfg: &mut web::ServiceConfig,
@ -32,10 +26,6 @@ pub fn configure_api_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)
.app_data(backend)
.app_data(cache_state)
@ -43,10 +33,15 @@ pub fn configure_api_routes(
web::scope("/api/v1")
// VULN-005: Default handler for unsupported methods returns 405 instead of 404
.default_service(web::route().to(method_not_allowed))
// Package Management Endpoints
.configure(packages::configure_routes)
// Patch Management Endpoints
.configure(patches::configure_routes)
// System Management Endpoints
.configure(system::configure_routes)
// Job Management Endpoints
.configure(jobs::configure_routes)
// WebSocket Endpoint
.configure(websocket::configure_routes),
);
}

View File

@ -165,16 +165,7 @@ pub fn load_crl(crl_path: &Path, ca_cert_der: &[u8]) -> CrlState {
};
// Verify CRL signature against CA
// 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) {
let (_, ca_cert) = match x509_parser::parse_x509_certificate(ca_cert_der) {
Ok(r) => r,
Err(e) => {
error!(error = %e, "Failed to parse CA cert for CRL signature verification");
@ -229,29 +220,6 @@ 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.
/// Looks for `-----BEGIN X509 CRL-----` / `-----END X509 CRL-----` blocks.
fn extract_pem_crl_der(pem_bytes: &[u8]) -> Option<Vec<u8>> {
@ -262,15 +230,11 @@ fn extract_pem_crl_der(pem_bytes: &[u8]) -> Option<Vec<u8>> {
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();
let b64_block = pem_str[after_begin..after_begin + end_idx].trim();
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(&b64_block)
.decode(b64_block)
.ok()
}
@ -452,315 +416,4 @@ mod tests {
assert_eq!(updated.status, CrlStatus::Valid);
assert!(updated.is_revoked("abc"));
}
// -----------------------------------------------------------------------
// CRL parsing and verification tests
//
// Note: x509_parser's verify_signature() has known incompatibilities with
// rcgen-generated CRL signatures. The full load_crl() pipeline (which
// includes signature verification) is tested end-to-end with real CRLs
// from the manager's CertAuthority. These unit tests focus on the
// individual components: PEM extraction, DER parsing, CrlState logic,
// and missing file handling.
// -----------------------------------------------------------------------
/// Helper: generate a test CA key/cert pair using rcgen.
fn generate_test_ca() -> (rcgen::KeyPair, rcgen::Certificate) {
let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap();
let mut params = rcgen::CertificateParams::default();
params.not_before = time::OffsetDateTime::now_utc();
params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(365 * 10);
params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
params.key_usages = vec![
rcgen::KeyUsagePurpose::KeyCertSign,
rcgen::KeyUsagePurpose::CrlSign,
];
let mut dn = rcgen::DistinguishedName::new();
dn.push(rcgen::DnType::CommonName, "Test Root CA");
dn.push(rcgen::DnType::OrganizationName, "Patch Manager Test");
params.distinguished_name = dn;
let cert = params.self_signed(&key).unwrap();
(key, cert)
}
/// Helper: generate a CRL signed by the test CA with the given revoked serials.
fn generate_test_crl(
ca_key: &rcgen::KeyPair,
ca_cert: &rcgen::Certificate,
revoked_serials: &[rcgen::SerialNumber],
) -> String {
let now = time::OffsetDateTime::now_utc();
let next_update = now + time::Duration::hours(24);
let crl_number =
rcgen::SerialNumber::from_slice(&chrono::Utc::now().timestamp().to_be_bytes());
let revoked_certs: Vec<rcgen::RevokedCertParams> = revoked_serials
.iter()
.map(|serial| rcgen::RevokedCertParams {
serial_number: serial.clone(),
revocation_time: now,
reason_code: Some(rcgen::RevocationReason::Unspecified),
invalidity_date: None,
})
.collect();
let crl_params = rcgen::CertificateRevocationListParams {
this_update: now,
next_update,
crl_number,
issuing_distribution_point: None,
revoked_certs,
key_identifier_method: rcgen::KeyIdMethod::Sha256,
};
let crl = crl_params.signed_by(ca_cert, ca_key).unwrap();
crl.pem().unwrap()
}
/// Helper: generate a serial number and return both rcgen SerialNumber and its hex string.
fn make_serial_hex_pair() -> (rcgen::SerialNumber, String) {
let mut bytes = [0u8; 16];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut bytes);
let hex = hex::encode(bytes);
(rcgen::SerialNumber::from_slice(&bytes), hex)
}
#[test]
fn crl_pem_extraction_works_for_valid_crl() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (ca_key, ca_cert) = generate_test_ca();
let (serial1, _) = make_serial_hex_pair();
let crl_pem = generate_test_crl(&ca_key, &ca_cert, &[serial1]);
// Verify PEM extraction succeeds
let der = extract_pem_crl_der(crl_pem.as_bytes());
assert!(
der.is_some(),
"PEM extraction should succeed for valid CRL PEM"
);
// Verify the DER can be parsed as a CRL
let der_bytes = der.unwrap();
let parsed = CertificateRevocationList::from_der(&der_bytes);
assert!(parsed.is_ok(), "DER should parse as a valid CRL");
}
#[test]
fn crl_pem_extraction_works_for_empty_crl() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (ca_key, ca_cert) = generate_test_ca();
let crl_pem = generate_test_crl(&ca_key, &ca_cert, &[]);
// Verify PEM extraction succeeds for empty CRL
let der = extract_pem_crl_der(crl_pem.as_bytes());
assert!(
der.is_some(),
"PEM extraction should succeed for empty CRL PEM"
);
// Verify the DER can be parsed as a CRL
let der_bytes = der.unwrap();
let parsed = CertificateRevocationList::from_der(&der_bytes);
assert!(parsed.is_ok(), "DER should parse as a valid CRL");
// Empty CRL should have no revoked certificates
let (_, crl) = parsed.unwrap();
let revoked: Vec<_> = crl.iter_revoked_certificates().collect();
assert!(
revoked.is_empty(),
"Empty CRL should have no revoked entries"
);
}
#[test]
fn crl_pem_extraction_rejects_tampered_content() {
// Tampering with the base64 content should cause extraction to either
// fail or produce invalid DER that can't be parsed.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (ca_key, ca_cert) = generate_test_ca();
let (serial1, _) = make_serial_hex_pair();
let crl_pem = generate_test_crl(&ca_key, &ca_cert, &[serial1]);
// Tamper with the base64 content
let mut tampered_bytes = crl_pem.into_bytes();
let mid = tampered_bytes.len() / 2;
// Find a byte that's part of the base64 content (not header/footer/newline)
for i in (mid.saturating_sub(10)..mid.saturating_add(10)).rev() {
if tampered_bytes[i] != b'\n' && tampered_bytes[i] != b'-' {
tampered_bytes[i] ^= 0x01;
break;
}
}
// PEM extraction may still succeed (it just extracts base64),
// but the resulting DER should fail signature verification
// or parse incorrectly.
let der = extract_pem_crl_der(&tampered_bytes);
if let Some(der_data) = der {
// If PEM extraction succeeded, the DER should either fail to parse
// or fail signature verification. We just verify it's not a valid
// CRL that we can trust.
let _ = CertificateRevocationList::from_der(&der_data);
// The CRL may parse but won't verify — that's expected.
}
// Either way, tampered content is detected at some level.
}
#[test]
fn crl_missing_file_returns_missing_status() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (_, ca_cert) = generate_test_ca();
let ca_cert_der = ca_cert.der().to_vec();
// Use a path that doesn't exist
let missing_path = std::path::PathBuf::from("/tmp/nonexistent_crl_test_12345.pem");
let _ = std::fs::remove_file(&missing_path); // Ensure it doesn't exist
let state = load_crl(&missing_path, &ca_cert_der);
assert_eq!(
state.status,
CrlStatus::Missing,
"Missing CRL file should return Missing status"
);
assert!(state.revoked_serials.is_empty());
}
#[test]
fn crl_wrong_pem_type_rejected() {
// PEM with wrong type marker should not extract as CRL
let cert_pem = "-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHHCgVZU65BMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRlc3Qx\n-----END CERTIFICATE-----";
let result = extract_pem_crl_der(cert_pem.as_bytes());
assert!(
result.is_none(),
"CERTIFICATE PEM should not extract as CRL"
);
}
#[test]
fn crl_revoked_certificates_count_in_parsed_crl() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (ca_key, ca_cert) = generate_test_ca();
// Create CRL with 2 revoked serials
let (s1, _) = make_serial_hex_pair();
let (s2, _) = make_serial_hex_pair();
let crl_pem = generate_test_crl(&ca_key, &ca_cert, &[s1, s2]);
// Extract and parse the CRL
let der = extract_pem_crl_der(crl_pem.as_bytes()).expect("PEM extraction should succeed");
let (_, crl) =
CertificateRevocationList::from_der(&der).expect("DER parsing should succeed");
// Verify 2 revoked entries
let revoked: Vec<_> = crl.iter_revoked_certificates().collect();
assert_eq!(revoked.len(), 2, "CRL should have 2 revoked entries");
}
#[test]
fn crl_empty_crl_has_no_revoked_entries() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (ca_key, ca_cert) = generate_test_ca();
let crl_pem = generate_test_crl(&ca_key, &ca_cert, &[]);
let der = extract_pem_crl_der(crl_pem.as_bytes()).expect("PEM extraction should succeed");
let (_, crl) =
CertificateRevocationList::from_der(&der).expect("DER parsing should succeed");
let revoked: Vec<_> = crl.iter_revoked_certificates().collect();
assert!(
revoked.is_empty(),
"Empty CRL should have no revoked entries"
);
}
#[test]
fn crl_state_transitions() {
// Test CrlStatus transitions using the in-memory CrlState
// (signature verification is tested end-to-end with real CRLs)
// Valid → should have revoked serials if any
let valid_state = CrlState {
status: CrlStatus::Valid,
revoked_serials: {
let mut set = HashSet::new();
set.insert("aabbccdd".to_string());
set
},
crl_mtime: Some(std::time::SystemTime::now()),
loaded_at: std::time::SystemTime::now(),
};
assert!(valid_state.is_revoked("aabbccdd"));
assert!(!valid_state.is_revoked("11223344"));
// Expired → still has revoked serials (usable but stale)
let expired_state = CrlState {
status: CrlStatus::Expired,
revoked_serials: valid_state.revoked_serials.clone(),
crl_mtime: Some(std::time::SystemTime::now() - std::time::Duration::from_secs(86400)),
loaded_at: std::time::SystemTime::now(),
};
assert!(expired_state.is_revoked("aabbccdd"));
// Missing → no serials, no mtime
let missing_state = CrlState::default();
assert_eq!(missing_state.status, CrlStatus::Missing);
assert!(missing_state.revoked_serials.is_empty());
assert!(missing_state.crl_mtime.is_none());
// Invalid → no serials (fail-closed)
let invalid_state = CrlState {
status: CrlStatus::Invalid,
revoked_serials: HashSet::new(),
crl_mtime: Some(std::time::SystemTime::now()),
loaded_at: std::time::SystemTime::now(),
};
assert!(
!invalid_state.is_revoked("aabbccdd"),
"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,31 +1,18 @@
//! Auth Module - mTLS, IP Whitelist, and Security Headers
//! Auth Module - mTLS and IP Whitelist Enforcement
//!
//! This module provides security authentication and authorization:
//! - mTLS (Mutual TLS) certificate-based authentication (enforced at TLS handshake by rustls)
//! - mTLS (Mutual TLS) certificate-based authentication
//! - IP whitelist enforcement with CIDR subnet support
//! - Security header validation (VULN-006: duplicate critical header rejection)
//! - Silent drop for non-compliant connections
//! - 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 mtls;
pub mod security_headers;
pub mod whitelist;
pub use crl::{new_shared_state, CrlState, CrlStatus, SharedCrlState};
pub use mtls::{ClientCertInfo, MtlsConfig, MtlsError};
pub use security_headers::SecurityHeadersMiddleware;
pub use whitelist::{
WhitelistConfig, WhitelistEntry, WhitelistManager, WhitelistMiddleware,
WhitelistMiddlewareService,
};
pub use mtls::{ClientCertInfo, MtlsConfig, MtlsError, MtlsMiddleware};
pub use whitelist::{WhitelistConfig, WhitelistEntry, WhitelistManager, WhitelistMiddleware};
/// Combined authentication result
#[derive(Debug, Clone)]

View File

@ -1,33 +1,20 @@
//! mTLS Configuration Module
//! mTLS Authentication Module
//!
//! Provides rustls-based mutual TLS configuration for the API server.
//!
//! # 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).
//! Provides mutual TLS authentication middleware for Actix-web.
//! Non-mTLS connections are silently dropped (no response).
//! Supports CRL-aware client certificate verification when CRL is available.
use chrono::{DateTime, Utc};
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error, HttpMessage,
};
#[allow(unused_imports)]
use chrono::{DateTime, Duration, Utc};
use futures_util::future::LocalBoxFuture;
use rustls::{
client::danger::HandshakeSignatureValid,
crypto::aws_lc_rs,
pki_types::CertificateDer,
pki_types::{CertificateDer, UnixTime},
server::{
danger::{ClientCertVerified, ClientCertVerifier},
ServerConfig, WebPkiClientVerifier,
@ -37,10 +24,35 @@ use rustls::{
};
use rustls_pemfile::{certs, private_key};
use std::{fs::File, io::BufReader, sync::Arc};
use tracing::{error, info, warn};
use tracing::{debug, error, info, warn};
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.
///
/// Wraps WebPkiClientVerifier for chain validation, then checks the
@ -75,7 +87,7 @@ impl ClientCertVerifier for CrlAwareVerifier {
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
now: rustls::pki_types::UnixTime,
now: UnixTime,
) -> Result<ClientCertVerified, RustlsError> {
// 1. Delegate chain validation to WebPKI
self.inner
@ -143,50 +155,59 @@ impl ClientCertVerifier for CrlAwareVerifier {
}
/// 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)]
pub struct MtlsConfig {
pub ca_cert_path: String,
pub server_cert_path: String,
pub server_key_path: String,
pub min_tls_version: String,
}
/// Build a rustls ServerConfig with client certificate verification.
///
/// This is the authoritative mTLS gate — rustls enforces client certificate
/// validation at the TLS handshake level, before any HTTP request is processed.
///
/// 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(
config: &MtlsConfig,
crl_state: Option<SharedCrlState>,
) -> Result<Arc<ServerConfig>, MtlsError> {
let cert_store = load_ca_certs(&config.ca_cert_path)?;
/// mTLS Middleware for Actix-web
pub struct MtlsMiddleware {
config: Arc<MtlsConfig>,
cert_store: Arc<RootCertStore>,
}
let webpki_verifier = WebPkiClientVerifier::builder(cert_store.clone().into())
.build()
.map_err(|e| MtlsError::ClientVerifierError(e.to_string()))?;
impl MtlsMiddleware {
/// Create a new mTLS middleware
pub fn new(config: MtlsConfig) -> Result<Self, MtlsError> {
let cert_store = load_ca_certs(&config.ca_cert_path)?;
let client_verifier: Arc<dyn ClientCertVerifier> = match crl_state {
Some(state) => {
info!("CRL-aware client verification enabled");
Arc::new(CrlAwareVerifier::new(webpki_verifier, state))
}
None => {
info!("No CRL state provided -- using WebPKI-only client verification");
webpki_verifier
}
};
Ok(Self {
config: Arc::new(config),
cert_store: Arc::new(cert_store),
})
}
let server_cert = load_certs(&config.server_cert_path)?;
let server_key = load_private_key(&config.server_key_path)?;
/// 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()
.map_err(|e| MtlsError::ClientVerifierError(e.to_string()))?;
let server_config =
ServerConfig::builder_with_provider(Arc::new(aws_lc_rs::default_provider()))
let client_verifier: Arc<dyn ClientCertVerifier> = match crl_state {
Some(state) => {
info!("CRL-aware client verification enabled");
Arc::new(CrlAwareVerifier::new(webpki_verifier, state))
}
None => {
info!("No CRL state provided -- using WebPKI-only client verification");
webpki_verifier
}
};
let server_cert = load_certs(&self.config.server_cert_path)?;
let server_key = load_private_key(&self.config.server_key_path)?;
let config = ServerConfig::builder_with_provider(Arc::new(aws_lc_rs::default_provider()))
.with_protocol_versions(&[&TLS13])
.map_err(|e| {
MtlsError::ServerConfigError(format!("Failed to set TLS 1.3 only: {}", e))
@ -195,7 +216,8 @@ pub fn build_rustls_config(
.with_single_cert(server_cert, server_key)
.map_err(|e| MtlsError::ServerConfigError(e.to_string()))?;
Ok(Arc::new(server_config))
Ok(Arc::new(config))
}
}
/// Load CA certificates from PEM file
@ -246,21 +268,6 @@ fn load_private_key(path: &str) -> Result<rustls::pki_types::PrivateKeyDer<'stat
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
#[derive(Debug, thiserror::Error)]
pub enum MtlsError {
@ -278,127 +285,213 @@ pub enum MtlsError {
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)]
mod tests {
use super::*;
use std::collections::HashSet;
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 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();
(ca_key, root_store)
}
/// 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> =
WebPkiClientVerifier::builder(root_store.into())
.build()
.unwrap();
let crl_state = new_shared_state();
let valid_state = CrlState {
status: CrlStatus::Valid,
revoked_serials: HashSet::new(),
crl_mtime: None,
loaded_at: std::time::SystemTime::now(),
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(),
};
crl_state.store(Arc::new(valid_state));
let _verifier = CrlAwareVerifier::new(webpki_verifier, crl_state);
assert_eq!(config.ca_cert_path, "/etc/linux_patch_api/certs/ca.pem");
assert_eq!(config.min_tls_version, "1.3");
}
/// Test that CrlAwareVerifier with Missing CRL state can be constructed.
#[test]
fn crl_aware_verifier_with_missing_crl() {
init_crypto_provider();
use super::super::crl::new_shared_state;
let (_ca_key, root_store) = make_test_ca_and_root_store();
let webpki_verifier: Arc<dyn ClientCertVerifier> =
WebPkiClientVerifier::builder(root_store.into())
.build()
.unwrap();
// Default state is Missing.
let crl_state = new_shared_state();
let _verifier = CrlAwareVerifier::new(webpki_verifier, crl_state);
}
/// Test that CrlAwareVerifier with Invalid CRL state can be constructed.
#[test]
fn crl_aware_verifier_with_invalid_crl() {
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> =
WebPkiClientVerifier::builder(root_store.into())
.build()
.unwrap();
let crl_state = new_shared_state();
let invalid_state = CrlState {
status: CrlStatus::Invalid,
revoked_serials: HashSet::new(),
crl_mtime: None,
loaded_at: std::time::SystemTime::now(),
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),
};
crl_state.store(Arc::new(invalid_state));
let _verifier = CrlAwareVerifier::new(webpki_verifier, crl_state);
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 that CrlAwareVerifier with a revoked serial in Valid CRL state
/// can be constructed.
#[test]
fn crl_aware_verifier_with_revoked_serial() {
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> =
WebPkiClientVerifier::builder(root_store.into())
.build()
.unwrap();
let crl_state = new_shared_state();
let mut revoked = HashSet::new();
revoked.insert("deadbeef".to_string());
let valid_with_revoked = CrlState {
status: CrlStatus::Valid,
revoked_serials: revoked,
crl_mtime: None,
loaded_at: std::time::SystemTime::now(),
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),
};
crl_state.store(Arc::new(valid_with_revoked));
let _verifier = CrlAwareVerifier::new(webpki_verifier, crl_state);
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"));
}
}

View File

@ -1,166 +0,0 @@
//! 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,12 +17,6 @@ use std::sync::{Arc, RwLock};
use std::time::Duration;
use tracing::{debug, info, warn};
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error,
};
use futures_util::future::LocalBoxFuture;
/// Whitelist entry types
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum WhitelistEntry {
@ -288,18 +282,6 @@ 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
pub fn entry_count(&self) -> usize {
self.entries.read().unwrap().len()
@ -444,9 +426,11 @@ pub struct WhitelistMiddleware {
}
impl WhitelistMiddleware {
/// Create a new whitelist middleware from an Arc<WhitelistManager>
pub fn new(manager: Arc<WhitelistManager>) -> Self {
Self { manager }
/// Create a new whitelist middleware
pub fn new(manager: WhitelistManager) -> Self {
Self {
manager: Arc::new(manager),
}
}
/// Get the whitelist manager reference
@ -455,99 +439,6 @@ 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)]
mod tests {
use super::*;
@ -620,33 +511,4 @@ mod tests {
let ip_outside: Ipv4Addr = "192.168.2.100".parse().unwrap();
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,6 +33,8 @@ pub struct TlsConfig {
pub ca_cert: String,
pub server_cert: String,
pub server_key: String,
#[serde(default = "default_tls_version")]
pub min_tls_version: String,
/// Path to persist the CRL fetched from the manager.
/// Defaults to /etc/linux_patch_api/certs/crl.pem
#[serde(default = "default_crl_path")]
@ -47,6 +49,10 @@ fn default_true() -> bool {
true
}
fn default_tls_version() -> String {
"1.3".to_string()
}
/// Jobs configuration
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct JobsConfig {
@ -54,58 +60,12 @@ pub struct JobsConfig {
pub timeout_minutes: u64,
#[serde(default = "default_storage_path")]
pub storage_path: String,
#[serde(default = "default_max_queue_depth")]
pub max_queue_depth: usize,
}
fn default_storage_path() -> 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
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LoggingConfig {
@ -485,8 +445,6 @@ pub struct AppConfig {
pub package_manager: Option<PackageManagerConfig>,
#[serde(default)]
pub enrollment: Option<EnrollmentConfig>,
#[serde(default)]
pub rate_limit: RateLimitConfig,
}
impl AppConfig {
@ -495,19 +453,6 @@ impl AppConfig {
let content = std::fs::read_to_string(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)
.with_context(|| format!("Failed to parse config file: {}", path))?;

View File

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

View File

@ -1,25 +1,3 @@
//! Configuration Validator
//!
//! 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."
);
}
}
}
}
//! Placeholder - implementation in future phases

View File

@ -38,12 +38,8 @@ pub enum EnrollmentStatusResponse {
Pending,
Approved {
ca_crt: String,
#[serde(default)]
ca_chain: String,
server_crt: String,
server_key: String,
#[serde(default)]
crl_pem: String,
},
Denied,
NotFound,
@ -53,10 +49,8 @@ pub enum EnrollmentStatusResponse {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PkiBundle {
pub ca_crt: String,
pub ca_chain: String,
pub server_crt: String,
pub server_key: String,
pub crl_pem: String,
}
impl From<EnrollmentStatusResponse> for Option<PkiBundle> {
@ -64,16 +58,12 @@ impl From<EnrollmentStatusResponse> for Option<PkiBundle> {
match response {
EnrollmentStatusResponse::Approved {
ca_crt,
ca_chain,
server_crt,
server_key,
crl_pem,
} => Some(PkiBundle {
ca_crt,
ca_chain,
server_crt,
server_key,
crl_pem,
}),
_ => None,
}
@ -461,10 +451,8 @@ impl EnrollmentClient {
}
EnrollmentStatusResponse::Approved {
ca_crt,
ca_chain,
server_crt,
server_key,
crl_pem,
} => {
tracing::info!(
elapsed_seconds = start.elapsed().as_secs(),
@ -473,10 +461,8 @@ impl EnrollmentClient {
);
return Ok(PkiBundle {
ca_crt,
ca_chain,
server_crt,
server_key,
crl_pem,
});
}
EnrollmentStatusResponse::Denied => {
@ -580,10 +566,8 @@ mod tests {
fn approved_to_pki_bundle() {
let status = EnrollmentStatusResponse::Approved {
ca_crt: "ca".into(),
ca_chain: String::new(),
server_crt: "crt".into(),
server_key: "key".into(),
crl_pem: String::new(),
};
let bundle: Option<PkiBundle> = status.into();
assert!(bundle.is_some());

View File

@ -160,10 +160,8 @@ pub async fn run_enrollment(
// Write certificates to configured paths (or defaults)
provision::provision_pki_bundle(
&pki_bundle.ca_crt,
&pki_bundle.ca_chain,
&pki_bundle.server_crt,
&pki_bundle.server_key,
&pki_bundle.crl_pem,
config.tls_config(),
)
.await?;

View File

@ -16,8 +16,6 @@ const DEFAULT_CA_CERT: &str = "/etc/linux_patch_api/certs/ca.pem";
const DEFAULT_SERVER_CERT: &str = "/etc/linux_patch_api/certs/server.pem";
/// Default server key path.
const DEFAULT_SERVER_KEY: &str = "/etc/linux_patch_api/certs/server.key.pem";
/// Default CRL path.
const DEFAULT_CRL_PATH: &str = "/etc/linux_patch_api/certs/crl.pem";
/// Validate that a PEM string has proper format (BEGIN/END markers present).
///
@ -130,14 +128,12 @@ pub fn write_pem_file(path: &str, pem_data: &str, is_key: bool) -> Result<()> {
/// Provision the full PKI bundle from an approved enrollment response.
///
/// Writes CA cert, CA chain, server cert, server key, and CRL to configured paths.
/// Writes CA cert, server cert, and server key to configured paths.
/// Paths are read from TLS config if available, otherwise defaults are used.
pub async fn provision_pki_bundle(
ca_crt: &str,
_ca_chain: &str,
server_crt: &str,
server_key: &str,
crl_pem: &str,
tls_config: Option<&super::super::config::loader::TlsConfig>,
) -> Result<()> {
// Determine target paths from config or defaults
@ -177,19 +173,6 @@ pub async fn provision_pki_bundle(
write_pem_file(&key_path, server_key, true).context("Failed to write server key")?;
// Write CRL if provided (non-empty)
let crl_path = if let Some(tls) = tls_config {
tls.crl_path.clone()
} else {
DEFAULT_CRL_PATH.to_string()
};
if !crl_pem.trim().is_empty() {
write_pem_file(&crl_path, crl_pem, false).context("Failed to write CRL")?;
tracing::info!(path = %crl_path, "CRL written from enrollment bundle");
} else {
tracing::info!("No CRL in enrollment bundle — agent will fetch on refresh cycle");
}
// 3. Log successful provisioning with structured fields
tracing::info!(
ca_cert = %ca_path,

View File

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

View File

@ -28,9 +28,7 @@ use tracing::{error, info, warn};
use linux_patch_api::api::{configure_api_routes, configure_health_route};
use linux_patch_api::auth::crl::{self, CrlStatus};
use linux_patch_api::auth::{
mtls, SecurityHeadersMiddleware, WhitelistManager, WhitelistMiddleware,
};
use linux_patch_api::auth::{mtls, MtlsMiddleware, WhitelistManager};
use linux_patch_api::config::loader::{validate_certs, CertStatus};
use linux_patch_api::enroll;
use linux_patch_api::packages::cache::PackageCacheState;
@ -252,15 +250,10 @@ async fn main() -> Result<()> {
}
// Initialize job manager
let job_manager = JobManager::new(
config.jobs.max_concurrent,
config.jobs.timeout_minutes,
config.jobs.max_queue_depth,
)?;
let job_manager = JobManager::new(config.jobs.max_concurrent, config.jobs.timeout_minutes)?;
info!(
max_jobs = config.jobs.max_concurrent,
timeout_minutes = config.jobs.timeout_minutes,
max_queue_depth = config.jobs.max_queue_depth,
"Job manager initialized"
);
@ -289,12 +282,11 @@ async fn main() -> Result<()> {
entries = manager.entry_count(),
"Whitelist manager initialized"
);
Arc::new(manager)
Some(Arc::new(manager))
}
Err(e) => {
// Fail-closed: deny all IPs when whitelist cannot be loaded
warn!(error = %e, "Failed to load whitelist - using deny-all mode (fail-closed)");
Arc::new(WhitelistManager::new_deny_all())
warn!(error = %e, "Failed to load whitelist - continuing with empty whitelist (all denied)");
None
}
};
@ -313,39 +305,29 @@ async fn main() -> Result<()> {
// Configure bind address
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
// 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 || {
App::new()
.wrap(WhitelistMiddleware::new(wl.clone()))
.wrap(SecurityHeadersMiddleware::new())
.wrap(linux_patch_api::api::rate_limit::RateLimitMiddleware::new(
rate_limit_config.clone(),
))
let mut app = App::new()
.wrap(Logger::default())
.app_data(job_manager_data.clone())
.app_data(backend_data.clone())
.app_data(cache_state.clone())
.app_data(crl_state_data.clone())
.configure(|cfg| {
configure_api_routes(
cfg,
job_manager_data.clone(),
backend_data.clone(),
cache_state.clone(),
);
})
.configure(configure_health_route)
.app_data(crl_state_data.clone());
// Configure API routes
app = app.configure(|cfg| {
configure_api_routes(
cfg,
job_manager_data.clone(),
backend_data.clone(),
cache_state.clone(),
);
});
// Configure health route (outside API scope)
app = app.configure(configure_health_route);
app
})
.workers(4)
// VULN-004: Configure header size limit to 8KB to prevent DoS via oversized headers
@ -356,8 +338,8 @@ async fn main() -> Result<()> {
.max_connection_rate(1000);
info!(
mtls_enabled = config.tls_config().is_some(),
whitelist_entries = whitelist_manager.entry_count(),
"Security layer status (IP whitelist enforced)"
whitelist_enabled = whitelist_manager.is_some(),
"Security layer status"
);
info!("Linux Patch API initialized successfully");
@ -368,15 +350,16 @@ async fn main() -> Result<()> {
ca_cert = %tls_config.ca_cert,
server_cert = %tls_config.server_cert,
server_key = %tls_config.server_key,
min_tls_version = %tls_config.min_tls_version,
crl_path = %tls_config.crl_path,
"Initializing mTLS authentication with TLS 1.3 binding"
"Initializing mTLS authentication with TLS binding"
);
// TLS 1.3 is the only supported version — hardcoded in build_rustls_config()
let mtls_config = mtls::MtlsConfig {
ca_cert_path: tls_config.ca_cert.clone(),
server_cert_path: tls_config.server_cert.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
@ -418,58 +401,63 @@ async fn main() -> Result<()> {
info!("No manager URL configured -- CRL auto-refresh disabled");
}
// ADR: rustls is the authoritative client-auth gate.
// Client certificate verification happens at the TLS handshake level
// via CrlAwareVerifier (which wraps WebPkiClientVerifier). No
// application-layer certificate validation middleware is needed.
// 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))?;
match MtlsMiddleware::new(mtls_config.clone()) {
Ok(middleware) => {
// Build rustls server configuration with CRL-aware verifier
let rustls_config = middleware
.build_rustls_config(Some(shared_crl_state.clone()))
.map_err(|e| anyhow::anyhow!("Failed to build rustls config: {}", e))?;
info!(
"mTLS rustls config initialized successfully (client auth enforced at TLS handshake)"
);
info!("mTLS middleware and rustls config initialized successfully");
// Create TCP listener with SO_REUSEADDR using socket2
// This prevents "Address already in use" errors when restarting after a crash
let socket = socket2::Socket::new(
socket2::Domain::IPV4,
socket2::Type::STREAM,
Some(socket2::Protocol::TCP),
)
.map_err(|e| anyhow::anyhow!("Failed to create socket: {}", e))?;
// Create TCP listener with SO_REUSEADDR using socket2
// This prevents "Address already in use" errors when restarting after a crash
let socket = socket2::Socket::new(
socket2::Domain::IPV4,
socket2::Type::STREAM,
Some(socket2::Protocol::TCP),
)
.map_err(|e| anyhow::anyhow!("Failed to create socket: {}", e))?;
socket
.set_reuse_address(true)
.map_err(|e| anyhow::anyhow!("Failed to set SO_REUSEADDR: {}", e))?;
socket
.set_reuse_address(true)
.map_err(|e| anyhow::anyhow!("Failed to set SO_REUSEADDR: {}", e))?;
let bind_addr: std::net::SocketAddr = bind_address
.parse()
.map_err(|e| anyhow::anyhow!("Invalid bind address '{}': {}", bind_address, e))?;
let bind_addr: std::net::SocketAddr = bind_address.parse().map_err(|e| {
anyhow::anyhow!("Invalid bind address '{}': {}", bind_address, e)
})?;
socket
.bind(&socket2::SockAddr::from(bind_addr))
.map_err(|e| anyhow::anyhow!("Failed to bind socket to {}: {}", bind_address, e))?;
socket
.bind(&socket2::SockAddr::from(bind_addr))
.map_err(|e| {
anyhow::anyhow!("Failed to bind socket to {}: {}", bind_address, e)
})?;
socket
.listen(128)
.map_err(|e| anyhow::anyhow!("Failed to listen on socket: {}", e))?;
socket
.listen(128)
.map_err(|e| anyhow::anyhow!("Failed to listen on socket: {}", e))?;
let tcp_listener: std::net::TcpListener = socket.into();
let tcp_listener: std::net::TcpListener = socket.into();
// Log listening AFTER successful bind
info!("Listening on {} (mTLS enabled)", bind_address);
// Log listening AFTER successful bind
info!("Listening on {} (mTLS enabled)", bind_address);
// Clone the ServerConfig from Arc for listen_rustls_0_23
let server_config = (*rustls_config).clone();
// Clone the ServerConfig from Arc for listen_rustls_0_23
let server_config = (*rustls_config).clone();
info!("Binding server with TLS 1.3 - non-TLS connections will be rejected");
info!("Binding server with TLS 1.3 - non-TLS connections will be rejected");
// Bind with TLS using rustls 0.23 - non-TLS connections fail at handshake
server_builder
.listen_rustls_0_23(tcp_listener, server_config)?
.run()
.await?;
// Bind with TLS using rustls 0.23 - non-TLS connections fail at handshake
server_builder
.listen_rustls_0_23(tcp_listener, server_config)?
.run()
.await?;
}
Err(e) => {
error!(error = %e, "Failed to initialize mTLS middleware");
return Err(anyhow::anyhow!("mTLS initialization failed: {}", e));
}
}
} else {
// Create TCP listener with SO_REUSEADDR for non-TLS mode
let socket = socket2::Socket::new(

View File

@ -12,112 +12,6 @@ use serde::{Deserialize, Serialize};
use std::process::Command;
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
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum PackageStatus {
@ -417,20 +311,10 @@ impl PackageManagerBackend for AptBackend {
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 {
tracing::warn!(
"--force-yes requested: package signature verification will be bypassed"
);
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 {
let pkg_arg = if let Some(version) = &pkg.version {
format!("{}={}", pkg.name, version)
@ -450,18 +334,16 @@ impl PackageManagerBackend for AptBackend {
}
fn update_package(&self, name: &str) -> Result<()> {
// SECURITY: -- separator prevents argument injection via package name
self.run_apt(&["install", "-y", "--only-upgrade", "--", name])?;
self.run_apt(&["install", "-y", "--only-upgrade", name])?;
info!("Updated package: {}", name);
Ok(())
}
fn remove_package(&self, name: &str, purge: bool) -> Result<()> {
// SECURITY: -- separator prevents argument injection via package name
let args = if purge {
vec!["purge", "-y", "--", name]
vec!["purge", "-y", name]
} else {
vec!["remove", "-y", "--", name]
vec!["remove", "-y", name]
};
self.run_apt(&args)?;
@ -510,8 +392,7 @@ impl PackageManagerBackend for AptBackend {
fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> {
let args = match packages {
Some(pkgs) => {
// SECURITY: -- separator prevents argument injection via package names
let mut a: Vec<&str> = vec!["install", "-y", "--"];
let mut a = vec!["install", "-y"];
for pkg in pkgs {
a.push(pkg);
}
@ -625,8 +506,10 @@ impl PackageManagerBackend for AptBackend {
}
fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> {
// SECURITY: Strict allowlist validation to prevent argument/shell injection
validate_service_name(name).map_err(|e| anyhow::anyhow!("{}", e))?;
// Validate service name to prevent shell injection
if name.is_empty() || name.contains('/') || name.contains("..") {
return Err(anyhow::anyhow!("Invalid service name: {}", name));
}
// Determine init system and query accordingly
let is_systemd = std::path::Path::new("/run/systemd/system").exists();
@ -666,11 +549,9 @@ impl PackageManagerBackend for AptBackend {
/// Query systemd service status via systemctl
fn get_systemd_service_status(name: &str) -> Result<Option<ServiceStatus>> {
// SECURITY: -- separator prevents argument injection via service name
let output = Command::new("systemctl")
.args([
"show",
"--",
name,
"--property=Id,Description,ActiveState,SubState,LoadState,UnitFileState,MainPID",
"--no-pager",
@ -1097,9 +978,6 @@ impl PackageManagerBackend for ApkBackend {
args.push("--force".to_string());
}
// SECURITY: -- separator prevents argument injection via package names
args.push("--".to_string());
for pkg in packages {
let pkg_arg = if let Some(version) = &pkg.version {
format!("{}={}", pkg.name, version)
@ -1119,16 +997,14 @@ impl PackageManagerBackend for ApkBackend {
}
fn update_package(&self, name: &str) -> Result<()> {
// SECURITY: -- separator prevents argument injection via package name
self.run_apk(&["upgrade", "--", name])?;
self.run_apk(&["upgrade", name])?;
info!("Updated package: {}", name);
Ok(())
}
fn remove_package(&self, name: &str, _purge: bool) -> Result<()> {
// APK doesn't have a purge concept - just remove the package
// SECURITY: -- separator prevents argument injection via package name
self.run_apk(&["del", "--", name])?;
self.run_apk(&["del", name])?;
info!("Removed package: {}", name);
Ok(())
}
@ -1190,8 +1066,7 @@ impl PackageManagerBackend for ApkBackend {
fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> {
match packages {
Some(pkgs) => {
// SECURITY: -- separator prevents argument injection via package names
let mut args: Vec<&str> = vec!["upgrade", "--"];
let mut args: Vec<&str> = vec!["upgrade"];
for pkg in pkgs {
args.push(pkg);
}
@ -1311,8 +1186,10 @@ impl PackageManagerBackend for ApkBackend {
}
fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> {
// SECURITY: Strict allowlist validation to prevent argument/shell injection
validate_service_name(name).map_err(|e| anyhow::anyhow!("{}", e))?;
// Validate service name to prevent shell injection
if name.is_empty() || name.contains('/') || name.contains("..") {
return Err(anyhow::anyhow!("Invalid service name: {}", name));
}
// Alpine uses OpenRC for service management
get_openrc_service_status(name)
@ -1656,9 +1533,6 @@ impl PackageManagerBackend for DnfBackend {
args.push("--allowerasing".to_string());
}
// SECURITY: -- separator prevents argument injection via package names
args.push("--".to_string());
for pkg in packages {
let pkg_arg = if let Some(version) = &pkg.version {
format!("{}-{}", pkg.name, version)
@ -1678,18 +1552,16 @@ impl PackageManagerBackend for DnfBackend {
}
fn update_package(&self, name: &str) -> Result<()> {
// SECURITY: -- separator prevents argument injection via package name
self.run_dnf(&["upgrade", "-y", "--", name])?;
self.run_dnf(&["upgrade", "-y", name])?;
info!("Updated package: {}", name);
Ok(())
}
fn remove_package(&self, name: &str, purge: bool) -> Result<()> {
// SECURITY: -- separator prevents argument injection via package name
let args = if purge {
vec!["remove", "-y", "--noautoremove", "--", name]
vec!["remove", "-y", "--noautoremove", name]
} else {
vec!["remove", "-y", "--", name]
vec!["remove", "-y", name]
};
self.run_dnf(&args)?;
info!("Removed package: {} (purge={})", name, purge);
@ -1769,8 +1641,7 @@ impl PackageManagerBackend for DnfBackend {
fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> {
match packages {
Some(pkgs) => {
// SECURITY: -- separator prevents argument injection via package names
let mut args: Vec<&str> = vec!["upgrade", "-y", "--"];
let mut args: Vec<&str> = vec!["upgrade", "-y"];
for pkg in pkgs {
args.push(pkg);
}
@ -1887,8 +1758,10 @@ impl PackageManagerBackend for DnfBackend {
}
fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> {
// SECURITY: Strict allowlist validation to prevent argument/shell injection
validate_service_name(name).map_err(|e| anyhow::anyhow!("{}", e))?;
// Validate service name to prevent shell injection
if name.is_empty() || name.contains('/') || name.contains("..") {
return Err(anyhow::anyhow!("Invalid service name: {}", name));
}
// Fedora/RHEL use systemd for service management
get_systemd_service_status(name)
@ -2214,9 +2087,6 @@ impl PackageManagerBackend for YumBackend {
// yum doesn't have --allowerasing, skip force option
// SECURITY: -- separator prevents argument injection via package names
args.push("--".to_string());
for pkg in packages {
let pkg_arg = if let Some(version) = &pkg.version {
format!("{}-{}", pkg.name, version)
@ -2236,8 +2106,7 @@ impl PackageManagerBackend for YumBackend {
}
fn update_package(&self, name: &str) -> Result<()> {
// SECURITY: -- separator prevents argument injection via package name
self.run_yum(&["update", "-y", "--", name])?;
self.run_yum(&["update", "-y", name])?;
info!("Updated package: {}", name);
Ok(())
}
@ -2245,8 +2114,7 @@ impl PackageManagerBackend for YumBackend {
fn remove_package(&self, name: &str, purge: bool) -> Result<()> {
// yum doesn't distinguish between remove and purge
let _ = purge;
// SECURITY: -- separator prevents argument injection via package name
self.run_yum(&["remove", "-y", "--", name])?;
self.run_yum(&["remove", "-y", name])?;
info!("Removed package: {} (purge={})", name, purge);
Ok(())
}
@ -2317,8 +2185,7 @@ impl PackageManagerBackend for YumBackend {
fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> {
match packages {
Some(pkgs) => {
// SECURITY: -- separator prevents argument injection via package names
let mut args: Vec<&str> = vec!["update", "-y", "--"];
let mut args: Vec<&str> = vec!["update", "-y"];
for pkg in pkgs {
args.push(pkg);
}
@ -2434,8 +2301,10 @@ impl PackageManagerBackend for YumBackend {
}
fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> {
// SECURITY: Strict allowlist validation to prevent argument/shell injection
validate_service_name(name).map_err(|e| anyhow::anyhow!("{}", e))?;
// Validate service name to prevent shell injection
if name.is_empty() || name.contains('/') || name.contains("..") {
return Err(anyhow::anyhow!("Invalid service name: {}", name));
}
// CentOS 7 uses systemd for service management
get_systemd_service_status(name)
@ -2688,9 +2557,6 @@ impl PackageManagerBackend for PacmanBackend {
args.push("'*'".to_string());
}
// SECURITY: -- separator prevents argument injection via package names
args.push("--".to_string());
for pkg in packages {
args.push(pkg.name.clone());
}
@ -2705,16 +2571,14 @@ impl PackageManagerBackend for PacmanBackend {
}
fn update_package(&self, name: &str) -> Result<()> {
// SECURITY: -- separator prevents argument injection via package name
self.run_pacman(&["-S", "--noconfirm", "--", name])?;
self.run_pacman(&["-S", "--noconfirm", name])?;
info!("Updated package: {}", name);
Ok(())
}
fn remove_package(&self, name: &str, _purge: bool) -> Result<()> {
// pacman doesn't have a purge concept - just remove the package
// SECURITY: -- separator prevents argument injection via package name
self.run_pacman(&["-R", "--noconfirm", "--", name])?;
self.run_pacman(&["-R", "--noconfirm", name])?;
info!("Removed package: {} (purge={})", name, _purge);
Ok(())
}
@ -2765,8 +2629,7 @@ impl PackageManagerBackend for PacmanBackend {
fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> {
match packages {
Some(pkgs) => {
// SECURITY: -- separator prevents argument injection via package names
let mut args: Vec<&str> = vec!["-S", "--noconfirm", "--needed", "--"];
let mut args: Vec<&str> = vec!["-S", "--noconfirm", "--needed"];
for pkg in pkgs {
args.push(pkg);
}
@ -2883,8 +2746,10 @@ impl PackageManagerBackend for PacmanBackend {
}
fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>> {
// SECURITY: Strict allowlist validation to prevent argument/shell injection
validate_service_name(name).map_err(|e| anyhow::anyhow!("{}", e))?;
// Validate service name to prevent shell injection
if name.is_empty() || name.contains('/') || name.contains("..") {
return Err(anyhow::anyhow!("Invalid service name: {}", name));
}
// Arch Linux uses systemd for service management
get_systemd_service_status(name)
@ -3133,160 +2998,4 @@ mod tests {
assert_eq!(pkg.dependencies.len(), 3);
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());
}
}

View File

@ -1,24 +0,0 @@
# 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

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

View File

@ -16,7 +16,6 @@ Usage:
import argparse
import json
import subprocess
import sys
import time
from dataclasses import dataclass, field
@ -38,19 +37,6 @@ CA_CERT = CERTS_DIR / "ca.crt"
CLIENT_CERT = CERTS_DIR / "client.crt"
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 = {
"dev": {
"name": "linux-patch-manager-dev",
@ -551,51 +537,14 @@ def test_wrong_cert_connection(client: PatchAPIClient) -> str:
"""Verify that connections with wrong cert are rejected.
Per spec: invalid/expired certificates should be silently dropped.
Generates a separate "wrong CA" certificate at runtime to test that
certificates signed by an untrusted CA are rejected.
Uses project test certs (different CA) which should be rejected.
"""
import tempfile
wrong_ca_dir = Path(tempfile.mkdtemp(prefix="lpa-wrong-ca-"))
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"
project_ca = "/a0/usr/projects/linux_patch_api/configs/certs/ca.pem"
project_cert = "/a0/usr/projects/linux_patch_api/configs/certs/client001.pem"
project_key = "/a0/usr/projects/linux_patch_api/configs/certs/client001.key.pem"
subprocess.run(
["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"
if not Path(project_ca).exists():
return "SKIPPED: Project test certs not available"
session = requests.Session()
session.cert = (project_cert, project_key)
@ -610,8 +559,6 @@ def test_wrong_cert_connection(client: PatchAPIClient) -> str:
return "Correctly rejected connection with untrusted client certificate"
finally:
session.close()
import shutil
shutil.rmtree(wrong_ca_dir, ignore_errors=True)
def test_job_lifecycle(client: PatchAPIClient) -> str:
@ -829,10 +776,7 @@ def main():
)
args = parser.parse_args()
# Generate certs at runtime if missing (private keys are not committed)
ensure_certs()
# Verify certs exist after generation attempt
# Verify certs exist
if not CA_CERT.exists():
print(f"ERROR: CA cert not found: {CA_CERT}")
sys.exit(1)

View File

@ -77,6 +77,7 @@ fn build_tls_config(cert_dir: &std::path::Path) -> TlsConfig {
.join("server.key.pem")
.to_string_lossy()
.to_string(),
min_tls_version: "1.3".to_string(),
crl_path: String::new(), // No CRL in E2E tests
}
}
@ -177,10 +178,8 @@ async fn test_full_enrollment_flow_happy_path() {
let tls_config = build_tls_config(cert_dir.path());
provision::provision_pki_bundle(
&bundle.ca_crt,
&bundle.ca_chain,
&bundle.server_crt,
&bundle.server_key,
&bundle.crl_pem,
Some(&tls_config),
)
.await
@ -446,10 +445,8 @@ async fn test_certificate_permission_verification() {
let tls_config = build_tls_config(cert_dir.path());
provision::provision_pki_bundle(
&bundle.ca_crt,
&bundle.ca_chain,
&bundle.server_crt,
&bundle.server_key,
&bundle.crl_pem,
Some(&tls_config),
)
.await

View File

@ -15,17 +15,13 @@ mod mtls_tests {
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.server_cert_path,
"/etc/linux_patch_api/certs/server.pem"
);
assert_eq!(
config.server_key_path,
"/etc/linux_patch_api/certs/server.key"
);
assert_eq!(config.server_cert_path, "/etc/linux_patch_api/certs/server.pem");
assert_eq!(config.server_key_path, "/etc/linux_patch_api/certs/server.key");
assert_eq!(config.min_tls_version, "1.3");
}
#[test]
@ -236,61 +232,9 @@ mod auth_result_tests {
assert!(result.is_authenticated());
assert!(result.cert_info.is_some());
assert_eq!(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));
assert_eq!(
result.cert_info.unwrap().subject,
"CN=client001"
);
}
}

View File

@ -1,340 +0,0 @@
//! 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);
}