485 lines
13 KiB
Markdown
485 lines
13 KiB
Markdown
# Milestone 10: Network - WebSocket
|
|
|
|
**Status**: Complete
|
|
**Goal**: Secure WebSocket connections with same validation as HTTP.
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
This milestone extends the network system with WebSocket support:
|
|
- Reuse HttpValidator for URL/domain validation (wss:// only)
|
|
- Connection limits per app
|
|
- Message size limits
|
|
- Idle timeout handling
|
|
- Clean disconnection on app stop
|
|
|
|
### Key Deliverables
|
|
|
|
1. **WebSocketManager class** - Connection pool management
|
|
2. **WebSocket class** - Individual connection wrapper
|
|
3. **Lua websocket API** - `network.websocket()` function
|
|
4. **Mock implementation** - For desktop testing
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
```
|
|
src/main/cpp/sandbox/
|
|
├── http_validator.h # Existing - add WSS support
|
|
├── http_validator.cpp # Existing - add WSS support
|
|
├── websocket_manager.h # NEW - WebSocket pool
|
|
└── websocket_manager.cpp # NEW - Connection management
|
|
```
|
|
|
|
---
|
|
|
|
## Implementation Details
|
|
|
|
### 1. WebSocketManager Class
|
|
|
|
```cpp
|
|
// websocket_manager.h
|
|
#pragma once
|
|
|
|
#include <string>
|
|
#include <map>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <functional>
|
|
#include <vector>
|
|
#include "http_validator.h"
|
|
|
|
struct lua_State;
|
|
|
|
namespace mosis {
|
|
|
|
struct WebSocketLimits {
|
|
int max_connections_per_app = 5;
|
|
size_t max_message_size = 1 * 1024 * 1024; // 1 MB
|
|
int idle_timeout_ms = 5 * 60 * 1000; // 5 minutes
|
|
int connect_timeout_ms = 30000; // 30 seconds
|
|
};
|
|
|
|
enum class WebSocketState {
|
|
Connecting,
|
|
Open,
|
|
Closing,
|
|
Closed
|
|
};
|
|
|
|
class WebSocket {
|
|
public:
|
|
using MessageCallback = std::function<void(const std::string& data, bool binary)>;
|
|
using CloseCallback = std::function<void(int code, const std::string& reason)>;
|
|
using ErrorCallback = std::function<void(const std::string& error)>;
|
|
using OpenCallback = std::function<void()>;
|
|
|
|
WebSocket(int id, const std::string& url);
|
|
~WebSocket();
|
|
|
|
int GetId() const { return m_id; }
|
|
const std::string& GetUrl() const { return m_url; }
|
|
WebSocketState GetState() const { return m_state; }
|
|
|
|
// Send message (returns false if not connected or message too large)
|
|
bool Send(const std::string& data, bool binary = false);
|
|
|
|
// Close connection
|
|
void Close(int code = 1000, const std::string& reason = "");
|
|
|
|
// Event callbacks
|
|
void SetOnOpen(OpenCallback cb) { m_on_open = std::move(cb); }
|
|
void SetOnMessage(MessageCallback cb) { m_on_message = std::move(cb); }
|
|
void SetOnClose(CloseCallback cb) { m_on_close = std::move(cb); }
|
|
void SetOnError(ErrorCallback cb) { m_on_error = std::move(cb); }
|
|
|
|
// For mock mode - simulate events
|
|
void SimulateOpen();
|
|
void SimulateMessage(const std::string& data, bool binary);
|
|
void SimulateClose(int code, const std::string& reason);
|
|
void SimulateError(const std::string& error);
|
|
|
|
private:
|
|
int m_id;
|
|
std::string m_url;
|
|
WebSocketState m_state;
|
|
size_t m_max_message_size;
|
|
|
|
OpenCallback m_on_open;
|
|
MessageCallback m_on_message;
|
|
CloseCallback m_on_close;
|
|
ErrorCallback m_on_error;
|
|
};
|
|
|
|
class WebSocketManager {
|
|
public:
|
|
WebSocketManager(const std::string& app_id, const WebSocketLimits& limits = WebSocketLimits{});
|
|
~WebSocketManager();
|
|
|
|
// Configure domain restrictions (reuses HttpValidator)
|
|
void SetAllowedDomains(const std::vector<std::string>& domains);
|
|
void ClearDomainRestrictions();
|
|
|
|
// Create new WebSocket connection
|
|
// Returns connection ID on success, -1 on failure (sets error)
|
|
std::shared_ptr<WebSocket> Connect(const std::string& url, std::string& error);
|
|
|
|
// Get connection by ID
|
|
std::shared_ptr<WebSocket> GetConnection(int id);
|
|
|
|
// Close specific connection
|
|
void CloseConnection(int id, int code = 1000, const std::string& reason = "");
|
|
|
|
// Close all connections (called on app stop)
|
|
void CloseAll();
|
|
|
|
// Stats
|
|
int GetActiveConnectionCount() const;
|
|
|
|
// Access validator for testing
|
|
HttpValidator& GetValidator() { return m_validator; }
|
|
|
|
// For testing: set mock mode
|
|
void SetMockMode(bool enabled) { m_mock_mode = enabled; }
|
|
bool IsMockMode() const { return m_mock_mode; }
|
|
|
|
private:
|
|
std::string m_app_id;
|
|
WebSocketLimits m_limits;
|
|
HttpValidator m_validator;
|
|
std::map<int, std::shared_ptr<WebSocket>> m_connections;
|
|
int m_next_id = 1;
|
|
mutable std::mutex m_mutex;
|
|
bool m_mock_mode = true;
|
|
|
|
bool ValidateUrl(const std::string& url, std::string& error);
|
|
};
|
|
|
|
// Register websocket APIs
|
|
void RegisterWebSocketAPI(lua_State* L, WebSocketManager* manager);
|
|
|
|
} // namespace mosis
|
|
```
|
|
|
|
### 2. URL Validation Changes
|
|
|
|
The HttpValidator needs to accept both `https://` and `wss://` schemes:
|
|
|
|
```cpp
|
|
// In Validate():
|
|
if (parsed->scheme != "https" && parsed->scheme != "wss") {
|
|
error = "HTTPS or WSS required, got: " + parsed->scheme;
|
|
return std::nullopt;
|
|
}
|
|
```
|
|
|
|
### 3. Lua API
|
|
|
|
```lua
|
|
-- Create WebSocket connection
|
|
local ws, err = network.websocket("wss://api.example.com/socket")
|
|
if not ws then
|
|
print("Failed to connect:", err)
|
|
return
|
|
end
|
|
|
|
-- Event handlers
|
|
ws:on("open", function()
|
|
print("Connected!")
|
|
ws:send("Hello server")
|
|
end)
|
|
|
|
ws:on("message", function(data, binary)
|
|
print("Received:", data)
|
|
end)
|
|
|
|
ws:on("close", function(code, reason)
|
|
print("Closed:", code, reason)
|
|
end)
|
|
|
|
ws:on("error", function(err)
|
|
print("Error:", err)
|
|
end)
|
|
|
|
-- Send message
|
|
ws:send("Hello")
|
|
ws:send(binaryData, true) -- binary mode
|
|
|
|
-- Close connection
|
|
ws:close()
|
|
ws:close(1000, "Normal closure")
|
|
|
|
-- Get state
|
|
local state = ws:state() -- "connecting", "open", "closing", "closed"
|
|
```
|
|
|
|
### 4. Connection Lifecycle
|
|
|
|
```
|
|
Connect(url) ──► Validating ──► Connecting ──► Open
|
|
│ │ │
|
|
│ error │ error │ message
|
|
▼ ▼ ▼
|
|
Closed Closed Handler
|
|
│
|
|
│ close/error
|
|
▼
|
|
Closed
|
|
```
|
|
|
|
### 5. Limits Enforcement
|
|
|
|
| Limit | Default | Description |
|
|
|-------|---------|-------------|
|
|
| max_connections_per_app | 5 | Maximum concurrent WebSocket connections |
|
|
| max_message_size | 1 MB | Maximum message size (send or receive) |
|
|
| idle_timeout_ms | 5 min | Close idle connections |
|
|
| connect_timeout_ms | 30 sec | Connection establishment timeout |
|
|
|
|
---
|
|
|
|
## Test Cases
|
|
|
|
### Test 1: WebSocket URL Validation
|
|
|
|
```cpp
|
|
bool Test_WebSocketUrlValidation(std::string& error_msg) {
|
|
mosis::WebSocketManager manager("test.app");
|
|
manager.ClearDomainRestrictions();
|
|
|
|
std::string err;
|
|
|
|
// WSS should be allowed
|
|
auto ws = manager.Connect("wss://example.com/socket", err);
|
|
// In mock mode, connection will fail but validation should pass
|
|
EXPECT_TRUE(err.find("mock") != std::string::npos ||
|
|
err.find("disabled") != std::string::npos ||
|
|
ws != nullptr);
|
|
|
|
// WS (plain) should be blocked
|
|
ws = manager.Connect("ws://example.com/socket", err);
|
|
EXPECT_TRUE(ws == nullptr);
|
|
EXPECT_TRUE(err.find("WSS") != std::string::npos ||
|
|
err.find("HTTPS") != std::string::npos ||
|
|
err.find("required") != std::string::npos);
|
|
|
|
return true;
|
|
}
|
|
```
|
|
|
|
### Test 2: Connection Limits
|
|
|
|
```cpp
|
|
bool Test_WebSocketConnectionLimits(std::string& error_msg) {
|
|
mosis::WebSocketLimits limits;
|
|
limits.max_connections_per_app = 2;
|
|
|
|
mosis::WebSocketManager manager("test.app", limits);
|
|
manager.ClearDomainRestrictions();
|
|
|
|
std::string err;
|
|
|
|
// Create max connections
|
|
auto ws1 = manager.Connect("wss://example.com/socket1", err);
|
|
auto ws2 = manager.Connect("wss://example.com/socket2", err);
|
|
|
|
// Third should fail (or be limited)
|
|
auto ws3 = manager.Connect("wss://example.com/socket3", err);
|
|
EXPECT_TRUE(ws3 == nullptr);
|
|
EXPECT_TRUE(err.find("limit") != std::string::npos ||
|
|
err.find("connections") != std::string::npos);
|
|
|
|
return true;
|
|
}
|
|
```
|
|
|
|
### Test 3: WebSocket Blocks Private IPs
|
|
|
|
```cpp
|
|
bool Test_WebSocketBlocksPrivateIP(std::string& error_msg) {
|
|
mosis::WebSocketManager manager("test.app");
|
|
manager.ClearDomainRestrictions();
|
|
|
|
std::string err;
|
|
|
|
std::vector<std::string> private_urls = {
|
|
"wss://127.0.0.1/socket",
|
|
"wss://localhost/socket",
|
|
"wss://10.0.0.1/socket",
|
|
"wss://192.168.1.1/socket",
|
|
"wss://169.254.169.254/socket"
|
|
};
|
|
|
|
for (const auto& url : private_urls) {
|
|
auto ws = manager.Connect(url, err);
|
|
EXPECT_TRUE(ws == nullptr);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
```
|
|
|
|
### Test 4: Domain Whitelist
|
|
|
|
```cpp
|
|
bool Test_WebSocketDomainWhitelist(std::string& error_msg) {
|
|
mosis::WebSocketManager manager("test.app");
|
|
manager.SetAllowedDomains({"api.example.com"});
|
|
|
|
std::string err;
|
|
|
|
// Allowed domain - should pass validation (may fail in mock mode for other reasons)
|
|
auto ws1 = manager.Connect("wss://api.example.com/socket", err);
|
|
bool allowed_passed = (ws1 != nullptr) ||
|
|
(err.find("mock") != std::string::npos) ||
|
|
(err.find("disabled") != std::string::npos);
|
|
EXPECT_TRUE(allowed_passed);
|
|
|
|
// Disallowed domain - should fail validation
|
|
auto ws2 = manager.Connect("wss://evil.com/socket", err);
|
|
EXPECT_TRUE(ws2 == nullptr);
|
|
EXPECT_TRUE(err.find("allowed") != std::string::npos ||
|
|
err.find("whitelist") != std::string::npos ||
|
|
err.find("Domain") != std::string::npos);
|
|
|
|
return true;
|
|
}
|
|
```
|
|
|
|
### Test 5: Message Size Limits
|
|
|
|
```cpp
|
|
bool Test_WebSocketMessageLimits(std::string& error_msg) {
|
|
mosis::WebSocketLimits limits;
|
|
limits.max_message_size = 1024; // 1 KB for testing
|
|
|
|
mosis::WebSocketManager manager("test.app", limits);
|
|
manager.ClearDomainRestrictions();
|
|
|
|
// Create a mock WebSocket directly to test send limits
|
|
mosis::WebSocket ws(1, "wss://example.com/socket");
|
|
|
|
// Small message should work (if connected)
|
|
// Large message should fail
|
|
std::string large_message(2048, 'X'); // 2 KB
|
|
bool send_result = ws.Send(large_message);
|
|
EXPECT_FALSE(send_result); // Should fail - not connected and/or too large
|
|
|
|
return true;
|
|
}
|
|
```
|
|
|
|
### Test 6: Close All Connections
|
|
|
|
```cpp
|
|
bool Test_WebSocketCloseAll(std::string& error_msg) {
|
|
mosis::WebSocketManager manager("test.app");
|
|
manager.ClearDomainRestrictions();
|
|
|
|
std::string err;
|
|
|
|
// Create some connections (may fail in mock but that's ok)
|
|
manager.Connect("wss://example.com/socket1", err);
|
|
manager.Connect("wss://example.com/socket2", err);
|
|
|
|
// Close all
|
|
manager.CloseAll();
|
|
|
|
// Should have no active connections
|
|
EXPECT_TRUE(manager.GetActiveConnectionCount() == 0);
|
|
|
|
return true;
|
|
}
|
|
```
|
|
|
|
### Test 7: Lua Integration
|
|
|
|
```cpp
|
|
bool Test_WebSocketLuaIntegration(std::string& error_msg) {
|
|
SandboxContext ctx = TestContext();
|
|
LuaSandbox sandbox(ctx);
|
|
|
|
mosis::WebSocketManager manager("test.app");
|
|
manager.ClearDomainRestrictions();
|
|
mosis::RegisterWebSocketAPI(sandbox.GetState(), &manager);
|
|
|
|
std::string script = R"lua(
|
|
-- Test that network.websocket exists
|
|
if not network then
|
|
error("network global not found")
|
|
end
|
|
if not network.websocket then
|
|
error("network.websocket not found")
|
|
end
|
|
|
|
-- Test validation rejection (private IP)
|
|
local ws, err = network.websocket("wss://127.0.0.1/socket")
|
|
if ws then
|
|
error("expected private IP to be blocked")
|
|
end
|
|
)lua";
|
|
|
|
bool ok = sandbox.LoadString(script, "websocket_test");
|
|
if (!ok) {
|
|
error_msg = "Lua test failed: " + sandbox.GetLastError();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Acceptance Criteria
|
|
|
|
All tests must pass:
|
|
|
|
- [x] `Test_WebSocketUrlValidation` - WSS required, WS blocked
|
|
- [x] `Test_WebSocketConnectionLimits` - Per-app connection limits enforced
|
|
- [x] `Test_WebSocketBlocksPrivateIP` - SSRF prevention works
|
|
- [x] `Test_WebSocketDomainWhitelist` - Domain restrictions work
|
|
- [x] `Test_WebSocketMessageLimits` - Message size limits enforced
|
|
- [x] `Test_WebSocketCloseAll` - Cleanup works
|
|
- [x] `Test_WebSocketLuaIntegration` - Lua API works
|
|
|
|
---
|
|
|
|
## Dependencies
|
|
|
|
- Milestone 1 (LuaSandbox)
|
|
- Milestone 9 (HttpValidator)
|
|
|
|
---
|
|
|
|
## Notes
|
|
|
|
### Desktop vs Android Implementation
|
|
|
|
For desktop testing, WebSocketManager operates in mock mode:
|
|
- URL validation runs normally
|
|
- Connection limits are tracked
|
|
- Actual WebSocket connections are not made
|
|
- Events can be simulated for testing
|
|
|
|
On Android, the real implementation would:
|
|
1. Use Java WebSocket client through JNI
|
|
2. Handle background thread for socket I/O
|
|
3. Marshal events back to Lua thread
|
|
|
|
### Security Considerations
|
|
|
|
1. **Same-origin**: WSS URLs validated same as HTTPS
|
|
2. **Connection limits**: Prevent resource exhaustion
|
|
3. **Message limits**: Prevent memory exhaustion
|
|
4. **Idle timeout**: Automatic cleanup of abandoned connections
|
|
5. **Clean shutdown**: All connections closed on app stop
|
|
|
|
---
|
|
|
|
## Next Steps
|
|
|
|
After Milestone 10 passes:
|
|
1. Milestone 11: Virtual Hardware - Camera
|