luajitos

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

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