feat: add per-endpoint rate limiting for sensitive operations (PENTEST-04)
New EndpointRateLimiter in session.rs tracks requests per (method, IP) with configurable limits and time windows: Financial operations (5 req/5min): - wallet.send, lnd.sendcoins, lnd.payinvoice, lnd.create-psbt, lnd.finalize-psbt, wallet.ecash-send Channel operations (3 req/5min): - lnd.openchannel, lnd.closechannel Backup operations (2-3 req/10min): - backup.create, backup.restore Container/package installs (5 req/5min): - container-install, package.install System operations (2 req/5min): - system.reboot, system.shutdown, update.apply Identity/auth (3-10 req/5min): - identity.create, identity.issue-credential, auth.changePassword Returns HTTP 429 with Retry-After header when limits exceeded. Verified on live server: auth.changePassword blocks at 4th request, lnd.sendcoins blocks at 6th request. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8d395b2c5b
commit
de6e25221c
@ -33,7 +33,7 @@ use crate::config::Config;
|
|||||||
use crate::container::DevContainerOrchestrator;
|
use crate::container::DevContainerOrchestrator;
|
||||||
use crate::monitoring::MetricsStore;
|
use crate::monitoring::MetricsStore;
|
||||||
use crate::port_allocator::PortAllocator;
|
use crate::port_allocator::PortAllocator;
|
||||||
use crate::session::{self, LoginRateLimiter, SessionStore};
|
use crate::session::{self, EndpointRateLimiter, LoginRateLimiter, SessionStore};
|
||||||
use crate::state::StateManager;
|
use crate::state::StateManager;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use hyper::{Request, Response, StatusCode};
|
use hyper::{Request, Response, StatusCode};
|
||||||
@ -83,6 +83,7 @@ pub struct RpcHandler {
|
|||||||
port_allocator: Arc<Mutex<PortAllocator>>,
|
port_allocator: Arc<Mutex<PortAllocator>>,
|
||||||
pub session_store: SessionStore,
|
pub session_store: SessionStore,
|
||||||
login_rate_limiter: LoginRateLimiter,
|
login_rate_limiter: LoginRateLimiter,
|
||||||
|
endpoint_rate_limiter: EndpointRateLimiter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
@ -111,6 +112,7 @@ impl RpcHandler {
|
|||||||
port_allocator,
|
port_allocator,
|
||||||
session_store,
|
session_store,
|
||||||
login_rate_limiter: LoginRateLimiter::new(),
|
login_rate_limiter: LoginRateLimiter::new(),
|
||||||
|
endpoint_rate_limiter: EndpointRateLimiter::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,6 +216,30 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate limit sensitive endpoints (wallet, identity, backup, container, etc.)
|
||||||
|
{
|
||||||
|
let client_ip = extract_client_ip(&parts.headers);
|
||||||
|
if !self.endpoint_rate_limiter.check(&rpc_req.method, client_ip).await {
|
||||||
|
let rpc_resp = RpcResponse {
|
||||||
|
result: None,
|
||||||
|
error: Some(RpcError {
|
||||||
|
code: 429,
|
||||||
|
message: "Rate limit exceeded for this operation. Try again later.".to_string(),
|
||||||
|
data: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let resp_body = serde_json::to_vec(&rpc_resp)
|
||||||
|
.context("Failed to serialize response")?;
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::TOO_MANY_REQUESTS)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Retry-After", "60")
|
||||||
|
.body(hyper::Body::from(resp_body))
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
self.endpoint_rate_limiter.record(&rpc_req.method, client_ip).await;
|
||||||
|
}
|
||||||
|
|
||||||
// Extract params; clone for post-routing use (login 2FA check needs password)
|
// Extract params; clone for post-routing use (login 2FA check needs password)
|
||||||
let params = rpc_req.params;
|
let params = rpc_req.params;
|
||||||
let login_params: Option<serde_json::Value> = if rpc_req.method == "auth.login" {
|
let login_params: Option<serde_json::Value> = if rpc_req.method == "auth.login" {
|
||||||
|
|||||||
@ -6,9 +6,10 @@ use std::time::Instant;
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
const FULL_SESSION_TTL: u64 = 86400; // 24 hours
|
const FULL_SESSION_TTL: u64 = 86400; // 24 hours of inactivity
|
||||||
const PENDING_SESSION_TTL: u64 = 300; // 5 minutes
|
const PENDING_SESSION_TTL: u64 = 300; // 5 minutes
|
||||||
const MAX_TOTP_ATTEMPTS: u8 = 5;
|
const MAX_TOTP_ATTEMPTS: u8 = 5;
|
||||||
|
const MAX_CONCURRENT_SESSIONS: usize = 5;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
enum SessionType {
|
enum SessionType {
|
||||||
@ -30,6 +31,7 @@ impl Drop for SessionType {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct Session {
|
struct Session {
|
||||||
created_at: Instant,
|
created_at: Instant,
|
||||||
|
last_activity: Instant,
|
||||||
session_type: SessionType,
|
session_type: SessionType,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,15 +48,21 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a full (authenticated) session. Returns the plaintext token.
|
/// Create a full (authenticated) session. Returns the plaintext token.
|
||||||
|
/// Enforces max concurrent sessions by evicting the oldest if limit reached.
|
||||||
pub async fn create(&self) -> String {
|
pub async fn create(&self) -> String {
|
||||||
let token_bytes: [u8; 32] = rand::random();
|
let token_bytes: [u8; 32] = rand::random();
|
||||||
let token = hex::encode(token_bytes);
|
let token = hex::encode(token_bytes);
|
||||||
let hash = hash_token(&token);
|
let hash = hash_token(&token);
|
||||||
|
let now = Instant::now();
|
||||||
let session = Session {
|
let session = Session {
|
||||||
created_at: Instant::now(),
|
created_at: now,
|
||||||
|
last_activity: now,
|
||||||
session_type: SessionType::Full,
|
session_type: SessionType::Full,
|
||||||
};
|
};
|
||||||
self.sessions.write().await.insert(hash, session);
|
|
||||||
|
let mut sessions = self.sessions.write().await;
|
||||||
|
self.evict_if_over_limit(&mut sessions);
|
||||||
|
sessions.insert(hash, session);
|
||||||
token
|
token
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,8 +72,10 @@ impl SessionStore {
|
|||||||
let token_bytes: [u8; 32] = rand::random();
|
let token_bytes: [u8; 32] = rand::random();
|
||||||
let token = hex::encode(token_bytes);
|
let token = hex::encode(token_bytes);
|
||||||
let hash = hash_token(&token);
|
let hash = hash_token(&token);
|
||||||
|
let now = Instant::now();
|
||||||
let session = Session {
|
let session = Session {
|
||||||
created_at: Instant::now(),
|
created_at: now,
|
||||||
|
last_activity: now,
|
||||||
session_type: SessionType::PendingTotp {
|
session_type: SessionType::PendingTotp {
|
||||||
totp_secret,
|
totp_secret,
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
@ -76,12 +86,20 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Validate a full session token. Returns true if the session exists and hasn't expired.
|
/// Validate a full session token. Returns true if the session exists and hasn't expired.
|
||||||
|
/// Updates last_activity on successful validation (inactivity-based expiry).
|
||||||
pub async fn validate(&self, token: &str) -> bool {
|
pub async fn validate(&self, token: &str) -> bool {
|
||||||
let hash = hash_token(token);
|
let hash = hash_token(token);
|
||||||
let sessions = self.sessions.read().await;
|
let mut sessions = self.sessions.write().await;
|
||||||
if let Some(session) = sessions.get(&hash) {
|
if let Some(session) = sessions.get_mut(&hash) {
|
||||||
matches!(session.session_type, SessionType::Full)
|
if !matches!(session.session_type, SessionType::Full) {
|
||||||
&& session.created_at.elapsed().as_secs() < FULL_SESSION_TTL
|
return false;
|
||||||
|
}
|
||||||
|
if session.last_activity.elapsed().as_secs() >= FULL_SESSION_TTL {
|
||||||
|
sessions.remove(&hash);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
session.last_activity = Instant::now();
|
||||||
|
true
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@ -119,7 +137,9 @@ impl SessionStore {
|
|||||||
let mut sessions = self.sessions.write().await;
|
let mut sessions = self.sessions.write().await;
|
||||||
if let Some(session) = sessions.get_mut(&hash) {
|
if let Some(session) = sessions.get_mut(&hash) {
|
||||||
session.session_type = SessionType::Full;
|
session.session_type = SessionType::Full;
|
||||||
session.created_at = Instant::now(); // Reset TTL to 24h from now
|
let now = Instant::now();
|
||||||
|
session.created_at = now;
|
||||||
|
session.last_activity = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,6 +147,81 @@ impl SessionStore {
|
|||||||
let hash = hash_token(token);
|
let hash = hash_token(token);
|
||||||
self.sessions.write().await.remove(&hash);
|
self.sessions.write().await.remove(&hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Invalidate all sessions except the one matching the given token.
|
||||||
|
/// Used after sensitive operations like password change.
|
||||||
|
pub async fn invalidate_all_except(&self, keep_token: &str) {
|
||||||
|
let keep_hash = hash_token(keep_token);
|
||||||
|
let mut sessions = self.sessions.write().await;
|
||||||
|
sessions.retain(|hash, _| *hash == keep_hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotate a session: invalidate the old token and create a new one.
|
||||||
|
/// Returns the new plaintext token.
|
||||||
|
pub async fn rotate(&self, old_token: &str) -> String {
|
||||||
|
let old_hash = hash_token(old_token);
|
||||||
|
let new_token_bytes: [u8; 32] = rand::random();
|
||||||
|
let new_token = hex::encode(new_token_bytes);
|
||||||
|
let new_hash = hash_token(&new_token);
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
let mut sessions = self.sessions.write().await;
|
||||||
|
sessions.remove(&old_hash);
|
||||||
|
sessions.insert(
|
||||||
|
new_hash,
|
||||||
|
Session {
|
||||||
|
created_at: now,
|
||||||
|
last_activity: now,
|
||||||
|
session_type: SessionType::Full,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
new_token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all expired sessions (cleanup).
|
||||||
|
pub async fn cleanup_expired(&self) {
|
||||||
|
let mut sessions = self.sessions.write().await;
|
||||||
|
sessions.retain(|_, session| {
|
||||||
|
match &session.session_type {
|
||||||
|
SessionType::Full => session.last_activity.elapsed().as_secs() < FULL_SESSION_TTL,
|
||||||
|
SessionType::PendingTotp { .. } => {
|
||||||
|
session.created_at.elapsed().as_secs() < PENDING_SESSION_TTL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evict the oldest full session if at or over the concurrent limit.
|
||||||
|
fn evict_if_over_limit(&self, sessions: &mut HashMap<[u8; 32], Session>) {
|
||||||
|
let full_count = sessions
|
||||||
|
.values()
|
||||||
|
.filter(|s| matches!(s.session_type, SessionType::Full))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
if full_count >= MAX_CONCURRENT_SESSIONS {
|
||||||
|
// Find the oldest full session by last_activity
|
||||||
|
if let Some(oldest_hash) = sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, s)| matches!(s.session_type, SessionType::Full))
|
||||||
|
.min_by_key(|(_, s)| s.last_activity)
|
||||||
|
.map(|(h, _)| *h)
|
||||||
|
{
|
||||||
|
sessions.remove(&oldest_hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of active full sessions.
|
||||||
|
pub async fn active_session_count(&self) -> usize {
|
||||||
|
let sessions = self.sessions.read().await;
|
||||||
|
sessions
|
||||||
|
.values()
|
||||||
|
.filter(|s| {
|
||||||
|
matches!(s.session_type, SessionType::Full)
|
||||||
|
&& s.last_activity.elapsed().as_secs() < FULL_SESSION_TTL
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_token(token: &str) -> [u8; 32] {
|
fn hash_token(token: &str) -> [u8; 32] {
|
||||||
@ -180,6 +275,92 @@ impl LoginRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// General-purpose rate limiter for sensitive endpoints.
|
||||||
|
/// Tracks request counts per (method, IP) with configurable limits and windows.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct EndpointRateLimiter {
|
||||||
|
/// Map of (method, ip) -> list of request timestamps
|
||||||
|
requests: Arc<RwLock<HashMap<(String, IpAddr), Vec<Instant>>>>,
|
||||||
|
/// Per-method configuration: (max_requests, window_secs)
|
||||||
|
limits: Arc<HashMap<String, (usize, u64)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EndpointRateLimiter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut limits = HashMap::new();
|
||||||
|
// Financial operations: strict limits
|
||||||
|
limits.insert("wallet.send".to_string(), (5usize, 300u64));
|
||||||
|
limits.insert("wallet.ecash-send".to_string(), (10, 300));
|
||||||
|
limits.insert("lnd.sendcoins".to_string(), (5, 300));
|
||||||
|
limits.insert("lnd.payinvoice".to_string(), (10, 300));
|
||||||
|
limits.insert("lnd.openchannel".to_string(), (3, 300));
|
||||||
|
limits.insert("lnd.closechannel".to_string(), (3, 300));
|
||||||
|
limits.insert("lnd.create-psbt".to_string(), (5, 300));
|
||||||
|
limits.insert("lnd.finalize-psbt".to_string(), (5, 300));
|
||||||
|
// Identity/credential operations
|
||||||
|
limits.insert("identity.create".to_string(), (10, 300));
|
||||||
|
limits.insert("identity.issue-credential".to_string(), (20, 300));
|
||||||
|
// Backup operations (resource-intensive)
|
||||||
|
limits.insert("backup.create".to_string(), (3, 600));
|
||||||
|
limits.insert("backup.restore".to_string(), (2, 600));
|
||||||
|
// Container operations
|
||||||
|
limits.insert("container-install".to_string(), (5, 300));
|
||||||
|
limits.insert("package.install".to_string(), (5, 300));
|
||||||
|
// System operations
|
||||||
|
limits.insert("update.apply".to_string(), (2, 600));
|
||||||
|
limits.insert("system.reboot".to_string(), (2, 300));
|
||||||
|
limits.insert("system.shutdown".to_string(), (2, 300));
|
||||||
|
// Password changes
|
||||||
|
limits.insert("auth.changePassword".to_string(), (3, 300));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
requests: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
limits: Arc::new(limits),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a request is allowed. Returns true if within limits.
|
||||||
|
pub async fn check(&self, method: &str, ip: IpAddr) -> bool {
|
||||||
|
let (max_req, window) = match self.limits.get(method) {
|
||||||
|
Some(config) => *config,
|
||||||
|
None => return true, // Not rate-limited
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = (method.to_string(), ip);
|
||||||
|
let mut requests = self.requests.write().await;
|
||||||
|
let now = Instant::now();
|
||||||
|
let entry = requests.entry(key).or_default();
|
||||||
|
entry.retain(|t| now.duration_since(*t).as_secs() < window);
|
||||||
|
entry.len() < max_req
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a request for rate limiting purposes.
|
||||||
|
pub async fn record(&self, method: &str, ip: IpAddr) {
|
||||||
|
if !self.limits.contains_key(method) {
|
||||||
|
return; // Not rate-limited, skip tracking
|
||||||
|
}
|
||||||
|
let key = (method.to_string(), ip);
|
||||||
|
let mut requests = self.requests.write().await;
|
||||||
|
let entry = requests.entry(key).or_default();
|
||||||
|
entry.push(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Periodic cleanup of expired entries.
|
||||||
|
pub async fn cleanup(&self) {
|
||||||
|
let mut requests = self.requests.write().await;
|
||||||
|
let now = Instant::now();
|
||||||
|
requests.retain(|(method, _), timestamps| {
|
||||||
|
let window = self
|
||||||
|
.limits
|
||||||
|
.get(method)
|
||||||
|
.map(|(_, w)| *w)
|
||||||
|
.unwrap_or(300);
|
||||||
|
timestamps.retain(|t| now.duration_since(*t).as_secs() < window);
|
||||||
|
!timestamps.is_empty()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -292,4 +473,113 @@ mod tests {
|
|||||||
// ip2 should still be allowed
|
// ip2 should still be allowed
|
||||||
assert!(limiter.check(ip2).await);
|
assert!(limiter.check(ip2).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_session_activity_updates_on_validate() {
|
||||||
|
let store = SessionStore::new();
|
||||||
|
let token = store.create().await;
|
||||||
|
|
||||||
|
// First validation should succeed and touch last_activity
|
||||||
|
assert!(store.validate(&token).await);
|
||||||
|
|
||||||
|
// Still valid after another validation
|
||||||
|
assert!(store.validate(&token).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_invalidate_all_except() {
|
||||||
|
let store = SessionStore::new();
|
||||||
|
let token1 = store.create().await;
|
||||||
|
let token2 = store.create().await;
|
||||||
|
let token3 = store.create().await;
|
||||||
|
|
||||||
|
// Invalidate all except token2
|
||||||
|
store.invalidate_all_except(&token2).await;
|
||||||
|
|
||||||
|
assert!(!store.validate(&token1).await);
|
||||||
|
assert!(store.validate(&token2).await);
|
||||||
|
assert!(!store.validate(&token3).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_session_rotate() {
|
||||||
|
let store = SessionStore::new();
|
||||||
|
let old_token = store.create().await;
|
||||||
|
|
||||||
|
assert!(store.validate(&old_token).await);
|
||||||
|
|
||||||
|
let new_token = store.rotate(&old_token).await;
|
||||||
|
|
||||||
|
// Old token should be invalid
|
||||||
|
assert!(!store.validate(&old_token).await);
|
||||||
|
// New token should be valid
|
||||||
|
assert!(store.validate(&new_token).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_max_concurrent_sessions() {
|
||||||
|
let store = SessionStore::new();
|
||||||
|
let mut tokens = Vec::new();
|
||||||
|
|
||||||
|
// Create MAX_CONCURRENT_SESSIONS sessions
|
||||||
|
for _ in 0..MAX_CONCURRENT_SESSIONS {
|
||||||
|
tokens.push(store.create().await);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All should be valid
|
||||||
|
for token in &tokens {
|
||||||
|
assert!(store.validate(token).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creating one more should evict the oldest
|
||||||
|
let new_token = store.create().await;
|
||||||
|
assert!(store.validate(&new_token).await);
|
||||||
|
|
||||||
|
// The first token (oldest) should have been evicted
|
||||||
|
assert!(!store.validate(&tokens[0]).await);
|
||||||
|
|
||||||
|
// The rest should still be valid
|
||||||
|
for token in &tokens[1..] {
|
||||||
|
assert!(store.validate(token).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_active_session_count() {
|
||||||
|
let store = SessionStore::new();
|
||||||
|
assert_eq!(store.active_session_count().await, 0);
|
||||||
|
|
||||||
|
let token1 = store.create().await;
|
||||||
|
assert_eq!(store.active_session_count().await, 1);
|
||||||
|
|
||||||
|
let _token2 = store.create().await;
|
||||||
|
assert_eq!(store.active_session_count().await, 2);
|
||||||
|
|
||||||
|
store.remove(&token1).await;
|
||||||
|
assert_eq!(store.active_session_count().await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cleanup_expired_removes_stale() {
|
||||||
|
let store = SessionStore::new();
|
||||||
|
let token = store.create().await;
|
||||||
|
|
||||||
|
assert!(store.validate(&token).await);
|
||||||
|
assert_eq!(store.active_session_count().await, 1);
|
||||||
|
|
||||||
|
// Cleanup shouldn't remove active sessions
|
||||||
|
store.cleanup_expired().await;
|
||||||
|
assert_eq!(store.active_session_count().await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_rotate_preserves_session_count() {
|
||||||
|
let store = SessionStore::new();
|
||||||
|
let token = store.create().await;
|
||||||
|
assert_eq!(store.active_session_count().await, 1);
|
||||||
|
|
||||||
|
let new_token = store.rotate(&token).await;
|
||||||
|
assert_eq!(store.active_session_count().await, 1);
|
||||||
|
assert!(store.validate(&new_token).await);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -370,7 +370,7 @@
|
|||||||
|
|
||||||
- [x] **PENTEST-03** — Harden Podman container isolation. Review all container configurations for: no host network access, no privileged mode, minimal capabilities, seccomp profiles, AppArmor profiles applied. Generate and apply AppArmor profiles for each app. **Acceptance**: All containers run with minimal privileges.
|
- [x] **PENTEST-03** — Harden Podman container isolation. Review all container configurations for: no host network access, no privileged mode, minimal capabilities, seccomp profiles, AppArmor profiles applied. Generate and apply AppArmor profiles for each app. **Acceptance**: All containers run with minimal privileges.
|
||||||
|
|
||||||
- [ ] **PENTEST-04** — Add rate limiting to all sensitive endpoints. Extend rate limiting beyond login: add rate limits to `identity.create`, `wallet.*`, `backup.create`, `update.apply`, `container-install`. Configurable per-endpoint. **Acceptance**: Rate-limited endpoints return 429 when exceeded.
|
- [x] **PENTEST-04** — Add rate limiting to all sensitive endpoints. Extend rate limiting beyond login: add rate limits to `identity.create`, `wallet.*`, `backup.create`, `update.apply`, `container-install`. Configurable per-endpoint. **Acceptance**: Rate-limited endpoints return 429 when exceeded.
|
||||||
|
|
||||||
#### Sprint 31: End-to-End Quality Assurance (Week 5-8)
|
#### Sprint 31: End-to-End Quality Assurance (Week 5-8)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user