SafeHTTP.lua (16069B)
1 -- SafeHTTP: Sandboxed HTTP Access 2 -- Provides isolated HTTP access with domain restrictions and security 3 4 local SafeHTTP = {} 5 SafeHTTP.__index = SafeHTTP 6 SafeHTTP.__metatable = false -- Prevent metatable access/modification 7 8 -- Helper: Check if domain matches allowed pattern 9 local function matchesDomain(domain, pattern) 10 -- Normalize domains to lowercase 11 domain = domain:lower() 12 pattern = pattern:lower() 13 14 -- Exact match 15 if domain == pattern then 16 return true 17 end 18 19 -- Wildcard subdomain pattern (*.example.com) 20 if pattern:sub(1, 2) == "*." then 21 local base = pattern:sub(3) 22 -- Match exact base or any subdomain 23 if domain == base or domain:sub(-#base - 1) == "." .. base then 24 return true 25 end 26 end 27 28 -- Wildcard prefix (example.*) 29 if pattern:sub(-2) == ".*" then 30 local prefix = pattern:sub(1, -3) 31 if domain:sub(1, #prefix) == prefix then 32 return true 33 end 34 end 35 36 return false 37 end 38 39 -- Helper: Extract domain from URL 40 local function extractDomain(url) 41 -- Remove protocol 42 local domain = url:gsub("^%w+://", "") 43 44 -- Remove port 45 domain = domain:gsub(":%d+", "") 46 47 -- Remove path 48 domain = domain:gsub("/.*$", "") 49 50 return domain:lower() 51 end 52 53 -- Helper: Normalize URL 54 local function normalizeURL(url) 55 -- Add http:// if no protocol specified 56 if not url:match("^%w+://") then 57 url = "http://" .. url 58 end 59 60 return url 61 end 62 63 ---Create a new SafeHTTP instance 64 ---@param NetworkStack table Network stack instance 65 ---@param allowedDomains table|string Allowed domain(s) or patterns 66 ---@param options table|nil Optional settings {timeout, max_size} 67 ---@return table safehttp SafeHTTP instance 68 function SafeHTTP.new(NetworkStack, allowedDomains, options) 69 if not NetworkStack then 70 error("NetworkStack is required", 2) 71 end 72 73 -- Convert single domain to table 74 if type(allowedDomains) == "string" then 75 allowedDomains = {allowedDomains} 76 end 77 78 if not allowedDomains or #allowedDomains == 0 then 79 error("At least one allowed domain must be specified", 2) 80 end 81 82 options = options or {} 83 84 local instance = { 85 NetworkStack = NetworkStack, 86 allowedDomains = allowedDomains, 87 timeout = options.timeout or 30, 88 max_size = options.max_size or 1048576, -- 1MB default 89 user_agent = options.user_agent or "LuajitOS-SafeHTTP/1.0", 90 HTTP = nil, -- Loaded on demand 91 Socket = nil, -- Loaded on demand 92 active_requests = {}, 93 } 94 95 setmetatable(instance, SafeHTTP) 96 return instance 97 end 98 99 ---Check if a domain is allowed 100 ---@param self table SafeHTTP instance 101 ---@param domain string Domain to check 102 ---@return boolean allowed True if domain is allowed 103 function SafeHTTP:isDomainAllowed(domain) 104 domain = domain:lower() 105 106 for _, pattern in ipairs(self.allowedDomains) do 107 if matchesDomain(domain, pattern) then 108 return true 109 end 110 end 111 112 return false 113 end 114 115 ---Validate and normalize URL 116 ---@param self table SafeHTTP instance 117 ---@param url string URL to validate 118 ---@return string|nil url Normalized URL or nil if invalid 119 ---@return string|nil error Error message if invalid 120 function SafeHTTP:validateURL(url) 121 if not url or type(url) ~= "string" then 122 return nil, "Invalid URL" 123 end 124 125 -- Normalize URL 126 url = normalizeURL(url) 127 128 -- Extract and check domain 129 local domain = extractDomain(url) 130 131 if not self:isDomainAllowed(domain) then 132 return nil, "Domain not allowed: " .. domain 133 end 134 135 return url 136 end 137 138 ---Load HTTP library if not loaded 139 ---@param self table SafeHTTP instance 140 ---@return boolean success True if loaded 141 function SafeHTTP:ensureHTTP() 142 if self.HTTP then 143 return true 144 end 145 146 -- Try to load HTTP library 147 if CRamdiskOpen then 148 local http_handle = CRamdiskOpen("/os/libs/HTTP.lua", "r") 149 if http_handle then 150 local http_code = CRamdiskRead(http_handle) 151 CRamdiskClose(http_handle) 152 153 if http_code then 154 local http_func, err = load(http_code, "/os/libs/HTTP.lua", "t") 155 if http_func then 156 self.HTTP = http_func() 157 return true 158 end 159 end 160 end 161 end 162 163 return false 164 end 165 166 ---Perform HTTP GET request 167 ---@param self table SafeHTTP instance 168 ---@param url string URL to request 169 ---@param success_callback function Callback(response) on success 170 ---@param error_callback function|nil Optional callback(error_msg) on error 171 ---@return boolean started True if request started 172 function SafeHTTP:get(url, success_callback, error_callback) 173 -- Validate URL 174 local normalized_url, err = self:validateURL(url) 175 if not normalized_url then 176 if error_callback then 177 error_callback(err) 178 end 179 return false 180 end 181 182 -- Ensure HTTP library loaded 183 if not self:ensureHTTP() then 184 if error_callback then 185 error_callback("HTTP library not available") 186 end 187 return false 188 end 189 190 -- Create request context 191 local request_id = #self.active_requests + 1 192 local request = { 193 id = request_id, 194 method = "GET", 195 url = normalized_url, 196 success_callback = success_callback, 197 error_callback = error_callback, 198 start_time = os.time and os.time() or 0, 199 socket = nil, 200 response_data = {}, 201 connected = false, 202 closed = false, 203 } 204 205 self.active_requests[request_id] = request 206 207 -- Parse URL 208 local parsed = self.HTTP.parse_url(normalized_url) 209 if not parsed or not parsed.host then 210 self:completeRequest(request_id, nil, "Invalid URL format") 211 return false 212 end 213 214 -- Resolve host to IP 215 local ip 216 if parsed.host:match("^%d+%.%d+%.%d+%.%d+$") then 217 ip = self.NetworkStack.parse_ip(parsed.host) 218 else 219 -- For now, use gateway as default (real implementation would need DNS) 220 ip = self.NetworkStack.config.gateway 221 end 222 223 if not ip then 224 self:completeRequest(request_id, nil, "Could not resolve host") 225 return false 226 end 227 228 -- Load Socket library if needed 229 if not self.Socket then 230 local socket_handle = CRamdiskOpen("/os/libs/Socket.lua", "r") 231 if socket_handle then 232 local socket_code = CRamdiskRead(socket_handle) 233 CRamdiskClose(socket_handle) 234 if socket_code then 235 local socket_func = load(socket_code, "/os/libs/Socket.lua", "t") 236 if socket_func then 237 self.Socket = socket_func() 238 end 239 end 240 end 241 end 242 243 if not self.Socket then 244 self:completeRequest(request_id, nil, "Socket library not available") 245 return false 246 end 247 248 -- Create TCP socket 249 local sock = self.Socket.tcp(self.NetworkStack) 250 request.socket = sock 251 252 -- Set up callbacks 253 sock:on("connected", function() 254 request.connected = true 255 256 -- Build and send request 257 local headers = { 258 Host = parsed.host, 259 ["User-Agent"] = self.user_agent, 260 Connection = "close" 261 } 262 263 local path = parsed.path 264 if parsed.query then 265 path = path .. "?" .. parsed.query 266 end 267 268 local http_request = self.HTTP.build_request("GET", path, headers) 269 sock:send(http_request) 270 end) 271 272 sock:on("data", function(data) 273 table.insert(request.response_data, data) 274 275 -- Check size limit 276 local total_size = 0 277 for _, chunk in ipairs(request.response_data) do 278 total_size = total_size + #chunk 279 end 280 281 if total_size > self.max_size then 282 sock:close() 283 self:completeRequest(request_id, nil, "Response too large") 284 end 285 end) 286 287 sock:on("closed", function() 288 request.closed = true 289 local response_str = table.concat(request.response_data) 290 291 if #response_str > 0 then 292 local response = self.HTTP.parse_response(response_str) 293 if response then 294 self:completeRequest(request_id, response) 295 else 296 self:completeRequest(request_id, nil, "Failed to parse response") 297 end 298 else 299 self:completeRequest(request_id, nil, "Empty response") 300 end 301 end) 302 303 -- Connect 304 if not sock:connect(ip, parsed.port) then 305 self:completeRequest(request_id, nil, "Connection failed") 306 return false 307 end 308 309 return true 310 end 311 312 ---Perform HTTP POST request 313 ---@param self table SafeHTTP instance 314 ---@param url string URL to request 315 ---@param data table|string Data to post (table will be JSON encoded) 316 ---@param success_callback function Callback(response) on success 317 ---@param error_callback function|nil Optional callback(error_msg) on error 318 ---@return boolean started True if request started 319 function SafeHTTP:post(url, data, success_callback, error_callback) 320 -- Validate URL 321 local normalized_url, err = self:validateURL(url) 322 if not normalized_url then 323 if error_callback then 324 error_callback(err) 325 end 326 return false 327 end 328 329 -- Ensure HTTP library loaded 330 if not self:ensureHTTP() then 331 if error_callback then 332 error_callback("HTTP library not available") 333 end 334 return false 335 end 336 337 -- Encode data if table 338 local body 339 local content_type 340 if type(data) == "table" then 341 body = self.HTTP.json_encode(data) 342 content_type = "application/json" 343 else 344 body = tostring(data) 345 content_type = "application/x-www-form-urlencoded" 346 end 347 348 -- Create request context 349 local request_id = #self.active_requests + 1 350 local request = { 351 id = request_id, 352 method = "POST", 353 url = normalized_url, 354 body = body, 355 content_type = content_type, 356 success_callback = success_callback, 357 error_callback = error_callback, 358 start_time = os.time and os.time() or 0, 359 socket = nil, 360 response_data = {}, 361 connected = false, 362 closed = false, 363 } 364 365 self.active_requests[request_id] = request 366 367 -- Parse URL 368 local parsed = self.HTTP.parse_url(normalized_url) 369 if not parsed or not parsed.host then 370 self:completeRequest(request_id, nil, "Invalid URL format") 371 return false 372 end 373 374 -- Resolve host to IP 375 local ip 376 if parsed.host:match("^%d+%.%d+%.%d+%.%d+$") then 377 ip = self.NetworkStack.parse_ip(parsed.host) 378 else 379 ip = self.NetworkStack.config.gateway 380 end 381 382 if not ip then 383 self:completeRequest(request_id, nil, "Could not resolve host") 384 return false 385 end 386 387 -- Load Socket library if needed 388 if not self.Socket then 389 local socket_handle = CRamdiskOpen("/os/libs/Socket.lua", "r") 390 if socket_handle then 391 local socket_code = CRamdiskRead(socket_handle) 392 CRamdiskClose(socket_handle) 393 if socket_code then 394 local socket_func = load(socket_code, "/os/libs/Socket.lua", "t") 395 if socket_func then 396 self.Socket = socket_func() 397 end 398 end 399 end 400 end 401 402 if not self.Socket then 403 self:completeRequest(request_id, nil, "Socket library not available") 404 return false 405 end 406 407 -- Create TCP socket 408 local sock = self.Socket.tcp(self.NetworkStack) 409 request.socket = sock 410 411 -- Set up callbacks 412 sock:on("connected", function() 413 request.connected = true 414 415 -- Build and send request 416 local headers = { 417 Host = parsed.host, 418 ["User-Agent"] = self.user_agent, 419 ["Content-Type"] = content_type, 420 Connection = "close" 421 } 422 423 local path = parsed.path 424 if parsed.query then 425 path = path .. "?" .. parsed.query 426 end 427 428 local http_request = self.HTTP.build_request("POST", path, headers, body) 429 sock:send(http_request) 430 end) 431 432 sock:on("data", function(data) 433 table.insert(request.response_data, data) 434 435 -- Check size limit 436 local total_size = 0 437 for _, chunk in ipairs(request.response_data) do 438 total_size = total_size + #chunk 439 end 440 441 if total_size > self.max_size then 442 sock:close() 443 self:completeRequest(request_id, nil, "Response too large") 444 end 445 end) 446 447 sock:on("closed", function() 448 request.closed = true 449 local response_str = table.concat(request.response_data) 450 451 if #response_str > 0 then 452 local response = self.HTTP.parse_response(response_str) 453 if response then 454 self:completeRequest(request_id, response) 455 else 456 self:completeRequest(request_id, nil, "Failed to parse response") 457 end 458 else 459 self:completeRequest(request_id, nil, "Empty response") 460 end 461 end) 462 463 -- Connect 464 if not sock:connect(ip, parsed.port) then 465 self:completeRequest(request_id, nil, "Connection failed") 466 return false 467 end 468 469 return true 470 end 471 472 ---Complete a request and invoke callback 473 ---@param self table SafeHTTP instance 474 ---@param request_id number Request ID 475 ---@param response table|nil Response object 476 ---@param error_msg string|nil Error message 477 function SafeHTTP:completeRequest(request_id, response, error_msg) 478 local request = self.active_requests[request_id] 479 if not request then 480 return 481 end 482 483 -- Close socket if still open 484 if request.socket then 485 request.socket:close() 486 end 487 488 -- Invoke appropriate callback 489 if response and request.success_callback then 490 local success, err = pcall(request.success_callback, response) 491 if not success and request.error_callback then 492 request.error_callback("Callback error: " .. tostring(err)) 493 end 494 elseif error_msg and request.error_callback then 495 local success, err = pcall(request.error_callback, error_msg) 496 -- Silently fail if error callback also errors 497 end 498 499 -- Remove from active requests 500 self.active_requests[request_id] = nil 501 end 502 503 ---Process pending requests (must be called regularly) 504 ---@param self table SafeHTTP instance 505 function SafeHTTP:poll() 506 local current_time = os.time and os.time() or 0 507 508 -- Check for timeouts 509 for request_id, request in pairs(self.active_requests) do 510 if current_time - request.start_time > self.timeout then 511 self:completeRequest(request_id, nil, "Request timeout") 512 end 513 end 514 515 -- Poll network stack 516 if self.NetworkStack and self.NetworkStack.RTL8139 then 517 self.NetworkStack.RTL8139.poll() 518 end 519 end 520 521 ---Cancel a specific request 522 ---@param self table SafeHTTP instance 523 ---@param request_id number Request ID to cancel 524 function SafeHTTP:cancelRequest(request_id) 525 self:completeRequest(request_id, nil, "Request cancelled") 526 end 527 528 ---Cancel all active requests 529 ---@param self table SafeHTTP instance 530 function SafeHTTP:cancelAll() 531 local ids = {} 532 for request_id, _ in pairs(self.active_requests) do 533 table.insert(ids, request_id) 534 end 535 536 for _, request_id in ipairs(ids) do 537 self:cancelRequest(request_id) 538 end 539 end 540 541 ---Get list of allowed domains 542 ---@param self table SafeHTTP instance 543 ---@return table domains List of allowed domain patterns 544 function SafeHTTP:getAllowedDomains() 545 local copy = {} 546 for i, domain in ipairs(self.allowedDomains) do 547 copy[i] = domain 548 end 549 return copy 550 end 551 552 ---Get number of active requests 553 ---@param self table SafeHTTP instance 554 ---@return number count Number of active requests 555 function SafeHTTP:getActiveRequestCount() 556 local count = 0 557 for _, _ in pairs(self.active_requests) do 558 count = count + 1 559 end 560 return count 561 end 562 563 return SafeHTTP