diff --git a/.gitignore b/.gitignore index 9756fc7..4f0b631 100644 --- a/.gitignore +++ b/.gitignore @@ -39,5 +39,7 @@ luac.out *.x86_64 *.hex +# Backup files +*.bak tags diff --git a/docker-compose.yml b/docker-compose.yml index 9d51f0c..b85e847 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: build: dockerfile: Dockerfile context: . + target: build container_name: resty ports: - "8080:80" @@ -13,7 +14,13 @@ services: volumes: - "./src/conf/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf" - "./src/lua_resty_netacea.lua:/usr/local/openresty/site/lualib/lua_resty_netacea.lua" + - "./src/lua_resty_netacea_cookies_v3.lua:/usr/local/openresty/site/lualib/lua_resty_netacea_cookies_v3.lua" - "./src/kinesis_resty.lua:/usr/local/openresty/site/lualib/kinesis_resty.lua" + - "./src/lua_resty_netacea_ingest.lua:/usr/local/openresty/site/lualib/lua_resty_netacea_ingest.lua" + - "./src/netacea_utils.lua:/usr/local/openresty/site/lualib/netacea_utils.lua" + - "./src/lua_resty_netacea_constants.lua:/usr/local/openresty/site/lualib/lua_resty_netacea_constants.lua" + - "./src/lua_resty_netacea_protector_client.lua:/usr/local/openresty/site/lualib/lua_resty_netacea_protector_client.lua" + test: build: diff --git a/lua_resty_netacea-0.2-2.rockspec b/lua_resty_netacea-0.2-2.rockspec index 0cd557b..b0bba24 100644 --- a/lua_resty_netacea-0.2-2.rockspec +++ b/lua_resty_netacea-0.2-2.rockspec @@ -15,7 +15,8 @@ dependencies = { "luaossl", "lua-resty-http", "lbase64", - "lua-cjson" + "lua-cjson", + "lua-resty-jwt" } external_dependencies = {} build = { diff --git a/src/conf/nginx.conf b/src/conf/nginx.conf index b307d22..ef5ea78 100644 --- a/src/conf/nginx.conf +++ b/src/conf/nginx.conf @@ -16,26 +16,37 @@ http { lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; init_worker_by_lua_block { netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', mitigationEndpoint = '', apiKey = '', secretKey = '', realIpHeader = '', ingestEnabled = false, mitigationEnabled = false, - mitigationType = '' + mitigationType = '', + cookieName = '', + kinesisProperties = { + region = '', + stream_name = '', + aws_access_key = '', + aws_secret_key = '', + } }) } log_by_lua_block { netacea:ingest() } access_by_lua_block { - netacea:mitigate() + if ngx.var.uri == "/AtaVerifyCaptcha" then + netacea:handleCaptcha() + else + netacea:mitigate() + end } server { listen 80; server_name localhost; + server_tokens off; location / { default_type text/html; content_by_lua 'ngx.say("

hello, world

