diff --git a/clamav/clamav.lua b/clamav/clamav.lua index 7d9dd22..047e179 100644 --- a/clamav/clamav.lua +++ b/clamav/clamav.lua @@ -1,318 +1,297 @@ +-- ClamAV plugin for BunkerWeb with improved multipart parsing and HTTP/2 support local class = require("middleclass") local plugin = require("bunkerweb.plugin") -local sha512 = require("resty.sha512") -local str = require("resty.string") -local upload = require("resty.upload") local utils = require("bunkerweb.utils") local clamav = class("clamav", plugin) local ngx = ngx -local NOTICE = ngx.NOTICE local ERR = ngx.ERR -local socket = ngx.socket +local NOTICE = ngx.NOTICE local HTTP_INTERNAL_SERVER_ERROR = ngx.HTTP_INTERNAL_SERVER_ERROR +local HTTP_FORBIDDEN = ngx.HTTP_FORBIDDEN local HTTP_OK = ngx.HTTP_OK -local to_hex = str.to_hex local has_variable = utils.has_variable local get_deny_status = utils.get_deny_status -local tonumber = tonumber -local floor = math.floor - -local stream_size = function(size) - return ("%c%c%c%c") - :format( - size % 0x100, - floor(size / 0x100) % 0x100, - floor(size / 0x10000) % 0x100, - floor(size / 0x1000000) % 0x100 - ) - :reverse() -end -local read_all = function(form) - while true do - local typ = form:read() - if not typ then - return - end - if typ == "eof" then - return - end - end -end +local bit = require("bit") function clamav:initialize(ctx) - -- Call parent initialize - plugin.initialize(self, "clamav", ctx) + plugin.initialize(self, "clamav", ctx) end -function clamav:init_worker() - -- Check if worker is needed - local init_needed, err = has_variable("USE_CLAMAV", "yes") - if init_needed == nil then - return self:ret(false, "can't check USE_CLAMAV variable : " .. err) - end - if not init_needed or self.is_loading then - return self:ret(true, "init_worker not needed") - end - -- Send PING to ClamAV - local ok, data = self:command("PING") - if not ok then - return self:ret(false, "connectivity with ClamAV failed : " .. data) - end - if data ~= "PONG" then - return self:ret(false, "wrong data received from ClamAV : " .. data) - end - self.logger:log( - NOTICE, - "connectivity with " - .. self.variables["CLAMAV_HOST"] - .. ":" - .. self.variables["CLAMAV_PORT"] - .. " is successful" - ) - return self:ret(true, "success") +function clamav:socket() + local sock = ngx.socket.tcp() + sock:settimeout(tonumber(self.variables["CLAMAV_TIMEOUT"] or 5000)) + local ok, err = sock:connect(self.variables["CLAMAV_HOST"], tonumber(self.variables["CLAMAV_PORT"])) + if not ok then + return nil, err + end + return sock end -function clamav:access() - -- Check if ClamAV is activated - if self.variables["USE_CLAMAV"] ~= "yes" then - return self:ret(true, "ClamAV plugin not enabled") - end +function clamav:ping() + local sock, err = self:socket() + if not sock then + return false, err + end + local ok = sock:send("nPING\n") + if not ok then + sock:close() + return false, "send failed" + end + local res = sock:receive("*l") + sock:close() + return res == "PONG", res +end - -- Check if we have downloads - if - not self.ctx.bw.http_content_type - or ( - not self.ctx.bw.http_content_type:match("boundary") - or not self.ctx.bw.http_content_type:match("multipart/form%-data") - ) - then - return self:ret(true, "no file upload detected") - end +-- Improved boundary extraction function with better quote handling +function clamav:extract_boundary(content_type) + if not content_type then + return nil + end + + -- Handle both boundary=value and boundary="value" formats + local boundary = content_type:match('boundary=([^;%s]+)') + if not boundary then + boundary = content_type:match('boundary="([^"]+)"') + end + + if boundary then + -- Remove quotes if present + boundary = boundary:gsub('^"', ''):gsub('"$', '') + return "--" .. boundary + end + + return nil +end - -- Check files - local ok, detected, checksum = self:scan() - if not ok then - return self:ret(false, "error while scanning file(s) : " .. detected) - end - if detected then - return self:ret( - true, - "file with checksum " .. checksum .. "is detected : " .. detected, - get_deny_status(), - nil, - { - id = "detected", - checksum = checksum, - signature = detected, - } - ) - end - return self:ret(true, "no file detected") +-- Enhanced multipart parsing function with proper HTTP/2 support +function clamav:parse_multipart(body, boundary) + if not body or not boundary then + return {} + end + + local parts = {} + + -- Split body by boundary markers + local sections = {} + local current_pos = 1 + + -- Find first boundary + local first_boundary_pos = body:find(boundary, 1, true) + if not first_boundary_pos then + return {} + end + + current_pos = first_boundary_pos + #boundary + + while true do + -- Find next boundary + local next_boundary_pos = body:find(boundary, current_pos, true) + if not next_boundary_pos then + -- Last section + local section = body:sub(current_pos) + if section and #section > 10 then -- Minimum length check + table.insert(sections, section) + end + break + end + + -- Extract section from current position to next boundary + local section = body:sub(current_pos, next_boundary_pos - 1) + if section and #section > 10 then -- Minimum length check + table.insert(sections, section) + end + + current_pos = next_boundary_pos + #boundary + + -- Check for end boundary (ends with --) + if body:sub(current_pos, current_pos + 1) == "--" then + break + end + end + + -- Parse each section into headers and data + for i, section in ipairs(sections) do + if section and #section > 0 then + -- Remove leading whitespace and newlines + section = section:gsub("^%s*\r?\n", "") + + -- Separate headers and body (\r\n\r\n or \n\n) + local header_end = section:find("\r\n\r\n", 1, true) + local separator_len = 4 + if not header_end then + header_end = section:find("\n\n", 1, true) + separator_len = 2 + end + + if header_end then + local headers = section:sub(1, header_end - 1) + local data = section:sub(header_end + separator_len) + + -- Remove trailing \r\n + data = data:gsub("\r\n$", ""):gsub("\n$", "") + + -- Extract filename from Content-Disposition header + local filename = self:extract_filename(headers) + if filename and #data > 0 then + table.insert(parts, { + headers = headers, + data = data, + filename = filename + }) + end + end + end + end + + return parts end -function clamav:command(cmd) - -- Get socket - local clamav_socket, err = self:socket() - if not clamav_socket then - return false, err - end - -- Send command - local bytes - bytes, err = clamav_socket:send("n" .. cmd .. "\n") - if not bytes then - clamav_socket:close() - return false, err - end - -- Receive response - local data - data, err = clamav_socket:receive("*l") - if not data then - clamav_socket:close() - return false, err - end - clamav_socket:close() - return true, data +-- Improved filename extraction function +function clamav:extract_filename(headers) + if not headers then + return nil + end + + -- Extract filename from Content-Disposition header + local content_disposition = headers:match("Content%-Disposition:%s*([^\r\n]+)") + if not content_disposition then + return nil + end + + -- Handle both filename="value" and filename=value formats + local filename = content_disposition:match('filename="([^"]+)"') + if not filename then + filename = content_disposition:match('filename=([^;%s\r\n]+)') + end + + return filename end -function clamav:socket() - -- Init socket - local tcp_socket = socket.tcp() - tcp_socket:settimeout(tonumber(self.variables["CLAMAV_TIMEOUT"])) - local ok, err = tcp_socket:connect(self.variables["CLAMAV_HOST"], tonumber(self.variables["CLAMAV_PORT"])) - if not ok then - return false, err - end - return tcp_socket +function clamav:api() + if self.ctx.bw.uri == "/clamav/ping" and self.ctx.bw.request_method == "POST" then + local enabled, err = has_variable("USE_CLAMAV", "yes") + if not enabled then + return self:ret(true, "ClamAV plugin not enabled") + end + local ok, res = self:ping() + if not ok then + return self:ret(true, "ClamAV ping failed: " .. tostring(res), HTTP_INTERNAL_SERVER_ERROR) + end + return self:ret(true, "ClamAV ping successful", HTTP_OK) + end + return self:ret(false, "success") end -function clamav:scan() - -- Loop on files - local form = upload:new(4096, 512, true) - if not form then - return false, "failed to create upload form" - end - local sha = sha512:new() - local scan_socket = nil - while true do - -- Read part - local typ, res, err = form:read() - if not typ then - if scan_socket then - scan_socket:close() - end - return false, "form:read() failed : " .. err - end +function clamav:access() + -- Check if ClamAV plugin is enabled + if self.variables["USE_CLAMAV"] ~= "yes" then + return self:ret(true, "ClamAV plugin not enabled") + end + + -- Only process POST requests + if ngx.req.get_method() ~= "POST" then + return self:ret(true, "Not a POST request") + end - local bytes + -- Check for multipart/form-data content type + local content_type = ngx.var.content_type or ngx.req.get_headers()["content-type"] + if not content_type or not content_type:find("multipart/form-data", 1, true) then + return self:ret(true, "Not a multipart/form-data request") + end - -- Header case : check if we have a filename - if typ == "header" then - local found = false - for _, header in ipairs(res) do - if header:find('^.*filename="(.*)".*$') then - found = true - break - end - end - if found then - if scan_socket then - scan_socket:close() - end - scan_socket, err = self:socket() - if not scan_socket then - read_all(form) - return false, "socket failed : " .. err - end - bytes, err = scan_socket:send("nINSTREAM\n") - if not bytes then - scan_socket:close() - read_all(form) - return false, "socket:send() failed : " .. err - end - end - -- Body case : update checksum and send to clamav - elseif typ == "body" and scan_socket then - sha:update(res) - bytes, err = scan_socket:send(stream_size(#res) .. res) - if not bytes then - scan_socket:close() - read_all(form) - return false, "socket:send() failed : " .. err - end - -- Part end case : get final checksum and clamav result - elseif typ == "part_end" and scan_socket then - local checksum = to_hex(sha:final()) - sha:reset() - -- Check if file is in cache - local ok, cached = self:is_in_cache(checksum) - if not ok then - self.logger:log( - ngx.ERR, - "can't check if file with checksum " .. checksum .. " is in cache : " .. cached - ) - elseif cached then - scan_socket:close() - scan_socket = nil - if cached ~= "clean" then - read_all(form) - return true, cached, checksum - end - else - -- End the INSTREAM - bytes, err = scan_socket:send(stream_size(0)) - if not bytes then - scan_socket:close() - read_all(form) - return false, "socket:send() failed : " .. err - end - -- Read result - local data - data, err = scan_socket:receive("*l") - if not data then - scan_socket:close() - read_all(form) - return false, err - end - scan_socket:close() - scan_socket = nil - if data:match("^.*INSTREAM size limit exceeded.*$") then - self.logger:log( - ERR, - "can't scan file with checksum " - .. checksum - .. " because size exceeded StreamMaxLength in clamd.conf" - ) - else - -- luacheck: ignore iend - local istart, iend - istart, iend, data = data:find("^stream: (.*) FOUND$") - local detected = "clean" - if istart then - detected = data - end - ok, err = self:add_to_cache(checksum, detected) - if not ok then - self.logger:log(ERR, "can't cache result : " .. err) - end - if detected ~= "clean" then - read_all(form) - return true, detected, checksum - end - end - end - -- End of body case : no file detected - elseif typ == "eof" then - if scan_socket then - scan_socket:close() - end - return true - end - end - -- luacheck: ignore 511 - return false, "malformed content" -end + -- Extract boundary from content type + local boundary = self:extract_boundary(content_type) + if not boundary then + self.logger:log(ERR, "[clamav] No boundary found in Content-Type") + return self:ret(true, "Invalid multipart/form-data") + end -function clamav:is_in_cache(checksum) - local ok, data = self.cachestore:get("plugin_clamav_" .. checksum) - if not ok then - return false, data - end - return true, data -end + -- Read request body + ngx.req.read_body() + local body = ngx.req.get_body_data() + if not body then + local file_path = ngx.req.get_body_file() + if file_path then + local f = io.open(file_path, "rb") + if f then + body = f:read("*a") + f:close() + end + end + end -function clamav:add_to_cache(checksum, value) - local ok, err = self.cachestore:set("plugin_clamav_" .. checksum, value, 86400) - if not ok then - return false, err - end - return true -end + if not body then + self.logger:log(ERR, "[clamav] Failed to read request body") + return self:ret(true, "Empty request body") + end -function clamav:api() - if self.ctx.bw.uri == "/clamav/ping" and self.ctx.bw.request_method == "POST" then - -- Check clamav connection - local check, err = has_variable("USE_CLAMAV", "yes") - if check == nil then - return self:ret(true, "error while checking variable USE_CLAMAV (" .. err .. ")") - end - if not check then - return self:ret(true, "Clamav plugin not enabled") - end + -- Parse multipart data to extract file parts + local parts = self:parse_multipart(body, boundary) + + -- Scan each file part with ClamAV + for i, part in ipairs(parts) do + if #part.data > 0 then + local sock, err = self:socket() + if not sock then + self.logger:log(ERR, "[clamav] Socket error: " .. tostring(err)) + return self:ret(false, "Socket error: " .. err) + end + + -- Initiate INSTREAM command + local ok = sock:send("nINSTREAM\n") + if not ok then + self.logger:log(ERR, "[clamav] Failed to initiate INSTREAM") + sock:close() + return self:ret(false, "Failed to initiate INSTREAM") + end + + -- Send file size as 4-byte big-endian integer + local len = #part.data + local prefix = string.char( + bit.rshift(len, 24), + bit.band(bit.rshift(len, 16), 0xFF), + bit.band(bit.rshift(len, 8), 0xFF), + bit.band(len, 0xFF) + ) + + local sent = sock:send(prefix .. part.data) + if not sent then + self.logger:log(ERR, "[clamav] Failed to send file data") + sock:close() + return self:ret(false, "Failed to send file data") + end + + -- Send stream termination marker + sock:send(string.char(0, 0, 0, 0)) + local result = sock:receive("*l") + sock:close() + + if not result then + self.logger:log(ERR, "[clamav] No response from ClamAV") + return self:ret(false, "No response from ClamAV") + else + self.logger:log(ERR, "[clamav] ClamAV result for " .. part.filename .. ": " .. tostring(result)) + end + + -- Check if malware was detected + if result and result:find("FOUND") then + return self:ret(true, "Malware detected in " .. part.filename .. ": " .. result, get_deny_status(), nil, { + id = "clamav", + result = result, + filename = part.filename + }) + end + end + end + + if #parts == 0 then + return self:ret(true, "No files found in multipart data") + end - -- Send PING to ClamAV - local ok, data = self:command("PING") - if not ok then - return self:ret(true, "connectivity with ClamAV failed : " .. data, HTTP_INTERNAL_SERVER_ERROR) - end - if data ~= "PONG" then - return self:ret(true, "wrong data received from ClamAV : " .. data, HTTP_INTERNAL_SERVER_ERROR) - end - return self:ret(true, "connectivity with ClamAV is successful", HTTP_OK) - end - return self:ret(false, "success") + return self:ret(true, "No malware detected") end -return clamav +return clamav \ No newline at end of file