From 66b9ef9be719d18038128695328dd05018b6f759 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Thu, 14 May 2026 12:54:25 +0100 Subject: [PATCH 1/3] add ingest only mode support --- Dockerfile | 4 +- README.md | 54 ++++++++ ...spec => lua_resty_netacea-1.2.0-0.rockspec | 2 +- src/lua_resty_netacea.lua | 16 ++- test/lua_resty_netacea_spec.lua | 117 +++++++++++++++++- 5 files changed, 179 insertions(+), 14 deletions(-) rename lua_resty_netacea-1.0-0.rockspec => lua_resty_netacea-1.2.0-0.rockspec (96%) diff --git a/Dockerfile b/Dockerfile index 382c399..0fb304b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,9 +9,9 @@ RUN apt-get install -y libssl-dev FROM base AS build -COPY ./lua_resty_netacea-1.0-0.rockspec ./ +COPY ./lua_resty_netacea-1.2.0-0.rockspec ./ COPY ./src ./src -RUN /usr/local/openresty/luajit/bin/luarocks make ./lua_resty_netacea-1.0-0.rockspec +RUN /usr/local/openresty/luajit/bin/luarocks make ./lua_resty_netacea-1.2.0-0.rockspec FROM build AS test diff --git a/README.md b/README.md index 8108241..81b54b8 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,60 @@ With coverage report (sent to stdout) `docker compose run -e LUACOV_REPORT=1 --b ## Configuration +### nginx.conf - ingest only + +Use ingest-only mode when you want to send request data to the ingest pipeline without calling the Mitigation Endpoint. + +Set `ingestEnabled` to `true`, set `mitigationEnabled` to `false`, and leave `mitigationType` empty. + +`kinesisProperties` must be provided for ingest to remain enabled. + +```conf +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + lua_package_path "/usr/local/share/lua/5.1/?.lua;;"; + lua_max_running_timers 2048; + lua_max_pending_timers 4096; + lua_socket_pool_size 1024; + lua_need_request_body on; + resolver 8.8.8.8 ipv6=off; + lua_ssl_verify_depth 2; + lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; + init_worker_by_lua_block { + netacea = (require 'lua_resty_netacea'):new({ + apiKey = 'your-api-key', + realIpHeader = 'realip-header', + ingestEnabled = true, + mitigationEnabled = false, + mitigationType = '', + kinesisProperties = { + stream_name = 'your-kinesis-stream', + region = 'eu-west-1', + aws_access_key = 'your-aws-access-key', + aws_secret_key = 'your-aws-secret-key' + } + }) + } + log_by_lua_block { + netacea:ingest() + } + + server { + listen 80; + server_name localhost; + location / { + default_type text/html; + content_by_lua 'ngx.say("

hello, world

")'; + } + } +} +``` + ### nginx.conf - mitigate ```conf diff --git a/lua_resty_netacea-1.0-0.rockspec b/lua_resty_netacea-1.2.0-0.rockspec similarity index 96% rename from lua_resty_netacea-1.0-0.rockspec rename to lua_resty_netacea-1.2.0-0.rockspec index e9c3a9e..22b76c0 100644 --- a/lua_resty_netacea-1.0-0.rockspec +++ b/lua_resty_netacea-1.2.0-0.rockspec @@ -1,5 +1,5 @@ package = "lua_resty_netacea" -version = "1.0-0" +version = "1.2.0-0" source = { url = "git://github.com/Netacea/lua_resty_netacea", branch = "master" diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 5c9b6ea..16a8516 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -8,7 +8,7 @@ local Constants = require("lua_resty_netacea_constants") local mitigation = require("lua_resty_netacea_mitigation") local _N = {} -_N._VERSION = '1.1.0' +_N._VERSION = '1.2.0' _N._TYPE = 'nginx' local ngx = require 'ngx' @@ -118,11 +118,15 @@ 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) - ) + local NetaceaState = ngx.ctx.NetaceaState + local protector_result = NetaceaState and NetaceaState.protector_result + if protector_result then + NetaceaState.bc_type = self:setBcType( + tostring(protector_result.match or Constants['idTypes'].NONE), + tostring(protector_result.mitigate or Constants['mitigationTypes'].NONE), + tostring(protector_result.captcha or Constants['captchaStates'].NONE) + ) + end return self.ingestPipeline:ingest() end diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index 9edf206..e4ac140 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -1,12 +1,119 @@ require("silence_g_write_guard") require 'busted.runner'() --- Test file for lua_resty_netacea + +package.path = "../src/?.lua;" .. package.path insulate("lua_resty_netacea", function() - describe("lua_resty_netacea", function() - it("should always pass", function() - assert.is_true(true) + local Netacea + local ngx_mock + local ingest_instance + + before_each(function() + ngx_mock = { + ctx = {}, + log = spy.new(function() end), + DEBUG = 7, + ERR = 3 + } + + ingest_instance = { + start_timers = spy.new(function() end), + ingest = spy.new(function() return "ingested" end) + } + + package.loaded['ngx'] = ngx_mock + package.loaded['ngx.base64'] = { + decode_base64url = spy.new(function() return "decoded-secret" end) + } + package.loaded['lua_resty_netacea_ingest'] = { + new = spy.new(function() return ingest_instance end) + } + package.loaded['lua_resty_netacea_cookies_v3'] = {} + package.loaded['netacea_utils'] = { + parseOption = function(value, default) + if value == nil then return default end + return value + end + } + package.loaded['lua_resty_netacea_protector_client'] = { + new = spy.new(function() return {} end) + } + package.loaded['lua_resty_netacea_mitigation'] = {} + package.loaded['cjson'] = { + encode = function() return "{}" end + } + package.loaded['lua_resty_netacea'] = nil + + Netacea = require('lua_resty_netacea') + end) + + after_each(function() + package.loaded['lua_resty_netacea'] = nil + package.loaded['ngx'] = nil + package.loaded['ngx.base64'] = nil + package.loaded['lua_resty_netacea_ingest'] = nil + package.loaded['lua_resty_netacea_cookies_v3'] = nil + package.loaded['netacea_utils'] = nil + package.loaded['lua_resty_netacea_protector_client'] = nil + package.loaded['lua_resty_netacea_mitigation'] = nil + package.loaded['cjson'] = nil + end) + + local function new_ingest_enabled_netacea(options) + options = options or {} + return Netacea:new({ + ingestEnabled = true, + mitigationEnabled = options.mitigationEnabled or false, + mitigationType = options.mitigationType or '', + mitigationEndpoint = options.mitigationEndpoint or '', + apiKey = "test-api-key", + secretKey = "test-secret-key", + kinesisProperties = { + stream_name = "test-stream", + region = "eu-west-1", + aws_access_key = "test-access-key", + aws_secret_key = "test-secret-key" + } + }) + end + + describe("ingest", function() + it("should support ingest-only mode when NetaceaState is missing", function() + local netacea = new_ingest_enabled_netacea() + ngx_mock.ctx.NetaceaState = nil + + local result = netacea:ingest() + + assert.are.equal("ingested", result) + assert.spy(ingest_instance.ingest).was.called(1) + end) + + it("should support ingest-only mode when protector_result is missing", function() + local netacea = new_ingest_enabled_netacea() + ngx_mock.ctx.NetaceaState = {} + + netacea:ingest() + + assert.is_nil(ngx_mock.ctx.NetaceaState.bc_type) + assert.spy(ingest_instance.ingest).was.called(1) + end) + + it("should set bc_type when mitigation state is available", function() + local netacea = new_ingest_enabled_netacea() + ngx_mock.ctx.NetaceaState = { + protector_result = { + match = "2", + mitigate = "1", + captcha = "0" + } + } + + netacea:ingest() + + assert.are.equal("ip_blocked", ngx_mock.ctx.NetaceaState.bc_type) + assert.spy(ingest_instance.ingest).was.called(1) + end) end) end) -end) \ No newline at end of file +end) From adb6b2cb38b3bff83de5dac401214f54197f2a20 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Thu, 14 May 2026 21:14:24 +0100 Subject: [PATCH 2/3] set session cookie in ingest only mode --- src/lua_resty_netacea.lua | 36 +++++++++++++- test/lua_resty_netacea_spec.lua | 85 +++++++++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 16a8516..1e2c3d1 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -57,6 +57,7 @@ function _N:new(options) end -- mitigate:required:secretKey n.secretKey = b64.decode_base64url(options.secretKey) or '' + n.sessionEnabled = n.secretKey and n.secretKey ~= '' if not n.secretKey or n.secretKey == '' then n.mitigationEnabled = false end @@ -137,6 +138,7 @@ function _N:handleSession() -- Check cookie local cookie = ngx.var['cookie_' .. self.cookieName] or '' + ngx.ctx.mitata = cookie 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 @@ -153,7 +155,11 @@ function _N:handleSession() end function _N:refreshSession(reason) - local protector_result = ngx.ctx.NetaceaState.protector_result + local protector_result = ngx.ctx.NetaceaState.protector_result or { + match = Constants['idTypes'].NONE, + mitigate = Constants['mitigationTypes'].NONE, + captcha = Constants['captchaStates'].NONE + } local grace_period = ngx.ctx.NetaceaState.grace_period or 60 @@ -173,6 +179,7 @@ function _N:refreshSession(reason) local cookies = { self.cookieName .. '=' .. new_cookie.mitata_jwe .. ';' .. self.cookieAttributes } + ngx.ctx.mitata = new_cookie.mitata_jwe if protector_result.captcha_cookie and protector_result.captcha_cookie ~= '' then local captcha_cookie_encrypted = netacea_cookies.encrypt(self.secretKey, protector_result.captcha_cookie) @@ -198,8 +205,33 @@ function _N:handleCaptcha() end +function _N:refreshIngestSession() + local parsed_cookie = self:handleSession() + + if parsed_cookie.valid then + ngx.ctx.NetaceaState.protector_result = { + match = parsed_cookie.data.mat, + mitigate = parsed_cookie.data.mit, + captcha = parsed_cookie.data.cap + } + return parsed_cookie + end + + if not ngx.ctx.NetaceaState.UserId then + ngx.ctx.NetaceaState.UserId = netacea_cookies.newUserId() + end + + self:refreshSession(parsed_cookie.reason) + return parsed_cookie +end + function _N:mitigate() - if not self.mitigationEnabled then return nil end + if not self.mitigationEnabled then + if self.sessionEnabled then + return self:refreshIngestSession() + end + return nil + end local parsed_cookie = self:handleSession() if not parsed_cookie.valid then diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index e4ac140..49924f6 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -8,10 +8,19 @@ insulate("lua_resty_netacea", function() local Netacea local ngx_mock local ingest_instance + local cookies_mock + local protector_client_mock + local protector_client_instance before_each(function() ngx_mock = { ctx = {}, + var = { + remote_addr = "127.0.0.1", + http_user_agent = "Test-Agent", + cookie__mitata = "" + }, + header = {}, log = spy.new(function() end), DEBUG = 7, ERR = 3 @@ -29,16 +38,46 @@ insulate("lua_resty_netacea", function() package.loaded['lua_resty_netacea_ingest'] = { new = spy.new(function() return ingest_instance end) } - package.loaded['lua_resty_netacea_cookies_v3'] = {} + cookies_mock = { + parseMitataCookie = spy.new(function() + return { + valid = false, + reason = "no_session" + } + end), + generateNewCookieValue = spy.new(function() + return { + mitata_jwe = "new-session-cookie", + mitata_plaintext = "plaintext" + } + end), + newUserId = spy.new(function() return "new-user-id" end), + decrypt = spy.new(function() return nil end), + encrypt = spy.new(function() return "encrypted" end) + } + package.loaded['lua_resty_netacea_cookies_v3'] = cookies_mock package.loaded['netacea_utils'] = { parseOption = function(value, default) if value == nil then return default end return value + end, + getIpAddress = function() + return "127.0.0.1" end } - package.loaded['lua_resty_netacea_protector_client'] = { - new = spy.new(function() return {} end) + protector_client_instance = { + checkReputation = spy.new(function() + return { + match = "0", + mitigate = "0", + captcha = "0" + } + end) } + protector_client_mock = { + new = spy.new(function() return protector_client_instance end) + } + package.loaded['lua_resty_netacea_protector_client'] = protector_client_mock package.loaded['lua_resty_netacea_mitigation'] = {} package.loaded['cjson'] = { encode = function() return "{}" end @@ -115,5 +154,45 @@ insulate("lua_resty_netacea", function() assert.spy(ingest_instance.ingest).was.called(1) end) end) + + describe("session cookie in ingest-only mode", function() + it("should set a session cookie when mitigation is disabled", function() + local netacea = new_ingest_enabled_netacea() + + netacea:mitigate() + + assert.are.same({ + "_mitata=new-session-cookie;Max-Age=86400; Path=/;" + }, ngx_mock.header["Set-Cookie"]) + assert.are.equal("new-session-cookie", ngx_mock.ctx.mitata) + assert.are.equal("new-user-id", ngx_mock.ctx.NetaceaState.UserId) + assert.spy(cookies_mock.generateNewCookieValue).was.called(1) + assert.spy(protector_client_instance.checkReputation).was_not_called() + end) + + it("should not refresh a valid session cookie when mitigation is disabled", function() + cookies_mock.parseMitataCookie = spy.new(function() + return { + valid = true, + user_id = "existing-user-id", + data = { + mat = "0", + mit = "0", + cap = "0" + } + } + end) + ngx_mock.var.cookie__mitata = "existing-session-cookie" + local netacea = new_ingest_enabled_netacea() + + netacea:mitigate() + + assert.is_nil(ngx_mock.header["Set-Cookie"]) + assert.are.equal("existing-session-cookie", ngx_mock.ctx.mitata) + assert.are.equal("existing-user-id", ngx_mock.ctx.NetaceaState.UserId) + assert.spy(cookies_mock.generateNewCookieValue).was_not_called() + assert.spy(protector_client_instance.checkReputation).was_not_called() + end) + end) end) end) From 12842b232409e2d3d140b944698ee73ffaad2a13 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Fri, 15 May 2026 15:11:27 +0100 Subject: [PATCH 3/3] handle invalid encrypted cookie --- src/lua_resty_netacea_cookies_v3.lua | 11 +++++++++-- test/lua_resty_netacea_cookies_v3_spec.lua | 17 ++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/lua_resty_netacea_cookies_v3.lua b/src/lua_resty_netacea_cookies_v3.lua index d13d824..1169208 100644 --- a/src/lua_resty_netacea_cookies_v3.lua +++ b/src/lua_resty_netacea_cookies_v3.lua @@ -9,7 +9,7 @@ NetaceaCookies.__index = NetaceaCookies function NetaceaCookies.decrypt(secretKey, value) local decoded = jwt:verify(secretKey, value) - if not decoded.verified then + if not decoded or not decoded.verified then return nil end return decoded.payload @@ -82,6 +82,13 @@ function NetaceaCookies.parseMitataCookie(cookie, secretKey) end local decoded_str = NetaceaCookies.decrypt(secretKey, cookie) + if not decoded_str then + return { + valid = false, + reason = constants['issueReasons'].INVALID_SESSION + } + end + local decoded = ngx.decode_args(decoded_str) if not decoded then return { @@ -142,4 +149,4 @@ function NetaceaCookies.parseMitataCookie(cookie, secretKey) } end -return NetaceaCookies \ No newline at end of file +return NetaceaCookies diff --git a/test/lua_resty_netacea_cookies_v3_spec.lua b/test/lua_resty_netacea_cookies_v3_spec.lua index 644cdc6..4849750 100644 --- a/test/lua_resty_netacea_cookies_v3_spec.lua +++ b/test/lua_resty_netacea_cookies_v3_spec.lua @@ -74,7 +74,7 @@ describe("lua_resty_netacea_cookies_v3", function() return nil end if not str then - return nil + error("bad argument #1 to 'decode_args' (string expected, got nil)") end local result = {} for pair in str:gmatch("[^&]+") do @@ -560,6 +560,7 @@ describe("lua_resty_netacea_cookies_v3", function() 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") + assert.spy(ngx_mock.decode_args).was_not_called() end) it("should return invalid result for invalid payload format", function() @@ -792,6 +793,20 @@ describe("lua_resty_netacea_cookies_v3", function() assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "invalid_token") end) + it("should return nil when JWT decryption fails without a decoded token", function() + jwt_mock.verify = spy.new(function() + return nil + end) + + local ok, result = pcall(function() + return NetaceaCookies.decrypt("secret_key", "invalid_token") + end) + + assert.is_true(ok) + 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 }