")'; diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 6345d16..0f08941 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -1,4 +1,9 @@ -local Kinesis = require("kinesis_resty") +local b64 = require("ngx.base64") + +local Ingest = require("lua_resty_netacea_ingest") +local netacea_cookies = require('lua_resty_netacea_cookies_v3') +local utils = require("netacea_utils") +local protector_client = require("lua_resty_netacea_protector_client") local _N = {} _N._VERSION = '0.2.2' @@ -8,30 +13,6 @@ local ngx = require 'ngx' local cjson = require 'cjson' local http = require 'resty.http' -local COOKIE_DELIMITER = '_/@#/' -local ONE_HOUR = 60 * 60 -local ONE_DAY = ONE_HOUR * 24 - -local function createHttpConnection() - local hc = http:new() - - -- hc will be nil on error - if hc then - -- syntax: httpc:set_timeouts(connect_timeout, send_timeout, read_timeout) - hc:set_timeouts(500, 750, 750) - end - - return hc -end - -local function buildResult(idType, mitigationType, captchaState) - return { - idType = idType or _N.idTypes.NONE, - mitigationType = mitigationType or _N.mitigationTypes.NONE, - captchaState = captchaState or _N.captchaStates.NONE - } -end - local function serveCaptcha(captchaBody) ngx.status = ngx.HTTP_FORBIDDEN ngx.header["content-type"] = "text/html" @@ -47,415 +28,105 @@ local function serveBlock() return ngx.exit(ngx.HTTP_FORBIDDEN); end -local function buildRandomString(length) - local chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' - local randomString = '' - - math.randomseed(os.time()) - - local charTable = {} - for c in chars:gmatch"." do - table.insert(charTable, c) - end - - for i=1, length do -- luacheck: ignore i - randomString = randomString .. charTable[math.random(1, #charTable)] - end - - return randomString -end - function _N:new(options) local n = {} setmetatable(n, self) self.__index = self - + -- ingest:optional:ingestEnabled - self.ingestEnabled = options.ingestEnabled or false + n.ingestEnabled = options.ingestEnabled or false -- ingest:required:ingestEndpoint - self.ingestEndpoint = options.ingestEndpoint - if not self.ingestEndpoint or self.ingestEndpoint == '' then - self.ingestEnabled = false + n.ingestEndpoint = options.ingestEndpoint + + n.kinesisProperties = options.kinesisProperties or nil + + if not n.kinesisProperties then + n.ingestEnabled = false + else + -- Validate kinesisProperties structure + if type(n.kinesisProperties) ~= 'table' or + not n.kinesisProperties.stream_name or + not n.kinesisProperties.region or + not n.kinesisProperties.aws_access_key or + not n.kinesisProperties.aws_secret_key + then + ngx.log(ngx.ERR, "NETACEA CONFIG - Invalid kinesisProperties structure") + n.ingestEnabled = false + end end - self.kinesisProperties = options.kinesisProperties or nil -- mitigate:optional:mitigationEnabled - self.mitigationEnabled = options.mitigationEnabled or false + n.mitigationEnabled = options.mitigationEnabled or false -- mitigate:required:mitigationEndpoint - self.mitigationEndpoint = options.mitigationEndpoint - if type(self.mitigationEndpoint) ~= 'table' then - self.mitigationEndpoint = { self.mitigationEndpoint } + n.mitigationEndpoint = options.mitigationEndpoint + if type(n.mitigationEndpoint) ~= 'table' then + n.mitigationEndpoint = { n.mitigationEndpoint } end - if not self.mitigationEndpoint[1] or self.mitigationEndpoint[1] == '' then - self.mitigationEnabled = false + if not n.mitigationEndpoint[1] or n.mitigationEndpoint[1] == '' then + n.mitigationEnabled = false end -- mitigate:required:mitigationType - self.mitigationType = options.mitigationType or '' - if not self.mitigationType or (self.mitigationType ~= 'MITIGATE' and self.mitigationType ~= 'INJECT') then - self.mitigationEnabled = false + n.mitigationType = options.mitigationType or '' + if not n.mitigationType or (n.mitigationType ~= 'MITIGATE' and n.mitigationType ~= 'INJECT') then + n.mitigationEnabled = false end -- mitigate:required:secretKey - self.secretKey = options.secretKey - if not self.secretKey or self.secretKey == '' then - self.mitigationEnabled = false + n.secretKey = b64.decode_base64url(options.secretKey) or '' + if not n.secretKey or n.secretKey == '' then + n.mitigationEnabled = false end -- global:optional:cookieName - self.cookieName = options.cookieName or '_mitata' + n.cookieName = options.cookieName or '_mitata' -- global:optional:captchaCookieName - self.captchaCookieName = options.captchaCookieName or '_mitatacaptcha' + n.captchaCookieName = options.captchaCookieName or '_mitatacaptcha' -- global:optional:realIpHeader - self.realIpHeader = options.realIpHeader or '' + n.realIpHeader = options.realIpHeader or '' -- global:optional:userIdKey - self.userIdKey = options.userIdKey or '' + n.userIdKey = options.userIdKey or '' -- global:required:apiKey - self.apiKey = options.apiKey - if not self.apiKey then - self.ingestEnabled = false - self.mitigationEnabled = false - end - - self.endpointIndex = 0 - self._MODULE_TYPE = _N._TYPE - self._MODULE_VERSION = _N._VERSION - - _N:start_timers(); - - return n -end - -function _N:getIpAddress(vars) - if not self.realIpHeader then return vars.remote_addr end - return vars['http_' .. self.realIpHeader] or vars.remote_addr -end - -function _N:getMitigationRequestHeaders() - local vars = ngx.var - - local cookie_name = "cookie_" .. self.cookieName - local captcha_cookie_name = "cookie_" .. self.captchaCookieName - - local requestMitata = vars[cookie_name] or '' - local requestMitataCaptcha = vars[captcha_cookie_name] or '' - local cookie = self.cookieName .. '=' .. requestMitata .. ';' .. self.captchaCookieName .. '=' .. requestMitataCaptcha - local headers = { - ["x-netacea-api-key"] = self.apiKey, - ["content-type"] = 'application/x-www-form-urlencoded', - ["cookie"] = cookie, - ["user-agent"] = vars.http_user_agent, - ["x-netacea-client-ip"] = self:getIpAddress(vars) - } - - if (self.userIdKey ~= '' and vars[self.userIdKey]) then - headers['x-netacea-userid'] = vars[self.userIdKey] - end - - return headers -end - -function _N:validateCaptcha(onEventFunc) - local hc = createHttpConnection() - - ngx.req.read_body() - local payload = ngx.req.get_body_data() - - local headers = self:getMitigationRequestHeaders() - - self.endpointIndex = (self.endpointIndex + 1) % table.getn(self.mitigationEndpoint) - - local res, err = hc:request_uri( - self.mitigationEndpoint[self.endpointIndex + 1] .. '/AtaVerifyCaptcha', - { - method = 'POST', - headers = headers, - body = payload - } - ) - if (err) then return nil end - - local mitataCaptchaVal = res.headers['x-netacea-mitatacaptcha-value'] or '' - local mitataCaptchaExp = res.headers['x-netacea-mitatacaptcha-expiry'] or 0 - - local idType = res.headers['x-netacea-match'] or self.idTypes.NONE - local mitigationType = res.headers['x-netacea-mitigate'] or self.mitigationTypes.NONE - local captchaState = res.headers['x-netacea-captcha'] or self.captchaStates.NONE - - self:addCookie(self.captchaCookieName, mitataCaptchaVal, mitataCaptchaExp) - - local exit_status = ngx.HTTP_FORBIDDEN - if (captchaState == self.captchaStates.PASS) then - exit_status = ngx.HTTP_OK - - local mitataVal = res.headers['x-netacea-mitata-value'] or '' - local mitataExp = res.headers['x-netacea-mitata-expiry'] or 0 - self:addMitataCookie(mitataVal, mitataExp) - end - - if onEventFunc then onEventFunc(buildResult(idType, mitigationType, captchaState)) end - - ngx.status = exit_status - return ngx.exit(exit_status) -end - -function _N:addMitataCookie(mitataVal, mitataExp) - self:addCookie(self.cookieName, mitataVal, mitataExp) - -- set to context so we can get this value for ingest service - ngx.ctx.mitata = mitataVal -end - -function _N:addCookie(name, value, expiry) - local cookies = ngx.ctx.cookies or {}; - local expiryTime = ngx.cookie_time(ngx.time() + expiry) - local newCookie = name .. '=' .. value .. '; Path=/; Expires=' .. expiryTime - cookies[name] = newCookie - ngx.ctx.cookies = cookies - - local setCookies = {} - for _, val in pairs(cookies) do - table.insert(setCookies, val) - end - ngx.header["Set-Cookie"] = setCookies -end - -function _N:bToHex(b) - local hex = '' - for i = 1, #b do - hex = hex .. string.format('%.2x', b:byte(i)) - end - return hex -end - -function _N:parseMitataCookie() - - local mitata_cookie = ngx.var['cookie_' .. self.cookieName] or '' - if (mitata_cookie == '') then return nil end - - local hash, epoch, uid, mitigation_values = mitata_cookie:match( - '(.*)' .. COOKIE_DELIMITER .. '(.*)' .. COOKIE_DELIMITER .. '(.*)' .. COOKIE_DELIMITER .. '(.*)') - epoch = tonumber(epoch) - if (hash == nil or - epoch == nil or - uid == nil or - uid == '' or - mitigation_values == nil or - mitigation_values == '' - ) then - return nil - end - - return { - mitata_cookie = mitata_cookie, - hash = hash, - epoch = epoch, - uid = uid, - mitigation_values = mitigation_values - } -end - -function _N:buildMitataValToHash(hash, epoch, uid, mitigation_values) - local unhashed = self:buildNonHashedMitataVal(epoch, uid, mitigation_values) - return hash .. COOKIE_DELIMITER .. unhashed -end - -function _N:buildNonHashedMitataVal(epoch, uid, mitigation_values) - return epoch .. COOKIE_DELIMITER .. uid .. COOKIE_DELIMITER .. mitigation_values -end - -function _N:generateUid() - local randomString = buildRandomString(15) - return 'c' .. randomString -end - -function _N:setIngestMitataCookie() - local mitata_values = self:parseMitataCookie() - local currentTime = ngx.time() - local epoch = currentTime + ONE_HOUR - local uid = self:generateUid() - local mitigation_values = _N.idTypes.NONE .. _N.mitigationTypes.NONE .. _N.captchaStates.NONE - local mitataExpiry = ONE_DAY - - local new_hash = self:hashMitataCookie(epoch, uid, mitigation_values) - local mitataVal = self:buildMitataValToHash(new_hash, epoch, uid, mitigation_values) - - if (not mitata_values) then - self:addMitataCookie(mitataVal, mitataExpiry) - return nil - end - - local our_hash = self:hashMitataCookie(mitata_values.epoch, mitata_values.uid, mitata_values.mitigation_values) - - if (our_hash ~= mitata_values.hash) then - self:addMitataCookie(mitataVal, mitataExpiry) - return nil - end - - if (currentTime >= mitata_values.epoch) then - uid = mitata_values.uid - new_hash = self:hashMitataCookie(epoch, uid, mitigation_values) - mitataVal = self:buildMitataValToHash(new_hash, epoch, uid, mitigation_values) - self:addMitataCookie(mitataVal, mitataExpiry) - return nil - end - -end - -function _N:get_mitata_cookie() - local mitata_values = self:parseMitataCookie() - - if (not mitata_values) then - return nil + n.apiKey = options.apiKey + if not n.apiKey then + n.ingestEnabled = false + n.mitigationEnabled = false end - if (ngx.time() >= mitata_values.epoch) then - return nil - end + n.endpointIndex = 0 + n._MODULE_TYPE = _N._TYPE + n._MODULE_VERSION = _N._VERSION - local our_hash = self:hashMitataCookie(mitata_values.epoch, mitata_values.uid, mitata_values.mitigation_values) - - if (our_hash ~= mitata_values.hash) then - return nil - end - - return { - original = mitata_values.mitata_cookie, - hash = mitata_values.hash, - epoch = mitata_values.epoch, - uid = mitata_values.uid, - mitigation = mitata_values.mitigation_values - } -end - -function _N:hashMitataCookie(epoch, uid, mitigation_values) - local hmac = require 'openssl.hmac' - local base64 = require('base64') - local to_hash = self:buildNonHashedMitataVal(epoch, uid, mitigation_values) - local hashed = hmac.new(self.secretKey, 'sha256'):final(to_hash) - hashed = self:bToHex(hashed) - hashed = base64.encode(hashed) - - return hashed -end - -function _N:getMitigationResultFromService(onEventFunc) - if not self.mitigationEnabled then return nil end - local mitata_cookie = self:get_mitata_cookie() - - if (mitata_cookie) then - local idType = string.sub(mitata_cookie.mitigation, 1, 1) - local mitigationType = string.sub(mitata_cookie.mitigation, 2, 2) - local captchaState = string.sub(mitata_cookie.mitigation, 3, 3) - self:setBcType(idType, mitigationType, captchaState) - if (mitigationType == _N.mitigationTypes.NONE) then return nil end - - if (captchaState ~= _N.captchaStates.SERVE) then - if (captchaState == _N.captchaStates.PASS) then - captchaState = _N.captchaStates.COOKIEPASS - elseif (captchaState == _N.captchaStates.FAIL) then - captchaState = _N.captchaStates.COOKIEFAIL - end - - local shouldForwardToMitService = captchaState == _N.captchaStates.COOKIEFAIL - if not shouldForwardToMitService then - if onEventFunc then onEventFunc(buildResult(idType, mitigationType, captchaState)) end - self:setBcType(idType, mitigationType, captchaState) - return { - match = idType, - mitigate = mitigationType, - captcha = captchaState, - res = nil - } - end - end + if n.ingestEnabled then + n.ingestPipeline = Ingest:new(options.kinesisProperties or {}, n) + n.ingestPipeline:start_timers() end - local hc = createHttpConnection() - - local headers = self:getMitigationRequestHeaders() - - self.endpointIndex = (self.endpointIndex + 1) % table.getn(self.mitigationEndpoint) - - local res, err = hc:request_uri( - self.mitigationEndpoint[self.endpointIndex + 1], - { - method = 'GET', - headers = headers + if n.mitigationEnabled then + n.protectorClient = protector_client:new{ + apiKey = n.apiKey, + mitigationEndpoint = n.mitigationEndpoint } - ) - if (err) then return nil end - - local mitataVal = res.headers['x-netacea-mitata-value'] or '' - local mitataExp = res.headers['x-netacea-mitata-expiry'] or 0 - self:addMitataCookie(mitataVal, mitataExp) - local match = res.headers['x-netacea-match'] or self.idTypes.NONE - local mitigate = res.headers['x-netacea-mitigate'] or self.mitigationTypes.NONE - local captcha = res.headers['x-netacea-captcha'] or self.captchaStates.NONE - if onEventFunc then onEventFunc(buildResult(match, mitigate, captcha)) end - self:setBcType(match, mitigate, captcha) - return { - match = match, - mitigate = mitigate, - captcha = captcha, - res = res - } -end - -function _N:mitigate(onEventFunc) - if not self.mitigationEnabled then return nil end - local vars = ngx.var - - local captchaMatch = string.match(vars.request_uri, '.*AtaVerifyCaptcha.*') - if captchaMatch then - return self:validateCaptcha(onEventFunc) - end - local mitigationResult = self:getMitigationResultFromService(onEventFunc) - if mitigationResult == nil then - return nil end - return self:getBestMitigation(mitigationResult.mitigate, mitigationResult.captcha, mitigationResult.res) -end -function _N:inject(onEventFunc) - if not self.mitigationEnabled then return nil end - local mitigationResult = self:getMitigationResultFromService(onEventFunc) - if mitigationResult == nil then - mitigationResult = { - match = self.idTypes.NONE, - mitigate = self.mitigationTypes.NONE, - captcha = self.mitigationTypes.NONE - } - end - ngx.req.set_header('x-netacea-match', mitigationResult.match) - ngx.req.set_header('x-netacea-mitigate', mitigationResult.mitigate) - ngx.req.set_header('x-netacea-captcha', mitigationResult.captcha) - return nil + return n end -function _N:run(onEventFunc) - if self.ingestEnabled and not self.mitigationEnabled then - self:setIngestMitataCookie() - end +function _N:getBestMitigation(protector_result) + if not protector_result then return nil end - if self.mitigationEnabled then - if self.mitigationType == 'MITIGATE' then - self:mitigate(onEventFunc) - elseif self.mitigationType == 'INJECT' then - self:inject(onEventFunc) - end - end -end + local mitigate = protector_result.mitigate + local captcha = protector_result.captcha -function _N:getBestMitigation(mitigationType, captchaState, res) - if (mitigationType == _N.mitigationTypes.NONE) then return nil end - if (not _N.mitigationTypesText[mitigationType]) then return nil end + if (mitigate == Constants.mitigationTypes.NONE) then return nil end + if (not Constants.mitigationTypesText[mitigate]) then return nil end - if (mitigationType == _N.mitigationTypes.ALLOW) then return nil end - if (captchaState == _N.captchaStates.PASS) then return nil end - if (captchaState == _N.captchaStates.COOKIEPASS) then return nil end + if (mitigate == Constants.mitigationTypes.ALLOW) then return nil end + if (captcha == Constants.captchaStates.PASS) then return nil end + if (captcha == Constants.captchaStates.COOKIEPASS) then return nil end - if (mitigationType == _N.mitigationTypes.BLOCKED and captchaState == _N.captchaStates.SERVE and res ~= nil) then - return serveCaptcha(res.body) + if (mitigate == Constants.mitigationTypes.BLOCKED and (captcha == Constants.captchaStates.SERVE or captcha == Constants['captchaStates'].COOKIEFAIL )) then + return 'captcha' end - return serveBlock() + return 'block' end function _N:setBcType(match, mitigate, captcha) @@ -463,303 +134,133 @@ function _N:setBcType(match, mitigate, captcha) local mitigationApplied = '' if (match ~= '0') then - mitigationApplied = mitigationApplied .. (self.matchBcTypes[match] or UNKNOWN) .. '_' + mitigationApplied = mitigationApplied .. (Constants.matchBcTypes[match] or UNKNOWN) .. '_' end if (mitigate ~= '0') then - mitigationApplied = mitigationApplied .. (self.mitigateBcTypes[mitigate] or UNKNOWN) + mitigationApplied = mitigationApplied .. (Constants.mitigateBcTypes[mitigate] or UNKNOWN) end if (captcha ~= '0') then - mitigationApplied = mitigationApplied .. ',' .. (self.captchaBcTypes[captcha] or UNKNOWN) + mitigationApplied = mitigationApplied .. ',' .. (Constants.captchaBcTypes[captcha] or UNKNOWN) end - ngx.ctx.bc_type = mitigationApplied return mitigationApplied end ---------------------------------------------------------- --- Async ingest from logging context - -local function new_queue(size, allow_wrapping) - -- Head is next insert, tail is next read - local head, tail = 1, 1; - local items = 0; -- Number of stored items - local t = {}; -- Table to hold items - return { - _items = t; - size = size; - count = function (_) return items; end; - push = function (_, item) - if items >= size then - if allow_wrapping then - tail = (tail%size)+1; -- Advance to next oldest item - items = items - 1; - else - return nil, "queue full"; - end - end - t[head] = item; - items = items + 1; - head = (head%size)+1; - return true; - end; - pop = function (_) - if items == 0 then - return nil; - end - local item; - item, t[tail] = t[tail], 0; - tail = (tail%size)+1; - items = items - 1; - return item; - end; - peek = function (_) - if items == 0 then - return nil; - end - return t[tail]; - end; - items = function (self) - return function (pos) - if pos >= t:count() then - return nil; - end - local read_pos = tail + pos; - if read_pos > t.size then - read_pos = (read_pos%size); - end - return pos+1, t._items[read_pos]; - end, self, 0; - end; - }; +function _N:ingest() + ngx.log(ngx.DEBUG, "NETACEA INGEST - in netacea:ingest(): ", self.ingestEnabled) + if not self.ingestEnabled then return nil end + ngx.ctx.NetaceaState.bc_type = self:setBcType( + tostring(ngx.ctx.NetaceaState.protector_result.match or Constants['idTypes'].NONE), + tostring(ngx.ctx.NetaceaState.protector_result.mitigate or Constants['mitigationTypes'].NONE), + tostring(ngx.ctx.NetaceaState.protector_result.captcha or Constants['captchaStates'].NONE) + ) + return self.ingestPipeline:ingest() end --- Data queue for batch processing -local data_queue = new_queue(5000, true); -local dead_letter_queue = new_queue(1000, true); -local BATCH_SIZE = 25; -- Kinesis PutRecords supports up to 500 records, using 25 for more frequent sends -local BATCH_TIMEOUT = 1.0; -- Send batch after 1 second even if not full +function _N:handleSession() + ngx.ctx.NetaceaState = {} + ngx.ctx.NetaceaState.client = utils:getIpAddress(ngx.var, self.realIpHeader) + ngx.ctx.NetaceaState.user_agent = ngx.var.http_user_agent or '' --------------------------------------------------------- --- start batch processor for Kinesis data - -function _N:start_timers() + -- Check cookie + local cookie = ngx.var['cookie_' .. self.cookieName] or '' + local parsed_cookie = netacea_cookies.parseMitataCookie(cookie, self.secretKey) + ngx.log(ngx.DEBUG, "NETACEA MITIGATE - parsed cookie: ", cjson.encode(parsed_cookie)) + if parsed_cookie.user_id then + ngx.ctx.NetaceaState.UserId = parsed_cookie.user_id + end - -- start batch processor - local batch_processor; - batch_processor = function( premature ) + -- Get captcha cookie + local captcha_cookie = ngx.var['cookie_' .. self.captchaCookieName] or '' + if captcha_cookie and captcha_cookie ~= '' then + ngx.ctx.NetaceaState.captcha_cookie = captcha_cookie + end + return parsed_cookie +end - if premature then return end +function _N:refreshSession(reason) + local protector_result = ngx.ctx.NetaceaState.protector_result + + local grace_period = ngx.ctx.NetaceaState.grace_period or 60 + + local new_cookie = netacea_cookies.generateNewCookieValue( + self.secretKey, + ngx.ctx.NetaceaState.client, + ngx.ctx.NetaceaState.UserId, + netacea_cookies.newUserId(), + reason, + os.time(), + grace_period, + protector_result.match, + protector_result.mitigate, + protector_result.captcha, + {} + ) + local cookies = { + self.cookieName .. '=' .. new_cookie.mitata_jwe .. ';' + } - local execution_thread = ngx.thread.spawn( function() - local batch = {} - local last_send_time = ngx.now() - - while true do - -- Check if worker is exiting - if ngx.worker.exiting() == true then - -- Send any remaining data before exiting - if #batch > 0 then - self:send_batch_to_kinesis(batch) - end - return - end - - local current_time = ngx.now() - local should_send_batch = false - local dead_letter_items = 0 - -- Check dead_letter_queue first - while dead_letter_queue:count() > 0 and #batch < BATCH_SIZE do - local dlq_item = dead_letter_queue:pop() - if dlq_item then - table.insert(batch, dlq_item) - dead_letter_items = dead_letter_items + 1 - end - end - - if (dead_letter_items > 0) then - ngx.log(ngx.DEBUG, "NETACEA BATCH - added ", dead_letter_items, " items from dead letter queue to batch") - end - - -- Collect data items for batch - while data_queue:count() > 0 and #batch < BATCH_SIZE do - local data_item = data_queue:pop() - if data_item then - table.insert(batch, data_item) - end - end - - -- Determine if we should send the batch - if #batch >= BATCH_SIZE then - should_send_batch = true - ngx.log(ngx.DEBUG, "NETACEA BATCH - sending full batch of ", #batch, " items") - elseif #batch > 0 and (current_time - last_send_time) >= BATCH_TIMEOUT then - should_send_batch = true - ngx.log(ngx.DEBUG, "NETACEA BATCH - sending timeout batch of ", #batch, " items") - end - - -- Send batch if conditions are met - if should_send_batch then - self:send_batch_to_kinesis(batch) - batch = {} -- Reset batch - last_send_time = current_time - end - - -- Sleep briefly if no data to process - if data_queue:count() == 0 and dead_letter_queue:count() == 0 then - ngx.sleep(0.1) - end - end - end ) - - local ok, err = ngx.thread.wait( execution_thread ); - if not ok and err then - ngx.log( ngx.ERR, "NETACEA - batch processor thread has failed with error: ", err ); + if protector_result.captcha_cookie and protector_result.captcha_cookie ~= '' then + table.insert(cookies, self.captchaCookieName .. '=' .. protector_result.captcha_cookie .. ';') end - - -- If the worker is exiting, don't queue another processor - if ngx.worker.exiting() then - return - end - - ngx.timer.at( 0, batch_processor ); - end - - ngx.timer.at( 0, batch_processor ); - + + ngx.header['Set-Cookie'] = cookies end -function _N:send_batch_to_kinesis(batch) - if not batch or #batch == 0 then return end +function _N:handleCaptcha() + local parsed_cookie = self:handleSession() + + ngx.req.read_body() + local captcha_data = ngx.req.get_body_data() + local protector_result = self.protectorClient:validateCaptcha(captcha_data) + ngx.ctx.NetaceaState.protector_result = protector_result + ngx.ctx.NetaceaState.grace_period = -1000 + ngx.log(ngx.DEBUG, "NETACEA CAPTCHA - protector result: ", cjson.encode(ngx.ctx.NetaceaState)) - local client = Kinesis.new( - self.kinesisProperties.stream_name, - self.kinesisProperties.region, - self.kinesisProperties.aws_access_key, - self.kinesisProperties.aws_secret_key - ) + self:refreshSession(Constants['issueReasons'].CAPTCHA_POST) + ngx.exit(protector_result.exit_status) +end - -- Convert batch data to Kinesis records format - local records = {} - for _, data_item in ipairs(batch) do - table.insert(records, { - partition_key = buildRandomString(10), - data = "[" .. cjson.encode(data_item) .. "]" - }) - end - ngx.log( ngx.DEBUG, "NETACEA BATCH - sending batch of ", #records, " records to Kinesis stream ", self.kinesisProperties.stream_name ); +function _N:mitigate() + if not self.mitigationEnabled then return nil end + local parsed_cookie = self:handleSession() - local res, err = client:put_records(records) - if err then - ngx.log( ngx.ERR, "NETACEA BATCH - error sending batch to Kinesis: ", err ); - for _, data_item in ipairs(batch) do - local ok, dlq_err = dead_letter_queue:push(data_item) - if not ok and dlq_err then - ngx.log( ngx.ERR, "NETACEA BATCH - failed to push record to dead letter queue: ", dlq_err ); - end + if not parsed_cookie.valid then + if not ngx.ctx.NetaceaState.UserId then + ngx.ctx.NetaceaState.UserId = netacea_cookies.newUserId() end - else - ngx.log( ngx.DEBUG, "NETACEA BATCH - successfully sent batch to Kinesis, response status: ", res.status .. ", body: " .. (res.body or '') ); - end -end + local protector_result = self.protectorClient:checkReputation() -function _N:ingest() - if not self.ingestEnabled then return nil end - local vars = ngx.var - local mitata = ngx.ctx.mitata or vars.cookie__mitata or '' - - local data = { - Request = vars.request_method .. " " .. vars.request_uri .. " " .. vars.server_protocol, - TimeLocal = vars.time_local, - TimeUnixMsUTC = vars.msec * 1000, - RealIp = self:getIpAddress(vars), - UserAgent = vars.http_user_agent or "-", - Status = vars.status, - RequestTime = vars.request_time, - BytesSent = vars.bytes_sent, - Referer = vars.http_referer or "-", - NetaceaUserIdCookie = mitata, - NetaceaMitigationApplied = ngx.ctx.bc_type, - IntegrationType = self._MODULE_TYPE, - IntegrationVersion = self._MODULE_VERSION, - Query = vars.query_string or "", - RequestHost = vars.host or "-", - RequestId = vars.request_id or "-", - ProtectionMode = self.mitigationType or "ERROR", - -- TODO - BytesReceived = vars.bytes_received or 0, -- Doesn't seem to work - NetaceaUserIdCookieStatus = 1, - Optional = {} - } - - -- Add data directly to the queue for batch processing - local ok, err = data_queue:push(data) - if not ok and err then - ngx.log(ngx.WARN, "NETACEA INGEST - failed to queue data: ", err) - else - ngx.log(ngx.DEBUG, "NETACEA INGEST - queued data item, queue size: ", data_queue:count()) - end + ngx.ctx.NetaceaState.protector_result = protector_result -end + ngx.log(ngx.DEBUG, "NETACEA MITIGATE - protector result: ", cjson.encode(ngx.ctx.NetaceaState)) -_N['idTypesText'] = {} -_N['idTypes'] = { - NONE = '0', - UA = '1', - IP = '2', - VISITOR = '3', - DATACENTER = '4', - SEV = '5' -} - -_N['mitigationTypesText'] = {} -_N['mitigationTypes'] = { - NONE = '0', - BLOCKED = '1', - ALLOW = '2', - HARDBLOCKED = '3' -} - -_N['captchaStatesText'] = {} -_N['captchaStates'] = { - NONE = '0', - SERVE = '1', - PASS = '2', - FAIL = '3', - COOKIEPASS = '4', - COOKIEFAIL = '5' -} - - -_N['matchBcTypes'] = { - ['1'] = 'ua', - ['2'] = 'ip', - ['3'] = 'visitor', - ['4'] = 'datacenter', - ['5'] = 'sev' -} - -_N['mitigateBcTypes'] = { - ['1'] = 'blocked', - ['2'] = 'allow', - ['3'] = 'hardblocked', - ['4'] = 'block' -} - -_N['captchaBcTypes'] = { - ['1'] = 'captcha_serve', - ['2'] = 'captcha_pass', - ['3'] = 'captcha_fail', - ['4'] = 'captcha_cookiepass', - ['5'] = 'captcha_cookiefail' -} - -local function reversifyTable(table) - for k,v in pairs(_N[table]) do _N[table .. 'Text'][v] = k end + local best_mitigation = self:getBestMitigation(protector_result) + if best_mitigation == 'captcha' then + ngx.log(ngx.DEBUG, "NETACEA MITIGATE - serving captcha") + local captchaBody = protector_result.response.body + ngx.ctx.NetaceaState.grace_period = -1000 + self:refreshSession(parsed_cookie.reason) + serveCaptcha(captchaBody) + return + elseif best_mitigation == 'block' then + ngx.log(ngx.DEBUG, "NETACEA MITIGATE - serving block") + ngx.ctx.NetaceaState.grace_period = -1000 + self:refreshSession(parsed_cookie.reason) + serveBlock() + return + else + ngx.log(ngx.DEBUG, "NETACEA MITIGATE - no mitigation applied") + self:refreshSession(parsed_cookie.reason) + end + else + ngx.log(ngx.DEBUG, "NETACEA MITIGATE - valid cookie found, skipping mitigation") + ngx.ctx.NetaceaState.protector_result = { + match = parsed_cookie.data.mat, + mitigate = parsed_cookie.data.mit, + captcha = parsed_cookie.data.cap + } + end end - -reversifyTable('idTypes') -reversifyTable('mitigationTypes') -reversifyTable('captchaStates') - -return _N +return _N \ No newline at end of file diff --git a/src/lua_resty_netacea_constants.lua b/src/lua_resty_netacea_constants.lua new file mode 100644 index 0000000..eb06ce3 --- /dev/null +++ b/src/lua_resty_netacea_constants.lua @@ -0,0 +1,73 @@ +Constants = {} + +Constants['idTypesText'] = {} +Constants['idTypes'] = { + NONE = '0', + UA = '1', + IP = '2', + VISITOR = '3', + DATACENTER = '4', + SEV = '5' +} + +Constants['mitigationTypesText'] = {} +Constants['mitigationTypes'] = { + NONE = '0', + BLOCKED = '1', + ALLOW = '2', + HARDBLOCKED = '3' +} + +Constants['captchaStatesText'] = {} +Constants['captchaStates'] = { + NONE = '0', + SERVE = '1', + PASS = '2', + FAIL = '3', + COOKIEPASS = '4', + COOKIEFAIL = '5' +} + +Constants['issueReasons'] = { + NO_SESSION = 'no_session', + EXPIRED_SESSION = 'expired_session', + INVALID_SESSION = 'invalid_session', + IP_CHANGE = 'ip_change', + FORCED_REVALIDATION = 'forced_validation', + CAPTCHA_POST = 'captcha_post', + CAPTCHA_GET = 'captcha_get', +} + + +Constants['matchBcTypes'] = { + ['1'] = 'ua', + ['2'] = 'ip', + ['3'] = 'visitor', + ['4'] = 'datacenter', + ['5'] = 'sev' +} + +Constants['mitigateBcTypes'] = { + ['1'] = 'blocked', + ['2'] = 'allow', + ['3'] = 'hardblocked', + ['4'] = 'block' +} + +Constants['captchaBcTypes'] = { + ['1'] = 'captcha_serve', + ['2'] = 'captcha_pass', + ['3'] = 'captcha_fail', + ['4'] = 'captcha_cookiepass', + ['5'] = 'captcha_cookiefail' +} + +local function reversifyTable(table) + for k,v in pairs(Constants[table]) do Constants[table .. 'Text'][v] = k end +end + +reversifyTable('idTypes') +reversifyTable('mitigationTypes') +reversifyTable('captchaStates') + +return Constants \ No newline at end of file diff --git a/src/lua_resty_netacea_cookies_v3.lua b/src/lua_resty_netacea_cookies_v3.lua new file mode 100644 index 0000000..f6ae630 --- /dev/null +++ b/src/lua_resty_netacea_cookies_v3.lua @@ -0,0 +1,118 @@ +local jwt = require "resty.jwt" +local ngx = require 'ngx' + +local constants = require 'lua_resty_netacea_constants' +local utils = require 'netacea_utils' +local NetaceaCookies = {} +NetaceaCookies.__index = NetaceaCookies + + +function NetaceaCookies.newUserId() + local randomBytes = utils.buildRandomString(15) + return 'c'..randomBytes +end + +--- Creates a formatted HTTP cookie string with expiration +-- @param name string The cookie name +-- @param value string The cookie value +-- @param expiry number The expiry time in seconds from now +-- @return table Array of formatted cookie strings ready for Set-Cookie header +function NetaceaCookies.createSetCookieValues(name, value, expiry) + local cookies = ngx.ctx.cookies or {}; + local expiryTime = ngx.cookie_time(ngx.time() + tonumber(expiry)) + local newCookie = name .. '=' .. value .. '; Path=/; Expires=' .. expiryTime + cookies[name] = newCookie + ngx.ctx.cookies = cookies + + local setCookies = {} + for _, val in pairs(cookies) do + table.insert(setCookies, val) + end + return setCookies +end + + +function NetaceaCookies.generateNewCookieValue(secretKey, client, user_id, cookie_id, issue_reason, issue_timestamp, grace_period, match, mitigation, captcha, settings) + local plaintext = ngx.encode_args({ + cip = client, + uid = user_id, + cid = cookie_id, + isr = issue_reason, + ist = issue_timestamp, + grp = grace_period, + mat = match or 0, + mit = mitigation or 0, + cap = captcha or 0, + fCAPR = settings.fCAPR or 0 + }) + + local encoded = jwt:sign(secretKey, { + header={ typ="JWE", alg="dir", enc="A128CBC-HS256" }, + payload = plaintext + }) + + return { + mitata_jwe = encoded, + mitata_plaintext = plaintext + } +end + +function NetaceaCookies.parseMitataCookie(cookie, secretKey) + if not cookie or cookie == '' then + return { + valid = false, + reason = constants['issueReasons'].NO_SESSION + } + end + + local decoded = jwt:verify(secretKey, cookie) + if not decoded.verified then + return { + valid = false, + reason = constants['issueReasons'].INVALID_SESSION + } + end + + local result = ngx.decode_args(decoded.payload) + if not result or type(result) ~= 'table' then + return { + valid = false, + reason = constants['issueReasons'].INVALID_SESSION + } + end + + -- Check for required properties + local required_fields = {'cip', 'uid', 'cid', 'isr', 'ist', 'grp', 'mat', 'mit', 'cap', 'fCAPR'} + for _, field in ipairs(required_fields) do + if not result[field] then + return { + valid = false, + reason = constants['issueReasons'].INVALID_SESSION + } + end + end + + if tonumber(result.ist) + tonumber(result.grp) < ngx.time() then + return { + valid = false, + user_id = result.uid, + reason = constants['issueReasons'].EXPIRED_SESSION + } + end + + if result.cip ~= ngx.ctx.NetaceaState.client then + return { + valid = false, + user_id = result.uid, + reason = constants['issueReasons'].IP_CHANGE + } + end + + return { + valid = true, + user_id = result.uid, + data = result + } +end + +return NetaceaCookies \ No newline at end of file diff --git a/src/lua_resty_netacea_ingest.lua b/src/lua_resty_netacea_ingest.lua new file mode 100644 index 0000000..43d661a --- /dev/null +++ b/src/lua_resty_netacea_ingest.lua @@ -0,0 +1,257 @@ +local Kinesis = require("kinesis_resty") +local Ingest = {} +local utils = require("netacea_utils") +local cjson = require 'cjson' +local ngx = require 'ngx' + +local function new_queue(size, allow_wrapping) + -- Head is next insert, tail is next read + local head, tail = 1, 1; + local items = 0; -- Number of stored items + local t = {}; -- Table to hold items + return { + _items = t; + size = size; + count = function (_) return items; end; + push = function (_, item) + if items >= size then + if allow_wrapping then + tail = (tail%size)+1; -- Advance to next oldest item + items = items - 1; + else + return nil, "queue full"; + end + end + t[head] = item; + items = items + 1; + head = (head%size)+1; + return true; + end; + pop = function (_) + if items == 0 then + return nil; + end + local item; + item, t[tail] = t[tail], 0; + tail = (tail%size)+1; + items = items - 1; + return item; + end; + peek = function (_) + if items == 0 then + return nil; + end + return t[tail]; + end; + items = function (self) + return function (pos) + if pos >= t:count() then + return nil; + end + local read_pos = tail + pos; + if read_pos > t.size then + read_pos = (read_pos%size); + end + return pos+1, t._items[read_pos]; + end, self, 0; + end; + }; +end + + +function Ingest:new(options, _N_parent) + local n = {} + setmetatable(n, self) + self.__index = self + + n._N = _N_parent + + n.stream_name = options.stream_name or '' + n.region = options.region or 'eu-west-1' + n.aws_access_key = options.aws_access_key or '' + n.aws_secret_key = options.aws_secret_key or '' + + n.queue_size = options.queue_size or 5000 + n.dead_letter_queue_size = options.dead_letter_queue_size or 1000 + n.batch_size = options.batch_size or 25 + n.batch_timeout = options.batch_timeout or 1.0 + + n.data_queue = new_queue(n.queue_size, true); + n.dead_letter_queue = new_queue(n.dead_letter_queue_size, true); + n.BATCH_SIZE = n.batch_size; -- Kinesis PutRecords supports up to 500 records, using 25 for more frequent sends + n.BATCH_TIMEOUT = n.batch_timeout; -- Send batch after 1 second even if not full + ngx.log( ngx.DEBUG, "NETACEA INGEST - initialized with queue size ", n.queue_size, ", dead letter queue size ", n.dead_letter_queue_size, ", batch size ", n.BATCH_SIZE, ", batch timeout ", n.BATCH_TIMEOUT ); + return n +end +-- Data queue for batch processing + + +-------------------------------------------------------- +-- start batch processor for Kinesis data + +function Ingest:start_timers() + + -- start batch processor + local batch_processor; + ngx.log( ngx.DEBUG, "NETACEA INGEST - starting batch processor timer" ); + batch_processor = function( premature ) + + if premature then return end + + local execution_thread = ngx.thread.spawn( function() + local batch = {} + local last_send_time = ngx.now() + + while true do + -- Check if worker is exiting + if ngx.worker.exiting() == true then + -- Send any remaining data before exiting + if #batch > 0 then + self:send_batch_to_kinesis(batch) + end + return + end + + -- ngx.log( ngx.DEBUG, "NETACEA BATCH - checking for data to batch, current queue size: ", self.data_queue:count(), ", dead letter queue size: ", self.dead_letter_queue:count() ); + + local current_time = ngx.now() + local should_send_batch = false + local dead_letter_items = 0 + -- Check dead_letter_queue first + while self.dead_letter_queue:count() > 0 and #batch < self.BATCH_SIZE do + local dlq_item = self.dead_letter_queue:pop() + if dlq_item then + table.insert(batch, dlq_item) + dead_letter_items = dead_letter_items + 1 + end + end + + if (dead_letter_items > 0) then + ngx.log(ngx.DEBUG, "NETACEA BATCH - added ", dead_letter_items, " items from dead letter queue to batch") + end + + -- Collect data items for batch + while self.data_queue:count() > 0 and #batch < self.BATCH_SIZE do + local data_item = self.data_queue:pop() + if data_item then + table.insert(batch, data_item) + end + end + + -- Determine if we should send the batch + if #batch >= self.BATCH_SIZE then + should_send_batch = true + ngx.log(ngx.DEBUG, "NETACEA BATCH - sending full batch of ", #batch, " items") + elseif #batch > 0 and (current_time - last_send_time) >= self.BATCH_TIMEOUT then + should_send_batch = true + ngx.log(ngx.DEBUG, "NETACEA BATCH - sending timeout batch of ", #batch, " items") + end + + -- Send batch if conditions are met + if should_send_batch then + self:send_batch_to_kinesis(batch) + batch = {} -- Reset batch + last_send_time = current_time + end + + -- Sleep briefly if no data to process + if self.data_queue:count() == 0 and self.dead_letter_queue:count() == 0 then + ngx.sleep(0.1) + end + end + end ) + + local ok, err = ngx.thread.wait( execution_thread ); + if not ok and err then + ngx.log( ngx.ERR, "NETACEA - batch processor thread has failed with error: ", err ); + end + + -- If the worker is exiting, don't queue another processor + if ngx.worker.exiting() then + return + end + + ngx.timer.at( 0, batch_processor ); + end + + ngx.timer.at( 0, batch_processor ); + +end + +function Ingest:send_batch_to_kinesis(batch) + if not batch or #batch == 0 then return end + + local client = Kinesis.new( + self.stream_name, + self.region, + self.aws_access_key, + self.aws_secret_key + ) + + -- Convert batch data to Kinesis records format + local records = {} + for _, data_item in ipairs(batch) do + table.insert(records, { + partition_key = utils.buildRandomString(10), + data = "[" .. cjson.encode(data_item) .. "]" + }) + end + + ngx.log( ngx.DEBUG, "NETACEA BATCH - sending batch of ", #records, " records to Kinesis stream ", self.stream_name ); + + local res, err = client:put_records(records) + if err then + ngx.log( ngx.ERR, "NETACEA BATCH - error sending batch to Kinesis: ", err ); + for _, data_item in ipairs(batch) do + local ok, dlq_err = self.dead_letter_queue:push(data_item) + if not ok and dlq_err then + ngx.log( ngx.ERR, "NETACEA BATCH - failed to push record to dead letter queue: ", dlq_err ); + end + end + else + ngx.log( ngx.DEBUG, "NETACEA BATCH - successfully sent batch to Kinesis, response status: ", res.status .. ", body: " .. (res.body or '') ); + end + +end + +function Ingest:ingest() + local vars = ngx.var + local mitata = ngx.ctx.mitata or vars.cookie__mitata or '' + local NetaceaState = ngx.ctx.NetaceaState or {} + + local data = { + Request = vars.request_method .. " " .. vars.request_uri .. " " .. vars.server_protocol, + TimeLocal = vars.time_local, + TimeUnixMsUTC = vars.msec * 1000, + RealIp = NetaceaState.client or utils:getIpAddress(vars, self._N.realIpHeader), + UserAgent = vars.http_user_agent or "-", + Status = vars.status, + RequestTime = vars.request_time, + BytesSent = vars.bytes_sent, + Referer = vars.http_referer or "-", + NetaceaUserIdCookie = mitata, + UserId = NetaceaState.UserId or "-", + NetaceaMitigationApplied = NetaceaState.bc_type, + IntegrationType = self._N._MODULE_TYPE, + IntegrationVersion = self._N._MODULE_VERSION, + Query = vars.query_string or "", + RequestHost = vars.host or "-", + RequestId = vars.request_id or "-", + ProtectionMode = self._N.mitigationType or "ERROR", + -- TODO + BytesReceived = vars.bytes_received or 0, -- Doesn't seem to work + NetaceaUserIdCookieStatus = 1, + Optional = {} + } + + -- Add data directly to the queue for batch processing + local ok, err = self.data_queue:push(data) + if not ok and err then + ngx.log(ngx.ERR, "NETACEA INGEST - failed to queue data: ", err) + else + ngx.log(ngx.DEBUG, "NETACEA INGEST - queued data item, queue size: ", self.data_queue:count()) + end + +end + +return Ingest \ No newline at end of file diff --git a/src/lua_resty_netacea_protector_client.lua b/src/lua_resty_netacea_protector_client.lua new file mode 100644 index 0000000..d9e7c5b --- /dev/null +++ b/src/lua_resty_netacea_protector_client.lua @@ -0,0 +1,121 @@ +local http = require 'resty.http' +local constants = require 'lua_resty_netacea_constants' + +local ProtectorClient = {} +ProtectorClient.__index = ProtectorClient + +local function createHttpConnection() + local hc = http:new() + + -- hc will be nil on error + if hc then + -- syntax: httpc:set_timeouts(connect_timeout, send_timeout, read_timeout) + hc:set_timeouts(500, 750, 750) + end + + return hc +end + +function ProtectorClient:new(options) + local n = {} + setmetatable(n, self) + + n.apiKey = options.apiKey + n.mitigationEndpoint = options.mitigationEndpoint or {} + n.endpointIndex = 0 + + return n +end + +function ProtectorClient:getMitigationRequestHeaders() + local NetaceaState = ngx.ctx.NetaceaState + + local cookie = '' + if NetaceaState ~= nil and NetaceaState.captcha_cookie ~= nil then + cookie = '_mitatacaptcha=' .. NetaceaState.captcha_cookie + end + + local headers = { + ["x-netacea-api-key"] = self.apiKey, + ["content-type"] = 'application/x-www-form-urlencoded', + ["cookie"] = cookie, + ["user-agent"] = NetaceaState.user_agent or '', + ["x-netacea-client-ip"] = NetaceaState.client or '', + ['x-netacea-userid'] = NetaceaState.UserId or '' + } + + return headers +end + +function ProtectorClient:checkReputation() + local headers = self:getMitigationRequestHeaders() + local hc = createHttpConnection() + ngx.log(ngx.ERR, 'Netacea mitigation headers: ' .. require('cjson').encode(headers)) + self.endpointIndex = (self.endpointIndex + 1) % table.getn(self.mitigationEndpoint) + + local res, err = hc:request_uri( + self.mitigationEndpoint[self.endpointIndex + 1], + { + method = 'GET', + headers = headers + } + ) + if (err) then return nil end + + local result = { + response = { + status = res.status, + body = res.body, + headers = res.headers + }, + match = res['headers']['x-netacea-match'] or constants['idTypes'].NONE, + mitigate = res['headers']['x-netacea-mitigate'] or constants['mitigationTypes'].NONE, + captcha = res['headers']['x-netacea-captcha'] or constants['captchaStates'].NONE + } + return result +end + +function ProtectorClient:validateCaptcha(captcha_data) + local hc = createHttpConnection() + + local headers = self:getMitigationRequestHeaders() + + self.endpointIndex = (self.endpointIndex + 1) % table.getn(self.mitigationEndpoint) + + local res, err = hc:request_uri( + self.mitigationEndpoint[self.endpointIndex + 1] .. '/AtaVerifyCaptcha', + { + method = 'POST', + headers = headers, + body = captcha_data + } + ) + if (err) then return nil end + + local idType = res.headers['x-netacea-match'] or constants['idTypes'].NONE + local mitigationType = res.headers['x-netacea-mitigate'] or constants['mitigationTypes'].NONE + local captchaState = res.headers['x-netacea-captcha'] or constants['captchaStates'].NONE + + ngx.log(ngx.ERR, 'Netacea captcha validation response: match=' .. idType .. ', mitigate=' .. mitigationType .. ', captcha=' .. captchaState) + + local exit_status = ngx.HTTP_FORBIDDEN + if (captchaState == constants['captchaStates'].PASS) then + exit_status = ngx.HTTP_OK + + end + return { + response = { + status = res.status, + body = res.body, + headers = res.headers + }, + match = idType, + mitigate = mitigationType, + captcha = captchaState, + exit_status = exit_status, + captcha_cookie = res.headers['X-Netacea-MitATACaptcha-Value'] or nil + } +end + + +return ProtectorClient \ No newline at end of file diff --git a/src/netacea_utils.lua b/src/netacea_utils.lua new file mode 100644 index 0000000..4a5ee15 --- /dev/null +++ b/src/netacea_utils.lua @@ -0,0 +1,32 @@ +local M = {} + +function M.buildRandomString(length) + local chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + local randomString = '' + + local seed = os.time() * 1000000 + (os.clock() * 1000000) % 1000000 + math.randomseed(seed) + + local charTable = {} + for c in chars:gmatch"." do + table.insert(charTable, c) + end + + for i=1, length do -- luacheck: ignore i + randomString = randomString .. charTable[math.random(1, #charTable)] + end + + return randomString +end + +function M:getIpAddress(vars, realIpHeader) + if not realIpHeader then return vars.remote_addr end + local realIpHeaderValue = vars['http_' .. realIpHeader] + if not realIpHeaderValue or realIpHeaderValue == '' then + return vars.remote_addr + end + return realIpHeaderValue or vars.remote_addr +end + + +return M \ No newline at end of file diff --git a/test/lua_resty_netacea.test.lua b/test/lua_resty_netacea.test.lua index 9277a03..f63b690 100644 --- a/test/lua_resty_netacea.test.lua +++ b/test/lua_resty_netacea.test.lua @@ -51,10 +51,11 @@ local function build_mitata_cookie(epoch, uid, mitigation_values, key) local hmac = require 'openssl.hmac' local base64 = require 'base64' local netacea = require 'lua_resty_netacea' + local netacea_cookies = require 'lua_resty_netacea_cookies' local value = epoch .. COOKIE_DELIMITER .. uid .. COOKIE_DELIMITER .. mitigation_values local hash = hmac.new(key, 'sha256'):final(value) - hash = netacea:bToHex(hash) + hash = netacea_cookies.bToHex(hash) hash = base64.encode(hash) return hash .. COOKIE_DELIMITER .. value @@ -1279,4 +1280,199 @@ insulate("lua_resty_netacea.lua", function() assert.spy(logFunc).was.called() end) end) + describe('ingest only mode', function() + local luaMatch = require('luassert.match') + local mit_svc_url = 'someurl' + local mit_svc_api_key = 'somekey' + local mit_svc_secret = 'somesecret' + local ngx = nil + + local function stubNgx() + local ngx_stub = {} + + ngx_stub.var = { + http_user_agent = 'some_user_agent', + remote_addr = 'some_remote_addr', + cookie__mitata = '', + request_uri = '-' + } + + ngx_stub.req = { + read_body = function() return nil end, + get_body_data = function() return nil end, + set_header = spy.new(function(_, _) return nil end) + } + + ngx_stub.header = {} + ngx_stub.status = 0 + ngx_stub.HTTP_FORBIDDEN = 402 + ngx_stub.OK = 200 + + ngx_stub.exit = spy.new(function(_, _) return nil end) + ngx_stub.print = spy.new(function(_, _) return nil end) + ngx_stub.time = spy.new(function() return os.time() end) + ngx_stub.cookie_time = spy.new(function(time) return os.date("!%a, %d %b %Y %H:%M:%S GMT", time) end) + ngx_stub.ctx = { + } + ngx = wrap_table(require 'ngx', ngx_stub) + package.loaded['ngx'] = ngx + end + + before_each(function() + package.loaded['lua_resty_netacea'] = nil + + stubNgx() + end) + + it('Sets a cookie if one does not yet exist', function() + -- Clear any existing cookie + ngx.var.cookie__mitata = '' + + local netacea = (require 'lua_resty_netacea'):new({ + ingestEndpoint = 'https://fakedomain.com', + mitigationEndpoint = mit_svc_url, + apiKey = mit_svc_api_key, + secretKey = mit_svc_secret, + realIpHeader = '', + ingestEnabled = true, + mitigationEnabled = false, + mitigationType = 'INJECT' + }) + + netacea:run(nil) + + -- Verify that Set-Cookie header was called + assert.is_not_nil(ngx.header['Set-Cookie']) + + -- Verify cookie format matches expected pattern + local cookieString = ngx.header['Set-Cookie'][1] + assert.is_string(cookieString) + assert.is_string(cookieString:match('_mitata=.*; Path=/; Expires=.*')) + + -- Verify ngx.ctx.mitata was set + assert.is_not_nil(ngx.ctx.mitata) + assert.is_string(ngx.ctx.mitata) + + -- Verify the cookie value structure (hash_/@#/_epoch_/@#/_uid_/@#/_mitigation) + local mitataValue = ngx.ctx.mitata + local parts = {} + for part in mitataValue:gmatch('([^' .. COOKIE_DELIMITER .. ']+)') do + table.insert(parts, part) + end + + assert.is_equal(4, #parts, 'Cookie should have 4 parts separated by delimiters') + assert.is_not_nil(parts[1], 'Hash should be present') + assert.is_not_nil(parts[2], 'Epoch should be present') + assert.is_not_nil(parts[3], 'UID should be present') + assert.is_not_nil(parts[4], 'Mitigation values should be present') + + -- Verify epoch is in the future (should be current time + 1 hour) + local epoch = tonumber(parts[2]) + assert.is_number(epoch) + assert.is_true(epoch > ngx.time(), 'Cookie expiration should be in the future') + assert.is_true(epoch <= ngx.time() + 3600, 'Cookie expiration should be approximately 1 hour from now') + + -- Verify UID is not empty + assert.is_true(#parts[3] > 0, 'UID should not be empty') + + -- Verify mitigation values are default (000 for ingest-only mode) + local netacea_mod = require 'lua_resty_netacea' + local expected_mitigation = netacea_mod.idTypes.NONE .. netacea_mod.mitigationTypes.NONE .. netacea_mod.captchaStates.NONE + assert.is_equal(expected_mitigation, parts[4], 'Mitigation values should be default for ingest-only mode') + end) + + it('Refreshes an existing valid cookie when expired', function() + local current_time = ngx.time() + local expired_time = current_time - 10 -- 10 seconds ago + local original_uid = generate_uid() + + -- Set up an expired but otherwise valid cookie + ngx.var.cookie__mitata = build_mitata_cookie(expired_time, original_uid, '000', mit_svc_secret) + + local netacea = (require 'lua_resty_netacea'):new({ + ingestEndpoint = 'https://fakedomain.com', + mitigationEndpoint = mit_svc_url, + apiKey = mit_svc_api_key, + secretKey = mit_svc_secret, + realIpHeader = '', + ingestEnabled = true, + mitigationEnabled = false, + mitigationType = 'INJECT' + }) + + netacea:run(nil) + + -- Verify that Set-Cookie header was set for refresh + assert.is_not_nil(ngx.header['Set-Cookie']) + assert.is_not_nil(ngx.ctx.mitata) + + -- Verify the refreshed cookie preserves the UID + local mitataValue = ngx.ctx.mitata + local parts = {} + for part in mitataValue:gmatch('([^' .. COOKIE_DELIMITER .. ']+)') do + table.insert(parts, part) + end + + assert.is_equal(original_uid, parts[3], 'UID should be preserved when refreshing expired cookie') + + -- Verify new epoch is in the future + local new_epoch = tonumber(parts[2]) + assert.is_true(new_epoch > current_time, 'Refreshed cookie should have future expiration') + end) + + it('Sets new cookie if existing cookie has invalid hash', function() + -- Create a cookie with invalid hash + local current_time = ngx.time() + local future_time = current_time + 3600 + local invalid_cookie = 'invalid_hash' .. COOKIE_DELIMITER .. future_time .. COOKIE_DELIMITER .. generate_uid() .. COOKIE_DELIMITER .. '000' + + ngx.var.cookie__mitata = invalid_cookie + + local netacea = (require 'lua_resty_netacea'):new({ + ingestEndpoint = 'https://fakedomain.com', + mitigationEndpoint = mit_svc_url, + apiKey = mit_svc_api_key, + secretKey = mit_svc_secret, + realIpHeader = '', + ingestEnabled = true, + mitigationEnabled = false, + mitigationType = 'INJECT' + }) + + netacea:run(nil) + + -- Verify that new cookie was set + assert.is_not_nil(ngx.header['Set-Cookie']) + assert.is_not_nil(ngx.ctx.mitata) + + -- Verify the new cookie is different from the invalid one + assert.is_not_equal(invalid_cookie, ngx.ctx.mitata) + end) + + it('Does not modify valid existing cookie', function() + local current_time = ngx.time() + local future_time = current_time + 1800 -- 30 minutes in future + local uid = generate_uid() + local valid_cookie = build_mitata_cookie(future_time, uid, '000', mit_svc_secret) + + ngx.var.cookie__mitata = valid_cookie + + local netacea = (require 'lua_resty_netacea'):new({ + ingestEndpoint = 'https://fakedomain.com', + mitigationEndpoint = mit_svc_url, + apiKey = mit_svc_api_key, + secretKey = mit_svc_secret, + realIpHeader = '', + ingestEnabled = true, + mitigationEnabled = false, + mitigationType = 'INJECT' + }) + + netacea:run(nil) + + -- Valid cookie should not trigger new Set-Cookie header + assert.is_nil(ngx.header['Set-Cookie']) + assert.is_nil(ngx.ctx.mitata) + end) + end) end) diff --git a/test/lua_resty_netacea_cookies_v3.test.lua b/test/lua_resty_netacea_cookies_v3.test.lua new file mode 100644 index 0000000..103b88c --- /dev/null +++ b/test/lua_resty_netacea_cookies_v3.test.lua @@ -0,0 +1,380 @@ +require 'busted.runner'() + +package.path = "../src/?.lua;" .. package.path +local match = require("luassert.match") + +describe("lua_resty_netacea_cookies_v3", function() + local NetaceaCookies + local jwt_mock + local ngx_mock + local constants_mock + + before_each(function() + -- Mock jwt module + jwt_mock = { + sign = spy.new(function(self, secretKey, payload) + return "mock_jwt_token_" .. secretKey + end), + verify = spy.new(function(self, secretKey, token) + if token == "valid_token" then + return { + verified = true, + payload = "cip=192.168.1.1&uid=user123&cid=cookie123&isr=no_session&ist=1640995200&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + elseif token == "expired_token" then + return { + verified = true, + payload = "cip=192.168.1.1&uid=user123&cid=cookie123&isr=no_session&ist=1000000000&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + elseif token == "ip_mismatch_token" then + return { + verified = true, + payload = "cip=10.0.0.1&uid=user123&cid=cookie123&isr=no_session&ist=1640995200&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + elseif token == "missing_fields_token" then + return { + verified = true, + payload = "cip=192.168.1.1&uid=user123" + } + elseif token == "invalid_payload_token" then + return { + verified = true, + payload = "invalid_payload_format" + } + else + return { + verified = false + } + end + end) + } + + -- Mock ngx module + ngx_mock = { + ctx = { + cookies = nil, + NetaceaState = { + client = "192.168.1.1" + } + }, + time = spy.new(function() return 1640995200 end), -- Fixed timestamp + cookie_time = spy.new(function(timestamp) + return "Thu, 01 Jan 2022 00:00:00 GMT" + end), + encode_args = spy.new(function(args) + local parts = {} + for k, v in pairs(args) do + table.insert(parts, k .. "=" .. tostring(v)) + end + return table.concat(parts, "&") + end), + decode_args = spy.new(function(str) + if str == "invalid_payload_format" then + return nil + end + local result = {} + for pair in str:gmatch("[^&]+") do + local key, value = pair:match("([^=]+)=([^=]*)") + if key and value then + result[key] = value + end + end + return result + end) + } + + -- Mock constants + constants_mock = { + issueReasons = { + NO_SESSION = 'no_session', + EXPIRED_SESSION = 'expired_session', + INVALID_SESSION = 'invalid_session', + IP_CHANGE = 'ip_change' + } + } + + -- Set up package mocks + package.loaded['resty.jwt'] = jwt_mock + package.loaded['ngx'] = ngx_mock + package.loaded['lua_resty_netacea_constants'] = constants_mock + + NetaceaCookies = require('lua_resty_netacea_cookies_v3') + end) + + after_each(function() + -- Clear mocks and cached modules + package.loaded['lua_resty_netacea_cookies_v3'] = nil + package.loaded['resty.jwt'] = nil + package.loaded['ngx'] = nil + package.loaded['lua_resty_netacea_constants'] = nil + + -- Reset ngx context + ngx_mock.ctx.cookies = nil + end) + + describe("createSetCookieValues", function() + it("should create a properly formatted cookie string", function() + local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", 3600) + + assert.is.table(result) + assert.is.equal(1, #result) + assert.is.equal("test_cookie=test_value; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", result[1]) + + assert.spy(ngx_mock.time).was.called() + assert.spy(ngx_mock.cookie_time).was.called_with(1640995200 + 3600) + end) + + it("should store cookie in ngx.ctx.cookies", function() + NetaceaCookies.createSetCookieValues("test_cookie", "test_value", 3600) + + assert.is.table(ngx_mock.ctx.cookies) + assert.is.equal("test_cookie=test_value; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", + ngx_mock.ctx.cookies["test_cookie"]) + end) + + it("should handle multiple cookies", function() + NetaceaCookies.createSetCookieValues("cookie1", "value1", 3600) + local result = NetaceaCookies.createSetCookieValues("cookie2", "value2", 7200) + + assert.is.equal(2, #result) + assert.is.table(ngx_mock.ctx.cookies) + assert.is.truthy(ngx_mock.ctx.cookies["cookie1"]) + assert.is.truthy(ngx_mock.ctx.cookies["cookie2"]) + end) + + it("should handle existing cookies in context", function() + ngx_mock.ctx.cookies = { + existing_cookie = "existing_cookie=existing_value; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT" + } + + local result = NetaceaCookies.createSetCookieValues("new_cookie", "new_value", 3600) + + assert.is.equal(2, #result) + end) + + it("should handle zero expiry time", function() + local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", 0) + + assert.is.table(result) + assert.is.equal(1, #result) + assert.spy(ngx_mock.cookie_time).was.called_with(1640995200) + end) + + it("should convert expiry to number", function() + local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", "3600") + + assert.is.table(result) + assert.spy(ngx_mock.cookie_time).was.called_with(1640995200 + 3600) + end) + end) + + describe("generateNewCookieValue", function() + it("should generate a new cookie value with all parameters", function() + local _ = match._ + local settings = { fCAPR = 1 } + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "192.168.1.1", + "user123", + "cookie123", + "no_session", + 1640995200, + 3600, + 1, + 2, + 3, + settings + ) + + assert.is.table(result) + assert.is.string(result.mitata_jwe) + assert.is.string(result.mitata_plaintext) + assert.is.equal("mock_jwt_token_secret_key", result.mitata_jwe) + + assert.spy(ngx_mock.encode_args).was.called() + assert.spy(jwt_mock.sign).was.called_with(match.is_not_nil(), "secret_key", { + header = { typ="JWE", alg="dir", enc="A128CBC-HS256" }, + payload = "ist=1640995200&mit=2&isr=no_session&cip=192.168.1.1&grp=3600&uid=user123&fCAPR=1&cid=cookie123&cap=3&mat=1" + }) + end) + + it("should use default values for optional parameters", function() + local settings = {} + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "192.168.1.1", + "user123", + "cookie123", + "no_session", + 1640995200, + 3600, + nil, -- match + nil, -- mitigation + nil, -- captcha + settings + ) + + assert.is.table(result) + assert.spy(ngx_mock.encode_args).was.called_with({ + cip = "192.168.1.1", + uid = "user123", + cid = "cookie123", + isr = "no_session", + ist = 1640995200, + grp = 3600, + mat = 0, + mit = 0, + cap = 0, + fCAPR = 0 + }) + end) + + it("should handle empty settings", function() + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "192.168.1.1", + "user123", + "cookie123", + "no_session", + 1640995200, + 3600, + 1, + 2, + 3, + {} + ) + + assert.is.table(result) + assert.is.string(result.mitata_jwe) + assert.is.string(result.mitata_plaintext) + end) + end) + + describe("parseMitataCookie", function() + it("should return invalid result for nil cookie", function() + local result = NetaceaCookies.parseMitataCookie(nil, "secret_key") + + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('no_session', result.reason) + end) + + it("should return invalid result for empty cookie", function() + local result = NetaceaCookies.parseMitataCookie("", "secret_key") + + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('no_session', result.reason) + end) + + it("should return invalid result for unverified JWT", function() + local _ = match._ + local result = NetaceaCookies.parseMitataCookie("invalid_token", "secret_key") + + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('invalid_session', result.reason) + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "invalid_token") + end) + + it("should return invalid result for invalid payload format", function() + local result = NetaceaCookies.parseMitataCookie("invalid_payload_token", "secret_key") + + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('invalid_session', result.reason) + end) + + it("should return invalid result for missing required fields", function() + local result = NetaceaCookies.parseMitataCookie("missing_fields_token", "secret_key") + + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('invalid_session', result.reason) + end) + + it("should return invalid result for expired cookie", function() + local result = NetaceaCookies.parseMitataCookie("expired_token", "secret_key") + + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('expired_session', result.reason) + assert.is.equal('user123', result.user_id) + end) + + it("should return invalid result for IP mismatch", function() + local result = NetaceaCookies.parseMitataCookie("ip_mismatch_token", "secret_key") + + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('ip_change', result.reason) + assert.is.equal('user123', result.user_id) + end) + + it("should return valid result for valid cookie", function() + local result = NetaceaCookies.parseMitataCookie("valid_token", "secret_key") + + assert.is.table(result) + assert.is_true(result.valid) + assert.is.equal('user123', result.user_id) + assert.is.table(result.data) + assert.is.equal('192.168.1.1', result.data.cip) + assert.is.equal('user123', result.data.uid) + assert.is.equal('cookie123', result.data.cid) + end) + + it("should call jwt.verify with correct parameters", function() + NetaceaCookies.parseMitataCookie("test_cookie", "test_secret") + + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "test_secret", "test_cookie") + end) + + it("should check all required fields", function() + -- This test ensures all required fields are checked + local required_fields = {'cip', 'uid', 'cid', 'isr', 'ist', 'grp', 'mat', 'mit', 'cap', 'fCAPR'} + + -- Create a mock that returns a payload missing each field one at a time + for _, missing_field in ipairs(required_fields) do + jwt_mock.verify = spy.new(function(secretKey, token) + local payload_parts = {} + for _, field in ipairs(required_fields) do + if field ~= missing_field then + table.insert(payload_parts, field .. "=value") + end + end + return { + verified = true, + payload = table.concat(payload_parts, "&") + } + end) + + local result = NetaceaCookies.parseMitataCookie("test_token", "secret_key") + assert.is_false(result.valid, "Should be invalid when missing field: " .. missing_field) + assert.is.equal('invalid_session', result.reason) + end + end) + end) + describe("newUserId #only", function() + it("should generate a user ID starting with 'c' followed by 15 characters", function() + local userId = NetaceaCookies.newUserId() + + assert.is_string(userId) + assert.is.equal(16, #userId) + assert.is.equal('c', userId:sub(1,1)) + end) + + it("should generate different user IDs on multiple calls", function() + local userId1 = NetaceaCookies.newUserId() + local userId2 = NetaceaCookies.newUserId() + + assert.is_not.equal(userId1, userId2) + end) + + it("should generate user ID with alphanumeric characters", function() + local userId = NetaceaCookies.newUserId() + local pattern = "^c[%w_%-]+$" -- Alphanumeric, underscore, hyphen + + assert.is_true(userId:match(pattern) ~= nil, "User ID should match pattern: " .. pattern) + end) + end) +end) diff --git a/test/netacea_utils.test.lua b/test/netacea_utils.test.lua new file mode 100644 index 0000000..9feac13 --- /dev/null +++ b/test/netacea_utils.test.lua @@ -0,0 +1,177 @@ +require 'busted.runner'() + +package.path = "../src/?.lua;" .. package.path + +describe("netacea_utils", function() + local utils + + before_each(function() + utils = require('netacea_utils') + end) + + after_each(function() + package.loaded['netacea_utils'] = nil + end) + + describe("buildRandomString", function() + it("should generate a string of the specified length", function() + local result = utils.buildRandomString(10) + assert.is.equal(10, #result) + end) + + it("should generate a string of length 1 when passed 1", function() + local result = utils.buildRandomString(1) + assert.is.equal(1, #result) + end) + + it("should generate an empty string when passed 0", function() + local result = utils.buildRandomString(0) + assert.is.equal(0, #result) + assert.is.equal('', result) + end) + + it("should generate strings with only alphanumeric characters", function() + local result = utils.buildRandomString(50) + local validChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + + for i = 1, #result do + local char = result:sub(i, i) + assert.is_truthy(validChars:find(char, 1, true), "Character '" .. char .. "' should be alphanumeric") + end + end) + + it("should generate different strings on multiple calls", function() + local result1 = utils.buildRandomString(20) + local result2 = utils.buildRandomString(20) + + -- While theoretically possible to be equal, it's extremely unlikely + -- with 62^20 possible combinations + assert.is_not.equal(result1, result2) + end) + + it("should handle large string lengths", function() + local result = utils.buildRandomString(1000) + assert.is.equal(1000, #result) + end) + + it("should contain at least some variety in character types for longer strings", function() + local result = utils.buildRandomString(100) + + -- Check that we have at least some variety (not all the same character) + local firstChar = result:sub(1, 1) + local hasVariety = false + + for i = 2, #result do + if result:sub(i, i) ~= firstChar then + hasVariety = true + break + end + end + + assert.is_true(hasVariety, "String should contain character variety") + end) + end) + + describe("getIpAddress", function() + it("should return remote_addr when realIpHeader is nil", function() + local vars = { + remote_addr = "192.168.1.1" + } + + local result = utils:getIpAddress(vars, nil) + assert.is.equal("192.168.1.1", result) + end) + + it("should return remote_addr when realIpHeader is not provided", function() + local vars = { + remote_addr = "192.168.1.1" + } + + local result = utils:getIpAddress(vars) + assert.is.equal("192.168.1.1", result) + end) + + it("should return remote_addr when realIpHeader is empty string", function() + local vars = { + remote_addr = "192.168.1.1" + } + + local result = utils:getIpAddress(vars, "") + assert.is.equal("192.168.1.1", result) + end) + + it("should return the real IP header value when it exists", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "203.0.113.1" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is.equal("203.0.113.1", result) + end) + + it("should return remote_addr when real IP header doesn't exist", function() + local vars = { + remote_addr = "192.168.1.1" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is.equal("192.168.1.1", result) + end) + + it("should handle different header formats", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_real_ip = "203.0.113.2", + http_cf_connecting_ip = "203.0.113.3" + } + + local result1 = utils:getIpAddress(vars, "x_real_ip") + assert.is.equal("203.0.113.2", result1) + + local result2 = utils:getIpAddress(vars, "cf_connecting_ip") + assert.is.equal("203.0.113.3", result2) + end) + + it("should fall back to remote_addr when real IP header is empty", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is.equal("192.168.1.1", result) + end) + + it("should fall back to remote_addr when real IP header is nil", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = nil + } + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is.equal("192.168.1.1", result) + end) + + it("should handle IPv6 addresses", function() + local vars = { + remote_addr = "2001:db8::1", + http_x_forwarded_for = "2001:db8::2" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is.equal("2001:db8::2", result) + end) + + it("should handle special header names with underscores and dashes", function() + local vars = { + remote_addr = "192.168.1.1", + ["http_x_forwarded_for"] = "203.0.113.1", + ["http_x_real_ip"] = "203.0.113.2" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is.equal("203.0.113.1", result) + end) + end) +end)