From c7d98f8fc7245477973647d6f3b6fd382c486b51 Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Wed, 26 Nov 2025 09:42:00 +0000 Subject: [PATCH 1/6] Add support for custom cookie names --- src/lua_resty_netacea.lua | 21 ++++++++--- test/lua_resty_netacea.test.lua | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index adc9af2..a803b41 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -95,6 +95,10 @@ function _N:new(options) if not self.secretKey or self.secretKey == '' then self.mitigationEnabled = false end + -- global:optional:cookieName + self.cookieName = options.cookieName or '_mitata' + -- global:optional:captchaCookieName + self.captchaCookieName = options.captchaCookieName or '_mitatacaptcha' -- global:optional:realIpHeader self.realIpHeader = options.realIpHeader or '' -- global:optional:userIdKey @@ -122,9 +126,13 @@ end function _N:getMitigationRequestHeaders() local vars = ngx.var - local requestMitata = vars.cookie__mitata or '' - local requestMitataCaptcha = vars.cookie__mitatacaptcha or '' - local cookie = '_mitata=' .. requestMitata .. ';_mitatacaptcha=' .. requestMitataCaptcha + + 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', @@ -167,7 +175,7 @@ function _N:validateCaptcha(onEventFunc) local mitigationType = res.headers['x-netacea-mitigate'] or self.mitigationTypes.NONE local captchaState = res.headers['x-netacea-captcha'] or self.captchaStates.NONE - self:addCookie('_mitatacaptcha', mitataCaptchaVal, mitataCaptchaExp) + self:addCookie(self.captchaCookieName, mitataCaptchaVal, mitataCaptchaExp) local exit_status = ngx.HTTP_FORBIDDEN if (captchaState == self.captchaStates.PASS) then @@ -185,7 +193,7 @@ function _N:validateCaptcha(onEventFunc) end function _N:addMitataCookie(mitataVal, mitataExp) - self:addCookie('_mitata', 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 @@ -213,7 +221,8 @@ function _N:bToHex(b) end function _N:parseMitataCookie() - local mitata_cookie = ngx.var.cookie__mitata or '' + + 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( diff --git a/test/lua_resty_netacea.test.lua b/test/lua_resty_netacea.test.lua index 95db198..9277a03 100644 --- a/test/lua_resty_netacea.test.lua +++ b/test/lua_resty_netacea.test.lua @@ -285,6 +285,36 @@ insulate("lua_resty_netacea.lua", function() local result = netacea:get_mitata_cookie() assert.are.same(expected, result) end) + + it('works with custom cookie names', function() + local ngx_stub = require 'ngx' + local t = ngx_stub.time() + 20 + local custom_cookie_name = 'custom_mitata' + local cookie = build_mitata_cookie(t, generate_uid(), '000', netacea_init_params.secretKey) + ngx_stub.var = { + ['cookie_' .. custom_cookie_name] = cookie + } + package.loaded['ngx'] = ngx_stub + + local netacea = (require 'lua_resty_netacea'):new( + copy_table( + netacea_init_params, + { cookieName = custom_cookie_name } + ) + ) + + local hash, epoch, uid, mitigation = cookie:match('(.*)_/@#/(.*)_/@#/(.*)_/@#/(.*)') + local expected = { + original = ngx_stub.var['cookie_' .. custom_cookie_name], + hash = hash, + epoch = tonumber(epoch), + uid = uid, + mitigation = mitigation + } + local result = netacea:get_mitata_cookie() + assert.are.same(expected, result) + + end) end) describe('mitigate', function() @@ -417,6 +447,43 @@ insulate("lua_resty_netacea.lua", function() assert.spy(logFunc).was.called() end) + + + it('works with custom cookies', function() + local custom_cookie_name = 'custom_mitata' + local req_spy = setHttpResponse('-', nil, 'error') + + local netacea = require 'lua_resty_netacea' + local mit = netacea.idTypes.IP .. netacea.mitigationTypes.ALLOW .. netacea.captchaStates.NONE + ngx.var["cookie_" .. custom_cookie_name] = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) + + package.loaded['lua_resty_netacea'] = nil + + netacea = (require 'lua_resty_netacea'):new( + copy_table( + netacea_default_params, + { + ingestEnabled = false, + mitigationEndpoint = mit_svc_url, + apiKey = mit_svc_api_key, + secretKey = mit_svc_secret, + cookieName = custom_cookie_name + } + ) + ) + + local logFunc = spy.new(function(res) + assert.equal(netacea.idTypes.IP, res.idType) + assert.equal(netacea.mitigationTypes.ALLOW, res.mitigationType) + assert.equal(netacea.captchaStates.NONE, res.captchaState) + end) + + netacea:run(logFunc) + + assert.spy(req_spy).was.not_called() + assert.spy(logFunc).was.called() + end) + it('does not forward to mit service if mitata cookie is BLOCK', function() local req_spy = setHttpResponse('-', nil, 'error') From c917f2ca0ebe755d6dbe6d6b57052e46e492d26d Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Wed, 10 Dec 2025 12:30:52 +0000 Subject: [PATCH 2/6] Kinesis ingest (#14) --- Dockerfile | 5 +- docker-compose.yml | 1 + src/kinesis_resty.lua | 167 ++++++++++++++++++++++++++++ src/lua_resty_netacea.lua | 226 ++++++++++++++++++++------------------ 4 files changed, 291 insertions(+), 108 deletions(-) create mode 100644 src/kinesis_resty.lua diff --git a/Dockerfile b/Dockerfile index 21361b3..ad74671 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,5 @@ FROM openresty/openresty:xenial AS base -LABEL author="Curtis Johnson " -LABEL maintainer="Curtis Johnson " - USER root WORKDIR /usr/src @@ -39,4 +36,4 @@ CMD ["bash", "-c", "./run_lua_tests.sh"] FROM test AS lint -CMD ["bash", "-c", "luacheck --no-self -- ./src"] \ No newline at end of file +CMD ["bash", "-c", "luacheck --no-self -- ./src"] diff --git a/docker-compose.yml b/docker-compose.yml index 6e3f0d9..9d51f0c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ 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/kinesis_resty.lua:/usr/local/openresty/site/lualib/kinesis_resty.lua" test: build: diff --git a/src/kinesis_resty.lua b/src/kinesis_resty.lua new file mode 100644 index 0000000..74857da --- /dev/null +++ b/src/kinesis_resty.lua @@ -0,0 +1,167 @@ +-- kinesis_resty.lua +-- OpenResty-compatible AWS Kinesis client +-- No external dependencies, fully thread-safe + +local ffi = require "ffi" +local http = require "resty.http" +local cjson = require "cjson.safe" +local sha256 = require "resty.sha256" +local str = require "resty.string" +local ngx = ngx + +local Kinesis = {} +Kinesis.__index = Kinesis + +ngx.log(ngx.ERR, "*** kinesis_resty module loaded ***") + +-- FFI-based HMAC-SHA256 +ffi.cdef[[ +unsigned char *HMAC(const void *evp_md, + const void *key, int key_len, + const unsigned char *d, size_t n, + unsigned char *md, unsigned int *md_len); +const void* EVP_sha256(void); +]] + +local function hmac_sha256(key, data) + local md = ffi.new("unsigned char[32]") + local md_len = ffi.new("unsigned int[1]") + ffi.C.HMAC(ffi.C.EVP_sha256(), + key, #key, + data, #data, + md, md_len) + return ffi.string(md, md_len[0]) +end + +-- SHA256 helper +local function sha256_bin(data) + local sha = sha256:new() + sha:update(data) + return sha:final() +end + +local function hex(bin) + return str.to_hex(bin) +end + +-- Derive AWS signing key +local function get_signing_key(secret_key, date, region, service) + local kDate = hmac_sha256("AWS4"..secret_key, date) + local kRegion = hmac_sha256(kDate, region) + local kService= hmac_sha256(kRegion, service) + local kSign = hmac_sha256(kService, "aws4_request") + return kSign +end + +-- Constructor +function Kinesis.new(stream_name, region, access_key, secret_key) + local self = setmetatable({}, Kinesis) + self.stream_name = stream_name + self.region = region + self.access_key = access_key + self.secret_key = secret_key + self.host = "kinesis."..region..".amazonaws.com" + self.endpoint = "https://"..self.host.."/" + return self +end + +-- Generate SigV4 headers +function Kinesis:_sign_request(payload, target) + local now = os.date("!%Y%m%dT%H%M%SZ") -- UTC time in ISO8601 basic + local date = os.date("!%Y%m%d") -- YYYYMMDD for scope + + local headers = { + ["Host"] = self.host, + ["Content-Type"] = "application/x-amz-json-1.1", + ["X-Amz-Date"] = now, + ["X-Amz-Target"] = target + } + + -- canonical headers + local canonical_headers = "" + local signed_headers = {} + local keys = {} + for k,_ in pairs(headers) do table.insert(keys,k) end + table.sort(keys, function(a,b) return a:lower() < b:lower() end) + for _,k in ipairs(keys) do + canonical_headers = canonical_headers .. k:lower()..":"..headers[k].."\n" + table.insert(signed_headers, k:lower()) + end + local signed_headers_str = table.concat(signed_headers,";") + + local payload_hash = hex(sha256_bin(payload)) + + local canonical_request = table.concat{ + "POST\n", + "/\n", + "\n", + canonical_headers .. "\n", + signed_headers_str .. "\n", + payload_hash + } + + local canonical_request_hash = hex(sha256_bin(canonical_request)) + + local scope = date.."/"..self.region.."/kinesis/aws4_request" + local string_to_sign = table.concat{ + "AWS4-HMAC-SHA256\n", + now.."\n", + scope.."\n", + canonical_request_hash + } + + local signing_key = get_signing_key(self.secret_key, date, self.region, "kinesis") + local signature = hex(hmac_sha256(signing_key, string_to_sign)) + + headers["Authorization"] = string.format( + "AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", + self.access_key, scope, signed_headers_str, signature + ) + + headers["Content-Length"] = #payload + + return headers +end + +-- Internal send +function Kinesis:_send(target, payload) + local httpc = http.new() + httpc:set_timeout(5000) + local headers = self:_sign_request(payload, target) + ngx.log(ngx.DEBUG, "Kinesis Request Headers: ", cjson.encode(headers)) + local res, err = httpc:request_uri(self.endpoint, { + method = "POST", + body = payload, + headers = headers, + ssl_verify = true + }) + return res, err +end + +-- PutRecord +function Kinesis:put_record(partition_key, data) + local payload = cjson.encode{ + StreamName = self.stream_name, + PartitionKey = partition_key, + Data = ngx.encode_base64(data) + } + return self:_send("Kinesis_20131202.PutRecord", payload) +end + +-- PutRecords +function Kinesis:put_records(records) + local recs = {} + for _,r in ipairs(records) do + table.insert(recs, { + PartitionKey = r.partition_key, + Data = ngx.encode_base64(r.data) + }) + end + local payload = cjson.encode{ + StreamName = self.stream_name, + Records = recs + } + return self:_send("Kinesis_20131202.PutRecords", payload) +end + +return Kinesis diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index a803b41..6345d16 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -1,3 +1,5 @@ +local Kinesis = require("kinesis_resty") + local _N = {} _N._VERSION = '0.2.2' _N._TYPE = 'nginx' @@ -75,6 +77,7 @@ function _N:new(options) if not self.ingestEndpoint or self.ingestEndpoint == '' then self.ingestEnabled = false end + self.kinesisProperties = options.kinesisProperties or nil -- mitigate:optional:mitigationEnabled self.mitigationEnabled = options.mitigationEnabled or false -- mitigate:required:mitigationEndpoint @@ -472,8 +475,8 @@ function _N:setBcType(match, mitigate, captcha) return mitigationApplied end ----------------------------------------------------------------------- --- start STASH code to enable async HTTP requests from logging context +--------------------------------------------------------- +-- Async ingest from logging context local function new_queue(size, allow_wrapping) -- Head is next insert, tail is next read @@ -529,106 +532,136 @@ local function new_queue(size, allow_wrapping) }; end -local semaphore = require "ngx.semaphore"; - -local async_queue_low_priority = new_queue(5000, true); -local queue_sema_low_priority = semaphore.new(); -local requests_sema = semaphore.new(); - -requests_sema:post(1024); -- allow up to 1024 sending timer contexts +-- 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 -------------------------------------------------------- --- start timers to execute requests tasks +-- start batch processor for Kinesis data function _N:start_timers() - -- start requests executor - local executor; - executor = function( premature ) + -- start batch processor + local batch_processor; + 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 - while async_queue_low_priority:count() == 0 do - if ngx.worker.exiting() == true then return end + -- 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 - queue_sema_low_priority:wait(0.3); -- sleeping for 300 milliseconds + 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 - repeat - if ngx.worker.exiting() == true then return end - - -- to make sure that there are only up to 1024 executor's timers at any time - local ok, _ = requests_sema:wait(0.1); - until ok and ok == true; - - local task = async_queue_low_priority:pop(); - if task then - -- run tasks in separate timer contexts to avoid accumulating large numbers of dead corutines - ngx.timer.at( 0, function() - local ok, err = pcall( task ); - if not ok and err then - ngx.log( ngx.ERR, "NETACEA API - sending task has failed with error: ", err ); - end - - local cnt = 1; - - while async_queue_low_priority:count() > 0 and cnt < 100 do - - local next_task = async_queue_low_priority:pop(); - - if not next_task then - queue_sema_low_priority:wait(0.3); -- sleeping for 300 milliseconds - next_task = async_queue_low_priority:pop(); - end - - if next_task then - ok, err = pcall( next_task ); - if not ok and err then - ngx.log( ngx.ERR, "NETACEA - sending task has failed with error: ", err ); - else - ngx.sleep(0.01); - end - else - if queue_sema_low_priority:count() > async_queue_low_priority:count() then - queue_sema_low_priority:wait(0) - end - break; - end - - cnt = cnt + 1; - end - - requests_sema:post(1); - end ); - else -- semaphore is out of sync with queue - need to drain it - if queue_sema_low_priority:count() > async_queue_low_priority:count() then - queue_sema_low_priority:wait(0) + -- 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 - requests_sema:post(1); 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 ); + end ) local ok, err = ngx.thread.wait( execution_thread ); if not ok and err then - ngx.log( ngx.ERR, "NETACEA - executor thread has failed with error: ", err ); + ngx.log( ngx.ERR, "NETACEA - batch processor thread has failed with error: ", err ); end - -- If the worker is exiting, don't queue another executor + -- If the worker is exiting, don't queue another processor if ngx.worker.exiting() then return end - ngx.timer.at( 0, executor ); + ngx.timer.at( 0, batch_processor ); end - ngx.timer.at( 0, executor ); + ngx.timer.at( 0, batch_processor ); + +end + +function _N:send_batch_to_kinesis(batch) + if not batch or #batch == 0 then return end + + local client = Kinesis.new( + self.kinesisProperties.stream_name, + self.kinesisProperties.region, + self.kinesisProperties.aws_access_key, + self.kinesisProperties.aws_secret_key + ) + + -- 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 ); + + 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 + end + else + ngx.log( ngx.DEBUG, "NETACEA BATCH - successfully sent batch to Kinesis, response status: ", res.status .. ", body: " .. (res.body or '') ); + end end --- end STASH code function _N:ingest() if not self.ingestEnabled then return nil end @@ -637,7 +670,8 @@ function _N:ingest() local data = { Request = vars.request_method .. " " .. vars.request_uri .. " " .. vars.server_protocol, - TimeLocal = vars.msec * 1000, + TimeLocal = vars.time_local, + TimeUnixMsUTC = vars.msec * 1000, RealIp = self:getIpAddress(vars), UserAgent = vars.http_user_agent or "-", Status = vars.status, @@ -647,41 +681,25 @@ function _N:ingest() NetaceaUserIdCookie = mitata, NetaceaMitigationApplied = ngx.ctx.bc_type, IntegrationType = self._MODULE_TYPE, - IntegrationVersion = self._MODULE_VERSION + 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 = {} } - -- start STASH code - local request_params = {}; - - request_params.body = cjson.encode(data); - request_params.method = "POST"; - request_params.headers = { - ["Content-Length"] = #request_params.body, - ["Content-Type"] = "application/json", - ["x-netacea-api-key"] = self.apiKey; - }; - - local request_task = function() - local hc = http:new(); - - local res, err = hc:request_uri( self.ingestEndpoint, request_params ); - - if not res and err then - ngx.log( ngx.ERR, "Netacea ingest - failed API request - error: ", err ); - return; - else - if res.status ~= 200 and res.status ~= 201 then - ngx.log( ngx.ERR, "Netacea ingest - failed API request - status: ", res.status ); - return; - end - end + -- 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 - -- request_params are not going to get deallocated as long as function stays in the queue - local ok, _ = async_queue_low_priority:push( request_task ); - if ok then queue_sema_low_priority:post(1) end - - -- end STASH code end _N['idTypesText'] = {} From 3812c0a65b8f993ba388335c4ee4a3984431007a Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Fri, 23 Jan 2026 09:52:22 +0000 Subject: [PATCH 3/6] Refactor and add v3 cookies (#17) --- .gitignore | 2 + docker-compose.yml | 7 + lua_resty_netacea-0.2-2.rockspec | 3 +- src/conf/nginx.conf | 17 +- src/lua_resty_netacea.lua | 843 +++++---------------- src/lua_resty_netacea_constants.lua | 73 ++ src/lua_resty_netacea_cookies_v3.lua | 131 ++++ src/lua_resty_netacea_ingest.lua | 257 +++++++ src/lua_resty_netacea_protector_client.lua | 121 +++ src/netacea_utils.lua | 42 + test/lua_resty_netacea.test.lua | 198 ++++- test/lua_resty_netacea_cookies_v3.test.lua | 380 ++++++++++ test/netacea_utils.test.lua | 177 +++++ 13 files changed, 1576 insertions(+), 675 deletions(-) create mode 100644 src/lua_resty_netacea_constants.lua create mode 100644 src/lua_resty_netacea_cookies_v3.lua create mode 100644 src/lua_resty_netacea_ingest.lua create mode 100644 src/lua_resty_netacea_protector_client.lua create mode 100644 src/netacea_utils.lua create mode 100644 test/lua_resty_netacea_cookies_v3.test.lua create mode 100644 test/netacea_utils.test.lua 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..b579bef 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 = utils.parseOption(options.mitigationType, '') + 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 = utils.parseOption(options.cookieName, '_mitata') -- global:optional:captchaCookieName - self.captchaCookieName = options.captchaCookieName or '_mitatacaptcha' + n.captchaCookieName = utils.parseOption(options.captchaCookieName, '_mitatacaptcha') --options.captchaCookieName or '_mitatacaptcha' -- global:optional:realIpHeader - self.realIpHeader = options.realIpHeader or '' + n.realIpHeader = utils.parseOption(options.realIpHeader, '') -- global:optional:userIdKey - self.userIdKey = options.userIdKey or '' + n.userIdKey = utils.parseOption(options.userIdKey, '') -- 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 = utils.parseOption(options.apiKey, nil) + 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,135 @@ 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_raw = ngx.var['cookie_' .. self.captchaCookieName] or '' + local captcha_cookie = netacea_cookies.decrypt(self.secretKey, captcha_cookie_raw) + 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 + local captcha_cookie_encrypted = netacea_cookies.encrypt(self.secretKey, protector_result.captcha_cookie) + table.insert(cookies, self.captchaCookieName .. '=' .. captcha_cookie_encrypted .. ';') 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..9886ec4 --- /dev/null +++ b/src/lua_resty_netacea_cookies_v3.lua @@ -0,0 +1,131 @@ +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.decrypt(secretKey, value) + local decoded = jwt:verify(secretKey, value) + if not decoded.verified then + return nil + end + return decoded.payload +end + +function NetaceaCookies.encrypt(secretKey, payload) + local encoded = jwt:sign(secretKey, { + header={ typ="JWE", alg="dir", enc="A128CBC-HS256" }, + payload = payload + }) + return encoded +end + +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 = NetaceaCookies.encrypt(secretKey, 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_str = NetaceaCookies.decrypt(secretKey, cookie) + local decoded = ngx.decode_args(decoded_str) + if not decoded then + return { + valid = false, + reason = constants['issueReasons'].INVALID_SESSION + } + end + + if not decoded or type(decoded) ~= '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 decoded[field] then + return { + valid = false, + reason = constants['issueReasons'].INVALID_SESSION + } + end + end + + if tonumber(decoded.ist) + tonumber(decoded.grp) < ngx.time() then + return { + valid = false, + user_id = decoded.uid, + reason = constants['issueReasons'].EXPIRED_SESSION + } + end + + if decoded.cip ~= ngx.ctx.NetaceaState.client then + return { + valid = false, + user_id = decoded.uid, + reason = constants['issueReasons'].IP_CHANGE + } + end + + return { + valid = true, + user_id = decoded.uid, + data = decoded + } +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..a20f901 --- /dev/null +++ b/src/netacea_utils.lua @@ -0,0 +1,42 @@ +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 + +function M.parseOption(option, defaultValue) + if type(option) == "string" then + option = option:match("^%s*(.-)%s*$") + end + if option == nil or option == '' then + return defaultValue + end + return option +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) From 3aa2b3200f69dba211318f308e13f8c7a03a2ef9 Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Fri, 23 Jan 2026 10:43:30 +0000 Subject: [PATCH 4/6] Add in custom cookie attributes (#18) * Extract cookie functions * Refactor hash building * Extract bToHex and hashMitataCookie * Refactor get_mitata_cookie * Rename addCookie to something more descriptive. Add some comments to work on flow * Unit tests for ingest only mode cookie handling * Move user_id generation * Rename addMitataCookie to make it clear it's minting a new cookie * Refactor ingest mitata cookie handling. Needs further work * Remove unused constants * Fix Kinesis ingest after refactor * Merge refactor branch into v2-v3-cookies - Refactor main lua_resty_netacea.lua with improved code structure - Add new constants module (lua_resty_netacea_constants.lua) - Replace old cookies module with v3 implementation - Add new ingest module for data processing - Add protector client module - Add utility functions in netacea_utils.lua - Update docker-compose and rockspec configuration * Update standard config layout * Add in custom cookie attributes * UPdate options setting for cookie attributes * Replace captcha cookie attributes --- src/lua_resty_netacea.lua | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index b579bef..2f3d979 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -76,8 +76,12 @@ function _N:new(options) end -- global:optional:cookieName n.cookieName = utils.parseOption(options.cookieName, '_mitata') + -- global:optional:cookieAttributes + n.cookieAttributes = utils.parseOption(options.cookieAttributes, 'Max-Age=86400; Path=/;') -- global:optional:captchaCookieName n.captchaCookieName = utils.parseOption(options.captchaCookieName, '_mitatacaptcha') --options.captchaCookieName or '_mitatacaptcha' + -- global:optional:captchaCookieAttributes + n.captchaCookieAttributes = utils.parseOption(options.captchaCookieAttributes, 'Max-Age=86400; Path=/;') -- global:optional:realIpHeader n.realIpHeader = utils.parseOption(options.realIpHeader, '') -- global:optional:userIdKey @@ -197,12 +201,12 @@ function _N:refreshSession(reason) {} ) local cookies = { - self.cookieName .. '=' .. new_cookie.mitata_jwe .. ';' + self.cookieName .. '=' .. new_cookie.mitata_jwe .. ';' .. self.cookieAttributes } if protector_result.captcha_cookie and protector_result.captcha_cookie ~= '' then local captcha_cookie_encrypted = netacea_cookies.encrypt(self.secretKey, protector_result.captcha_cookie) - table.insert(cookies, self.captchaCookieName .. '=' .. captcha_cookie_encrypted .. ';') + table.insert(cookies, self.captchaCookieName .. '=' .. captcha_cookie_encrypted .. ';'.. self.captchaCookieAttributes) end ngx.header['Set-Cookie'] = cookies From 0c7ec51ba4142522af29c912f24e4ae7802c5e68 Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Fri, 23 Jan 2026 10:43:55 +0000 Subject: [PATCH 5/6] Update match dictionary values (#19) * Extract cookie functions * Refactor hash building * Extract bToHex and hashMitataCookie * Refactor get_mitata_cookie * Rename addCookie to something more descriptive. Add some comments to work on flow * Unit tests for ingest only mode cookie handling * Move user_id generation * Rename addMitataCookie to make it clear it's minting a new cookie * Refactor ingest mitata cookie handling. Needs further work * Remove unused constants * Fix Kinesis ingest after refactor * Merge refactor branch into v2-v3-cookies - Refactor main lua_resty_netacea.lua with improved code structure - Add new constants module (lua_resty_netacea_constants.lua) - Replace old cookies module with v3 implementation - Add new ingest module for data processing - Add protector client module - Add utility functions in netacea_utils.lua - Update docker-compose and rockspec configuration * Update standard config layout * Update match dictionary values * Add server_tokens off to server config * Unit tests for netacea_utils.lua * Unit tests for cookies_v3 * Standard userId format * Fix ingest. Set appropriate logging levels --- src/lua_resty_netacea_constants.lua | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/lua_resty_netacea_constants.lua b/src/lua_resty_netacea_constants.lua index eb06ce3..f9f40ce 100644 --- a/src/lua_resty_netacea_constants.lua +++ b/src/lua_resty_netacea_constants.lua @@ -7,7 +7,12 @@ Constants['idTypes'] = { IP = '2', VISITOR = '3', DATACENTER = '4', - SEV = '5' + SEV = '5', + ORGANISATION = '6', + ASN = '7', + COUNTRY = '8', + COMBINATION = '9', + HEADERFP = 'b' } Constants['mitigationTypesText'] = {} @@ -44,7 +49,12 @@ Constants['matchBcTypes'] = { ['2'] = 'ip', ['3'] = 'visitor', ['4'] = 'datacenter', - ['5'] = 'sev' + ['5'] = 'sev', + ['6'] = 'organisation', + ['7'] = 'asn', + ['8'] = 'country', + ['9'] = 'combination', + ['b'] = 'headerFP' } Constants['mitigateBcTypes'] = { From 76bc7787e599ebf3ae5c46fc5fbff9abb90c2da7 Mon Sep 17 00:00:00 2001 From: Mark Greenwood Date: Tue, 24 Mar 2026 09:07:50 +0000 Subject: [PATCH 6/6] Updates 2026-01 (#20) * Update container to Ubuntu 24.04(noble). Openresty 1.27.1.2. OpenSSL 3.4.3 * Update package version number * Dev environment setup and improvements (#21) * Some initial tests * Fix Constants global. Add more cookie handling tests. * More utils tests * Update test strategy. Add coverage back in * Update readme * Update github action * fix linter warnings * update readme * copy .luacov to dockerfile * Add dev container config * Readme updates. Exclude luacov artifacts in gitignore --------- Co-authored-by: Richard Walkden --------- Co-authored-by: Richard Walkden --- .devcontainer/devcontainer.json | 27 + .github/workflows/build.yml | 3 +- .gitignore | 6 + .luacov | 6 + Dockerfile | 14 +- README.md | 61 +- docker-compose.yml | 3 +- ...ckspec => lua_resty_netacea-1.0-0.rockspec | 4 +- run_lua_tests.sh | 85 +- src/kinesis_resty.lua | 2 +- src/lua_resty_netacea.lua | 23 +- src/lua_resty_netacea_constants.lua | 2 +- src/lua_resty_netacea_cookies_v3.lua | 22 +- src/lua_resty_netacea_ingest.lua | 23 +- src/lua_resty_netacea_protector_client.lua | 11 +- src/silence_g_write_guard.lua | 3 + test/lua_resty_netacea.test.lua | 1478 ----------------- test/lua_resty_netacea_cookies_v3.test.lua | 380 ----- test/lua_resty_netacea_cookies_v3_spec.lua | 920 ++++++++++ test/lua_resty_netacea_ingest_spec.lua | 677 ++++++++ test/lua_resty_netacea_spec.lua | 12 + test/netacea_utils.test.lua | 177 -- test/netacea_utils_spec.lua | 380 +++++ 23 files changed, 2149 insertions(+), 2170 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .luacov rename lua_resty_netacea-0.2-2.rockspec => lua_resty_netacea-1.0-0.rockspec (91%) mode change 100644 => 100755 run_lua_tests.sh create mode 100644 src/silence_g_write_guard.lua delete mode 100644 test/lua_resty_netacea.test.lua delete mode 100644 test/lua_resty_netacea_cookies_v3.test.lua create mode 100644 test/lua_resty_netacea_cookies_v3_spec.lua create mode 100644 test/lua_resty_netacea_ingest_spec.lua create mode 100644 test/lua_resty_netacea_spec.lua delete mode 100644 test/netacea_utils.test.lua create mode 100644 test/netacea_utils_spec.lua diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..79faf71 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,27 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.187.0/containers/docker-existing-dockerfile +{ + "name": "lua_resty_netacea Dev container", + "build": { + "dockerfile": "../Dockerfile", + "target": "test" + }, + // Add the IDs of extensions you want installed when the container is created. + "customizations": { + "vscode": { + "extensions": [ + "bierner.markdown-emoji", // Markdown Emoji + "bierner.markdown-mermaid", // Markdown Mermaid + "DavidAnson.vscode-markdownlint", // Markdown Lint + "ms-azuretools.vscode-docker", // Docker + "oderwat.indent-rainbow", // indent-rainbow + ], + "settings": { + } + } + }, + "remoteUser": "ubuntu", + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} + } +} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3fa090c..4bd30f0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,7 @@ name: lua-resty-netacea-build on: + workflow_dispatch: pull_request: - branches: - - master push: branches: - master diff --git a/.gitignore b/.gitignore index 4f0b631..4361458 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,9 @@ luac.out *.bak tags + +# luacov reports +luacov.report +luacov.report.* +luacov.stats.out +luacov.stats.out.* \ No newline at end of file diff --git a/.luacov b/.luacov new file mode 100644 index 0000000..799c435 --- /dev/null +++ b/.luacov @@ -0,0 +1,6 @@ +return { + ["reporter"] = "html", + ["reportfile"] = "luacov.report.html", + ["include"] = {"./src/" }, + runreport = true +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ad74671..382c399 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,17 @@ -FROM openresty/openresty:xenial AS base +FROM openresty/openresty:noble AS base USER root WORKDIR /usr/src -# ENV HOME=/usr/src RUN apt-get update RUN apt-get install -y libssl-dev -# RUN cd $HOME - -RUN curl -L -o /tmp/luarocks-3.12.2-1.src.rock https://luarocks.org/luarocks-3.12.2-1.src.rock &&\ - luarocks install /tmp/luarocks-3.12.2-1.src.rock &&\ - rm /tmp/luarocks-3.12.2-1.src.rock - FROM base AS build -COPY ./lua_resty_netacea-0.2-2.rockspec ./ +COPY ./lua_resty_netacea-1.0-0.rockspec ./ COPY ./src ./src -RUN /usr/local/openresty/luajit/bin/luarocks make ./lua_resty_netacea-0.2-2.rockspec +RUN /usr/local/openresty/luajit/bin/luarocks make ./lua_resty_netacea-1.0-0.rockspec FROM build AS test @@ -28,6 +21,7 @@ RUN /usr/local/openresty/luajit/bin/luarocks install cluacov RUN /usr/local/openresty/luajit/bin/luarocks install require RUN /usr/local/openresty/luajit/bin/luarocks install luacheck +COPY ./.luacov ./.luacov COPY ./test ./test COPY ./run_lua_tests.sh ./run_lua_tests.sh RUN chmod +x ./run_lua_tests.sh diff --git a/README.md b/README.md index e8aca52..8108241 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,53 @@ # lua_resty_netacea -An Openresty module for easy integration of Netacea services -# Building the base image -All the images used by docker rely on a specific base image being available on your local docker registry. You can ensure you have this by running the following command -```sh -docker build -t lua_resty_netacea:latest . -``` +An Openresty module for easy integration of Netacea services. This repo is for developing the package. The package can be accessed by the Luarocks package management platform. See the Netacea documentation for making use of the module. -# Running Tests -`docker-compose build` then `docker-compose run test` +## Published package -## nginx.conf - mitigate -``` +The Netacea package is available on the Luarocks package manager. Publishing is handled by the Netacea team. + +## Docker images + +The Dockerfile contains a multi-stage build, including: + +| Stage name | Based on | Description | +| -- | -- | -- | +| base | openresty/openresty:noble | Base image of Openresty with updated packages around openSSL | +| build | base | Working Openresty instance with Netacea plugin installed using luarocks and rockspec file | +| test | build | Lua packages installed for testing and linting. Command overridden to run unit tests | +| lint | test | Command overridden to run luacheck linter and output results | + +The docker compose file is used to mount local files to the right place in the image to support development. + +### Run development version + +1. Update `./src/conf/nginx.conf` to include Netacea configuration and server configuration. Default is the NGINX instance will just return a static "Hello world" page. See "Configuration" below +2. `docker compose up resty` +3. Access [](http://localhost:8080) + +### Run tests + +#### Unit tests + +##### In dev container + +Without coverage report: `./run_lua_tests.sh` +With coverage report (sent to stdout) `export LUACOV_REPORT=1 && ./run_lua_tests.sh` + +##### Docker compose + +Without coverage report: `docker compose run --build test` +With coverage report (sent to stdout) `docker compose run -e LUACOV_REPORT=1 --build test [> output.html]` + +#### Linter + +`docker compose run --build lint` + +## Configuration + +### nginx.conf - mitigate + +```conf worker_processes 1; events { @@ -57,8 +93,9 @@ http { } ``` -## nginx.conf - inject -``` +### nginx.conf - inject + +```conf worker_processes 1; events { diff --git a/docker-compose.yml b/docker-compose.yml index b85e847..e7f4200 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,6 @@ version: '3.5' services: resty: - image: lua_resty_netacea:latest build: dockerfile: Dockerfile context: . @@ -30,6 +29,8 @@ services: volumes: - "./src:/usr/src/src" - "./test:/usr/src/test" + - "./run_lua_tests.sh:/usr/src/run_lua_tests.sh" + - ".luacov:/usr/src/.luacov" lint: build: diff --git a/lua_resty_netacea-0.2-2.rockspec b/lua_resty_netacea-1.0-0.rockspec similarity index 91% rename from lua_resty_netacea-0.2-2.rockspec rename to lua_resty_netacea-1.0-0.rockspec index b0bba24..e9c3a9e 100644 --- a/lua_resty_netacea-0.2-2.rockspec +++ b/lua_resty_netacea-1.0-0.rockspec @@ -1,5 +1,5 @@ package = "lua_resty_netacea" -version = "0.2-2" +version = "1.0-0" source = { url = "git://github.com/Netacea/lua_resty_netacea", branch = "master" @@ -7,7 +7,7 @@ source = { description = { summary = "An Openresty module for easy integration of Netacea services", homepage = "https://github.com/Netacea/lua_resty_netacea", - maintainer = "Dan Lyon", + maintainer = "Netacea Ltd.", license = "MIT" } dependencies = { diff --git a/run_lua_tests.sh b/run_lua_tests.sh old mode 100644 new mode 100755 index 2a6ad1f..2d1d068 --- a/run_lua_tests.sh +++ b/run_lua_tests.sh @@ -1,80 +1,7 @@ -# sh /docker/pull-changes.sh -OPENRESTY="/usr/local/openresty" -RESTY="${OPENRESTY}/bin/resty" -WD="${OPENRESTY}/nginx" -BASE_DIR="/usr/src" -TEST_DIR="${BASE_DIR}/test" - -STATS_FILE="luacov.stats.out" -STATS_SRC="${TEST_DIR}/${STATS_FILE}" -REPORT_FILE="luacov.report.out" -REPORT_SRC="${TEST_DIR}/${REPORT_FILE}" - -EXIT_CODE=0 - -################################################################################ - -OPT_PROCESS_STATS=0 -OPT_EARLY_EXIT=1 - -while getopts "s" opt; do - case $opt in - s) OPT_PROCESS_STATS=1;; - \?) echo "invalid argument";; - esac -done - -################################################################################ - -function exit_script { - echo "" - echo "END TESTS" - echo "" - echo "coverage stats file: ${STATS_SRC}" - end_tests - echo $1 - exit $1 -} - -function end_tests { - echo "done" - # if [ $OPT_PROCESS_STATS -eq 1 ]; then - # cd $TEST_DIR - # (luacov) - # echo "coverage report file: ${REPORT_SRC}" - # fi -} - - ################################################################################ - -echo "" -echo "BEGIN TESTS" -echo "" - -cd $TEST_DIR -PREV=$(pwd) - -files=$(find . -name '*.test.lua') - -while read line; do - echo " -- TEST FILE: ${line}" - DIR=$(dirname "${line}") - FILE=$(basename "${line}") - - onlytag="" - grep '#only' "${line}" && onlytag="--tags='only'" - - bash -c "$RESTY $line --exclude-tags='skip' ${onlytag}" - RES=$? - - if [ $RES -ne 0 ]; then - EXIT_CODE=$RES - if [ $OPT_EARLY_EXIT -eq 1 ]; then break; fi - fi - - cd "$PREV" - echo "" -done <<< "$files" - -exit_script $EXIT_CODE +if [ "$LUACOV_REPORT" = "1" ]; then + busted --coverage-config-file ./.luacov --coverage ./test >&2 + cat ./luacov.report.html +else + busted ./test +fi \ No newline at end of file diff --git a/src/kinesis_resty.lua b/src/kinesis_resty.lua index 74857da..bd7c40c 100644 --- a/src/kinesis_resty.lua +++ b/src/kinesis_resty.lua @@ -7,7 +7,7 @@ local http = require "resty.http" local cjson = require "cjson.safe" local sha256 = require "resty.sha256" local str = require "resty.string" -local ngx = ngx +local ngx = require 'ngx' local Kinesis = {} Kinesis.__index = Kinesis diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 2f3d979..9beb36f 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -4,6 +4,7 @@ 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 Constants = require("lua_resty_netacea_constants") local _N = {} _N._VERSION = '0.2.2' @@ -11,7 +12,6 @@ _N._TYPE = 'nginx' local ngx = require 'ngx' local cjson = require 'cjson' -local http = require 'resty.http' local function serveCaptcha(captchaBody) ngx.status = ngx.HTTP_FORBIDDEN @@ -32,12 +32,12 @@ function _N:new(options) local n = {} setmetatable(n, self) self.__index = self - + -- ingest:optional:ingestEnabled n.ingestEnabled = options.ingestEnabled or false -- ingest:required:ingestEndpoint n.ingestEndpoint = options.ingestEndpoint - + n.kinesisProperties = options.kinesisProperties or nil if not n.kinesisProperties then @@ -79,7 +79,7 @@ function _N:new(options) -- global:optional:cookieAttributes n.cookieAttributes = utils.parseOption(options.cookieAttributes, 'Max-Age=86400; Path=/;') -- global:optional:captchaCookieName - n.captchaCookieName = utils.parseOption(options.captchaCookieName, '_mitatacaptcha') --options.captchaCookieName or '_mitatacaptcha' + n.captchaCookieName = utils.parseOption(options.captchaCookieName, '_mitatacaptcha') -- global:optional:captchaCookieAttributes n.captchaCookieAttributes = utils.parseOption(options.captchaCookieAttributes, 'Max-Age=86400; Path=/;') -- global:optional:realIpHeader @@ -126,7 +126,9 @@ function _N:getBestMitigation(protector_result) if (captcha == Constants.captchaStates.PASS) then return nil end if (captcha == Constants.captchaStates.COOKIEPASS) then return nil end - if (mitigate == Constants.mitigationTypes.BLOCKED and (captcha == Constants.captchaStates.SERVE or captcha == Constants['captchaStates'].COOKIEFAIL )) then + if (mitigate == Constants.mitigationTypes.BLOCKED + and (captcha == Constants.captchaStates.SERVE + or captcha == Constants['captchaStates'].COOKIEFAIL)) then return 'captcha' end @@ -203,17 +205,18 @@ function _N:refreshSession(reason) local cookies = { self.cookieName .. '=' .. new_cookie.mitata_jwe .. ';' .. self.cookieAttributes } - + if protector_result.captcha_cookie and protector_result.captcha_cookie ~= '' then local captcha_cookie_encrypted = netacea_cookies.encrypt(self.secretKey, protector_result.captcha_cookie) - table.insert(cookies, self.captchaCookieName .. '=' .. captcha_cookie_encrypted .. ';'.. self.captchaCookieAttributes) + table.insert(cookies, + self.captchaCookieName .. '=' .. captcha_cookie_encrypted .. ';'.. self.captchaCookieAttributes) end - + ngx.header['Set-Cookie'] = cookies end function _N:handleCaptcha() - local parsed_cookie = self:handleSession() + self:handleSession() ngx.req.read_body() local captcha_data = ngx.req.get_body_data() @@ -221,7 +224,7 @@ function _N:handleCaptcha() 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)) - + self:refreshSession(Constants['issueReasons'].CAPTCHA_POST) ngx.exit(protector_result.exit_status) end diff --git a/src/lua_resty_netacea_constants.lua b/src/lua_resty_netacea_constants.lua index f9f40ce..31d0fb0 100644 --- a/src/lua_resty_netacea_constants.lua +++ b/src/lua_resty_netacea_constants.lua @@ -1,4 +1,4 @@ -Constants = {} +local Constants = {} Constants['idTypesText'] = {} Constants['idTypes'] = { diff --git a/src/lua_resty_netacea_cookies_v3.lua b/src/lua_resty_netacea_cookies_v3.lua index 9886ec4..d13d824 100644 --- a/src/lua_resty_netacea_cookies_v3.lua +++ b/src/lua_resty_netacea_cookies_v3.lua @@ -30,7 +30,7 @@ end --- Creates a formatted HTTP cookie string with expiration -- @param name string The cookie name --- @param value string The cookie value +-- @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) @@ -48,7 +48,10 @@ function NetaceaCookies.createSetCookieValues(name, value, expiry) end -function NetaceaCookies.generateNewCookieValue(secretKey, client, user_id, cookie_id, issue_reason, issue_timestamp, grace_period, match, mitigation, captcha, settings) +function NetaceaCookies.generateNewCookieValue( + secretKey, client, user_id, cookie_id, issue_reason, + issue_timestamp, grace_period, match, mitigation, captcha, settings) + settings = settings or {} local plaintext = ngx.encode_args({ cip = client, uid = user_id, @@ -63,7 +66,7 @@ function NetaceaCookies.generateNewCookieValue(secretKey, client, user_id, cooki }) local encoded = NetaceaCookies.encrypt(secretKey, plaintext) - + return { mitata_jwe = encoded, mitata_plaintext = plaintext @@ -105,7 +108,18 @@ function NetaceaCookies.parseMitataCookie(cookie, secretKey) end end - if tonumber(decoded.ist) + tonumber(decoded.grp) < ngx.time() then + -- Validate numeric fields + local ist = tonumber(decoded.ist) + local grp = tonumber(decoded.grp) + + if not ist or not grp then + return { + valid = false, + reason = constants['issueReasons'].INVALID_SESSION + } + end + + if ist + grp < ngx.time() then return { valid = false, user_id = decoded.uid, diff --git a/src/lua_resty_netacea_ingest.lua b/src/lua_resty_netacea_ingest.lua index 43d661a..6cb8e08 100644 --- a/src/lua_resty_netacea_ingest.lua +++ b/src/lua_resty_netacea_ingest.lua @@ -59,12 +59,12 @@ local function new_queue(size, allow_wrapping) end -function Ingest:new(options, _N_parent) +function Ingest:new(options, N_parent) local n = {} setmetatable(n, self) self.__index = self - n._N = _N_parent + n._N = N_parent n.stream_name = options.stream_name or '' n.region = options.region or 'eu-west-1' @@ -80,7 +80,9 @@ function Ingest:new(options, _N_parent) 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 ); + 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 @@ -97,22 +99,24 @@ function Ingest:start_timers() 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 + if ngx.worker.exiting() == true then -- Send any remaining data before exiting if #batch > 0 then self:send_batch_to_kinesis(batch) end - return + 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() ); + -- 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 @@ -180,7 +184,7 @@ 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, @@ -209,7 +213,8 @@ function Ingest:send_batch_to_kinesis(batch) end end else - ngx.log( ngx.DEBUG, "NETACEA BATCH - successfully sent batch to Kinesis, response status: ", res.status .. ", body: " .. (res.body or '') ); + ngx.log( ngx.DEBUG, "NETACEA BATCH - successfully sent batch to Kinesis, response status: ", + res.status .. ", body: " .. (res.body or '') ); end end diff --git a/src/lua_resty_netacea_protector_client.lua b/src/lua_resty_netacea_protector_client.lua index d9e7c5b..6e1e9d8 100644 --- a/src/lua_resty_netacea_protector_client.lua +++ b/src/lua_resty_netacea_protector_client.lua @@ -1,5 +1,6 @@ local http = require 'resty.http' local constants = require 'lua_resty_netacea_constants' +local ngx = require 'ngx' local ProtectorClient = {} ProtectorClient.__index = ProtectorClient @@ -19,7 +20,7 @@ end function ProtectorClient:new(options) local n = {} setmetatable(n, self) - + n.apiKey = options.apiKey n.mitigationEndpoint = options.mitigationEndpoint or {} n.endpointIndex = 0 @@ -96,12 +97,14 @@ function ProtectorClient:validateCaptcha(captcha_data) 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) - + 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 = { diff --git a/src/silence_g_write_guard.lua b/src/silence_g_write_guard.lua new file mode 100644 index 0000000..d300f93 --- /dev/null +++ b/src/silence_g_write_guard.lua @@ -0,0 +1,3 @@ +-- Some QOL patches for warnings from g_write_guard. +-- See https://github.com/openresty/lua-nginx-module/issues/1558#issuecomment-512360451 +rawset(_G, 'lfs', false) -- silence g_write_guard about lfs module in busted \ No newline at end of file diff --git a/test/lua_resty_netacea.test.lua b/test/lua_resty_netacea.test.lua deleted file mode 100644 index f63b690..0000000 --- a/test/lua_resty_netacea.test.lua +++ /dev/null @@ -1,1478 +0,0 @@ -require 'busted.runner'() - -package.path = "../src/?.lua;" .. package.path - --- luacov is disabled because this runner causes the test to hang after completion. --- Need to take another look at this in future. --- local runner = require 'luacov.runner' --- runner.tick = true --- runner.init({savestepsize = 3}) --- jit.off() - -local COOKIE_DELIMITER = '_/@#/' - -local netacea_default_params = { - ingestEndpoint = 'ingest.endpoint', - mitigationEndpoint = 'mitigation.endpoint', - apiKey = 'api-key', - secretKey = 'secret-key', - realIpHeader = '', - ingestEnabled = true, - mitigationEnabled = true, - mitigationType = 'MITIGATE' -} - -local function copy_table(orig, overrides) - local copy = {} - for orig_key, orig_value in pairs(orig) do - copy[orig_key] = orig_value - end - if (overrides) then - for override_key, override_value in pairs(overrides) do - copy[override_key] = override_value - end - end - return copy -end - -local function wrap_table(table, proxy_table) - setmetatable(proxy_table, { - __index = function(_, key) - return table[key] - end, - __newindex = function(_, key, value) - table[key] = value - end - }) - return proxy_table -end - -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_cookies.bToHex(hash) - hash = base64.encode(hash) - - return hash .. COOKIE_DELIMITER .. value -end - -local function generate_uid() - return '0000000012345678' -end - -local function setHttpResponse(expectedUrl, response, err) - package.loaded['http'] = nil - local http_mock = require('resty.http') - - local request_uri_spy = spy.new(function(_, url, _) - if (expectedUrl) then - assert(url == expectedUrl) - end - return response, err - end) - - local set_timeouts_spy = spy.new(function(_self, connect_timeout, send_timeout, read_timeout) - assert.is.equal(connect_timeout, 500, 'connect_timeout is set') - assert.is.equal(send_timeout, 750, 'send_timeout is set') - assert.is.equal(read_timeout, 750, 'read_timeout is set') - end) - - http_mock.new = function() - return { - request_uri = request_uri_spy, - set_timeouts = set_timeouts_spy - } - end - - package.loaded['http'] = http_mock - - return request_uri_spy -end - -insulate("lua_resty_netacea.lua", function() - describe('new', function() - - it('returns an object with ingest and mitigation enabled on initialisation', function() - local netacea = (require 'lua_resty_netacea'):new(netacea_default_params) - assert.is_true(netacea.mitigationEnabled) - assert.is_true(netacea.ingestEnabled) - end) - - it('sets endpoint index to 0 on initialisation', function() - local netacea = (require 'lua_resty_netacea'):new(netacea_default_params) - assert.is.equal(netacea.endpointIndex, 0) - end) - - it('sets mitigationEnabled to false if mitigationEndpoint is an empty string', function() - local params = copy_table(netacea_default_params) - params.mitigationEndpoint = '' - local netacea = (require 'lua_resty_netacea'):new(params) - assert.is_false(netacea.mitigationEnabled) - end) - - it('sets mitigationEnabled to false if mitigationEndpoint is nil', function() - local params = copy_table(netacea_default_params) - params.mitigationEndpoint = nil - local netacea = (require 'lua_resty_netacea'):new(params) - assert.is_false(netacea.mitigationEnabled) - end) - - it('sets mitigationEnabled to false if mitigationEndpoint is nil', function() - local params = copy_table(netacea_default_params) - params.mitigationEndpoint = {} - local netacea = (require 'lua_resty_netacea'):new(params) - assert.is_false(netacea.mitigationEnabled) - end) - - it('sets mitigationEnabled to false if mitigationType is nil', function() - local params = copy_table(netacea_default_params) - params.mitigationType = {} - local netacea = (require 'lua_resty_netacea'):new(params) - assert.is_false(netacea.mitigationEnabled) - end) - - it('sets mitigationEnabled to false if mitigationType is not MITIGATE or INJECT', function() - local paramsMitigate = copy_table(netacea_default_params) - paramsMitigate.mitigationType = 'MITIGATE' - local netaceaMitigate = (require 'lua_resty_netacea'):new(paramsMitigate) - assert.is_true(netaceaMitigate.mitigationEnabled) - - local paramsInject = copy_table(netacea_default_params) - paramsInject.mitigationType = 'INJECT' - local netaceaInject = (require 'lua_resty_netacea'):new(paramsInject) - assert.is_true(netaceaInject.mitigationEnabled) - - local paramsOther = copy_table(netacea_default_params) - paramsOther.mitigationType = 'wefwwg' - local netaceaOther = (require 'lua_resty_netacea'):new(paramsOther) - assert.is_false(netaceaOther.mitigationEnabled) - end) - - it('sets mitigationEndpoint if an array is provided', function() - local endpointArray = { 'mitigation.endpoint', 'mitigation2.endpoint' } - local netacea = (require 'lua_resty_netacea'):new( - copy_table( - netacea_default_params, - { mitigationEndpoint = endpointArray } - ) - ) - assert.are.same(netacea.mitigationEndpoint, endpointArray) - end) - - it('sets mitigationEndpoint to an array if a string is provided', function() - local endpoint = 'mitigation.endpoint' - local netacea = (require 'lua_resty_netacea'):new( - copy_table( - netacea_default_params, - { mitigationEndpoint = endpoint } - ) - ) - assert.are.same(netacea.mitigationEndpoint, { endpoint }) - end) - - it('sets the module version and type', function() - local netacea = (require 'lua_resty_netacea'):new(netacea_default_params) - assert.is.equal(netacea._MODULE_VERSION, '0.2.2') - assert.is.equal(netacea._MODULE_TYPE, 'nginx') - end) - end) - - describe('get_mitata_cookie', function() - - local netacea_init_params = { - ingestEndpoint = '', - mitigationEndpoint = '', - apiKey = '', - secretKey = 'super_secret', - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - } - - it('returns nil if cookie is not present', function() - local ngx_stub = require 'ngx' - ngx_stub.var = { - cookie__mitata = '', - } - package.loaded['ngx'] = ngx_stub - - local netacea = (require 'lua_resty_netacea'):new(netacea_init_params) - - local result = netacea:get_mitata_cookie() - - assert(result == nil) - end) - - it('returns nil if the cookie expiration is not in the future', function() - local ngx_stub = require 'ngx' - local t = ngx_stub.time() - 20 - ngx_stub.var = { - cookie__mitata = build_mitata_cookie(t, generate_uid(), '000', netacea_init_params.secretKey, '000') - } - package.loaded['ngx'] = ngx_stub - - local netacea = (require 'lua_resty_netacea'):new(netacea_init_params) - - local result = netacea:get_mitata_cookie() - assert(result == nil) - end) - - it('returns nil if the userID is not present', function() - local ngx_stub = require 'ngx' - local t = ngx_stub.time() + 20 - ngx_stub.var = { - cookie__mitata = build_mitata_cookie(t, '', '000', netacea_init_params.secretKey) - } - package.loaded['ngx'] = ngx_stub - - local netacea = (require 'lua_resty_netacea'):new(netacea_init_params) - - local result = netacea:get_mitata_cookie() - assert(result == nil) - end) - - it('returns nil if the hash value does not match', function() - local ngx_stub = require 'ngx' - local t = ngx_stub.time() + 20 - ngx_stub.var = { - cookie__mitata = 'invalid_hash' .. COOKIE_DELIMITER .. t .. COOKIE_DELIMITER .. generate_uid() - } - package.loaded['ngx'] = ngx_stub - - local netacea = (require 'lua_resty_netacea'):new(netacea_init_params) - - local result = netacea:get_mitata_cookie() - assert(result == nil) - end) - - it('returns nil if the cookie is invalid', function() - local ngx_stub = require 'ngx' - ngx_stub.var = { - cookie__mitata = 'someinvalidcookie' - } - package.loaded['ngx'] = ngx_stub - - local netacea = (require 'lua_resty_netacea'):new(netacea_init_params) - - local result = netacea:get_mitata_cookie() - assert(result == nil) - end) - - it('returns the parsed cookie if the cookie is valid', function() - local ngx_stub = require 'ngx' - local t = ngx_stub.time() + 20 - local cookie = build_mitata_cookie(t, generate_uid(), '000', netacea_init_params.secretKey) - ngx_stub.var = { - cookie__mitata = cookie - } - package.loaded['ngx'] = ngx_stub - - local netacea = (require 'lua_resty_netacea'):new(netacea_init_params) - - local hash, epoch, uid, mitigation = cookie:match('(.*)_/@#/(.*)_/@#/(.*)_/@#/(.*)') - local expected = { - original = ngx_stub.var.cookie__mitata, - hash = hash, - epoch = tonumber(epoch), - uid = uid, - mitigation = mitigation - } - local result = netacea:get_mitata_cookie() - assert.are.same(expected, result) - end) - - it('works with custom cookie names', function() - local ngx_stub = require 'ngx' - local t = ngx_stub.time() + 20 - local custom_cookie_name = 'custom_mitata' - local cookie = build_mitata_cookie(t, generate_uid(), '000', netacea_init_params.secretKey) - ngx_stub.var = { - ['cookie_' .. custom_cookie_name] = cookie - } - package.loaded['ngx'] = ngx_stub - - local netacea = (require 'lua_resty_netacea'):new( - copy_table( - netacea_init_params, - { cookieName = custom_cookie_name } - ) - ) - - local hash, epoch, uid, mitigation = cookie:match('(.*)_/@#/(.*)_/@#/(.*)_/@#/(.*)') - local expected = { - original = ngx_stub.var['cookie_' .. custom_cookie_name], - hash = hash, - epoch = tonumber(epoch), - uid = uid, - mitigation = mitigation - } - local result = netacea:get_mitata_cookie() - assert.are.same(expected, result) - - end) - end) - - describe('mitigate', function() - local match = require('luassert.match') - local mit_svc_url = 'someurl' - local mit_svc_url_captcha = mit_svc_url .. '/AtaVerifyCaptcha' - 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 = 'some_mitata_cookie', - request_uri = '-' - } - ngx_stub.ctx = { - } - ngx_stub.req = { - read_body = function() return nil end, - get_body_data = function() return nil end - } - - ngx_stub.header = {} - ngx_stub.status = 0 - ngx_stub.HTTP_FORBIDDEN = 402 - - ngx_stub.exit = spy.new(function(_, _) return nil end) - ngx_stub.print = spy.new(function(_, _) return nil end) - - ngx = wrap_table(require 'ngx', ngx_stub) - package.loaded['ngx'] = ngx - end - - before_each(function() - package.loaded['lua_resty_netacea'] = nil - - stubNgx() - end) - - it('forwards to mit svc if mitata cookie check fails', function() - local req_spy = setHttpResponse(mit_svc_url, nil, 'error') - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - netacea.get_mitata_cookie = spy.new(function () return nil end) - - netacea:run() - - local _ = match._ - - assert.spy(req_spy).was.called(1) - assert.spy(req_spy).was.called_with(_, mit_svc_url, { - method = 'GET', - headers = { - ['x-netacea-api-key'] = mit_svc_api_key, - ['content-type'] = 'application/x-www-form-urlencoded', - ['user-agent'] = ngx.var.http_user_agent, - ['x-netacea-client-ip'] = ngx.var.remote_addr, - ["cookie"] = "_mitata=" .. ngx.var.cookie__mitata .. ';_mitatacaptcha=' - } - }) - end) - - it('does not forward to mit svc if mitata cookie is valid', function() - local req_spy = setHttpResponse('-', nil, 'error') - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local cookie = { - mitigation = "000" - } - - netacea.get_mitata_cookie = spy.new(function () return cookie end) - - netacea:run() - - assert.spy(req_spy).was.not_called() - end) - - it('does not forward to mit service if mitata cookie is ALLOW', function() - local req_spy = setHttpResponse('-', nil, 'error') - - local netacea = require 'lua_resty_netacea' - local mit = netacea.idTypes.IP .. netacea.mitigationTypes.ALLOW .. netacea.captchaStates.NONE - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - package.loaded['lua_resty_netacea'] = nil - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert.equal(netacea.idTypes.IP, res.idType) - assert.equal(netacea.mitigationTypes.ALLOW, res.mitigationType) - assert.equal(netacea.captchaStates.NONE, res.captchaState) - end) - - netacea:run(logFunc) - - assert.spy(req_spy).was.not_called() - assert.spy(logFunc).was.called() - end) - - - - it('works with custom cookies', function() - local custom_cookie_name = 'custom_mitata' - local req_spy = setHttpResponse('-', nil, 'error') - - local netacea = require 'lua_resty_netacea' - local mit = netacea.idTypes.IP .. netacea.mitigationTypes.ALLOW .. netacea.captchaStates.NONE - ngx.var["cookie_" .. custom_cookie_name] = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - package.loaded['lua_resty_netacea'] = nil - - netacea = (require 'lua_resty_netacea'):new( - copy_table( - netacea_default_params, - { - ingestEnabled = false, - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - cookieName = custom_cookie_name - } - ) - ) - - local logFunc = spy.new(function(res) - assert.equal(netacea.idTypes.IP, res.idType) - assert.equal(netacea.mitigationTypes.ALLOW, res.mitigationType) - assert.equal(netacea.captchaStates.NONE, res.captchaState) - end) - - netacea:run(logFunc) - - assert.spy(req_spy).was.not_called() - assert.spy(logFunc).was.called() - end) - - it('does not forward to mit service if mitata cookie is BLOCK', function() - local req_spy = setHttpResponse('-', nil, 'error') - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local mit = netacea.idTypes.IP .. netacea.mitigationTypes.BLOCKED .. netacea.captchaStates.NONE - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - local logFunc = spy.new(function(res) - assert(res.idType == netacea.idTypes.IP) - assert(res.mitigationType == netacea.mitigationTypes.BLOCKED) - assert(res.captchaState == netacea.captchaStates.NONE) - end) - - netacea:run(logFunc) - - assert.spy(req_spy).was.not_called() - assert(ngx.status == ngx.HTTP_FORBIDDEN) - assert.spy(ngx.print).was.called_with('403 Forbidden') - - assert(ngx.header['Cache-Control'] == 'max-age=0, no-cache, no-store, must-revalidate') - assert.spy(ngx.exit).was.called() - assert.spy(logFunc).was.called() - end) - - it('forwards to mit service if mitata cookie is CAPTCHA SERVE', function() - local expected_captcha_body = 'some captcha body' - local netacea = require 'lua_resty_netacea' - local req_spy = setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = netacea.idTypes.IP, - ['x-netacea-mitigate'] = netacea.mitigationTypes.BLOCKED, - ['x-netacea-captcha'] = netacea.captchaStates.SERVE - }, - status = 200, - body = expected_captcha_body - }, nil) - - package.loaded['lua_resty_netacea'] = nil - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local mit = netacea.idTypes.IP .. netacea.mitigationTypes.BLOCKED .. netacea.captchaStates.SERVE - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - local logFunc = spy.new(function(res) - assert(res.idType == netacea.idTypes.IP) - assert(res.mitigationType == netacea.mitigationTypes.BLOCKED) - assert(res.captchaState == netacea.captchaStates.SERVE) - end) - - netacea:run(logFunc) - - assert.spy(req_spy).was.called() - assert(ngx.status == ngx.HTTP_FORBIDDEN) - assert.spy(ngx.print).was.called_with(expected_captcha_body) - assert(ngx.header['Cache-Control'] == 'max-age=0, no-cache, no-store, must-revalidate') - assert.spy(ngx.exit).was.called() - assert.spy(logFunc).was.called() - end) - - it('serves captcha if client is mitigated', function() - local expected_captcha_body = 'some captcha body' - - local netacea = require 'lua_resty_netacea' - - local req_spy = setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = netacea.idTypes.IP, - ['x-netacea-mitigate'] = netacea.mitigationTypes.BLOCKED, - ['x-netacea-captcha'] = netacea.captchaStates.SERVE - }, - status = 200, - body = expected_captcha_body - }, nil) - - package.loaded['lua_resty_netacea'] = nil - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert(res.idType == netacea.idTypes.IP) - assert(res.mitigationType == netacea.mitigationTypes.BLOCKED) - assert(res.captchaState == netacea.captchaStates.SERVE) - end) - - netacea:run(logFunc) - - assert(ngx.status == ngx.HTTP_FORBIDDEN) - assert.spy(req_spy).was.called() - assert.spy(ngx.exit).was.called() - assert.spy(ngx.print).was.called_with(expected_captcha_body) - assert.spy(logFunc).was.called() - end) - - it('returns captcha pass state on positive verification', function() - ngx.var.request_uri = 'AtaVerifyCaptcha' - - local netacea = require 'lua_resty_netacea' - - setHttpResponse(mit_svc_url_captcha, { - headers = { - ['x-netacea-mitatacaptcha-value'] = nil, - ['x-netacea-mitatacaptcha-expiry'] = nil, - ['x-netacea-captcha'] = netacea.captchaStates.PASS - }, - status = 200, - body = 'body' - }, nil) - - package.loaded['lua_resty_netacea'] = nil - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert(res.captchaState == netacea.captchaStates.PASS) - end) - - netacea:run(logFunc) - - assert.spy(logFunc).was.called() - end) - - it('returns captcha fail state on negative verification', function() - ngx.var.request_uri = 'AtaVerifyCaptcha' - - local netacea = require 'lua_resty_netacea' - package.loaded['lua_resty_netacea'] = nil - - setHttpResponse(mit_svc_url_captcha, { - headers = { - ['x-netacea-mitatacaptcha-value'] = nil, - ['x-netacea-mitatacaptcha-expiry'] = nil, - ['x-netacea-captcha'] = netacea.captchaStates.FAIL - }, - status = 200, - body = 'body' - }, nil) - - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert(res.captchaState == netacea.captchaStates.FAIL) - end) - - netacea:run(logFunc) - - assert.spy(logFunc).was.called() - end) - - it('returns correct state', function() - local netacea = require 'lua_resty_netacea' - package.loaded['lua_resty_netacea'] = nil - - local testMitigationTypes = netacea.mitigationTypes - local testIdTypes = netacea.idTypes - local testCaptchaStates = netacea.captchaStates - local unknownHeader = 'Q' - testMitigationTypes['UNKNOWN'] = unknownHeader - testIdTypes['UNKNOWN'] = unknownHeader - testCaptchaStates['UNKNOWN'] = unknownHeader - - for _, id in pairs(testIdTypes) do - for _, mit in pairs(testMitigationTypes) do - for _, captcha in pairs(testCaptchaStates) do - local allowed = (mit == netacea.mitigationTypes.NONE or - mit == unknownHeader or - mit == netacea.mitigationTypes.ALLOW or - captcha == netacea.captchaStates.PASS or - captcha == netacea.captchaStates.COOKIEPASS) - - ngx.status = 0 - ngx.exit:clear() - - local req_spy = setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = id, - ['x-netacea-mitigate'] = mit, - ['x-netacea-captcha'] = captcha - }, - status = 200 - }, nil) - - package.loaded['lua_resty_netacea'] = nil - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - - local logFunc = spy.new(function(res) - assert.are.equal(res.idType, id) - assert.are.equal(res.mitigationType, mit) - assert.are.equal(res.captchaState, captcha) - end) - - netacea:run(logFunc) - - if not allowed then - if ngx.status ~= ngx.HTTP_FORBIDDEN then - assert.spy(ngx.exit).was.called_with(ngx.HTTP_FORBIDDEN) - else - assert.are.equal(ngx.HTTP_FORBIDDEN, ngx.status) - assert.spy(ngx.exit).was.called() - end - - assert.spy(logFunc).was.called() - else - if ngx.status ~= ngx.HTTP_FORBIDDEN then - assert.spy(ngx.exit).was_not_called_with(ngx.HTTP_FORBIDDEN) - assert.spy(ngx.exit).was_not.called() - else - assert.are.not_equal(ngx.HTTP_FORBIDDEN, ngx.status) - assert.spy(ngx.exit).was_not.called() - end - end - - assert.spy(req_spy).was.called() - end - end - end - end) - - it('Uses and forwards configured user id variable', function() - local userIdKey = 'customUserIdValue' - local userIdVal = 'someCustomUserId' - - local req_spy = setHttpResponse(mit_svc_url, nil, 'error') - ngx.var[userIdKey] = userIdVal - - local netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - userIdKey = userIdKey, - mitigationType = 'MITIGATE' - }) - - netacea:run() - - local _ = match._ - assert.spy(req_spy).was.called_with(_, mit_svc_url, { - method = 'GET', - headers = { - ['x-netacea-api-key'] = mit_svc_api_key, - ['user-agent'] = ngx.var.http_user_agent, - ["content-type"] = 'application/x-www-form-urlencoded', - ['x-netacea-client-ip'] = ngx.var.remote_addr, - ["cookie"] = "_mitata=" .. ngx.var.cookie__mitata .. ';_mitatacaptcha=', - ["x-netacea-userid"] = userIdVal - } - }) - end) - - it('Does not send custom id header if var is not set', function() - local userIdKey = 'customUserIdValue' - - local req_spy = setHttpResponse(mit_svc_url, nil, 'error') - - local netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - userIdKey = userIdKey, - mitigationType = 'MITIGATE' - }) - - netacea:run() - - local _ = match._ - assert.spy(req_spy).was.called_with(_, mit_svc_url, { - method = 'GET', - headers = { - ['x-netacea-api-key'] = mit_svc_api_key, - ['content-type'] = 'application/x-www-form-urlencoded', - ['user-agent'] = ngx.var.http_user_agent, - ['x-netacea-client-ip'] = ngx.var.remote_addr, - ["cookie"] = "_mitata=" .. ngx.var.cookie__mitata .. ';_mitatacaptcha=' - } - }) - end) - - it('converts non-captcha attempt captcha PASS to COOKIEPASS', function() - local netacea = (require 'lua_resty_netacea') - setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = netacea.idTypes.IP, - ['x-netacea-mitigate'] = netacea.mitigationTypes.BLOCKED, - ['x-netacea-captcha'] = netacea.captchaStates.PASS - }, - status = 200 - }, nil) - - local mit = netacea.idTypes.IP .. netacea.mitigationTypes.BLOCKED .. netacea.captchaStates.PASS - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - package.loaded['lua_resty_netacea'] = nil - - netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert.are.equal(res.idType, netacea.idTypes.IP) - assert.are.equal(res.mitigationType, netacea.mitigationTypes.BLOCKED) - assert.are.equal(res.captchaState, netacea.captchaStates.COOKIEPASS) - end) - - netacea:run(logFunc) - - assert.spy(logFunc).was.called() - end) - - it('forwards non-captcha attempt captcha FAIL to mit service and expects COOKIEFAIL response', function() - local netacea = (require 'lua_resty_netacea') - setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = netacea.idTypes.IP, - ['x-netacea-mitigate'] = netacea.mitigationTypes.BLOCKED, - ['x-netacea-captcha'] = netacea.captchaStates.COOKIEFAIL - }, - status = 200 - }, nil) - - local mit = netacea.idTypes.IP .. netacea.mitigationTypes.BLOCKED .. netacea.captchaStates.FAIL - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - package.loaded['lua_resty_netacea'] = nil - - netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert.are.equal(res.idType, netacea.idTypes.IP) - assert.are.equal(res.mitigationType, netacea.mitigationTypes.BLOCKED) - assert.are.equal(res.captchaState, netacea.captchaStates.COOKIEFAIL) - end) - - netacea:run(logFunc) - - assert.spy(logFunc).was.called() - - end) - - it('Uses and forwards configured user id variable when verifying captcha', function() - local userIdKey = 'customUserIdValue' - local userIdVal = 'someCustomUserId' - ngx.var[userIdKey] = userIdVal - ngx.var.request_uri = 'AtaVerifyCaptcha' - - local req_spy = setHttpResponse(mit_svc_url_captcha, nil, 'error') - local netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - userIdKey = userIdKey, - mitigationType = 'MITIGATE' - }) - - netacea:run() - - local _ = match._ - assert.spy(req_spy).was.called_with(_, mit_svc_url_captcha, { - method = 'POST', - headers = { - ['x-netacea-api-key'] = mit_svc_api_key, - ['content-type'] = 'application/x-www-form-urlencoded', - ["cookie"] = '_mitata=' .. ngx.var.cookie__mitata .. ';_mitatacaptcha=', - ["x-netacea-userid"] = userIdVal, - ['user-agent'] = ngx.var.http_user_agent, - ['x-netacea-client-ip'] = ngx.var.remote_addr - } - }) - end) - - it('Works with a single mitigation service endpoint', function() - local req_spy = setHttpResponse('mitigation.endpoint', nil, 'error') - local netacea = (require 'lua_resty_netacea'):new(netacea_default_params) - - netacea:run() - - assert.spy(req_spy).was.called(1) - assert.spy(req_spy).was.called_with(match._, 'mitigation.endpoint', match.is_table()) - end) - - it('Round Robins between multiple mitigation service endpoints', function() - local endpointArray = { 'mitigation.endpoint', 'mitigation2.endpoint', 'mitigation3.endpoint' } - local netacea = (require 'lua_resty_netacea'):new( - copy_table( - netacea_default_params, - { mitigationEndpoint = endpointArray } - ) - ) - - local req_spy = setHttpResponse(nil, nil, 'error') - netacea:run() -- request 1 - endpoint 2 - assert.spy(req_spy).was.called(1) - assert.spy(req_spy).was.called_with(match._, endpointArray[2], match.is_table()) - assert.spy(req_spy).was_not.called_with(match._, endpointArray[3], match.is_table()) - assert.spy(req_spy).was_not.called_with(match._, endpointArray[1], match.is_table()) - netacea:run() -- request 2 - endpoint 3 - assert.spy(req_spy).was.called(2) - assert.spy(req_spy).was.called_with(match._, endpointArray[3], match.is_table()) - assert.spy(req_spy).was_not.called_with(match._, endpointArray[1], match.is_table()) - netacea:run() -- request 3 - endpoint 1 - assert.spy(req_spy).was.called(3) - assert.spy(req_spy).was.called_with(match._, endpointArray[1], match.is_table()) - req_spy = setHttpResponse(nil, nil, 'error') -- reset call history for spy - netacea:run() -- request 4 - endpoint 2 - assert.spy(req_spy).was.called(1) - assert.spy(req_spy).was.called_with(match._, endpointArray[2], match.is_table()) - assert.spy(req_spy).was_not.called_with(match._, endpointArray[3], match.is_table()) - assert.spy(req_spy).was_not.called_with(match._, endpointArray[1], match.is_table()) - netacea:run() -- request 5 - endpoint 3 - assert.spy(req_spy).was.called(2) - assert.spy(req_spy).was.called_with(match._, endpointArray[3], match.is_table()) - assert.spy(req_spy).was_not.called_with(match._, endpointArray[1], match.is_table()) - netacea:run() -- request 6 - endpoint 1 - assert.spy(req_spy).was.called(3) - assert.spy(req_spy).was.called_with(match._, endpointArray[1], match.is_table()) - end) - - it('Passes idTypes even if idType is not found in idTypes dict', function() - local netacea = (require 'lua_resty_netacea') - setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = 'q', - ['x-netacea-mitigate'] = netacea.mitigationTypes.BLOCKED, - ['x-netacea-captcha'] = netacea.captchaStates.NONE - }, - status = 200 - }, nil) - - package.loaded['lua_resty_netacea'] = nil - - netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert.are.equal(res.idType, 'q') - assert.are.equal(res.mitigationType, netacea.mitigationTypes.BLOCKED) - assert.are.equal(res.captchaState, netacea.captchaStates.NONE) - end) - - netacea:run(logFunc) - - assert.spy(logFunc).was.called() - end) - - it('Passes mitigationType even if mitigationType is not found in mitigationTypes dict', function() - local netacea = (require 'lua_resty_netacea') - setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = netacea.idTypes.IP, - ['x-netacea-mitigate'] = 'q', - ['x-netacea-captcha'] = netacea.captchaStates.NONE - }, - status = 200 - }, nil) - - package.loaded['lua_resty_netacea'] = nil - - netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert.are.equal(res.idType, netacea.idTypes.IP) - assert.are.equal(res.mitigationType, 'q') - assert.are.equal(res.captchaState, netacea.captchaStates.NONE) - end) - - netacea:run(logFunc) - - assert.spy(logFunc).was.called() - end) - - it('Passes catchaState even if captchaState is not found in captchaStates dict', function() - local netacea = (require 'lua_resty_netacea') - setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = netacea.idTypes.IP, - ['x-netacea-mitigate'] = netacea.mitigationTypes.BLOCKED, - ['x-netacea-captcha'] = 'q' - }, - status = 200 - }, nil) - - package.loaded['lua_resty_netacea'] = nil - - netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - - local logFunc = spy.new(function(res) - assert.are.equal(res.idType, netacea.idTypes.IP) - assert.are.equal(res.mitigationType, netacea.mitigationTypes.BLOCKED) - assert.are.equal(res.captchaState, 'q') - end) - - netacea:run(logFunc) - - assert.spy(logFunc).was.called() - end) - end) - - describe('inject', 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 = 'some_mitata_cookie', - request_uri = '-' - } - - ngx_stub.req = { - read_body = function() return nil end, - get_body_data = function() return nil end - } - - ngx_stub.header = {} - ngx_stub.req = { - set_header = spy.new(function(_, _) return nil end) - } - ngx_stub.status = 0 - ngx_stub.HTTP_FORBIDDEN = 402 - - ngx_stub.exit = spy.new(function(_, _) return nil end) - ngx_stub.print = spy.new(function(_, _) return nil 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('forwards to mit svc if mitata cookie check fails', function() - local req_spy = setHttpResponse(mit_svc_url, nil, 'error') - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'INJECT' - }) - - netacea.get_mitata_cookie = spy.new(function () return nil end) - - netacea:run() - - local _ = luaMatch._ - - assert.spy(req_spy).was.called(1) - assert.spy(req_spy).was.called_with(_, mit_svc_url, { - method = 'GET', - headers = { - ['x-netacea-api-key'] = mit_svc_api_key, - ['content-type'] = 'application/x-www-form-urlencoded', - ['user-agent'] = ngx.var.http_user_agent, - ['x-netacea-client-ip'] = ngx.var.remote_addr, - ["cookie"] = "_mitata=" .. ngx.var.cookie__mitata .. ';_mitatacaptcha=' - } - }) - end) - - it('does not forward to mit svc if mitata cookie is valid', function() - local req_spy = setHttpResponse('-', nil, 'error') - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'INJECT' - }) - - local cookie = { - mitigation = "000" - } - - netacea.get_mitata_cookie = spy.new(function () return cookie end) - - netacea:run() - - assert.spy(req_spy).was.not_called() - end) - - it('does not forward to mit service if mitata cookie is ALLOW', function() - local req_spy = setHttpResponse('-', nil, 'error') - - local netacea = require 'lua_resty_netacea' - local match = netacea.idTypes.IP - local mitigate = netacea.mitigationTypes.ALLOW - local captcha = netacea.captchaStates.NONE - local mit = match .. mitigate .. captcha - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - package.loaded['lua_resty_netacea'] = nil - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'INJECT' - }) - - local logFunc = spy.new(function(res) - assert.equal(netacea.idTypes.IP, res.idType) - assert.equal(netacea.mitigationTypes.ALLOW, res.mitigationType) - assert.equal(netacea.captchaStates.NONE, res.captchaState) - end) - - netacea:run(logFunc) - assert(ngx.status == ngx.OK) - assert.spy(ngx.req.set_header).was.called_with('x-netacea-match', match) - assert.spy(ngx.req.set_header).was.called_with('x-netacea-mitigate', mitigate) - assert.spy(ngx.req.set_header).was.called_with('x-netacea-captcha', captcha) - assert.spy(req_spy).was.not_called() - assert.spy(logFunc).was.called() - end) - - it('does not forward to mit service if mitata cookie is BLOCK', function() - local req_spy = setHttpResponse('-', nil, 'error') - - local netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'INJECT' - }) - local match = netacea.idTypes.IP - local mitigate = netacea.mitigationTypes.BLOCKED - local captcha = netacea.captchaStates.NONE - local mit = match .. mitigate .. captcha - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - local logFunc = spy.new(function(res) - assert(res.idType == match) - assert(res.mitigationType == mitigate) - assert(res.captchaState == captcha) - end) - - netacea:run(logFunc) - - assert.spy(req_spy).was.not_called() - assert(ngx.status == ngx.OK) - assert.spy(ngx.req.set_header).was.called_with('x-netacea-match', match) - assert.spy(ngx.req.set_header).was.called_with('x-netacea-mitigate', mitigate) - assert.spy(ngx.req.set_header).was.called_with('x-netacea-captcha', captcha) - assert.spy(logFunc).was.called() - end) - - it('forwards to mit service if mitata cookie is CAPTCHA SERVE', function() - local expected_captcha_body = 'some captcha body' - local netacea = require 'lua_resty_netacea' - local req_spy = setHttpResponse(mit_svc_url, { - headers = { - ['x-netacea-match'] = netacea.idTypes.IP, - ['x-netacea-mitigate'] = netacea.mitigationTypes.BLOCKED, - ['x-netacea-captcha'] = netacea.captchaStates.SERVE - }, - status = 200, - body = expected_captcha_body - }, nil) - - package.loaded['lua_resty_netacea'] = nil - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = '', - mitigationEndpoint = mit_svc_url, - apiKey = mit_svc_api_key, - secretKey = mit_svc_secret, - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = true, - mitigationType = 'INJECT' - }) - - local mit = netacea.idTypes.IP .. netacea.mitigationTypes.BLOCKED .. netacea.captchaStates.SERVE - ngx.var.cookie__mitata = build_mitata_cookie(ngx.time() + 20, generate_uid(), mit, mit_svc_secret) - - local logFunc = spy.new(function(res) - assert(res.idType == netacea.idTypes.IP) - assert(res.mitigationType == netacea.mitigationTypes.BLOCKED) - assert(res.captchaState == netacea.captchaStates.SERVE) - end) - - netacea:run(logFunc) - - assert.spy(req_spy).was.called() - assert(ngx.status == ngx.OK) - assert.spy(ngx.print).was.not_called() - assert.spy(ngx.exit).was.not_called() - 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 deleted file mode 100644 index 103b88c..0000000 --- a/test/lua_resty_netacea_cookies_v3.test.lua +++ /dev/null @@ -1,380 +0,0 @@ -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/lua_resty_netacea_cookies_v3_spec.lua b/test/lua_resty_netacea_cookies_v3_spec.lua new file mode 100644 index 0000000..644cdc6 --- /dev/null +++ b/test/lua_resty_netacea_cookies_v3_spec.lua @@ -0,0 +1,920 @@ +require("silence_g_write_guard") +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 + if not str 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) + } + + -- Import actual constants + local constants = require('lua_resty_netacea_constants') + + -- Set up package mocks + package.loaded['resty.jwt'] = jwt_mock + package.loaded['ngx'] = ngx_mock + package.loaded['lua_resty_netacea_constants'] = constants + + 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) + + it("should handle negative expiry time", function() + local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", -3600) + + assert.is.table(result) + assert.is.equal(1, #result) + assert.spy(ngx_mock.cookie_time).was.called_with(1640995200 - 3600) + end) + + it("should handle very large expiry time", function() + local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", 31536000) -- 1 year + + assert.is.table(result) + assert.is.equal(1, #result) + assert.spy(ngx_mock.cookie_time).was.called_with(1640995200 + 31536000) + end) + + it("should handle float expiry time", function() + local result = NetaceaCookies.createSetCookieValues("test_cookie", "test_value", 3600.5) + + assert.is.table(result) + assert.spy(ngx_mock.cookie_time).was.called_with(1640995200 + 3600.5) + end) + + it("should handle special characters in cookie name and value", function() + local result = NetaceaCookies.createSetCookieValues("test-cookie_123", "value!@#$%^&*()", 3600) + + assert.is.table(result) + assert.is.equal(1, #result) + assert.is.equal("test-cookie_123=value!@#$%^&*(); Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", result[1]) + end) + + it("should handle empty cookie name", function() + local result = NetaceaCookies.createSetCookieValues("", "test_value", 3600) + + assert.is.table(result) + assert.is.equal(1, #result) + assert.is.equal("=test_value; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", result[1]) + end) + + it("should handle empty cookie value", function() + local result = NetaceaCookies.createSetCookieValues("test_cookie", "", 3600) + + assert.is.table(result) + assert.is.equal(1, #result) + assert.is.equal("test_cookie=; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", result[1]) + end) + + it("should handle cookie replacement with same name", function() + -- First create a cookie + NetaceaCookies.createSetCookieValues("same_cookie", "value1", 3600) + + -- Then replace it with the same name + local result = NetaceaCookies.createSetCookieValues("same_cookie", "value2", 7200) + + assert.is.table(result) + assert.is.equal(1, #result) -- Should still be only 1 cookie + assert.is.equal("same_cookie=value2; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", result[1]) + + -- Check that the context was updated + assert.is.equal("same_cookie=value2; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", + ngx_mock.ctx.cookies["same_cookie"]) + end) + + it("should maintain consistent cookie format", function() + local result = NetaceaCookies.createSetCookieValues("format_test", "format_value", 1800) + + assert.is.table(result) + assert.is.equal(1, #result) + + local cookie_string = result[1] + -- Check that it follows the expected format: name=value; Path=/; Expires=... + assert.is_true(cookie_string:match("^[^=]+=[^;]*; Path=/; Expires=.+$") ~= nil, + "Cookie should match expected format") + end) + + it("should handle complex cookie values with spaces and special chars", function() + local complex_value = "user data with spaces & special chars = test" + local result = NetaceaCookies.createSetCookieValues("complex_cookie", complex_value, 3600) + + assert.is.table(result) + assert.is.equal(1, #result) + assert.is.equal("complex_cookie=" .. complex_value .. "; Path=/; Expires=Thu, 01 Jan 2022 00:00:00 GMT", result[1]) + end) + + it("should call time functions in correct order", function() + NetaceaCookies.createSetCookieValues("timing_test", "test_value", 3600) + + -- ngx.time() should be called before ngx.cookie_time() + assert.spy(ngx_mock.time).was.called() + assert.spy(ngx_mock.cookie_time).was.called() + + -- Verify the time calculation + 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() + -- Note: ngx.encode_args output order may vary, so we just check that sign was called + assert.spy(jwt_mock.sign).was.called() + local call_args = jwt_mock.sign.calls[1].vals + assert.is.equal("secret_key", call_args[2]) + assert.is.table(call_args[3]) + assert.is.table(call_args[3].header) + assert.is.string(call_args[3].payload) + 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) + + it("should handle nil settings", function() + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "192.168.1.1", + "user123", + "cookie123", + "no_session", + 1640995200, + 3600, + 1, + 2, + 3, + nil + ) + + assert.is.table(result) + assert.is.string(result.mitata_jwe) + assert.is.string(result.mitata_plaintext) + end) + + it("should handle edge case with zero values", function() + local settings = { fCAPR = 0 } + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "192.168.1.1", + "user123", + "cookie123", + "no_session", + 0, -- zero timestamp + 0, -- zero grace period + 0, -- zero match + 0, -- zero mitigation + 0, -- zero 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 = 0, + grp = 0, + mat = 0, + mit = 0, + cap = 0, + fCAPR = 0 + }) + end) + + it("should handle large numeric values", function() + local settings = { fCAPR = 999999 } + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "192.168.1.1", + "user123", + "cookie123", + "no_session", + 2147483647, -- Max int32 + 999999, + 999999, + 999999, + 999999, + settings + ) + + assert.is.table(result) + assert.is.string(result.mitata_jwe) + assert.is.string(result.mitata_plaintext) + end) + + it("should handle empty string parameters", function() + local settings = { fCAPR = 0 } + local result = NetaceaCookies.generateNewCookieValue( + "", -- empty secret key + "", -- empty client IP + "", -- empty user ID + "", -- empty cookie ID + "", -- empty issue reason + 1640995200, + 3600, + 0, + 0, + 0, + settings + ) + + assert.is.table(result) + assert.is.string(result.mitata_jwe) + assert.is.string(result.mitata_plaintext) + end) + + it("should handle special characters in string parameters", function() + local settings = { fCAPR = 1 } + local result = NetaceaCookies.generateNewCookieValue( + "secret_key!@#$%", + "192.168.1.1", + "user@domain.com", + "cookie_id-123", + "reason with spaces", + 1640995200, + 3600, + 1, + 2, + 3, + settings + ) + + assert.is.table(result) + assert.is.string(result.mitata_jwe) + assert.is.string(result.mitata_plaintext) + assert.spy(ngx_mock.encode_args).was.called_with({ + cip = "192.168.1.1", + uid = "user@domain.com", + cid = "cookie_id-123", + isr = "reason with spaces", + ist = 1640995200, + grp = 3600, + mat = 1, + mit = 2, + cap = 3, + fCAPR = 1 + }) + end) + + it("should handle IPv6 addresses", function() + local settings = { fCAPR = 0 } + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "2001:0db8:85a3:0000:0000:8a2e:0370:7334", -- IPv6 + "user123", + "cookie123", + "no_session", + 1640995200, + 3600, + 1, + 2, + 3, + settings + ) + + assert.is.table(result) + assert.spy(ngx_mock.encode_args).was.called_with({ + cip = "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + uid = "user123", + cid = "cookie123", + isr = "no_session", + ist = 1640995200, + grp = 3600, + mat = 1, + mit = 2, + cap = 3, + fCAPR = 0 + }) + end) + + it("should create consistent plaintext format", function() + local settings = { fCAPR = 1 } + local result = NetaceaCookies.generateNewCookieValue( + "secret_key", + "192.168.1.1", + "user123", + "cookie123", + "no_session", + 1640995200, + 3600, + 5, + 10, + 15, + settings + ) + + -- The plaintext should contain all the encoded parameters + assert.is.string(result.mitata_plaintext) + local plaintext = result.mitata_plaintext + + -- Check that all expected parameters are present in the plaintext + assert.is_true(plaintext:match("cip=192%.168%.1%.1") ~= nil) + assert.is_true(plaintext:match("uid=user123") ~= nil) + assert.is_true(plaintext:match("cid=cookie123") ~= nil) + assert.is_true(plaintext:match("isr=no_session") ~= nil) + assert.is_true(plaintext:match("ist=1640995200") ~= nil) + assert.is_true(plaintext:match("grp=3600") ~= nil) + assert.is_true(plaintext:match("mat=5") ~= nil) + assert.is_true(plaintext:match("mit=10") ~= nil) + assert.is_true(plaintext:match("cap=15") ~= nil) + assert.is_true(plaintext:match("fCAPR=1") ~= nil) + 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) + + it("should handle malformed payload that doesn't decode", function() + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "malformed_payload_that_fails_decode" + } + end) + + -- Mock ngx.decode_args to return nil for malformed payload + ngx_mock.decode_args = spy.new(function(str) + if str == "malformed_payload_that_fails_decode" then + return nil + end + return {} + end) + + local result = NetaceaCookies.parseMitataCookie("test_token", "secret_key") + + assert.is_false(result.valid) + assert.is.equal('invalid_session', result.reason) + end) + + it("should handle non-table result from decode_args", function() + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "test_payload" + } + end) + + -- Mock ngx.decode_args to return a string instead of table + ngx_mock.decode_args = spy.new(function(str) + return "not_a_table" + end) + + local result = NetaceaCookies.parseMitataCookie("test_token", "secret_key") + + assert.is_false(result.valid) + assert.is.equal('invalid_session', result.reason) + end) + + it("should handle edge case where timestamp is exactly at expiry", function() + -- Set up a cookie that expires exactly at the current time + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "cip=192.168.1.1&uid=user123&cid=cookie123&isr=no_session&ist=1640991599&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + end) + + -- Current time is 1640995200, cookie was issued at 1640991599 with 3600s grace = expires at 1640995199 (1 second before current time) + local result = NetaceaCookies.parseMitataCookie("edge_case_token", "secret_key") + + assert.is_false(result.valid) + assert.is.equal('expired_session', result.reason) + assert.is.equal('user123', result.user_id) + end) + + it("should handle future timestamps correctly", function() + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "cip=192.168.1.1&uid=user123&cid=cookie123&isr=no_session&ist=2000000000&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + end) + + local result = NetaceaCookies.parseMitataCookie("future_token", "secret_key") + + assert.is_true(result.valid) + assert.is.equal('user123', result.user_id) + end) + + it("should handle client IP with different formats", function() + -- Test with loopback IP + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "cip=127.0.0.1&uid=user123&cid=cookie123&isr=no_session&ist=1640995200&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + end) + + ngx_mock.ctx.NetaceaState.client = "127.0.0.1" + + local result = NetaceaCookies.parseMitataCookie("loopback_token", "secret_key") + + assert.is_true(result.valid) + assert.is.equal('user123', result.user_id) + end) + + it("should handle empty string fields in payload", function() + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "cip=192.168.1.1&uid=&cid=&isr=&ist=1640995200&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + end) + + local result = NetaceaCookies.parseMitataCookie("empty_fields_token", "secret_key") + + assert.is_true(result.valid) + assert.is.equal('', result.user_id) -- uid is empty but present + assert.is.equal('', result.data.uid) + assert.is.equal('', result.data.cid) + assert.is.equal('', result.data.isr) + end) + + it("should handle numeric string conversion correctly", function() + jwt_mock.verify = spy.new(function(secretKey, token) + return { + verified = true, + payload = "cip=192.168.1.1&uid=user123&cid=cookie123&isr=no_session&ist=not_a_number&grp=3600&mat=0&mit=0&cap=0&fCAPR=0" + } + end) + + local result = NetaceaCookies.parseMitataCookie("non_numeric_token", "secret_key") + + -- Should return invalid session for non-numeric timestamps + assert.is.table(result) + assert.is_false(result.valid) + assert.is.equal('invalid_session', result.reason) + end) + + it("should handle whitespace in client IP comparison", function() + jwt_mock.verify = spy.new(function(secretKey, token) + 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" + } + end) + + local result = NetaceaCookies.parseMitataCookie("whitespace_ip_token", "secret_key") + + assert.is_false(result.valid) + assert.is.equal('ip_change', result.reason) + assert.is.equal('user123', result.user_id) + end) + end) + describe("decrypt", function() + it("should decrypt a valid JWT token", function() + local result = NetaceaCookies.decrypt("secret_key", "valid_token") + + assert.is_string(result) + assert.is.equal("cip=192.168.1.1&uid=user123&cid=cookie123&isr=no_session&ist=1640995200&grp=3600&mat=0&mit=0&cap=0&fCAPR=0", result) + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "valid_token") + end) + + it("should return nil for invalid JWT token", function() + local result = NetaceaCookies.decrypt("secret_key", "invalid_token") + + assert.is_nil(result) + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "invalid_token") + end) + + it("should return nil for unverified JWT token", function() + jwt_mock.verify = spy.new(function(self, secretKey, token) + return { verified = false } + end) + + local result = NetaceaCookies.decrypt("secret_key", "test_token") + + assert.is_nil(result) + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "test_token") + end) + + it("should handle empty secret key", function() + local result = NetaceaCookies.decrypt("", "valid_token") + + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "", "valid_token") + end) + + it("should handle empty token", function() + local result = NetaceaCookies.decrypt("secret_key", "") + + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "") + end) + end) + + describe("encrypt", function() + it("should encrypt payload with correct JWT structure", function() + local _ = match._ + local result = NetaceaCookies.encrypt("secret_key", "test_payload") + + assert.is_string(result) + assert.is.equal("mock_jwt_token_secret_key", result) + assert.spy(jwt_mock.sign).was.called_with(match.is_not_nil(), "secret_key", { + header = { typ="JWE", alg="dir", enc="A128CBC-HS256" }, + payload = "test_payload" + }) + end) + + it("should handle empty payload", function() + local result = NetaceaCookies.encrypt("secret_key", "") + + assert.is_string(result) + assert.spy(jwt_mock.sign).was.called_with(match.is_not_nil(), "secret_key", { + header = { typ="JWE", alg="dir", enc="A128CBC-HS256" }, + payload = "" + }) + end) + + it("should handle nil payload", function() + local result = NetaceaCookies.encrypt("secret_key", nil) + + assert.is_string(result) + assert.spy(jwt_mock.sign).was.called_with(match.is_not_nil(), "secret_key", { + header = { typ="JWE", alg="dir", enc="A128CBC-HS256" }, + payload = nil + }) + end) + + it("should handle empty secret key", function() + local result = NetaceaCookies.encrypt("", "test_payload") + + assert.is.equal("mock_jwt_token_", result) + assert.spy(jwt_mock.sign).was.called_with(match.is_not_nil(), "", { + header = { typ="JWE", alg="dir", enc="A128CBC-HS256" }, + payload = "test_payload" + }) + end) + + it("should use correct JWT header values", function() + local _ = match._ + NetaceaCookies.encrypt("secret_key", "test_payload") + + assert.spy(jwt_mock.sign).was.called_with(match.is_not_nil(), "secret_key", match._) + + -- Verify header structure + local call_args = jwt_mock.sign.calls[1].vals + local jwt_params = call_args[3] + assert.is.table(jwt_params.header) + assert.is.table(jwt_params) + assert.is.truthy(jwt_params.header) + assert.is.truthy(jwt_params.payload) + assert.is.equal("JWE", jwt_params.header.typ) + assert.is.equal("dir", jwt_params.header.alg) + assert.is.equal("A128CBC-HS256", jwt_params.header.enc) + end) + end) + + describe("newUserId", 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) + + it("should always generate IDs of consistent length", function() + for i = 1, 10 do + local userId = NetaceaCookies.newUserId() + assert.is.equal(16, #userId, "ID " .. i .. " should be 16 characters long") + end + end) + + it("should generate only alphanumeric characters after 'c'", function() + local userId = NetaceaCookies.newUserId() + local randomPart = userId:sub(2) -- Everything after 'c' + local alphanumericPattern = "^[a-zA-Z0-9]+$" + + assert.is_true(randomPart:match(alphanumericPattern) ~= nil, + "Random part should contain only alphanumeric characters") + end) + end) +end) diff --git a/test/lua_resty_netacea_ingest_spec.lua b/test/lua_resty_netacea_ingest_spec.lua new file mode 100644 index 0000000..061c494 --- /dev/null +++ b/test/lua_resty_netacea_ingest_spec.lua @@ -0,0 +1,677 @@ +require("silence_g_write_guard") +require 'busted.runner'() + +package.path = "../src/?.lua;" .. package.path +local match = require("luassert.match") + +describe("lua_resty_netacea_ingest", function() + local Ingest + local ngx_mock + local kinesis_mock + local utils_mock + local cjson_mock + + before_each(function() + -- Mock ngx module + ngx_mock = { + ctx = { + mitata = "test_mitata_cookie", + NetaceaState = { + client = "192.168.1.1", + UserId = "user123", + bc_type = "captcha" + } + }, + var = { + request_method = "GET", + request_uri = "/test/path", + server_protocol = "HTTP/1.1", + time_local = "01/Jan/2022:00:00:00 +0000", + msec = 1640995200.123, + http_user_agent = "Test-Agent/1.0", + status = "200", + request_time = "0.123", + bytes_sent = "1024", + http_referer = "https://example.com", + query_string = "param=value", + host = "test.example.com", + request_id = "req-12345", + bytes_received = "512", + cookie__mitata = "fallback_mitata" + }, + log = spy.new(function() end), + DEBUG = 7, + ERR = 3, + now = spy.new(function() return 1640995200.5 end), + sleep = spy.new(function() end), + timer = { + at = spy.new(function(delay, callback) + -- Simulate timer execution for testing + if callback then + callback(false) -- premature = false + end + return true + end) + }, + thread = { + spawn = spy.new(function(func) + -- For testing, just return a mock thread handle + return { thread_id = "mock_thread" } + end), + wait = spy.new(function(thread) + return true, nil -- success, no error + end) + }, + worker = { + exiting = spy.new(function() return false end) + } + } + + -- Mock kinesis module + kinesis_mock = { + new = spy.new(function(stream_name, region, access_key, secret_key) + local client = { + stream_name = stream_name, + region = region, + access_key = access_key, + secret_key = secret_key, + put_records = spy.new(function(self, records) + if self.stream_name == "test_stream" or self.stream_name == "integration_test_stream" or self.stream_name:match("test") then + return { status = 200, body = '{"Records":[]}' }, nil + else + return nil, "Stream not found" + end + end) + } + return client + end) + } + + -- Mock utils module + utils_mock = { + buildRandomString = spy.new(function(length) + return string.rep("a", length) + end), + getIpAddress = spy.new(function(self, vars, header) + if header and vars["http_" .. header] then + return vars["http_" .. header] + end + return vars.remote_addr or "127.0.0.1" + end) + } + + -- Mock cjson module + cjson_mock = { + encode = spy.new(function(obj) + if type(obj) == "table" then + return '{"mocked":"json"}' + end + return '"' .. tostring(obj) .. '"' + end) + } + + -- Set up package mocks + package.loaded['ngx'] = ngx_mock + package.loaded['kinesis_resty'] = kinesis_mock + package.loaded['netacea_utils'] = utils_mock + package.loaded['cjson'] = cjson_mock + + Ingest = require('lua_resty_netacea_ingest') + end) + + after_each(function() + -- Clear mocks and cached modules + package.loaded['lua_resty_netacea_ingest'] = nil + package.loaded['ngx'] = nil + package.loaded['kinesis_resty'] = nil + package.loaded['netacea_utils'] = nil + package.loaded['cjson'] = nil + end) + + describe("new_queue", function() + it("should create a queue with specified size", function() + local ingest = Ingest:new({ + stream_name = "test_stream", + queue_size = 10 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + assert.is.table(ingest.data_queue) + assert.is.equal(10, ingest.data_queue.size) + assert.is.equal(0, ingest.data_queue:count()) + end) + + it("should support push and pop operations", function() + local ingest = Ingest:new({ + stream_name = "test_stream", + queue_size = 5 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + -- Test push + local ok, err = ingest.data_queue:push("item1") + assert.is_true(ok) + assert.is_nil(err) + assert.is.equal(1, ingest.data_queue:count()) + + -- Test pop + local item = ingest.data_queue:pop() + assert.is.equal("item1", item) + assert.is.equal(0, ingest.data_queue:count()) + end) + + it("should handle queue wrapping when enabled", function() + local ingest = Ingest:new({ + stream_name = "test_stream", + queue_size = 2 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + -- Fill the queue + ingest.data_queue:push("item1") + ingest.data_queue:push("item2") + assert.is.equal(2, ingest.data_queue:count()) + + -- Push beyond capacity (should wrap since allow_wrapping is true) + ingest.data_queue:push("item3") + assert.is.equal(2, ingest.data_queue:count()) + + -- First item should be lost, second item should be available + local item = ingest.data_queue:pop() + assert.is.equal("item2", item) + end) + + it("should peek at next item without removing it", function() + local ingest = Ingest:new({ + stream_name = "test_stream", + queue_size = 5 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + ingest.data_queue:push("peek_item") + + local peeked = ingest.data_queue:peek() + assert.is.equal("peek_item", peeked) + assert.is.equal(1, ingest.data_queue:count()) + + local popped = ingest.data_queue:pop() + assert.is.equal("peek_item", popped) + assert.is.equal(0, ingest.data_queue:count()) + end) + end) + + describe("constructor", function() + it("should create ingest instance with default options", function() + local ingest = Ingest:new({}, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + assert.is.table(ingest) + assert.is.equal("", ingest.stream_name) + assert.is.equal("eu-west-1", ingest.region) + assert.is.equal("", ingest.aws_access_key) + assert.is.equal("", ingest.aws_secret_key) + assert.is.equal(5000, ingest.queue_size) + assert.is.equal(1000, ingest.dead_letter_queue_size) + assert.is.equal(25, ingest.batch_size) + assert.is.equal(1.0, ingest.batch_timeout) + end) + + it("should create ingest instance with custom options", function() + local options = { + stream_name = "my-stream", + region = "us-east-1", + aws_access_key = "AKIAIOSFODNN7EXAMPLE", + aws_secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + queue_size = 1000, + dead_letter_queue_size = 100, + batch_size = 50, + batch_timeout = 2.0 + } + + local ingest = Ingest:new(options, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + assert.is.equal("my-stream", ingest.stream_name) + assert.is.equal("us-east-1", ingest.region) + assert.is.equal("AKIAIOSFODNN7EXAMPLE", ingest.aws_access_key) + assert.is.equal("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", ingest.aws_secret_key) + assert.is.equal(1000, ingest.queue_size) + assert.is.equal(100, ingest.dead_letter_queue_size) + assert.is.equal(50, ingest.batch_size) + assert.is.equal(2.0, ingest.batch_timeout) + end) + + it("should initialize queues properly", function() + local ingest = Ingest:new({ + queue_size = 10, + dead_letter_queue_size = 5 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + assert.is.table(ingest.data_queue) + assert.is.table(ingest.dead_letter_queue) + assert.is.equal(10, ingest.data_queue.size) + assert.is.equal(5, ingest.dead_letter_queue.size) + assert.is.equal(0, ingest.data_queue:count()) + assert.is.equal(0, ingest.dead_letter_queue:count()) + end) + + it("should log initialization message", function() + Ingest:new({ + queue_size = 100, + dead_letter_queue_size = 50, + batch_size = 10, + batch_timeout = 0.5 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + assert.spy(ngx_mock.log).was.called_with(ngx_mock.DEBUG, match._, 100, match._, 50, match._, 10, match._, 0.5) + end) + end) + + describe("send_batch_to_kinesis", function() + local ingest + + before_each(function() + ingest = Ingest:new({ + stream_name = "test_stream", + region = "us-west-2", + aws_access_key = "test_key", + aws_secret_key = "test_secret" + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + end) + + it("should handle empty batch gracefully", function() + ingest:send_batch_to_kinesis({}) + + -- Should not call Kinesis + assert.spy(kinesis_mock.new).was_not_called() + end) + + it("should handle nil batch gracefully", function() + ingest:send_batch_to_kinesis(nil) + + -- Should not call Kinesis + assert.spy(kinesis_mock.new).was_not_called() + end) + + it("should create kinesis client with correct parameters", function() + local batch = { { test = "data1" }, { test = "data2" } } + + ingest:send_batch_to_kinesis(batch) + + assert.spy(kinesis_mock.new).was.called_with( + "test_stream", + "us-west-2", + "test_key", + "test_secret" + ) + end) + + it("should format batch data correctly for kinesis", function() + local batch = { { test = "data1" }, { test = "data2" } } + + ingest:send_batch_to_kinesis(batch) + + assert.spy(kinesis_mock.new).was.called() + assert.spy(utils_mock.buildRandomString).was.called(2) -- Once per record for partition key + assert.spy(cjson_mock.encode).was.called(2) -- Once per record + end) + + it("should log successful batch send", function() + local batch = { { test = "data1" } } + + ingest:send_batch_to_kinesis(batch) + + assert.spy(ngx_mock.log).was.called_with(ngx_mock.DEBUG, match.has_match("sending batch of"), 1, match._, "test_stream") + -- Check that at least one log call was made (for success) + assert.spy(ngx_mock.log).was.called() + end) + + it("should handle kinesis errors and move items to dead letter queue", function() + -- Mock kinesis to return error + local batch = { { test = "data1" }, { test = "data2" } } + kinesis_mock.new = spy.new(function() + return { + put_records = spy.new(function() + return nil, "Connection timeout" + end) + } + end) + + ingest:send_batch_to_kinesis(batch) + + assert.spy(ngx_mock.log).was.called_with(ngx_mock.ERR, match.has_match("error sending batch"), "Connection timeout") + assert.is.equal(2, ingest.dead_letter_queue:count()) + end) + + it("should handle dead letter queue overflow correctly", function() + -- Mock kinesis error + kinesis_mock.new = spy.new(function() + return { + put_records = spy.new(function() + return nil, "Stream not found" + end) + } + end) + + -- Create an ingest with very small DLQ size for testing + local small_dlq_ingest = Ingest:new({ + stream_name = "test_stream", + dead_letter_queue_size = 1 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + -- Fill DLQ to capacity first (the queue allows wrapping so it overwrites) + small_dlq_ingest.dead_letter_queue:push("existing_item") + + -- Now try to send a batch that will fail + local batch = { { test = "overflow_data1" } } + small_dlq_ingest:send_batch_to_kinesis(batch) + + -- Should log the kinesis error + assert.spy(ngx_mock.log).was.called_with(ngx_mock.ERR, match.has_match("error sending batch"), "Stream not found") + -- The DLQ should now contain the failed item (wrapping behavior) + assert.is.equal(1, small_dlq_ingest.dead_letter_queue:count()) + end) + end) + + describe("ingest", function() + local ingest + + before_each(function() + ingest = Ingest:new({ + stream_name = "test_stream" + }, { + _MODULE_TYPE = "nginx", + _MODULE_VERSION = "2.1.0", + realIpHeader = "x_forwarded_for", + mitigationType = "monitor" + }) + end) + + it("should collect request data from ngx variables", function() + ingest:ingest() + + assert.is.equal(1, ingest.data_queue:count()) + local queued_item = ingest.data_queue:pop() + + assert.is.table(queued_item) + assert.is.equal("GET /test/path HTTP/1.1", queued_item.Request) + assert.is.equal("01/Jan/2022:00:00:00 +0000", queued_item.TimeLocal) + assert.is.equal(1640995200123, queued_item.TimeUnixMsUTC) + assert.is.equal("192.168.1.1", queued_item.RealIp) + assert.is.equal("Test-Agent/1.0", queued_item.UserAgent) + assert.is.equal("200", queued_item.Status) + assert.is.equal("0.123", queued_item.RequestTime) + assert.is.equal("1024", queued_item.BytesSent) + assert.is.equal("https://example.com", queued_item.Referer) + assert.is.equal("test_mitata_cookie", queued_item.NetaceaUserIdCookie) + assert.is.equal("user123", queued_item.UserId) + assert.is.equal("captcha", queued_item.NetaceaMitigationApplied) + assert.is.equal("nginx", queued_item.IntegrationType) + assert.is.equal("2.1.0", queued_item.IntegrationVersion) + assert.is.equal("param=value", queued_item.Query) + assert.is.equal("test.example.com", queued_item.RequestHost) + assert.is.equal("req-12345", queued_item.RequestId) + assert.is.equal("monitor", queued_item.ProtectionMode) + end) + + it("should handle missing ngx.ctx.mitata by falling back to cookie", function() + ngx_mock.ctx.mitata = nil + + ingest:ingest() + + local queued_item = ingest.data_queue:pop() + assert.is.equal("fallback_mitata", queued_item.NetaceaUserIdCookie) + end) + + it("should handle missing NetaceaState gracefully", function() + ngx_mock.ctx.NetaceaState = nil + + ingest:ingest() + + local queued_item = ingest.data_queue:pop() + assert.is.equal("127.0.0.1", queued_item.RealIp) -- default from utils mock + assert.is.equal("-", queued_item.UserId) + assert.is_nil(queued_item.NetaceaMitigationApplied) + end) + + it("should use realIpHeader when configured", function() + ngx_mock.var.http_x_forwarded_for = "203.0.113.1" + ngx_mock.ctx.NetaceaState.client = nil -- Force it to use getIpAddress + + ingest:ingest() + + assert.spy(utils_mock.getIpAddress).was.called() + -- Verify the call was made with correct number of arguments + assert.is.equal(1, #utils_mock.getIpAddress.calls) + end) + + it("should handle missing optional fields with defaults", function() + ngx_mock.var.http_user_agent = nil + ngx_mock.var.http_referer = nil + ngx_mock.var.query_string = nil + ngx_mock.var.host = nil + ngx_mock.var.request_id = nil + ngx_mock.var.bytes_received = nil + + ingest:ingest() + + local queued_item = ingest.data_queue:pop() + assert.is.equal("-", queued_item.UserAgent) + assert.is.equal("-", queued_item.Referer) + assert.is.equal("", queued_item.Query) + assert.is.equal("-", queued_item.RequestHost) + assert.is.equal("-", queued_item.RequestId) + assert.is.equal(0, queued_item.BytesReceived) + end) + + it("should log successful data queueing", function() + ingest:ingest() + + assert.spy(ngx_mock.log).was.called_with(ngx_mock.DEBUG, match.has_match("queued data item"), 1) + end) + + it("should log error when queue is full", function() + -- Fill the queue to capacity + for i = 1, ingest.queue_size do + ingest.data_queue:push("item" .. i) + end + + -- Since wrapping is enabled, this should still work, but let's test error handling + -- by mocking the queue to return an error + ingest.data_queue.push = spy.new(function() return nil, "queue full" end) + + ingest:ingest() + + assert.spy(ngx_mock.log).was.called_with(ngx_mock.ERR, match.has_match("failed to queue data"), "queue full") + end) + + it("should include all required data fields", function() + ingest:ingest() + + local queued_item = ingest.data_queue:pop() + local required_fields = { + "Request", "TimeLocal", "TimeUnixMsUTC", "RealIp", "UserAgent", "Status", + "RequestTime", "BytesSent", "Referer", "NetaceaUserIdCookie", "UserId", + "NetaceaMitigationApplied", "IntegrationType", "IntegrationVersion", "Query", + "RequestHost", "RequestId", "ProtectionMode", "BytesReceived", + "NetaceaUserIdCookieStatus", "Optional" + } + + for _, field in ipairs(required_fields) do + assert.is_not_nil(queued_item[field], "Field " .. field .. " should be present") + end + end) + + it("should handle empty mitata cookie", function() + ngx_mock.ctx.mitata = "" + ngx_mock.var.cookie__mitata = "" + + ingest:ingest() + + local queued_item = ingest.data_queue:pop() + assert.is.equal("", queued_item.NetaceaUserIdCookie) + end) + end) + + describe("start_timers", function() + local ingest + + before_each(function() + ingest = Ingest:new({ + stream_name = "test_stream", + batch_size = 2, + batch_timeout = 0.1 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + -- Simplify timer mock to avoid recursion + ngx_mock.timer.at = spy.new(function(delay, callback) + return true -- Just return success, don't execute callback + end) + end) + + it("should start batch processor timer", function() + ingest:start_timers() + + assert.spy(ngx_mock.timer.at).was.called_with(0, match.is_function()) + assert.spy(ngx_mock.log).was.called_with(ngx_mock.DEBUG, match.has_match("starting batch processor timer")) + end) + + it("should setup timer correctly", function() + ingest:start_timers() + + assert.spy(ngx_mock.timer.at).was.called() + end) + end) + + describe("queue integration with kinesis", function() + local ingest + + before_each(function() + ingest = Ingest:new({ + stream_name = "integration_test_stream", + batch_size = 3, + batch_timeout = 0.1 + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + end) + + it("should process data from queue through kinesis pipeline", function() + -- Add test data to queue + ingest.data_queue:push({ test_data = "item1" }) + ingest.data_queue:push({ test_data = "item2" }) + ingest.data_queue:push({ test_data = "item3" }) + + -- Manually call send_batch_to_kinesis to test integration + local batch = {} + while ingest.data_queue:count() > 0 do + table.insert(batch, ingest.data_queue:pop()) + end + + ingest:send_batch_to_kinesis(batch) + + assert.spy(kinesis_mock.new).was.called() + assert.spy(utils_mock.buildRandomString).was.called(3) -- Once per record for partition key + assert.spy(cjson_mock.encode).was.called(3) -- Once per record for data serialization + end) + + it("should handle dead letter queue processing", function() + -- Add items to dead letter queue + ingest.dead_letter_queue:push({ failed_data = "item1" }) + ingest.dead_letter_queue:push({ failed_data = "item2" }) + + -- Process dead letter queue items + local batch = {} + while ingest.dead_letter_queue:count() > 0 do + table.insert(batch, ingest.dead_letter_queue:pop()) + end + + ingest:send_batch_to_kinesis(batch) + + assert.spy(kinesis_mock.new).was.called() + -- Verify kinesis new was called (put_records called internally) + assert.spy(kinesis_mock.new).was.called(1) + end) + + it("should handle mixed data and dead letter queue processing", function() + -- Add items to both queues + ingest.data_queue:push({ normal_data = "item1" }) + ingest.dead_letter_queue:push({ retry_data = "item2" }) + + local batch = {} + + -- Process dead letter queue first (as per implementation) + while ingest.dead_letter_queue:count() > 0 and #batch < ingest.batch_size do + table.insert(batch, ingest.dead_letter_queue:pop()) + end + + -- Then process normal queue + while ingest.data_queue:count() > 0 and #batch < ingest.batch_size do + table.insert(batch, ingest.data_queue:pop()) + end + + ingest:send_batch_to_kinesis(batch) + + assert.is.equal(2, #batch) + assert.spy(kinesis_mock.new).was.called() + end) + end) + + describe("AWS Kinesis integration specifics", function() + local ingest + + before_each(function() + ingest = Ingest:new({ + stream_name = "aws_test_stream", + region = "us-east-1", + aws_access_key = "AKIA1234567890EXAMPLE", + aws_secret_key = "abcd1234567890efgh1234567890ijklmnopqrstuvwx" + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + end) + + it("should create kinesis client with AWS credentials", function() + local batch = { { aws_test = "data" } } + + ingest:send_batch_to_kinesis(batch) + + assert.spy(kinesis_mock.new).was.called_with( + "aws_test_stream", + "us-east-1", + "AKIA1234567890EXAMPLE", + "abcd1234567890efgh1234567890ijklmnopqrstuvwx" + ) + end) + + it("should generate random partition keys for records", function() + local batch = { { data1 = "test" }, { data2 = "test" } } + + ingest:send_batch_to_kinesis(batch) + + assert.spy(utils_mock.buildRandomString).was.called(2) + assert.spy(utils_mock.buildRandomString).was.called_with(10) + end) + + it("should format data as JSON arrays for Kinesis", function() + local test_data = { field1 = "value1", field2 = "value2" } + local batch = { test_data } + + ingest:send_batch_to_kinesis(batch) + + assert.spy(kinesis_mock.new).was.called() + + -- The put_records call happens internally, we can verify the call structure + -- by checking that kinesis_mock.new was called properly + assert.spy(kinesis_mock.new).was.called_with( + "aws_test_stream", + "us-east-1", + "AKIA1234567890EXAMPLE", + "abcd1234567890efgh1234567890ijklmnopqrstuvwx" + ) + end) + + it("should handle different AWS regions", function() + local regional_ingest = Ingest:new({ + stream_name = "eu_stream", + region = "eu-west-1", + aws_access_key = "key", + aws_secret_key = "secret" + }, { _MODULE_TYPE = "test", _MODULE_VERSION = "1.0" }) + + regional_ingest:send_batch_to_kinesis({ { regional_test = "data" } }) + + assert.spy(kinesis_mock.new).was.called_with("eu_stream", "eu-west-1", "key", "secret") + end) + end) +end) \ No newline at end of file diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua new file mode 100644 index 0000000..9edf206 --- /dev/null +++ b/test/lua_resty_netacea_spec.lua @@ -0,0 +1,12 @@ +require("silence_g_write_guard") +require 'busted.runner'() +-- Test file for lua_resty_netacea + +insulate("lua_resty_netacea", function() + + describe("lua_resty_netacea", function() + it("should always pass", function() + assert.is_true(true) + end) + end) +end) \ No newline at end of file diff --git a/test/netacea_utils.test.lua b/test/netacea_utils.test.lua deleted file mode 100644 index 9feac13..0000000 --- a/test/netacea_utils.test.lua +++ /dev/null @@ -1,177 +0,0 @@ -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) diff --git a/test/netacea_utils_spec.lua b/test/netacea_utils_spec.lua new file mode 100644 index 0000000..f6ea611 --- /dev/null +++ b/test/netacea_utils_spec.lua @@ -0,0 +1,380 @@ +require("silence_g_write_guard") +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) + + it("should handle missing remote_addr gracefully", function() + local vars = { + 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 handle nil vars table", function() + local success, result = pcall(function() + return utils:getIpAddress(nil, "x_forwarded_for") + end) + + -- Should not crash, but will likely error when trying to access nil.remote_addr + assert.is_false(success) + end) + + it("should handle empty vars table", function() + local vars = {} + + local result = utils:getIpAddress(vars, "x_forwarded_for") + assert.is_nil(result) -- vars.remote_addr is nil + end) + + it("should handle whitespace in header values", 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) -- Should preserve whitespace + end) + + it("should handle very long header names", function() + local longHeaderName = string.rep("a", 100) + local vars = { + remote_addr = "192.168.1.1", + ["http_" .. longHeaderName] = "203.0.113.1" + } + + local result = utils:getIpAddress(vars, longHeaderName) + assert.is.equal("203.0.113.1", result) + end) + end) + + describe("parseOption", function() + it("should return the option when it's a valid string", function() + local result = utils.parseOption("test_value", "default") + assert.is.equal("test_value", result) + end) + + it("should return the default value when option is nil", function() + local result = utils.parseOption(nil, "default_value") + assert.is.equal("default_value", result) + end) + + it("should return the default value when option is empty string", function() + local result = utils.parseOption("", "default_value") + assert.is.equal("default_value", result) + end) + + it("should trim whitespace from string options", function() + local result = utils.parseOption(" test_value ", "default") + assert.is.equal("test_value", result) + end) + + it("should handle leading whitespace only", function() + local result = utils.parseOption(" test_value", "default") + assert.is.equal("test_value", result) + end) + + it("should handle trailing whitespace only", function() + local result = utils.parseOption("test_value ", "default") + assert.is.equal("test_value", result) + end) + + it("should return default when option is only whitespace", function() + local result = utils.parseOption(" ", "default_value") + assert.is.equal("default_value", result) + end) + + it("should handle tabs and newlines in whitespace", function() + local result = utils.parseOption("\t\n test_value \t\n", "default") + assert.is.equal("test_value", result) + end) + + it("should preserve internal whitespace", function() + local result = utils.parseOption(" test value with spaces ", "default") + assert.is.equal("test value with spaces", result) + end) + + it("should handle non-string types by returning them as-is", function() + local numberResult = utils.parseOption(123, "default") + assert.is.equal(123, numberResult) + + local boolResult = utils.parseOption(true, "default") + assert.is.equal(true, boolResult) + + local tableResult = utils.parseOption({test = "value"}, "default") + assert.is.same({test = "value"}, tableResult) + end) + + it("should handle nil default value", function() + local result = utils.parseOption(nil, nil) + assert.is_nil(result) + end) + + it("should handle empty string as default value", function() + local result = utils.parseOption(nil, "") + assert.is.equal("", result) + end) + + it("should handle numeric default values", function() + local result = utils.parseOption(nil, 42) + assert.is.equal(42, result) + end) + + it("should handle boolean default values", function() + local result = utils.parseOption(nil, false) + assert.is.equal(false, result) + end) + + it("should handle complex whitespace patterns", function() + -- Various Unicode whitespace characters + local result = utils.parseOption("\r\n\t test \t\r\n", "default") + assert.is.equal("test", result) + end) + + it("should handle single character strings", function() + local result = utils.parseOption(" a ", "default") + assert.is.equal("a", result) + end) + + it("should handle very long strings", function() + local longString = " " .. string.rep("a", 10000) .. " " + local result = utils.parseOption(longString, "default") + assert.is.equal(string.rep("a", 10000), result) + end) + + it("should handle special characters in strings", function() + local result = utils.parseOption(" !@#$%^&*() ", "default") + assert.is.equal("!@#$%^&*()", result) + end) + + it("should handle Unicode characters", function() + local result = utils.parseOption(" Hello 世界 ", "default") + assert.is.equal("Hello 世界", result) + end) + + it("should handle empty string after trimming", function() + local result = utils.parseOption("\t\n\r ", "default_value") + assert.is.equal("default_value", result) + end) + end) + + describe("buildRandomString edge cases", function() + it("should handle negative length gracefully", function() + -- The current implementation doesn't check for negative values + -- This might cause unexpected behavior + local success, result = pcall(function() + return utils.buildRandomString(-1) + end) + + if success then + -- If it doesn't error, it should return empty string + assert.is.equal("", result) + else + -- If it errors, that's also acceptable behavior + assert.is_true(true) + end + end) + + it("should handle very large lengths", function() + -- Test with a reasonably large number (not too large to avoid memory issues) + local result = utils.buildRandomString(10000) + assert.is.equal(10000, #result) + end) + + it("should maintain randomness across multiple calls with same seed conditions", function() + -- Since the function sets its own seed based on time, multiple rapid calls + -- might produce similar results, but we test that the seeding works + local results = {} + for i = 1, 10 do + results[i] = utils.buildRandomString(20) + -- Small delay to ensure different seeds + os.execute("sleep 0.001") + end + + -- Check that not all results are the same + local allSame = true + for i = 2, 10 do + if results[i] ~= results[1] then + allSame = false + break + end + end + + assert.is_false(allSame, "Random strings should vary across multiple calls") + end) + end) +end)