fix(security): comprehensive security hardening — TLS, HMAC WS auth, rate limiting, IP leak prevention

CRITICAL fixes:
- Auto-generated self-signed TLS certs (HTTPS/WSS by default)
- Removed session_key from /srp/verify response (was sent in plaintext)
- Replaced with HMAC-SHA256 ws_token for WebSocket authentication

HIGH fixes:
- WebSocket auth now validates ws_token via hmac.compare_digest()
- /clear endpoint requires Bearer admin_token (printed at server start)
- Password no longer required as CLI arg — supports env var + getpass prompt
- Removed user_ip from Message model (no longer broadcast to clients)

MEDIUM fixes:
- Rate limiter on /srp/init and /srp/verify (10 req/min/IP)
- MessageStore capped at 1000 messages (prevents RAM DoS)
- access_log disabled (was leaking request metadata)

LOW fixes:
- Username sanitization against rich markup injection
- Dead code removed from helpers.py

All 79 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-05-25 20:30:40 -07:00
parent 440b67da26
commit e7bacc93da
11 changed files with 255 additions and 80 deletions
+4
View File
@@ -28,6 +28,10 @@ def app():
app.ctx.connection_manager = ConnectionManager()
app.ctx.srp_manager = SRPAuthManager("testpassword")
app.ctx.room_salt = os.urandom(16)
app.ctx.ws_secret = os.urandom(32)
app.ctx.admin_token = "test-admin-token"
from cmd_chat.server.helpers import RateLimiter
app.ctx.rate_limiter = RateLimiter(max_requests=100, window_seconds=60)
app.ctx.cleanup_task = None
register_routes(app)
+8 -3
View File
@@ -52,14 +52,19 @@ class TestClientInit:
assert client.username == "testuser"
assert client.password == b"testpassword"
assert client.user_id is None
assert client.fernet is None
assert client.ws_token is None
assert client.room_fernet is None
assert client.connected is False
assert client.running is False
def test_client_urls(self, client):
assert client.base_url == "http://127.0.0.1:3000"
assert client.ws_url == "ws://127.0.0.1:3000"
assert client.base_url == "https://127.0.0.1:3000"
assert client.ws_url == "wss://127.0.0.1:3000"
def test_client_no_tls_urls(self):
c = Client("127.0.0.1", 3000, "user", "pass", no_tls=True)
assert c.base_url == "http://127.0.0.1:3000"
assert c.ws_url == "ws://127.0.0.1:3000"
def test_client_empty_password(self):
client = Client("localhost", 8080, "user", None)
+14 -10
View File
@@ -48,15 +48,20 @@ def room_fernet(room_salt):
class TestClientProperties:
def test_base_url_different_ports(self):
client = Client("example.com", 8080, "user", "pass")
assert client.base_url == "http://example.com:8080"
assert client.base_url == "https://example.com:8080"
def test_ws_url_different_ports(self):
client = Client("example.com", 8080, "user", "pass")
assert client.ws_url == "ws://example.com:8080"
assert client.ws_url == "wss://example.com:8080"
def test_base_url_localhost(self):
client = Client("localhost", 443, "user", "pass")
assert client.base_url == "http://localhost:443"
assert client.base_url == "https://localhost:443"
def test_no_tls_urls(self):
client = Client("example.com", 8080, "user", "pass", no_tls=True)
assert client.base_url == "http://example.com:8080"
assert client.ws_url == "ws://example.com:8080"
def test_password_encoding_unicode(self):
client = Client("localhost", 3000, "user", "пароль123")
@@ -84,7 +89,7 @@ class TestSRPAuthentication:
verify_response = MagicMock()
verify_response.json.return_value = {
"H_AMK": base64.b64encode(os.urandom(32)).decode(),
"session_key": base64.b64encode(Fernet.generate_key()).decode(),
"ws_token": "test-ws-token-hex",
}
verify_response.raise_for_status = MagicMock()
@@ -102,7 +107,7 @@ class TestSRPAuthentication:
assert client.user_id == "test-user-id-12345"
assert client.room_fernet is not None
assert client.fernet is not None
assert client.ws_token == "test-ws-token-hex"
@patch("cmd_chat.client.client.requests.post")
def test_srp_authenticate_init_fails(self, mock_post, client):
@@ -178,7 +183,7 @@ class TestSRPAuthentication:
verify_response = MagicMock()
verify_response.json.return_value = {
"H_AMK": base64.b64encode(os.urandom(32)).decode(),
"session_key": base64.b64encode(Fernet.generate_key()).decode(),
"ws_token": "test-ws-token-hex",
}
verify_response.raise_for_status = MagicMock()
@@ -237,7 +242,6 @@ class TestDecryptMessage:
"username": "sender",
"timestamp": "2024-01-01T12:00:00",
"id": "msg-123",
"user_ip": "192.168.1.1",
}
decrypted = client.decrypt_message(msg)
@@ -246,7 +250,6 @@ class TestDecryptMessage:
assert decrypted["username"] == "sender"
assert decrypted["timestamp"] == "2024-01-01T12:00:00"
assert decrypted["id"] == "msg-123"
assert decrypted["user_ip"] == "192.168.1.1"
def test_decrypt_wrong_key_marks_failed(self, client):
@@ -553,6 +556,7 @@ class TestRunAsync:
@pytest.mark.asyncio
async def test_run_successful_connection_and_disconnect(self, client):
client.user_id = "test-id-123"
client.ws_token = "test-token"
with patch.object(client, "srp_authenticate"):
with patch("cmd_chat.client.client.websockets.connect") as mock_connect:
@@ -844,8 +848,8 @@ class TestEdgeCases:
def test_port_zero(self):
client = Client("localhost", 0, "user", "pass")
assert client.port == 0
assert client.base_url == "http://localhost:0"
assert client.base_url == "https://localhost:0"
def test_ipv6_server(self):
client = Client("::1", 3000, "user", "pass")
assert client.base_url == "http://::1:3000"
assert client.base_url == "https://::1:3000"