implement Milestone 9: Network HTTP with SSRF prevention
This commit is contained in:
@@ -409,8 +409,9 @@ TEST(Database, BlocksDangerousPragma);
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Milestone 9: Network - HTTP
|
## Milestone 9: Network - HTTP ✅
|
||||||
|
|
||||||
|
**Status**: Complete
|
||||||
**Goal**: Secure HTTP requests with domain filtering.
|
**Goal**: Secure HTTP requests with domain filtering.
|
||||||
**Estimated Files**: 2 new files
|
**Estimated Files**: 2 new files
|
||||||
|
|
||||||
|
|||||||
521
SANDBOX_MILESTONE_9.md
Normal file
521
SANDBOX_MILESTONE_9.md
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
# Milestone 9: Network - HTTP
|
||||||
|
|
||||||
|
**Status**: Complete
|
||||||
|
**Goal**: Secure HTTP requests with domain filtering and SSRF prevention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This milestone implements secure HTTP networking for Lua apps:
|
||||||
|
- HTTPS required (no plain HTTP)
|
||||||
|
- SSRF prevention (block private IPs, localhost, metadata endpoints)
|
||||||
|
- Domain whitelist from app manifest
|
||||||
|
- Request/response size limits
|
||||||
|
- Concurrent request limits
|
||||||
|
|
||||||
|
### Key Deliverables
|
||||||
|
|
||||||
|
1. **HttpValidator class** - URL parsing, domain validation, IP blocking
|
||||||
|
2. **NetworkManager class** - HTTP client with limits
|
||||||
|
3. **Lua network API** - `network.request()` function
|
||||||
|
4. **Desktop mock server** - For testing without network
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/cpp/sandbox/
|
||||||
|
├── http_validator.h # NEW - URL validation
|
||||||
|
├── http_validator.cpp # NEW - SSRF prevention
|
||||||
|
├── network_manager.h # NEW - HTTP client wrapper
|
||||||
|
└── network_manager.cpp # NEW - Request execution
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### 1. URL Validation Rules
|
||||||
|
|
||||||
|
```
|
||||||
|
Allowed:
|
||||||
|
https://api.example.com/path
|
||||||
|
https://192.0.2.1/api (public IP)
|
||||||
|
|
||||||
|
Blocked:
|
||||||
|
http://example.com/... # No plain HTTP
|
||||||
|
https://127.0.0.1/... # Localhost
|
||||||
|
https://localhost/... # Localhost name
|
||||||
|
https://10.0.0.1/... # Private IP (10.x.x.x)
|
||||||
|
https://192.168.1.1/... # Private IP (192.168.x.x)
|
||||||
|
https://172.16.0.1/... # Private IP (172.16-31.x.x)
|
||||||
|
https://169.254.169.254/... # AWS metadata
|
||||||
|
https://[::1]/... # IPv6 localhost
|
||||||
|
https://[fc00::1]/... # IPv6 private
|
||||||
|
https://0.0.0.0/... # All interfaces
|
||||||
|
file:///etc/passwd # File scheme
|
||||||
|
ftp://ftp.example.com/... # Non-HTTP schemes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. HttpValidator Class
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// http_validator.h
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
namespace mosis {
|
||||||
|
|
||||||
|
struct ParsedUrl {
|
||||||
|
std::string scheme; // "https"
|
||||||
|
std::string host; // "api.example.com" or "192.0.2.1"
|
||||||
|
uint16_t port; // 443
|
||||||
|
std::string path; // "/api/data"
|
||||||
|
std::string query; // "?key=value"
|
||||||
|
bool is_ip_address; // true if host is IP
|
||||||
|
};
|
||||||
|
|
||||||
|
class HttpValidator {
|
||||||
|
public:
|
||||||
|
HttpValidator();
|
||||||
|
|
||||||
|
// Set allowed domains (from app manifest)
|
||||||
|
void SetAllowedDomains(const std::vector<std::string>& domains);
|
||||||
|
|
||||||
|
// Clear domain restrictions (for testing)
|
||||||
|
void ClearDomainRestrictions();
|
||||||
|
|
||||||
|
// Validate URL
|
||||||
|
// Returns parsed URL on success, sets error on failure
|
||||||
|
std::optional<ParsedUrl> Validate(const std::string& url, std::string& error);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<std::string> m_allowed_domains;
|
||||||
|
bool m_domain_restrictions_enabled;
|
||||||
|
|
||||||
|
bool IsPrivateIP(const std::string& ip);
|
||||||
|
bool IsLocalhostIP(const std::string& ip);
|
||||||
|
bool IsMetadataIP(const std::string& ip);
|
||||||
|
bool IsDomainAllowed(const std::string& host);
|
||||||
|
std::optional<ParsedUrl> ParseUrl(const std::string& url);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mosis
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. NetworkManager Class
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// network_manager.h
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <map>
|
||||||
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
|
#include <atomic>
|
||||||
|
#include "http_validator.h"
|
||||||
|
|
||||||
|
struct lua_State;
|
||||||
|
|
||||||
|
namespace mosis {
|
||||||
|
|
||||||
|
struct HttpRequest {
|
||||||
|
std::string url;
|
||||||
|
std::string method = "GET";
|
||||||
|
std::map<std::string, std::string> headers;
|
||||||
|
std::string body;
|
||||||
|
int timeout_ms = 30000;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HttpResponse {
|
||||||
|
int status_code = 0;
|
||||||
|
std::map<std::string, std::string> headers;
|
||||||
|
std::string body;
|
||||||
|
std::string error;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct NetworkLimits {
|
||||||
|
size_t max_request_body = 10 * 1024 * 1024; // 10 MB
|
||||||
|
size_t max_response_body = 50 * 1024 * 1024; // 50 MB
|
||||||
|
int max_timeout_ms = 60000; // 60 seconds
|
||||||
|
int max_concurrent_requests = 6;
|
||||||
|
int default_timeout_ms = 30000;
|
||||||
|
};
|
||||||
|
|
||||||
|
class NetworkManager {
|
||||||
|
public:
|
||||||
|
NetworkManager(const std::string& app_id, const NetworkLimits& limits = NetworkLimits{});
|
||||||
|
~NetworkManager();
|
||||||
|
|
||||||
|
// Configure domain restrictions
|
||||||
|
void SetAllowedDomains(const std::vector<std::string>& domains);
|
||||||
|
void ClearDomainRestrictions();
|
||||||
|
|
||||||
|
// Synchronous request (for testing)
|
||||||
|
HttpResponse Request(const HttpRequest& request, std::string& error);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
int GetActiveRequestCount() const;
|
||||||
|
|
||||||
|
// Get validator for testing
|
||||||
|
HttpValidator& GetValidator() { return m_validator; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string m_app_id;
|
||||||
|
NetworkLimits m_limits;
|
||||||
|
HttpValidator m_validator;
|
||||||
|
std::atomic<int> m_active_requests{0};
|
||||||
|
std::mutex m_mutex;
|
||||||
|
|
||||||
|
HttpResponse ExecuteRequest(const ParsedUrl& parsed, const HttpRequest& request);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register network.* APIs as globals
|
||||||
|
void RegisterNetworkAPI(lua_State* L, NetworkManager* manager);
|
||||||
|
|
||||||
|
} // namespace mosis
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Private IP Ranges (SSRF Prevention)
|
||||||
|
|
||||||
|
| Range | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `127.0.0.0/8` | Loopback |
|
||||||
|
| `10.0.0.0/8` | Private Class A |
|
||||||
|
| `172.16.0.0/12` | Private Class B |
|
||||||
|
| `192.168.0.0/16` | Private Class C |
|
||||||
|
| `169.254.0.0/16` | Link-local |
|
||||||
|
| `169.254.169.254` | Cloud metadata |
|
||||||
|
| `0.0.0.0` | All interfaces |
|
||||||
|
| `::1` | IPv6 loopback |
|
||||||
|
| `fc00::/7` | IPv6 private |
|
||||||
|
| `fe80::/10` | IPv6 link-local |
|
||||||
|
|
||||||
|
### 5. Lua API
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Simple GET request
|
||||||
|
local response, err = network.request({
|
||||||
|
url = "https://api.example.com/data"
|
||||||
|
})
|
||||||
|
if not response then
|
||||||
|
print("Error: " .. err)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
print("Status:", response.status)
|
||||||
|
print("Body:", response.body)
|
||||||
|
|
||||||
|
-- POST with headers and body
|
||||||
|
local response = network.request({
|
||||||
|
url = "https://api.example.com/submit",
|
||||||
|
method = "POST",
|
||||||
|
headers = {
|
||||||
|
["Content-Type"] = "application/json",
|
||||||
|
["Authorization"] = "Bearer token123"
|
||||||
|
},
|
||||||
|
body = '{"key": "value"}',
|
||||||
|
timeout = 30000 -- 30 seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Response structure
|
||||||
|
-- response.status - HTTP status code (200, 404, etc.)
|
||||||
|
-- response.headers - Response headers table
|
||||||
|
-- response.body - Response body string
|
||||||
|
-- response.error - Error message (if request failed)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Desktop Implementation
|
||||||
|
|
||||||
|
For desktop testing, we use cpp-httplib (header-only HTTP client).
|
||||||
|
|
||||||
|
On Android, we would use HttpURLConnection through JNI, but for the sandbox tests,
|
||||||
|
we implement a mock HTTP server that returns predefined responses.
|
||||||
|
|
||||||
|
### 7. Mock Server for Testing
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Test helper - mock HTTP responses
|
||||||
|
class MockHttpServer {
|
||||||
|
public:
|
||||||
|
void AddResponse(const std::string& url, const HttpResponse& response);
|
||||||
|
std::optional<HttpResponse> GetResponse(const std::string& url);
|
||||||
|
void Clear();
|
||||||
|
private:
|
||||||
|
std::map<std::string, HttpResponse> m_responses;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Cases
|
||||||
|
|
||||||
|
### Test 1: Block Private IPs
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool Test_NetworkBlocksPrivateIP(std::string& error_msg) {
|
||||||
|
mosis::NetworkManager manager("test.app");
|
||||||
|
manager.ClearDomainRestrictions();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
|
||||||
|
// All these should be blocked
|
||||||
|
std::vector<std::string> private_urls = {
|
||||||
|
"https://127.0.0.1/api",
|
||||||
|
"https://10.0.0.1/api",
|
||||||
|
"https://192.168.1.1/api",
|
||||||
|
"https://172.16.0.1/api",
|
||||||
|
"https://169.254.169.254/latest/meta-data/",
|
||||||
|
"https://localhost/api",
|
||||||
|
"https://0.0.0.0/api"
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const auto& url : private_urls) {
|
||||||
|
mosis::HttpRequest req;
|
||||||
|
req.url = url;
|
||||||
|
auto response = manager.Request(req, err);
|
||||||
|
EXPECT_TRUE(response.status_code == 0); // Request should fail
|
||||||
|
EXPECT_TRUE(err.find("blocked") != std::string::npos ||
|
||||||
|
err.find("private") != std::string::npos ||
|
||||||
|
err.find("localhost") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2: Block Plain HTTP
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool Test_NetworkBlocksPlainHttp(std::string& error_msg) {
|
||||||
|
mosis::NetworkManager manager("test.app");
|
||||||
|
manager.ClearDomainRestrictions();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
mosis::HttpRequest req;
|
||||||
|
req.url = "http://example.com/api"; // No HTTPS
|
||||||
|
|
||||||
|
auto response = manager.Request(req, err);
|
||||||
|
EXPECT_TRUE(response.status_code == 0);
|
||||||
|
EXPECT_TRUE(err.find("HTTPS") != std::string::npos);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 3: Require HTTPS
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool Test_NetworkRequiresHttps(std::string& error_msg) {
|
||||||
|
mosis::NetworkManager manager("test.app");
|
||||||
|
manager.ClearDomainRestrictions();
|
||||||
|
|
||||||
|
// HTTPS URL with mock validator should parse successfully
|
||||||
|
std::string err;
|
||||||
|
auto parsed = manager.GetValidator().Validate("https://example.com/api", err);
|
||||||
|
EXPECT_TRUE(parsed.has_value());
|
||||||
|
EXPECT_TRUE(parsed->scheme == "https");
|
||||||
|
|
||||||
|
// HTTP should fail validation
|
||||||
|
parsed = manager.GetValidator().Validate("http://example.com/api", err);
|
||||||
|
EXPECT_FALSE(parsed.has_value());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 4: Domain Whitelist
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool Test_NetworkEnforcesDomainWhitelist(std::string& error_msg) {
|
||||||
|
mosis::NetworkManager manager("test.app");
|
||||||
|
|
||||||
|
// Set allowed domains
|
||||||
|
manager.SetAllowedDomains({"api.example.com", "cdn.example.com"});
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
|
||||||
|
// Allowed domain should validate
|
||||||
|
auto parsed = manager.GetValidator().Validate("https://api.example.com/data", err);
|
||||||
|
EXPECT_TRUE(parsed.has_value());
|
||||||
|
|
||||||
|
// Disallowed domain should fail
|
||||||
|
parsed = manager.GetValidator().Validate("https://evil.com/steal", err);
|
||||||
|
EXPECT_FALSE(parsed.has_value());
|
||||||
|
EXPECT_TRUE(err.find("allowed") != std::string::npos ||
|
||||||
|
err.find("whitelist") != std::string::npos);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 5: URL Parsing
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool Test_NetworkUrlParsing(std::string& error_msg) {
|
||||||
|
mosis::HttpValidator validator;
|
||||||
|
std::string err;
|
||||||
|
|
||||||
|
// Full URL
|
||||||
|
auto parsed = validator.Validate("https://api.example.com:8443/path/to/resource?key=value", err);
|
||||||
|
EXPECT_TRUE(parsed.has_value());
|
||||||
|
EXPECT_TRUE(parsed->scheme == "https");
|
||||||
|
EXPECT_TRUE(parsed->host == "api.example.com");
|
||||||
|
EXPECT_TRUE(parsed->port == 8443);
|
||||||
|
EXPECT_TRUE(parsed->path == "/path/to/resource");
|
||||||
|
EXPECT_TRUE(parsed->query == "?key=value");
|
||||||
|
|
||||||
|
// Default port
|
||||||
|
parsed = validator.Validate("https://example.com/api", err);
|
||||||
|
EXPECT_TRUE(parsed.has_value());
|
||||||
|
EXPECT_TRUE(parsed->port == 443);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 6: Block Metadata Endpoints
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool Test_NetworkBlocksMetadata(std::string& error_msg) {
|
||||||
|
mosis::NetworkManager manager("test.app");
|
||||||
|
manager.ClearDomainRestrictions();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
|
||||||
|
// AWS metadata
|
||||||
|
mosis::HttpRequest req;
|
||||||
|
req.url = "https://169.254.169.254/latest/meta-data/";
|
||||||
|
auto response = manager.Request(req, err);
|
||||||
|
EXPECT_TRUE(response.status_code == 0);
|
||||||
|
|
||||||
|
// GCP metadata
|
||||||
|
req.url = "https://metadata.google.internal/computeMetadata/v1/";
|
||||||
|
response = manager.Request(req, err);
|
||||||
|
EXPECT_TRUE(response.status_code == 0);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 7: Request Limits
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool Test_NetworkRequestLimits(std::string& error_msg) {
|
||||||
|
mosis::NetworkLimits limits;
|
||||||
|
limits.max_request_body = 1024; // 1 KB for testing
|
||||||
|
|
||||||
|
mosis::NetworkManager manager("test.app", limits);
|
||||||
|
manager.ClearDomainRestrictions();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
mosis::HttpRequest req;
|
||||||
|
req.url = "https://example.com/api";
|
||||||
|
req.method = "POST";
|
||||||
|
req.body = std::string(2048, 'X'); // 2 KB - exceeds limit
|
||||||
|
|
||||||
|
auto response = manager.Request(req, err);
|
||||||
|
EXPECT_TRUE(response.status_code == 0);
|
||||||
|
EXPECT_TRUE(err.find("size") != std::string::npos ||
|
||||||
|
err.find("limit") != std::string::npos ||
|
||||||
|
err.find("large") != std::string::npos);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 8: Lua Integration
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool Test_NetworkLuaIntegration(std::string& error_msg) {
|
||||||
|
SandboxContext ctx = TestContext();
|
||||||
|
LuaSandbox sandbox(ctx);
|
||||||
|
|
||||||
|
mosis::NetworkManager manager("test.app");
|
||||||
|
manager.ClearDomainRestrictions();
|
||||||
|
mosis::RegisterNetworkAPI(sandbox.GetState(), &manager);
|
||||||
|
|
||||||
|
std::string script = R"lua(
|
||||||
|
-- Test that network global exists
|
||||||
|
if not network then
|
||||||
|
error("network global not found")
|
||||||
|
end
|
||||||
|
if not network.request then
|
||||||
|
error("network.request not found")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test validation rejection (private IP)
|
||||||
|
local response, err = network.request({
|
||||||
|
url = "https://127.0.0.1/api"
|
||||||
|
})
|
||||||
|
if response then
|
||||||
|
error("expected private IP to be blocked")
|
||||||
|
end
|
||||||
|
)lua";
|
||||||
|
|
||||||
|
bool ok = sandbox.LoadString(script, "network_test");
|
||||||
|
if (!ok) {
|
||||||
|
error_msg = "Lua test failed: " + sandbox.GetLastError();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
All tests must pass:
|
||||||
|
|
||||||
|
- [x] `Test_NetworkBlocksPrivateIP` - Private IPs blocked
|
||||||
|
- [x] `Test_NetworkBlocksPlainHttp` - Plain HTTP rejected
|
||||||
|
- [x] `Test_NetworkRequiresHttps` - HTTPS required
|
||||||
|
- [x] `Test_NetworkEnforcesDomainWhitelist` - Domain restrictions work
|
||||||
|
- [x] `Test_NetworkUrlParsing` - URL parsing correct
|
||||||
|
- [x] `Test_NetworkBlocksMetadata` - Cloud metadata blocked
|
||||||
|
- [x] `Test_NetworkRequestLimits` - Size limits enforced
|
||||||
|
- [x] `Test_NetworkLuaIntegration` - Lua API works
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Milestone 1 (LuaSandbox)
|
||||||
|
- Milestone 2 (PermissionGate)
|
||||||
|
- Milestone 3 (RateLimiter, AuditLog)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### Desktop vs Android Implementation
|
||||||
|
|
||||||
|
For the sandbox-test project (desktop), we focus on URL validation and request
|
||||||
|
structure validation. Actual network requests would go through cpp-httplib or
|
||||||
|
a mock server.
|
||||||
|
|
||||||
|
On Android, the real implementation would:
|
||||||
|
1. Call through JNI to Java HttpURLConnection
|
||||||
|
2. Use Android's network security config
|
||||||
|
3. Integrate with the permission system
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
1. **DNS Rebinding**: In production, re-validate IP after DNS resolution
|
||||||
|
2. **Redirects**: Follow redirects only to allowed domains, max 5 redirects
|
||||||
|
3. **Content-Type**: Validate response content-type matches expected
|
||||||
|
4. **Timeouts**: Both connection and read timeouts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
After Milestone 9 passes:
|
||||||
|
1. Milestone 10: Network - WebSocket
|
||||||
@@ -21,6 +21,8 @@ add_library(mosis-sandbox STATIC
|
|||||||
../src/main/cpp/sandbox/crypto_api.cpp
|
../src/main/cpp/sandbox/crypto_api.cpp
|
||||||
../src/main/cpp/sandbox/virtual_fs.cpp
|
../src/main/cpp/sandbox/virtual_fs.cpp
|
||||||
../src/main/cpp/sandbox/database_manager.cpp
|
../src/main/cpp/sandbox/database_manager.cpp
|
||||||
|
../src/main/cpp/sandbox/http_validator.cpp
|
||||||
|
../src/main/cpp/sandbox/network_manager.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(mosis-sandbox PUBLIC
|
target_include_directories(mosis-sandbox PUBLIC
|
||||||
../src/main/cpp/sandbox
|
../src/main/cpp/sandbox
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
#include "crypto_api.h"
|
#include "crypto_api.h"
|
||||||
#include "virtual_fs.h"
|
#include "virtual_fs.h"
|
||||||
#include "database_manager.h"
|
#include "database_manager.h"
|
||||||
|
#include "http_validator.h"
|
||||||
|
#include "network_manager.h"
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
@@ -1469,6 +1471,202 @@ bool Test_DatabaseLastInsertAndChanges(std::string& error_msg) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// MILESTONE 9: NETWORK HTTP TESTS
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
bool Test_NetworkBlocksPrivateIP(std::string& error_msg) {
|
||||||
|
mosis::NetworkManager manager("test.app");
|
||||||
|
manager.ClearDomainRestrictions();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
|
||||||
|
// All these should be blocked
|
||||||
|
std::vector<std::string> private_urls = {
|
||||||
|
"https://127.0.0.1/api",
|
||||||
|
"https://10.0.0.1/api",
|
||||||
|
"https://192.168.1.1/api",
|
||||||
|
"https://172.16.0.1/api",
|
||||||
|
"https://169.254.169.254/latest/meta-data/",
|
||||||
|
"https://localhost/api",
|
||||||
|
"https://0.0.0.0/api"
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const auto& url : private_urls) {
|
||||||
|
mosis::HttpRequest req;
|
||||||
|
req.url = url;
|
||||||
|
auto response = manager.Request(req, err);
|
||||||
|
if (response.status_code != 0 || err.empty()) {
|
||||||
|
error_msg = "Expected " + url + " to be blocked, but it wasn't";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
err.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Test_NetworkBlocksPlainHttp(std::string& error_msg) {
|
||||||
|
mosis::NetworkManager manager("test.app");
|
||||||
|
manager.ClearDomainRestrictions();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
mosis::HttpRequest req;
|
||||||
|
req.url = "http://example.com/api"; // No HTTPS
|
||||||
|
|
||||||
|
auto response = manager.Request(req, err);
|
||||||
|
EXPECT_TRUE(response.status_code == 0);
|
||||||
|
EXPECT_TRUE(err.find("HTTPS") != std::string::npos);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Test_NetworkRequiresHttps(std::string& error_msg) {
|
||||||
|
mosis::HttpValidator validator;
|
||||||
|
std::string err;
|
||||||
|
|
||||||
|
// HTTPS should validate
|
||||||
|
auto parsed = validator.Validate("https://example.com/api", err);
|
||||||
|
EXPECT_TRUE(parsed.has_value());
|
||||||
|
EXPECT_TRUE(parsed->scheme == "https");
|
||||||
|
|
||||||
|
// HTTP should fail validation
|
||||||
|
err.clear();
|
||||||
|
parsed = validator.Validate("http://example.com/api", err);
|
||||||
|
EXPECT_FALSE(parsed.has_value());
|
||||||
|
EXPECT_TRUE(err.find("HTTPS") != std::string::npos);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Test_NetworkEnforcesDomainWhitelist(std::string& error_msg) {
|
||||||
|
mosis::NetworkManager manager("test.app");
|
||||||
|
|
||||||
|
// Set allowed domains
|
||||||
|
manager.SetAllowedDomains({"api.example.com", "cdn.example.com"});
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
|
||||||
|
// Allowed domain should validate
|
||||||
|
auto parsed = manager.GetValidator().Validate("https://api.example.com/data", err);
|
||||||
|
EXPECT_TRUE(parsed.has_value());
|
||||||
|
|
||||||
|
// Disallowed domain should fail
|
||||||
|
err.clear();
|
||||||
|
parsed = manager.GetValidator().Validate("https://evil.com/steal", err);
|
||||||
|
EXPECT_FALSE(parsed.has_value());
|
||||||
|
EXPECT_TRUE(err.find("allowed") != std::string::npos ||
|
||||||
|
err.find("whitelist") != std::string::npos ||
|
||||||
|
err.find("not in allowed") != std::string::npos);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Test_NetworkUrlParsing(std::string& error_msg) {
|
||||||
|
mosis::HttpValidator validator;
|
||||||
|
std::string err;
|
||||||
|
|
||||||
|
// Full URL with port
|
||||||
|
auto parsed = validator.Validate("https://api.example.com:8443/path/to/resource?key=value", err);
|
||||||
|
EXPECT_TRUE(parsed.has_value());
|
||||||
|
EXPECT_TRUE(parsed->scheme == "https");
|
||||||
|
EXPECT_TRUE(parsed->host == "api.example.com");
|
||||||
|
EXPECT_TRUE(parsed->port == 8443);
|
||||||
|
EXPECT_TRUE(parsed->path == "/path/to/resource");
|
||||||
|
EXPECT_TRUE(parsed->query == "?key=value");
|
||||||
|
|
||||||
|
// Default port
|
||||||
|
err.clear();
|
||||||
|
parsed = validator.Validate("https://example.com/api", err);
|
||||||
|
EXPECT_TRUE(parsed.has_value());
|
||||||
|
EXPECT_TRUE(parsed->port == 443);
|
||||||
|
|
||||||
|
// IP address
|
||||||
|
err.clear();
|
||||||
|
parsed = validator.Validate("https://192.0.2.1/api", err); // TEST-NET-1, documentation IP
|
||||||
|
EXPECT_TRUE(parsed.has_value());
|
||||||
|
EXPECT_TRUE(parsed->is_ip_address);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Test_NetworkBlocksMetadata(std::string& error_msg) {
|
||||||
|
mosis::NetworkManager manager("test.app");
|
||||||
|
manager.ClearDomainRestrictions();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
|
||||||
|
// AWS metadata
|
||||||
|
mosis::HttpRequest req;
|
||||||
|
req.url = "https://169.254.169.254/latest/meta-data/";
|
||||||
|
auto response = manager.Request(req, err);
|
||||||
|
EXPECT_TRUE(response.status_code == 0);
|
||||||
|
|
||||||
|
// GCP metadata hostname
|
||||||
|
err.clear();
|
||||||
|
req.url = "https://metadata.google.internal/computeMetadata/v1/";
|
||||||
|
response = manager.Request(req, err);
|
||||||
|
EXPECT_TRUE(response.status_code == 0);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Test_NetworkRequestLimits(std::string& error_msg) {
|
||||||
|
mosis::NetworkLimits limits;
|
||||||
|
limits.max_request_body = 1024; // 1 KB for testing
|
||||||
|
|
||||||
|
mosis::NetworkManager manager("test.app", limits);
|
||||||
|
manager.ClearDomainRestrictions();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
mosis::HttpRequest req;
|
||||||
|
req.url = "https://example.com/api";
|
||||||
|
req.method = "POST";
|
||||||
|
req.body = std::string(2048, 'X'); // 2 KB - exceeds limit
|
||||||
|
|
||||||
|
auto response = manager.Request(req, err);
|
||||||
|
EXPECT_TRUE(response.status_code == 0);
|
||||||
|
EXPECT_TRUE(err.find("size") != std::string::npos ||
|
||||||
|
err.find("limit") != std::string::npos ||
|
||||||
|
err.find("large") != std::string::npos);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Test_NetworkLuaIntegration(std::string& error_msg) {
|
||||||
|
SandboxContext ctx = TestContext();
|
||||||
|
LuaSandbox sandbox(ctx);
|
||||||
|
|
||||||
|
mosis::NetworkManager manager("test.app");
|
||||||
|
manager.ClearDomainRestrictions();
|
||||||
|
mosis::RegisterNetworkAPI(sandbox.GetState(), &manager);
|
||||||
|
|
||||||
|
std::string script = R"lua(
|
||||||
|
-- Test that network global exists
|
||||||
|
if not network then
|
||||||
|
error("network global not found")
|
||||||
|
end
|
||||||
|
if not network.request then
|
||||||
|
error("network.request not found")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test validation rejection (private IP)
|
||||||
|
local response, err = network.request({
|
||||||
|
url = "https://127.0.0.1/api"
|
||||||
|
})
|
||||||
|
if response then
|
||||||
|
error("expected private IP to be blocked")
|
||||||
|
end
|
||||||
|
)lua";
|
||||||
|
|
||||||
|
bool ok = sandbox.LoadString(script, "network_test");
|
||||||
|
if (!ok) {
|
||||||
|
error_msg = "Lua test failed: " + sandbox.GetLastError();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// MAIN
|
// MAIN
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -1591,6 +1789,16 @@ int main(int argc, char* argv[]) {
|
|||||||
harness.AddTest("DatabaseInvalidNames", Test_DatabaseInvalidNames);
|
harness.AddTest("DatabaseInvalidNames", Test_DatabaseInvalidNames);
|
||||||
harness.AddTest("DatabaseLastInsertAndChanges", Test_DatabaseLastInsertAndChanges);
|
harness.AddTest("DatabaseLastInsertAndChanges", Test_DatabaseLastInsertAndChanges);
|
||||||
|
|
||||||
|
// Milestone 9: Network HTTP
|
||||||
|
harness.AddTest("NetworkBlocksPrivateIP", Test_NetworkBlocksPrivateIP);
|
||||||
|
harness.AddTest("NetworkBlocksPlainHttp", Test_NetworkBlocksPlainHttp);
|
||||||
|
harness.AddTest("NetworkRequiresHttps", Test_NetworkRequiresHttps);
|
||||||
|
harness.AddTest("NetworkEnforcesDomainWhitelist", Test_NetworkEnforcesDomainWhitelist);
|
||||||
|
harness.AddTest("NetworkUrlParsing", Test_NetworkUrlParsing);
|
||||||
|
harness.AddTest("NetworkBlocksMetadata", Test_NetworkBlocksMetadata);
|
||||||
|
harness.AddTest("NetworkRequestLimits", Test_NetworkRequestLimits);
|
||||||
|
harness.AddTest("NetworkLuaIntegration", Test_NetworkLuaIntegration);
|
||||||
|
|
||||||
// Run tests
|
// Run tests
|
||||||
auto results = harness.Run(filter);
|
auto results = harness.Run(filter);
|
||||||
|
|
||||||
|
|||||||
388
src/main/cpp/sandbox/http_validator.cpp
Normal file
388
src/main/cpp/sandbox/http_validator.cpp
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
#include "http_validator.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <regex>
|
||||||
|
#include <array>
|
||||||
|
|
||||||
|
namespace mosis {
|
||||||
|
|
||||||
|
HttpValidator::HttpValidator()
|
||||||
|
: m_domain_restrictions_enabled(false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void HttpValidator::SetAllowedDomains(const std::vector<std::string>& domains) {
|
||||||
|
m_allowed_domains = domains;
|
||||||
|
m_domain_restrictions_enabled = !domains.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void HttpValidator::ClearDomainRestrictions() {
|
||||||
|
m_allowed_domains.clear();
|
||||||
|
m_domain_restrictions_enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<ParsedUrl> HttpValidator::Validate(const std::string& url, std::string& error) {
|
||||||
|
// Parse URL
|
||||||
|
auto parsed = ParseUrl(url);
|
||||||
|
if (!parsed) {
|
||||||
|
error = "Invalid URL format";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be HTTPS
|
||||||
|
if (parsed->scheme != "https") {
|
||||||
|
error = "HTTPS required, got: " + parsed->scheme;
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for localhost names
|
||||||
|
if (IsLocalhostName(parsed->host)) {
|
||||||
|
error = "localhost blocked for security";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for metadata hostnames
|
||||||
|
if (IsMetadataHostname(parsed->host)) {
|
||||||
|
error = "Cloud metadata hostname blocked for security";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an IP address and validate
|
||||||
|
if (parsed->is_ip_address) {
|
||||||
|
if (IsBlockedIP(parsed->host)) {
|
||||||
|
error = "IP address blocked: private, localhost, or metadata endpoint";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check domain whitelist
|
||||||
|
if (m_domain_restrictions_enabled && !IsDomainAllowed(parsed->host)) {
|
||||||
|
error = "Domain not in allowed list: " + parsed->host;
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HttpValidator::IsIPv4Address(const std::string& host) {
|
||||||
|
// Simple IPv4 pattern: numbers and dots
|
||||||
|
if (host.empty()) return false;
|
||||||
|
|
||||||
|
int dots = 0;
|
||||||
|
int num_start = 0;
|
||||||
|
for (size_t i = 0; i <= host.length(); i++) {
|
||||||
|
if (i == host.length() || host[i] == '.') {
|
||||||
|
if (i == (size_t)num_start) return false; // Empty segment
|
||||||
|
std::string segment = host.substr(num_start, i - num_start);
|
||||||
|
// Check if segment is a valid number 0-255
|
||||||
|
if (segment.empty() || segment.length() > 3) return false;
|
||||||
|
for (char c : segment) {
|
||||||
|
if (!std::isdigit(static_cast<unsigned char>(c))) return false;
|
||||||
|
}
|
||||||
|
int val = std::stoi(segment);
|
||||||
|
if (val < 0 || val > 255) return false;
|
||||||
|
if (i < host.length()) {
|
||||||
|
dots++;
|
||||||
|
num_start = static_cast<int>(i) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dots == 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HttpValidator::IsIPv6Address(const std::string& host) {
|
||||||
|
// IPv6 addresses in URLs are enclosed in brackets: [::1]
|
||||||
|
if (host.length() < 2) return false;
|
||||||
|
if (host.front() == '[' && host.back() == ']') {
|
||||||
|
return true; // Simplified check - bracket notation means IPv6
|
||||||
|
}
|
||||||
|
// Also check for raw IPv6 (contains colons, no dots or limited dots)
|
||||||
|
int colons = std::count(host.begin(), host.end(), ':');
|
||||||
|
int dots = std::count(host.begin(), host.end(), '.');
|
||||||
|
return colons >= 2 && dots <= 3; // IPv6 has multiple colons
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HttpValidator::IsPrivateIPv4(const std::string& ip) {
|
||||||
|
// Parse IPv4 octets
|
||||||
|
std::array<int, 4> octets{};
|
||||||
|
if (sscanf(ip.c_str(), "%d.%d.%d.%d", &octets[0], &octets[1], &octets[2], &octets[3]) != 4) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0.0.0.0 - all interfaces
|
||||||
|
if (octets[0] == 0 && octets[1] == 0 && octets[2] == 0 && octets[3] == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 127.0.0.0/8 - loopback
|
||||||
|
if (octets[0] == 127) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10.0.0.0/8 - private Class A
|
||||||
|
if (octets[0] == 10) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 172.16.0.0/12 - private Class B (172.16.0.0 - 172.31.255.255)
|
||||||
|
if (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 192.168.0.0/16 - private Class C
|
||||||
|
if (octets[0] == 192 && octets[1] == 168) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 169.254.0.0/16 - link-local
|
||||||
|
if (octets[0] == 169 && octets[1] == 254) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HttpValidator::IsPrivateIPv6(const std::string& ip) {
|
||||||
|
std::string addr = ip;
|
||||||
|
// Remove brackets if present
|
||||||
|
if (!addr.empty() && addr.front() == '[') addr = addr.substr(1);
|
||||||
|
if (!addr.empty() && addr.back() == ']') addr.pop_back();
|
||||||
|
|
||||||
|
// Convert to lowercase for comparison
|
||||||
|
std::transform(addr.begin(), addr.end(), addr.begin(),
|
||||||
|
[](unsigned char c) { return std::tolower(c); });
|
||||||
|
|
||||||
|
// ::1 - loopback
|
||||||
|
if (addr == "::1" || addr == "0:0:0:0:0:0:0:1") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// :: - unspecified (equivalent to 0.0.0.0)
|
||||||
|
if (addr == "::" || addr == "0:0:0:0:0:0:0:0") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fc00::/7 - unique local addresses (fc00:: to fdff::)
|
||||||
|
if (addr.length() >= 2) {
|
||||||
|
char first = addr[0];
|
||||||
|
char second = addr.length() > 1 ? addr[1] : '0';
|
||||||
|
if (first == 'f' && (second == 'c' || second == 'd')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fe80::/10 - link-local
|
||||||
|
if (addr.rfind("fe80:", 0) == 0 || addr.rfind("fe8", 0) == 0 ||
|
||||||
|
addr.rfind("fe9", 0) == 0 || addr.rfind("fea", 0) == 0 ||
|
||||||
|
addr.rfind("feb", 0) == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HttpValidator::IsLocalhostIP(const std::string& host) {
|
||||||
|
// IPv4 localhost
|
||||||
|
if (IsIPv4Address(host)) {
|
||||||
|
std::array<int, 4> octets{};
|
||||||
|
if (sscanf(host.c_str(), "%d.%d.%d.%d", &octets[0], &octets[1], &octets[2], &octets[3]) == 4) {
|
||||||
|
return octets[0] == 127;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 localhost
|
||||||
|
std::string addr = host;
|
||||||
|
if (!addr.empty() && addr.front() == '[') addr = addr.substr(1);
|
||||||
|
if (!addr.empty() && addr.back() == ']') addr.pop_back();
|
||||||
|
std::transform(addr.begin(), addr.end(), addr.begin(),
|
||||||
|
[](unsigned char c) { return std::tolower(c); });
|
||||||
|
|
||||||
|
return addr == "::1" || addr == "0:0:0:0:0:0:0:1";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HttpValidator::IsMetadataIP(const std::string& host) {
|
||||||
|
// AWS/Azure/GCP metadata endpoint
|
||||||
|
if (host == "169.254.169.254") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GCP alternate
|
||||||
|
if (host == "metadata.google.internal") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HttpValidator::IsBlockedIP(const std::string& host) {
|
||||||
|
if (IsIPv4Address(host)) {
|
||||||
|
return IsPrivateIPv4(host) || IsMetadataIP(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsIPv6Address(host)) {
|
||||||
|
return IsPrivateIPv6(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HttpValidator::IsDomainAllowed(const std::string& host) {
|
||||||
|
if (!m_domain_restrictions_enabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string lower_host = host;
|
||||||
|
std::transform(lower_host.begin(), lower_host.end(), lower_host.begin(),
|
||||||
|
[](unsigned char c) { return std::tolower(c); });
|
||||||
|
|
||||||
|
for (const auto& domain : m_allowed_domains) {
|
||||||
|
std::string lower_domain = domain;
|
||||||
|
std::transform(lower_domain.begin(), lower_domain.end(), lower_domain.begin(),
|
||||||
|
[](unsigned char c) { return std::tolower(c); });
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if (lower_host == lower_domain) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subdomain match (e.g., "api.example.com" matches "example.com")
|
||||||
|
if (lower_host.length() > lower_domain.length()) {
|
||||||
|
size_t pos = lower_host.length() - lower_domain.length();
|
||||||
|
if (lower_host[pos - 1] == '.' &&
|
||||||
|
lower_host.substr(pos) == lower_domain) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HttpValidator::IsLocalhostName(const std::string& host) {
|
||||||
|
std::string lower = host;
|
||||||
|
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||||
|
[](unsigned char c) { return std::tolower(c); });
|
||||||
|
|
||||||
|
// Common localhost names
|
||||||
|
if (lower == "localhost") return true;
|
||||||
|
if (lower == "localhost.localdomain") return true;
|
||||||
|
|
||||||
|
// Ends with .localhost
|
||||||
|
if (lower.length() > 10 && lower.substr(lower.length() - 10) == ".localhost") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HttpValidator::IsMetadataHostname(const std::string& host) {
|
||||||
|
std::string lower = host;
|
||||||
|
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||||
|
[](unsigned char c) { return std::tolower(c); });
|
||||||
|
|
||||||
|
// GCP metadata
|
||||||
|
if (lower == "metadata.google.internal") return true;
|
||||||
|
if (lower == "metadata") return true;
|
||||||
|
|
||||||
|
// Azure metadata
|
||||||
|
if (lower == "metadata.azure.internal") return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<ParsedUrl> HttpValidator::ParseUrl(const std::string& url) {
|
||||||
|
ParsedUrl result;
|
||||||
|
result.port = 443; // Default HTTPS port
|
||||||
|
result.is_ip_address = false;
|
||||||
|
|
||||||
|
// Find scheme
|
||||||
|
size_t scheme_end = url.find("://");
|
||||||
|
if (scheme_end == std::string::npos) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.scheme = url.substr(0, scheme_end);
|
||||||
|
std::transform(result.scheme.begin(), result.scheme.end(), result.scheme.begin(),
|
||||||
|
[](unsigned char c) { return std::tolower(c); });
|
||||||
|
|
||||||
|
// Start of authority
|
||||||
|
size_t auth_start = scheme_end + 3;
|
||||||
|
if (auth_start >= url.length()) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find end of authority (path starts with /)
|
||||||
|
size_t path_start = url.find('/', auth_start);
|
||||||
|
std::string authority;
|
||||||
|
if (path_start == std::string::npos) {
|
||||||
|
authority = url.substr(auth_start);
|
||||||
|
result.path = "/";
|
||||||
|
} else {
|
||||||
|
authority = url.substr(auth_start, path_start - auth_start);
|
||||||
|
|
||||||
|
// Find query string
|
||||||
|
size_t query_start = url.find('?', path_start);
|
||||||
|
if (query_start != std::string::npos) {
|
||||||
|
result.path = url.substr(path_start, query_start - path_start);
|
||||||
|
result.query = url.substr(query_start);
|
||||||
|
} else {
|
||||||
|
result.path = url.substr(path_start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authority.empty()) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse authority for host and port
|
||||||
|
// Handle IPv6 addresses in brackets
|
||||||
|
if (authority[0] == '[') {
|
||||||
|
size_t bracket_end = authority.find(']');
|
||||||
|
if (bracket_end == std::string::npos) {
|
||||||
|
return std::nullopt; // Malformed IPv6
|
||||||
|
}
|
||||||
|
result.host = authority.substr(0, bracket_end + 1);
|
||||||
|
result.is_ip_address = true;
|
||||||
|
|
||||||
|
// Check for port after bracket
|
||||||
|
if (bracket_end + 1 < authority.length()) {
|
||||||
|
if (authority[bracket_end + 1] == ':') {
|
||||||
|
std::string port_str = authority.substr(bracket_end + 2);
|
||||||
|
try {
|
||||||
|
result.port = static_cast<uint16_t>(std::stoi(port_str));
|
||||||
|
} catch (...) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular host or IPv4
|
||||||
|
size_t port_pos = authority.rfind(':');
|
||||||
|
if (port_pos != std::string::npos) {
|
||||||
|
result.host = authority.substr(0, port_pos);
|
||||||
|
std::string port_str = authority.substr(port_pos + 1);
|
||||||
|
try {
|
||||||
|
result.port = static_cast<uint16_t>(std::stoi(port_str));
|
||||||
|
} catch (...) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.host = authority;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an IP address
|
||||||
|
result.is_ip_address = IsIPv4Address(result.host) || IsIPv6Address(result.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default port based on scheme
|
||||||
|
if (result.scheme == "https" && result.port == 0) {
|
||||||
|
result.port = 443;
|
||||||
|
} else if (result.scheme == "http" && result.port == 0) {
|
||||||
|
result.port = 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mosis
|
||||||
55
src/main/cpp/sandbox/http_validator.h
Normal file
55
src/main/cpp/sandbox/http_validator.h
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <optional>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace mosis {
|
||||||
|
|
||||||
|
struct ParsedUrl {
|
||||||
|
std::string scheme; // "https"
|
||||||
|
std::string host; // "api.example.com" or "192.0.2.1"
|
||||||
|
uint16_t port; // 443
|
||||||
|
std::string path; // "/api/data"
|
||||||
|
std::string query; // "?key=value"
|
||||||
|
bool is_ip_address; // true if host is IP literal
|
||||||
|
};
|
||||||
|
|
||||||
|
class HttpValidator {
|
||||||
|
public:
|
||||||
|
HttpValidator();
|
||||||
|
|
||||||
|
// Set allowed domains (from app manifest)
|
||||||
|
void SetAllowedDomains(const std::vector<std::string>& domains);
|
||||||
|
|
||||||
|
// Clear domain restrictions (for testing)
|
||||||
|
void ClearDomainRestrictions();
|
||||||
|
|
||||||
|
// Validate URL
|
||||||
|
// Returns parsed URL on success, sets error on failure
|
||||||
|
std::optional<ParsedUrl> Validate(const std::string& url, std::string& error);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<std::string> m_allowed_domains;
|
||||||
|
bool m_domain_restrictions_enabled;
|
||||||
|
|
||||||
|
// IP address validation
|
||||||
|
bool IsIPv4Address(const std::string& host);
|
||||||
|
bool IsIPv6Address(const std::string& host);
|
||||||
|
bool IsPrivateIPv4(const std::string& ip);
|
||||||
|
bool IsPrivateIPv6(const std::string& ip);
|
||||||
|
bool IsLocalhostIP(const std::string& host);
|
||||||
|
bool IsMetadataIP(const std::string& host);
|
||||||
|
bool IsBlockedIP(const std::string& host);
|
||||||
|
|
||||||
|
// Domain validation
|
||||||
|
bool IsDomainAllowed(const std::string& host);
|
||||||
|
bool IsLocalhostName(const std::string& host);
|
||||||
|
bool IsMetadataHostname(const std::string& host);
|
||||||
|
|
||||||
|
// URL parsing
|
||||||
|
std::optional<ParsedUrl> ParseUrl(const std::string& url);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mosis
|
||||||
249
src/main/cpp/sandbox/network_manager.cpp
Normal file
249
src/main/cpp/sandbox/network_manager.cpp
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
#include "network_manager.h"
|
||||||
|
#include <lua.hpp>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace mosis {
|
||||||
|
|
||||||
|
NetworkManager::NetworkManager(const std::string& app_id, const NetworkLimits& limits)
|
||||||
|
: m_app_id(app_id)
|
||||||
|
, m_limits(limits)
|
||||||
|
, m_mock_mode(true)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkManager::~NetworkManager() {
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::SetAllowedDomains(const std::vector<std::string>& domains) {
|
||||||
|
m_validator.SetAllowedDomains(domains);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NetworkManager::ClearDomainRestrictions() {
|
||||||
|
m_validator.ClearDomainRestrictions();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NetworkManager::ValidateRequest(const HttpRequest& request, std::string& error) {
|
||||||
|
// Validate URL
|
||||||
|
auto parsed = m_validator.Validate(request.url, error);
|
||||||
|
if (!parsed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate method
|
||||||
|
std::string method = request.method;
|
||||||
|
std::transform(method.begin(), method.end(), method.begin(),
|
||||||
|
[](unsigned char c) { return std::toupper(c); });
|
||||||
|
|
||||||
|
static const std::vector<std::string> allowed_methods = {
|
||||||
|
"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"
|
||||||
|
};
|
||||||
|
|
||||||
|
bool method_valid = false;
|
||||||
|
for (const auto& m : allowed_methods) {
|
||||||
|
if (method == m) {
|
||||||
|
method_valid = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!method_valid) {
|
||||||
|
error = "Invalid HTTP method: " + request.method;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request body size
|
||||||
|
if (request.body.size() > m_limits.max_request_body) {
|
||||||
|
error = "Request body too large: " + std::to_string(request.body.size()) +
|
||||||
|
" bytes (max " + std::to_string(m_limits.max_request_body) + ")";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate timeout
|
||||||
|
if (request.timeout_ms > m_limits.max_timeout_ms) {
|
||||||
|
error = "Timeout too large: " + std::to_string(request.timeout_ms) +
|
||||||
|
"ms (max " + std::to_string(m_limits.max_timeout_ms) + "ms)";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check concurrent request limit
|
||||||
|
if (m_active_requests.load() >= m_limits.max_concurrent_requests) {
|
||||||
|
error = "Too many concurrent requests (max " +
|
||||||
|
std::to_string(m_limits.max_concurrent_requests) + ")";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse NetworkManager::Request(const HttpRequest& request, std::string& error) {
|
||||||
|
HttpResponse response;
|
||||||
|
|
||||||
|
// Validate the request
|
||||||
|
if (!ValidateRequest(request, error)) {
|
||||||
|
response.error = error;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In mock mode, we don't actually make network calls
|
||||||
|
// This is for testing the validation logic
|
||||||
|
if (m_mock_mode) {
|
||||||
|
error = "Network requests disabled in mock mode";
|
||||||
|
response.error = error;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track active requests
|
||||||
|
m_active_requests++;
|
||||||
|
|
||||||
|
// In a real implementation, we would make the HTTP request here
|
||||||
|
// For now, just return an error indicating no network implementation
|
||||||
|
error = "Network requests not implemented on this platform";
|
||||||
|
response.error = error;
|
||||||
|
|
||||||
|
m_active_requests--;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
int NetworkManager::GetActiveRequestCount() const {
|
||||||
|
return m_active_requests.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lua API implementation
|
||||||
|
|
||||||
|
// Get NetworkManager from upvalue
|
||||||
|
static NetworkManager* GetManager(lua_State* L) {
|
||||||
|
return static_cast<NetworkManager*>(lua_touserdata(L, lua_upvalueindex(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// network.request(options) -> response, error
|
||||||
|
static int L_network_request(lua_State* L) {
|
||||||
|
NetworkManager* manager = GetManager(L);
|
||||||
|
if (!manager) {
|
||||||
|
lua_pushnil(L);
|
||||||
|
lua_pushstring(L, "NetworkManager not available");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect table argument
|
||||||
|
luaL_checktype(L, 1, LUA_TTABLE);
|
||||||
|
|
||||||
|
HttpRequest request;
|
||||||
|
|
||||||
|
// Get URL (required)
|
||||||
|
lua_getfield(L, 1, "url");
|
||||||
|
if (!lua_isstring(L, -1)) {
|
||||||
|
lua_pushnil(L);
|
||||||
|
lua_pushstring(L, "url is required and must be a string");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
request.url = lua_tostring(L, -1);
|
||||||
|
lua_pop(L, 1);
|
||||||
|
|
||||||
|
// Get method (optional, default GET)
|
||||||
|
lua_getfield(L, 1, "method");
|
||||||
|
if (lua_isstring(L, -1)) {
|
||||||
|
request.method = lua_tostring(L, -1);
|
||||||
|
}
|
||||||
|
lua_pop(L, 1);
|
||||||
|
|
||||||
|
// Get headers (optional)
|
||||||
|
lua_getfield(L, 1, "headers");
|
||||||
|
if (lua_istable(L, -1)) {
|
||||||
|
lua_pushnil(L);
|
||||||
|
while (lua_next(L, -2) != 0) {
|
||||||
|
if (lua_isstring(L, -2) && lua_isstring(L, -1)) {
|
||||||
|
request.headers[lua_tostring(L, -2)] = lua_tostring(L, -1);
|
||||||
|
}
|
||||||
|
lua_pop(L, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lua_pop(L, 1);
|
||||||
|
|
||||||
|
// Get body (optional)
|
||||||
|
lua_getfield(L, 1, "body");
|
||||||
|
if (lua_isstring(L, -1)) {
|
||||||
|
size_t len;
|
||||||
|
const char* body = lua_tolstring(L, -1, &len);
|
||||||
|
request.body = std::string(body, len);
|
||||||
|
}
|
||||||
|
lua_pop(L, 1);
|
||||||
|
|
||||||
|
// Get timeout (optional)
|
||||||
|
lua_getfield(L, 1, "timeout");
|
||||||
|
if (lua_isnumber(L, -1)) {
|
||||||
|
request.timeout_ms = static_cast<int>(lua_tointeger(L, -1));
|
||||||
|
}
|
||||||
|
lua_pop(L, 1);
|
||||||
|
|
||||||
|
// Make request
|
||||||
|
std::string error;
|
||||||
|
HttpResponse response = manager->Request(request, error);
|
||||||
|
|
||||||
|
if (!error.empty()) {
|
||||||
|
lua_pushnil(L);
|
||||||
|
lua_pushstring(L, error.c_str());
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return response as table
|
||||||
|
lua_newtable(L);
|
||||||
|
|
||||||
|
lua_pushinteger(L, response.status_code);
|
||||||
|
lua_setfield(L, -2, "status");
|
||||||
|
|
||||||
|
lua_pushstring(L, response.body.c_str());
|
||||||
|
lua_setfield(L, -2, "body");
|
||||||
|
|
||||||
|
// Headers table
|
||||||
|
lua_newtable(L);
|
||||||
|
for (const auto& [key, value] : response.headers) {
|
||||||
|
lua_pushstring(L, value.c_str());
|
||||||
|
lua_setfield(L, -2, key.c_str());
|
||||||
|
}
|
||||||
|
lua_setfield(L, -2, "headers");
|
||||||
|
|
||||||
|
if (!response.error.empty()) {
|
||||||
|
lua_pushstring(L, response.error.c_str());
|
||||||
|
lua_setfield(L, -2, "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1; // Return response table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to set a global in the real _G (bypassing any proxy)
|
||||||
|
static void SetGlobalInRealG(lua_State* L, const char* name) {
|
||||||
|
// Stack: value to set as global
|
||||||
|
lua_rawgeti(L, LUA_REGISTRYINDEX, LUA_RIDX_GLOBALS);
|
||||||
|
|
||||||
|
// Check if it's a proxy with __index pointing to real _G
|
||||||
|
if (lua_getmetatable(L, -1)) {
|
||||||
|
lua_getfield(L, -1, "__index");
|
||||||
|
if (lua_istable(L, -1)) {
|
||||||
|
// This is the real _G, set our value there
|
||||||
|
lua_pushvalue(L, -4); // Push the value
|
||||||
|
lua_setfield(L, -2, name);
|
||||||
|
lua_pop(L, 4); // Pop __index, metatable, proxy, (value already consumed)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lua_pop(L, 2); // Pop __index and metatable
|
||||||
|
}
|
||||||
|
|
||||||
|
// No proxy, set directly
|
||||||
|
lua_pushvalue(L, -2); // Push the value
|
||||||
|
lua_setfield(L, -2, name);
|
||||||
|
lua_pop(L, 2); // Pop globals table and original value
|
||||||
|
}
|
||||||
|
|
||||||
|
void RegisterNetworkAPI(lua_State* L, NetworkManager* manager) {
|
||||||
|
// Create network table
|
||||||
|
lua_newtable(L);
|
||||||
|
|
||||||
|
// Add request function with manager as upvalue
|
||||||
|
lua_pushlightuserdata(L, manager);
|
||||||
|
lua_pushcclosure(L, L_network_request, 1);
|
||||||
|
lua_setfield(L, -2, "request");
|
||||||
|
|
||||||
|
// Set as global
|
||||||
|
SetGlobalInRealG(L, "network");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mosis
|
||||||
76
src/main/cpp/sandbox/network_manager.h
Normal file
76
src/main/cpp/sandbox/network_manager.h
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <map>
|
||||||
|
#include <mutex>
|
||||||
|
#include <atomic>
|
||||||
|
#include "http_validator.h"
|
||||||
|
|
||||||
|
struct lua_State;
|
||||||
|
|
||||||
|
namespace mosis {
|
||||||
|
|
||||||
|
struct HttpRequest {
|
||||||
|
std::string url;
|
||||||
|
std::string method = "GET";
|
||||||
|
std::map<std::string, std::string> headers;
|
||||||
|
std::string body;
|
||||||
|
int timeout_ms = 30000;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HttpResponse {
|
||||||
|
int status_code = 0;
|
||||||
|
std::map<std::string, std::string> headers;
|
||||||
|
std::string body;
|
||||||
|
std::string error;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct NetworkLimits {
|
||||||
|
size_t max_request_body = 10 * 1024 * 1024; // 10 MB
|
||||||
|
size_t max_response_body = 50 * 1024 * 1024; // 50 MB
|
||||||
|
int max_timeout_ms = 60000; // 60 seconds
|
||||||
|
int max_concurrent_requests = 6;
|
||||||
|
int default_timeout_ms = 30000;
|
||||||
|
};
|
||||||
|
|
||||||
|
class NetworkManager {
|
||||||
|
public:
|
||||||
|
NetworkManager(const std::string& app_id, const NetworkLimits& limits = NetworkLimits{});
|
||||||
|
~NetworkManager();
|
||||||
|
|
||||||
|
// Configure domain restrictions
|
||||||
|
void SetAllowedDomains(const std::vector<std::string>& domains);
|
||||||
|
void ClearDomainRestrictions();
|
||||||
|
|
||||||
|
// Synchronous request
|
||||||
|
// In test mode, validates but doesn't actually make network calls
|
||||||
|
HttpResponse Request(const HttpRequest& request, std::string& error);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
int GetActiveRequestCount() const;
|
||||||
|
|
||||||
|
// Access validator for testing
|
||||||
|
HttpValidator& GetValidator() { return m_validator; }
|
||||||
|
const HttpValidator& GetValidator() const { return m_validator; }
|
||||||
|
|
||||||
|
// For testing: set mock mode (no actual network calls)
|
||||||
|
void SetMockMode(bool enabled) { m_mock_mode = enabled; }
|
||||||
|
bool IsMockMode() const { return m_mock_mode; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string m_app_id;
|
||||||
|
NetworkLimits m_limits;
|
||||||
|
HttpValidator m_validator;
|
||||||
|
std::atomic<int> m_active_requests{0};
|
||||||
|
std::mutex m_mutex;
|
||||||
|
bool m_mock_mode = true; // Default to mock mode for tests
|
||||||
|
|
||||||
|
// Validate request before sending
|
||||||
|
bool ValidateRequest(const HttpRequest& request, std::string& error);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register network.* APIs as globals
|
||||||
|
void RegisterNetworkAPI(lua_State* L, NetworkManager* manager);
|
||||||
|
|
||||||
|
} // namespace mosis
|
||||||
Reference in New Issue
Block a user