From 66b9ef9be719d18038128695328dd05018b6f759 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Thu, 14 May 2026 12:54:25 +0100 Subject: [PATCH 1/9] 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/9] 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/9] 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 } From be3291fff68508816787e9fe4537a6ef623c2368 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Mon, 18 May 2026 18:07:21 +0100 Subject: [PATCH 4/9] rename secretKey to cookieEncryptionKey --- README.md | 4 +- src/lua_resty_netacea.lua | 22 +++++--- src/lua_resty_netacea_cookies_v3.lua | 16 +++--- test/lua_resty_netacea_spec.lua | 79 +++++++++++++++++++++++++++- 4 files changed, 101 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 81b54b8..a79aa71 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ http { ingestEndpoint = 'ingest-endpoint', mitigationEndpoint = 'mitigation-endpoint', apiKey = 'your-api-key', - secretKey = 'your-secret-key', + cookieEncryptionKey = 'your-cookie-encryption-key', realIpHeader = 'realip-header', ingestEnabled = true, mitigationEnabled = true, @@ -170,7 +170,7 @@ http { ingestEndpoint = 'ingest-endpoint', mitigationEndpoint = 'mitigation-endpoint', apiKey = 'your-api-key', - secretKey = 'your-secret-key', + cookieEncryptionKey = 'your-cookie-encryption-key', realIpHeader = 'realip-header', ingestEnabled = true, mitigationEnabled = true, diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 1e2c3d1..af2277e 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -55,10 +55,13 @@ function _N:new(options) if not n.mitigationType or (n.mitigationType ~= 'MITIGATE' and n.mitigationType ~= 'INJECT') then n.mitigationEnabled = false 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 + -- mitigate:required:cookieEncryptionKey + -- secretKey is kept as a backwards-compatible alias. + local encodedCookieEncryptionKey = options.cookieEncryptionKey or options.secretKey + n.cookieEncryptionKey = b64.decode_base64url(encodedCookieEncryptionKey) or '' + n.secretKey = n.cookieEncryptionKey + n.sessionEnabled = n.cookieEncryptionKey and n.cookieEncryptionKey ~= '' + if not n.cookieEncryptionKey or n.cookieEncryptionKey == '' then n.mitigationEnabled = false end -- global:optional:cookieName @@ -139,7 +142,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) + local parsed_cookie = netacea_cookies.parseMitataCookie(cookie, self.cookieEncryptionKey) 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 @@ -147,7 +150,7 @@ function _N:handleSession() -- Get captcha cookie local captcha_cookie_raw = ngx.var['cookie_' .. self.captchaCookieName] or '' - local captcha_cookie = netacea_cookies.decrypt(self.secretKey, captcha_cookie_raw) + local captcha_cookie = netacea_cookies.decrypt(self.cookieEncryptionKey, captcha_cookie_raw) if captcha_cookie and captcha_cookie ~= '' then ngx.ctx.NetaceaState.captcha_cookie = captcha_cookie end @@ -164,7 +167,7 @@ function _N:refreshSession(reason) local grace_period = ngx.ctx.NetaceaState.grace_period or 60 local new_cookie = netacea_cookies.generateNewCookieValue( - self.secretKey, + self.cookieEncryptionKey, ngx.ctx.NetaceaState.client, ngx.ctx.NetaceaState.UserId, netacea_cookies.newUserId(), @@ -182,7 +185,10 @@ function _N:refreshSession(reason) 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) + local captcha_cookie_encrypted = netacea_cookies.encrypt( + self.cookieEncryptionKey, + protector_result.captcha_cookie + ) table.insert(cookies, self.captchaCookieName .. '=' .. captcha_cookie_encrypted .. ';'.. self.captchaCookieAttributes) end diff --git a/src/lua_resty_netacea_cookies_v3.lua b/src/lua_resty_netacea_cookies_v3.lua index 1169208..642cdf4 100644 --- a/src/lua_resty_netacea_cookies_v3.lua +++ b/src/lua_resty_netacea_cookies_v3.lua @@ -7,16 +7,16 @@ local NetaceaCookies = {} NetaceaCookies.__index = NetaceaCookies -function NetaceaCookies.decrypt(secretKey, value) - local decoded = jwt:verify(secretKey, value) +function NetaceaCookies.decrypt(cookieEncryptionKey, value) + local decoded = jwt:verify(cookieEncryptionKey, value) if not decoded or not decoded.verified then return nil end return decoded.payload end -function NetaceaCookies.encrypt(secretKey, payload) - local encoded = jwt:sign(secretKey, { +function NetaceaCookies.encrypt(cookieEncryptionKey, payload) + local encoded = jwt:sign(cookieEncryptionKey, { header={ typ="JWE", alg="dir", enc="A128CBC-HS256" }, payload = payload }) @@ -49,7 +49,7 @@ end function NetaceaCookies.generateNewCookieValue( - secretKey, client, user_id, cookie_id, issue_reason, + cookieEncryptionKey, client, user_id, cookie_id, issue_reason, issue_timestamp, grace_period, match, mitigation, captcha, settings) settings = settings or {} local plaintext = ngx.encode_args({ @@ -65,7 +65,7 @@ function NetaceaCookies.generateNewCookieValue( fCAPR = settings.fCAPR or 0 }) - local encoded = NetaceaCookies.encrypt(secretKey, plaintext) + local encoded = NetaceaCookies.encrypt(cookieEncryptionKey, plaintext) return { mitata_jwe = encoded, @@ -73,7 +73,7 @@ function NetaceaCookies.generateNewCookieValue( } end -function NetaceaCookies.parseMitataCookie(cookie, secretKey) +function NetaceaCookies.parseMitataCookie(cookie, cookieEncryptionKey) if not cookie or cookie == '' then return { valid = false, @@ -81,7 +81,7 @@ function NetaceaCookies.parseMitataCookie(cookie, secretKey) } end - local decoded_str = NetaceaCookies.decrypt(secretKey, cookie) + local decoded_str = NetaceaCookies.decrypt(cookieEncryptionKey, cookie) if not decoded_str then return { valid = false, diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index 49924f6..879c482 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -11,6 +11,7 @@ insulate("lua_resty_netacea", function() local cookies_mock local protector_client_mock local protector_client_instance + local decode_base64url_mock before_each(function() ngx_mock = { @@ -32,8 +33,13 @@ insulate("lua_resty_netacea", function() } package.loaded['ngx'] = ngx_mock + decode_base64url_mock = spy.new(function(value) + if value == nil or value == "" then return nil end + if value == "invalid-cookie-encryption-key" then return nil end + return "decoded-" .. value + end) package.loaded['ngx.base64'] = { - decode_base64url = spy.new(function() return "decoded-secret" end) + decode_base64url = decode_base64url_mock } package.loaded['lua_resty_netacea_ingest'] = { new = spy.new(function() return ingest_instance end) @@ -107,7 +113,8 @@ insulate("lua_resty_netacea", function() mitigationType = options.mitigationType or '', mitigationEndpoint = options.mitigationEndpoint or '', apiKey = "test-api-key", - secretKey = "test-secret-key", + cookieEncryptionKey = options.cookieEncryptionKey, + secretKey = options.secretKey or "test-secret-key", kinesisProperties = { stream_name = "test-stream", region = "eu-west-1", @@ -117,6 +124,74 @@ insulate("lua_resty_netacea", function() }) end + describe("cookie encryption key config", function() + it("should prefer cookieEncryptionKey as the internal key name", function() + local netacea = new_ingest_enabled_netacea({ + cookieEncryptionKey = "test-cookie-encryption-key" + }) + + assert.are.equal("decoded-test-cookie-encryption-key", netacea.cookieEncryptionKey) + assert.are.equal("decoded-test-cookie-encryption-key", netacea.secretKey) + assert.spy(decode_base64url_mock).was.called_with("test-cookie-encryption-key") + end) + + it("should keep secretKey as a backwards-compatible alias", function() + local netacea = new_ingest_enabled_netacea({ + secretKey = "test-secret-key" + }) + + assert.are.equal("decoded-test-secret-key", netacea.cookieEncryptionKey) + assert.are.equal("decoded-test-secret-key", netacea.secretKey) + assert.spy(decode_base64url_mock).was.called_with("test-secret-key") + end) + + it("should ignore secretKey when cookieEncryptionKey is also configured", function() + local netacea = new_ingest_enabled_netacea({ + cookieEncryptionKey = "test-cookie-encryption-key", + secretKey = "ignored-secret-key" + }) + + assert.are.equal("decoded-test-cookie-encryption-key", netacea.cookieEncryptionKey) + assert.are.equal("decoded-test-cookie-encryption-key", netacea.secretKey) + assert.spy(decode_base64url_mock).was.called(1) + assert.spy(decode_base64url_mock).was.called_with("test-cookie-encryption-key") + end) + + it("should disable sessions and mitigation when the configured key cannot be decoded", function() + local netacea = new_ingest_enabled_netacea({ + cookieEncryptionKey = "invalid-cookie-encryption-key", + mitigationEnabled = true, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example" + }) + + assert.are.equal("", netacea.cookieEncryptionKey) + assert.are.equal("", netacea.secretKey) + assert.is_false(netacea.sessionEnabled) + assert.is_false(netacea.mitigationEnabled) + assert.spy(decode_base64url_mock).was.called(1) + assert.spy(decode_base64url_mock).was.called_with("invalid-cookie-encryption-key") + assert.spy(protector_client_mock.new).was_not_called() + end) + + it("should use cookieEncryptionKey for session cookie operations", function() + local netacea = new_ingest_enabled_netacea({ + cookieEncryptionKey = "test-cookie-encryption-key" + }) + + netacea:mitigate() + + assert.spy(cookies_mock.parseMitataCookie).was.called_with( + "", + "decoded-test-cookie-encryption-key" + ) + assert.spy(cookies_mock.decrypt).was.called_with( + "decoded-test-cookie-encryption-key", + "" + ) + end) + end) + describe("ingest", function() it("should support ingest-only mode when NetaceaState is missing", function() local netacea = new_ingest_enabled_netacea() From fb60570a439a29f036ca9d99225415dcb35f2157 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Tue, 19 May 2026 12:53:09 +0100 Subject: [PATCH 5/9] add realIpHeaderIndex config option --- README.md | 10 +++- src/lua_resty_netacea.lua | 4 +- src/lua_resty_netacea_ingest.lua | 15 ++--- src/netacea_utils.lua | 34 ++++++++++- test/lua_resty_netacea_ingest_spec.lua | 25 +++++--- test/lua_resty_netacea_spec.lua | 26 ++++++++- test/netacea_utils_spec.lua | 80 ++++++++++++++++++++++++++ 7 files changed, 171 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index a79aa71..3c2911c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The docker compose file is used to mount local files to the right place in the i ### 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` +2. `docker compose up --build resty` 3. Access [](http://localhost:8080) ### Run tests @@ -36,12 +36,12 @@ With coverage report (sent to stdout) `export LUACOV_REPORT=1 && ./run_lua_tests ##### Docker compose -Without coverage report: `docker compose run --build test` +Without coverage report: `docker compose run --rm --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` +`docker compose run --rm --build lint` ## Configuration @@ -53,6 +53,9 @@ Set `ingestEnabled` to `true`, set `mitigationEnabled` to `false`, and leave `mi `kinesisProperties` must be provided for ingest to remain enabled. +When `realIpHeaderIndex` is set, `realIpHeader` is parsed as a comma-separated list and the indexed value is used. Indexing starts at `0`; negative indexes count from the end, so `-1` selects the last value. +This is useful for, though not limited to, parsing `X-Forwarded-For` values. + ```conf worker_processes 1; @@ -172,6 +175,7 @@ http { apiKey = 'your-api-key', cookieEncryptionKey = 'your-cookie-encryption-key', realIpHeader = 'realip-header', + -- realIpHeaderIndex = 0, -- Parses realIpHeader as a comma-separated list ingestEnabled = true, mitigationEnabled = true, mitigationType = 'INJECT' diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index af2277e..d64e59f 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -74,6 +74,8 @@ function _N:new(options) n.captchaCookieAttributes = utils.parseOption(options.captchaCookieAttributes, 'Max-Age=86400; Path=/;') -- global:optional:realIpHeader n.realIpHeader = utils.parseOption(options.realIpHeader, '') + -- global:optional:realIpHeaderIndex + n.realIpHeaderIndex = utils.parseOption(options.realIpHeaderIndex, nil) -- global:optional:userIdKey n.userIdKey = utils.parseOption(options.userIdKey, '') -- global:required:apiKey @@ -136,7 +138,7 @@ end function _N:handleSession() ngx.ctx.NetaceaState = {} - ngx.ctx.NetaceaState.client = utils:getIpAddress(ngx.var, self.realIpHeader) + ngx.ctx.NetaceaState.client = utils:getIpAddress(ngx.var, self.realIpHeader, self.realIpHeaderIndex) ngx.ctx.NetaceaState.user_agent = ngx.var.http_user_agent or '' -- Check cookie diff --git a/src/lua_resty_netacea_ingest.lua b/src/lua_resty_netacea_ingest.lua index 6cb8e08..c6fb30a 100644 --- a/src/lua_resty_netacea_ingest.lua +++ b/src/lua_resty_netacea_ingest.lua @@ -228,20 +228,21 @@ function Ingest:ingest() 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 "-", + RealIp = NetaceaState.client or utils:getIpAddress(vars, self._N.realIpHeader, self._N.realIpHeaderIndex), + XForwardedFor = vars.http_x_forwarded_for or "", + UserAgent = vars.http_user_agent or "", Status = vars.status, RequestTime = vars.request_time, BytesSent = vars.bytes_sent, - Referer = vars.http_referer or "-", + Referer = vars.http_referer or "", NetaceaUserIdCookie = mitata, - UserId = NetaceaState.UserId or "-", + 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 "-", + 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 @@ -259,4 +260,4 @@ function Ingest:ingest() end -return Ingest \ No newline at end of file +return Ingest diff --git a/src/netacea_utils.lua b/src/netacea_utils.lua index a20f901..884eb45 100644 --- a/src/netacea_utils.lua +++ b/src/netacea_utils.lua @@ -19,12 +19,40 @@ function M.buildRandomString(length) return randomString end -function M:getIpAddress(vars, realIpHeader) +local function normalizeHeaderName(headerName) + if type(headerName) ~= 'string' then return headerName end + return headerName:lower():gsub('-', '_') +end + +local function getIndexedHeaderValue(realIpHeaderValue, realIpHeaderIndex) + if type(realIpHeaderIndex) ~= 'number' then return realIpHeaderValue end + if realIpHeaderIndex % 1 ~= 0 then return realIpHeaderValue end + + local headerValues = {} + for value in string.gmatch(realIpHeaderValue, '([^,]+)') do + table.insert(headerValues, value:match("^%s*(.-)%s*$")) + end + + local luaIndex = realIpHeaderIndex + 1 + if realIpHeaderIndex < 0 then + luaIndex = #headerValues + realIpHeaderIndex + 1 + end + + local headerValue = headerValues[luaIndex] + if headerValue == '' then return nil end + return headerValue +end + +function M:getIpAddress(vars, realIpHeader, realIpHeaderIndex) if not realIpHeader then return vars.remote_addr end - local realIpHeaderValue = vars['http_' .. realIpHeader] + local normalizedRealIpHeader = normalizeHeaderName(realIpHeader) + local realIpHeaderValue = vars['http_' .. normalizedRealIpHeader] if not realIpHeaderValue or realIpHeaderValue == '' then return vars.remote_addr end + if realIpHeaderIndex ~= nil then + return getIndexedHeaderValue(realIpHeaderValue, realIpHeaderIndex) or vars.remote_addr + end return realIpHeaderValue or vars.remote_addr end @@ -39,4 +67,4 @@ function M.parseOption(option, defaultValue) end -return M \ No newline at end of file +return M diff --git a/test/lua_resty_netacea_ingest_spec.lua b/test/lua_resty_netacea_ingest_spec.lua index 061c494..0a64cf6 100644 --- a/test/lua_resty_netacea_ingest_spec.lua +++ b/test/lua_resty_netacea_ingest_spec.lua @@ -33,6 +33,7 @@ describe("lua_resty_netacea_ingest", function() request_time = "0.123", bytes_sent = "1024", http_referer = "https://example.com", + http_x_forwarded_for = "203.0.113.1, 203.0.113.2", query_string = "param=value", host = "test.example.com", request_id = "req-12345", @@ -377,6 +378,7 @@ describe("lua_resty_netacea_ingest", function() _MODULE_TYPE = "nginx", _MODULE_VERSION = "2.1.0", realIpHeader = "x_forwarded_for", + realIpHeaderIndex = -1, mitigationType = "monitor" }) end) @@ -392,6 +394,7 @@ describe("lua_resty_netacea_ingest", function() 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("203.0.113.1, 203.0.113.2", queued_item.XForwardedFor) 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) @@ -419,12 +422,13 @@ describe("lua_resty_netacea_ingest", function() it("should handle missing NetaceaState gracefully", function() ngx_mock.ctx.NetaceaState = nil + ngx_mock.var.http_x_forwarded_for = 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.equal("", queued_item.UserId) assert.is_nil(queued_item.NetaceaMitigationApplied) end) @@ -435,13 +439,19 @@ describe("lua_resty_netacea_ingest", function() ingest:ingest() assert.spy(utils_mock.getIpAddress).was.called() - -- Verify the call was made with correct number of arguments + assert.spy(utils_mock.getIpAddress).was.called_with( + utils_mock, + ngx_mock.var, + "x_forwarded_for", + -1 + ) 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.http_x_forwarded_for = nil ngx_mock.var.query_string = nil ngx_mock.var.host = nil ngx_mock.var.request_id = nil @@ -450,11 +460,12 @@ describe("lua_resty_netacea_ingest", function() 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.UserAgent) + assert.is.equal("", queued_item.Referer) + assert.is.equal("", queued_item.XForwardedFor) assert.is.equal("", queued_item.Query) - assert.is.equal("-", queued_item.RequestHost) - assert.is.equal("-", queued_item.RequestId) + assert.is.equal("", queued_item.RequestHost) + assert.is.equal("", queued_item.RequestId) assert.is.equal(0, queued_item.BytesReceived) end) @@ -674,4 +685,4 @@ describe("lua_resty_netacea_ingest", function() assert.spy(kinesis_mock.new).was.called_with("eu_stream", "eu-west-1", "key", "secret") end) end) -end) \ No newline at end of file +end) diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index 879c482..94e3d2b 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -67,9 +67,9 @@ insulate("lua_resty_netacea", function() if value == nil then return default end return value end, - getIpAddress = function() + getIpAddress = spy.new(function() return "127.0.0.1" - end + end) } protector_client_instance = { checkReputation = spy.new(function() @@ -125,6 +125,28 @@ insulate("lua_resty_netacea", function() end describe("cookie encryption key config", function() + it("should pass realIpHeaderIndex to IP address lookup", function() + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationEnabled = false, + mitigationEndpoint = "", + mitigationType = "", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + realIpHeader = "x_forwarded_for", + realIpHeaderIndex = -1 + }) + + netacea:mitigate() + + assert.spy(package.loaded['netacea_utils'].getIpAddress).was.called_with( + package.loaded['netacea_utils'], + ngx_mock.var, + "x_forwarded_for", + -1 + ) + end) + it("should prefer cookieEncryptionKey as the internal key name", function() local netacea = new_ingest_enabled_netacea({ cookieEncryptionKey = "test-cookie-encryption-key" diff --git a/test/netacea_utils_spec.lua b/test/netacea_utils_spec.lua index f6ea611..9e37288 100644 --- a/test/netacea_utils_spec.lua +++ b/test/netacea_utils_spec.lua @@ -111,6 +111,56 @@ describe("netacea_utils", function() assert.is.equal("203.0.113.1", result) end) + it("should return the indexed header value when index is positive", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "203.0.113.1, 203.0.113.2, 203.0.113.3" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for", 1) + assert.is.equal("203.0.113.2", result) + end) + + it("should return the indexed header value when index is zero", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "203.0.113.1, 203.0.113.2" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for", 0) + assert.is.equal("203.0.113.1", result) + end) + + it("should return the indexed header value when index is negative", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "203.0.113.1, 203.0.113.2, 203.0.113.3" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for", -2) + assert.is.equal("203.0.113.2", result) + end) + + it("should fall back to remote_addr when header index is out of range", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "203.0.113.1, 203.0.113.2" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for", -3) + assert.is.equal("192.168.1.1", result) + end) + + it("should apply realIpHeaderIndex to non x-forwarded-for headers", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_custom_ip = "203.0.113.1, 203.0.113.2" + } + + local result = utils:getIpAddress(vars, "x_custom_ip", 1) + assert.is.equal("203.0.113.2", result) + end) + it("should return remote_addr when real IP header doesn't exist", function() local vars = { remote_addr = "192.168.1.1" @@ -175,6 +225,36 @@ describe("netacea_utils", function() assert.is.equal("203.0.113.1", result) end) + it("should normalize realIpHeader dashes to nginx variable underscores", 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 normalize realIpHeader case", 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 apply header index after normalizing the header name", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "203.0.113.1, 203.0.113.2" + } + + local result = utils:getIpAddress(vars, "X-Forwarded-For", -1) + assert.is.equal("203.0.113.2", result) + end) + it("should handle missing remote_addr gracefully", function() local vars = { http_x_forwarded_for = "203.0.113.1" From 0ccd125ea8c77ba19993f660a8791bda151aa38d Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Tue, 19 May 2026 13:33:45 +0100 Subject: [PATCH 6/9] don't set session cookie on captcha fail --- src/lua_resty_netacea.lua | 4 ++- test/lua_resty_netacea_spec.lua | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index d64e59f..ea1e792 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -208,7 +208,9 @@ function _N:handleCaptcha() 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) + if protector_result.captcha == Constants['captchaStates'].PASS then + self:refreshSession(Constants['issueReasons'].CAPTCHA_POST) + end ngx.exit(protector_result.exit_status) end diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index 94e3d2b..108f919 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -23,6 +23,11 @@ insulate("lua_resty_netacea", function() }, header = {}, log = spy.new(function() end), + exit = spy.new(function() end), + req = { + read_body = spy.new(function() end), + get_body_data = spy.new(function() return "captcha-response" end) + }, DEBUG = 7, ERR = 3 } @@ -78,6 +83,15 @@ insulate("lua_resty_netacea", function() mitigate = "0", captcha = "0" } + end), + validateCaptcha = spy.new(function() + return { + match = "0", + mitigate = "0", + captcha = "2", + exit_status = 200, + captcha_cookie = "captcha-cookie-value" + } end) } protector_client_mock = { @@ -291,5 +305,55 @@ insulate("lua_resty_netacea", function() assert.spy(protector_client_instance.checkReputation).was_not_called() end) end) + + describe("captcha handling", function() + local function new_mitigation_enabled_netacea() + return Netacea:new({ + ingestEnabled = false, + mitigationEnabled = true, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key" + }) + end + + it("should not set session or captcha cookies when captcha fails", function() + protector_client_instance.validateCaptcha = spy.new(function() + return { + match = "0", + mitigate = "0", + captcha = "3", + exit_status = 403, + captcha_cookie = "failed-captcha-cookie" + } + end) + local netacea = new_mitigation_enabled_netacea() + + netacea:handleCaptcha() + + assert.is_nil(ngx_mock.header["Set-Cookie"]) + assert.spy(cookies_mock.generateNewCookieValue).was_not_called() + assert.spy(cookies_mock.encrypt).was_not_called() + assert.spy(ngx_mock.exit).was.called_with(403) + end) + + it("should refresh session and captcha cookies when captcha passes", function() + local netacea = new_mitigation_enabled_netacea() + + netacea:handleCaptcha() + + assert.are.same({ + "_mitata=new-session-cookie;Max-Age=86400; Path=/;", + "_mitatacaptcha=encrypted;Max-Age=86400; Path=/;" + }, ngx_mock.header["Set-Cookie"]) + assert.spy(cookies_mock.generateNewCookieValue).was.called(1) + assert.spy(cookies_mock.encrypt).was.called_with( + "decoded-test-cookie-encryption-key", + "captcha-cookie-value" + ) + assert.spy(ngx_mock.exit).was.called_with(200) + end) + end) end) end) From 28a172ad28dadcf9a46cc6f10e7ad640bbd31e95 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Tue, 19 May 2026 22:32:33 +0100 Subject: [PATCH 7/9] support INGEST as a mitigation type --- .env.example | 18 +++ .gitignore | 5 +- README.md | 230 ++++++++++++-------------------- docker-compose.yml | 4 + src/conf/nginx.conf | 59 ++++++-- src/lua_resty_netacea.lua | 21 ++- src/netacea_utils.lua | 13 ++ test/lua_resty_netacea_spec.lua | 100 +++++++++++++- test/netacea_utils_spec.lua | 73 ++++++++++ 9 files changed, 357 insertions(+), 166 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d5a35cc --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +NETACEA_INGEST_ENABLED=true +NETACEA_PROTECTION_MODE=INGEST +NETACEA_API_KEY= +NETACEA_COOKIE_ENCRYPTION_KEY= +NETACEA_COOKIE_NAME=_mitata +NETACEA_CAPTCHA_COOKIE_NAME=_mitatacaptcha +NETACEA_COOKIE_ATTRIBUTES=Max-Age=86400; Path=/; +NETACEA_CAPTCHA_COOKIE_ATTRIBUTES=Max-Age=86400; Path=/; +NETACEA_KINESIS_ACCESS_KEY= +NETACEA_KINESIS_BATCH_SIZE= +NETACEA_KINESIS_BATCH_TIMEOUT= +NETACEA_KINESIS_REGION=eu-west-1 +NETACEA_KINESIS_SECRET_KEY= +NETACEA_KINESIS_STREAM_NAME= +NETACEA_PROTECTOR_API_URL= +NETACEA_REAL_IP_HEADER_INDEX= +NETACEA_REAL_IP_HEADER= +NETACEA_SECRET_KEY= diff --git a/.gitignore b/.gitignore index 4361458..3b76bdf 100644 --- a/.gitignore +++ b/.gitignore @@ -44,8 +44,11 @@ luac.out tags +# Local environment config +.env + # luacov reports luacov.report luacov.report.* luacov.stats.out -luacov.stats.out.* \ No newline at end of file +luacov.stats.out.* diff --git a/README.md b/README.md index 3c2911c..77a42d1 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,24 @@ The Dockerfile contains a multi-stage build, including: The docker compose file is used to mount local files to the right place in the image to support development. +### Environment variables + +The Docker Compose services that run NGINX load Netacea configuration from a local `.env` file. +Create it from the example file, then fill in the values provided by the Netacea Solutions Engineering team: + +```sh +cp .env.example .env +``` + +The `.env` file is ignored by git because it can contain sensitive values such as API keys, cookie encryption keys, and Kinesis credentials. +Keep `.env.example` updated when adding or removing configuration variables. + ### 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 --build resty` -3. Access [](http://localhost:8080) +1. Create `./.env` from `./.env.example` and set the Netacea environment variables. +2. Update `./src/conf/nginx.conf` to include server configuration. See "Configuration" below. +3. `docker compose up --build resty` +4. Access [](http://localhost:8080) ### Run tests @@ -45,156 +58,87 @@ With coverage report (sent to stdout) `docker compose run -e LUACOV_REPORT=1 --b ## Configuration -### nginx.conf - ingest only +### .env - 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. +Ingest is enabled by default. Set `NETACEA_PROTECTION_MODE` to `INGEST`. -`kinesisProperties` must be provided for ingest to remain enabled. +Kinesis properties must be provided for ingest to remain enabled. When `realIpHeaderIndex` is set, `realIpHeader` is parsed as a comma-separated list and the indexed value is used. Indexing starts at `0`; negative indexes count from the end, so `-1` selects the last value. This is useful for, though not limited to, parsing `X-Forwarded-For` values. -```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

")'; - } - } -} +```dotenv +NETACEA_PROTECTION_MODE=INGEST +NETACEA_API_KEY=your-api-key +NETACEA_COOKIE_ENCRYPTION_KEY=your-cookie-encryption-key +NETACEA_COOKIE_NAME=your-session-cookie-name +NETACEA_CAPTCHA_COOKIE_NAME=your-captcha-cookie-name +NETACEA_REAL_IP_HEADER=X-Forwarded-For +NETACEA_REAL_IP_HEADER_INDEX=0 +NETACEA_KINESIS_ACCESS_KEY=your-aws-access-key +NETACEA_KINESIS_SECRET_KEY=your-aws-secret-key +NETACEA_KINESIS_STREAM_NAME=your-kinesis-stream ``` -### nginx.conf - mitigate - -```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({ - ingestEndpoint = 'ingest-endpoint', - mitigationEndpoint = 'mitigation-endpoint', - apiKey = 'your-api-key', - cookieEncryptionKey = 'your-cookie-encryption-key', - realIpHeader = 'realip-header', - ingestEnabled = true, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - } - log_by_lua_block { - netacea:ingest() - } - access_by_lua_block { - netacea:run() - } - - server { - listen 80; - server_name localhost; - location / { - default_type text/html; - content_by_lua 'ngx.say("

hello, world

")'; - } - } -} +### .env - mitigate + +Use MITIGATE as the NETACEA_PROTECTION_MODE when you want the integration to +call the Protector API and enforce mitigation responses. + +```dotenv +NETACEA_PROTECTION_MODE=MITIGATE +NETACEA_API_KEY=your-api-key +NETACEA_COOKIE_ENCRYPTION_KEY=your-cookie-encryption-key +NETACEA_COOKIE_NAME=your-session-cookie-name +NETACEA_CAPTCHA_COOKIE_NAME=your-captcha-cookie-name +NETACEA_REAL_IP_HEADER=X-Forwarded-For +NETACEA_REAL_IP_HEADER_INDEX=0 +NETACEA_KINESIS_ACCESS_KEY=your-aws-access-key +NETACEA_KINESIS_SECRET_KEY=your-aws-secret-key +NETACEA_KINESIS_STREAM_NAME=your-kinesis-stream +NETACEA_PROTECTOR_API_URL=https://your-protector-api-url ``` -### nginx.conf - inject - -```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({ - ingestEndpoint = 'ingest-endpoint', - mitigationEndpoint = 'mitigation-endpoint', - apiKey = 'your-api-key', - cookieEncryptionKey = 'your-cookie-encryption-key', - realIpHeader = 'realip-header', - -- realIpHeaderIndex = 0, -- Parses realIpHeader as a comma-separated list - ingestEnabled = true, - mitigationEnabled = true, - mitigationType = 'INJECT' - }) - } - log_by_lua_block { - netacea:ingest() - } - access_by_lua_block { - netacea:run() - } - - server { - listen 80; - server_name localhost; - location / { - default_type text/html; - content_by_lua 'ngx.say("

hello, world

")'; - } - } -} +### .env - inject + +Use INJECT as the NETACEA_PROTECTION_MODE when you want the integration to +call the Protector API but defer mitigation to downstream services. + +```dotenv +NETACEA_PROTECTION_MODE=INJECT +NETACEA_API_KEY=your-api-key +NETACEA_COOKIE_ENCRYPTION_KEY=your-cookie-encryption-key +NETACEA_COOKIE_NAME=your-session-cookie-name +NETACEA_CAPTCHA_COOKIE_NAME=your-captcha-cookie-name +NETACEA_REAL_IP_HEADER=X-Forwarded-For +NETACEA_REAL_IP_HEADER_INDEX=0 +NETACEA_KINESIS_ACCESS_KEY=your-aws-access-key +NETACEA_KINESIS_SECRET_KEY=your-aws-secret-key +NETACEA_KINESIS_STREAM_NAME=your-kinesis-stream +NETACEA_PROTECTOR_API_URL=https://your-protector-api-url ``` + +### Environment variable default values reference + +| Environment variable | Default | +| ----------------------------------- | ------------------------ | +| `NETACEA_PROTECTION_MODE` | `INGEST` | +| `NETACEA_INGEST_ENABLED` | `true` | +| `NETACEA_PROTECTOR_API_URL` | `""` | +| `NETACEA_API_KEY` | none | +| `NETACEA_COOKIE_ENCRYPTION_KEY` | none | +| `NETACEA_SECRET_KEY` | none | +| `NETACEA_COOKIE_NAME` | `_mitata` | +| `NETACEA_CAPTCHA_COOKIE_NAME` | `_mitatacaptcha` | +| `NETACEA_COOKIE_ATTRIBUTES` | `Max-Age=86400; Path=/;` | +| `NETACEA_CAPTCHA_COOKIE_ATTRIBUTES` | `Max-Age=86400; Path=/;` | +| `NETACEA_REAL_IP_HEADER` | `""` | +| `NETACEA_REAL_IP_HEADER_INDEX` | unset | +| `NETACEA_KINESIS_ACCESS_KEY` | `""` | +| `NETACEA_KINESIS_SECRET_KEY` | `""` | +| `NETACEA_KINESIS_STREAM_NAME` | `""` | +| `NETACEA_KINESIS_REGION` | `eu-west-1` | +| `NETACEA_KINESIS_BATCH_SIZE` | `25` | +| `NETACEA_KINESIS_BATCH_TIMEOUT` | `1.0` | diff --git a/docker-compose.yml b/docker-compose.yml index eaf4006..21a21ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: context: . target: build container_name: resty + env_file: + - ".env" ports: - "8080:80" - "443:443" @@ -39,6 +41,8 @@ services: build: dockerfile: Dockerfile.nginx_lua context: . + env_file: + - ".env" ports: - "80:80" volumes: diff --git a/src/conf/nginx.conf b/src/conf/nginx.conf index ef5ea78..ccee215 100644 --- a/src/conf/nginx.conf +++ b/src/conf/nginx.conf @@ -1,5 +1,24 @@ worker_processes 1; +env NETACEA_COOKIE_NAME; +env NETACEA_CAPTCHA_COOKIE_NAME; +env NETACEA_COOKIE_ATTRIBUTES; +env NETACEA_CAPTCHA_COOKIE_ATTRIBUTES; +env NETACEA_PROTECTOR_API_URL; +env NETACEA_PROTECTION_MODE; +env NETACEA_API_KEY; +env NETACEA_COOKIE_ENCRYPTION_KEY; +env NETACEA_SECRET_KEY; +env NETACEA_INGEST_ENABLED; +env NETACEA_REAL_IP_HEADER; +env NETACEA_REAL_IP_HEADER_INDEX; +env NETACEA_KINESIS_ACCESS_KEY; +env NETACEA_KINESIS_SECRET_KEY; +env NETACEA_KINESIS_STREAM_NAME; +env NETACEA_KINESIS_REGION; +env NETACEA_KINESIS_BATCH_SIZE; +env NETACEA_KINESIS_BATCH_TIMEOUT; + events { worker_connections 1024; } @@ -9,29 +28,41 @@ http { 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 { + local utils = require("netacea_utils") + local env = utils.env + local envEnabled = utils.envEnabled + netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = '', - apiKey = '', - secretKey = '', - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = false, - mitigationType = '', - cookieName = '', - kinesisProperties = { - region = '', - stream_name = '', - aws_access_key = '', - aws_secret_key = '', + mitigationEndpoint = env('NETACEA_PROTECTOR_API_URL', ''), + apiKey = env('NETACEA_API_KEY', ''), + cookieEncryptionKey = env('NETACEA_COOKIE_ENCRYPTION_KEY', ''), + secretKey = env('NETACEA_SECRET_KEY', ''), + ingestEnabled = envEnabled('NETACEA_INGEST_ENABLED', true), + mitigationType = env('NETACEA_PROTECTION_MODE', ''), + cookieName = env('NETACEA_COOKIE_NAME', ''), + captchaCookieName = env('NETACEA_CAPTCHA_COOKIE_NAME', ''), + cookieAttributes = env('NETACEA_COOKIE_ATTRIBUTES', ''), + captchaCookieAttributes = env('NETACEA_CAPTCHA_COOKIE_ATTRIBUTES', ''), + realIpHeader = env('NETACEA_REAL_IP_HEADER', ''), + realIpHeaderIndex = tonumber(env('NETACEA_REAL_IP_HEADER_INDEX', '')), + kinesisProperties = { + region = env('NETACEA_KINESIS_REGION', 'eu-west-1'), + stream_name = env('NETACEA_KINESIS_STREAM_NAME', ''), + aws_access_key = env('NETACEA_KINESIS_ACCESS_KEY', ''), + aws_secret_key = env('NETACEA_KINESIS_SECRET_KEY', ''), + batch_size = tonumber(env('NETACEA_KINESIS_BATCH_SIZE', '')), + batch_timeout = tonumber(env('NETACEA_KINESIS_BATCH_TIMEOUT', '')), } }) } + log_by_lua_block { netacea:ingest() } diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index ea1e792..f803efb 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -14,6 +14,12 @@ _N._TYPE = 'nginx' local ngx = require 'ngx' local cjson = require 'cjson' +local function getIntegrationMode(n) + if n.mitigationEnabled then return n.mitigationType end + if n.ingestEnabled then return 'INGEST' end + return 'DISABLED' +end + function _N:new(options) local n = {} setmetatable(n, self) @@ -40,8 +46,14 @@ function _N:new(options) n.ingestEnabled = false end end - -- mitigate:optional:mitigationEnabled - n.mitigationEnabled = options.mitigationEnabled or false + -- mitigate:optional:mitigationType + n.mitigationType = utils.parseOption(options.mitigationType, 'INGEST') + -- mitigationEnabled is deprecated. Use mitigationType instead. + if options.mitigationEnabled == false then + n.mitigationType = 'INGEST' + ngx.log(ngx.WARN, "NETACEA CONFIG - mitigationEnabled is deprecated; set mitigationType to INGEST instead") + end + n.mitigationEnabled = n.mitigationType == 'MITIGATE' or n.mitigationType == 'INJECT' -- mitigate:required:mitigationEndpoint n.mitigationEndpoint = options.mitigationEndpoint if type(n.mitigationEndpoint) ~= 'table' then @@ -50,9 +62,7 @@ function _N:new(options) if not n.mitigationEndpoint[1] or n.mitigationEndpoint[1] == '' then n.mitigationEnabled = false end - -- mitigate:required:mitigationType - n.mitigationType = utils.parseOption(options.mitigationType, '') - if not n.mitigationType or (n.mitigationType ~= 'MITIGATE' and n.mitigationType ~= 'INJECT') then + if n.mitigationType ~= 'INGEST' and n.mitigationType ~= 'MITIGATE' and n.mitigationType ~= 'INJECT' then n.mitigationEnabled = false end -- mitigate:required:cookieEncryptionKey @@ -89,6 +99,7 @@ function _N:new(options) n._MODULE_TYPE = _N._TYPE n._MODULE_VERSION = _N._VERSION + ngx.log(ngx.DEBUG, "NETACEA CONFIG - integration mode: ", getIntegrationMode(n)) if n.ingestEnabled then n.ingestPipeline = Ingest:new(options.kinesisProperties or {}, n) diff --git a/src/netacea_utils.lua b/src/netacea_utils.lua index 884eb45..1549f2d 100644 --- a/src/netacea_utils.lua +++ b/src/netacea_utils.lua @@ -66,5 +66,18 @@ function M.parseOption(option, defaultValue) return option end +function M.env(name, defaultValue) + return os.getenv(name) or defaultValue +end + +function M.envEnabled(name, defaultValue) + local value = os.getenv(name) + if value == nil then + return defaultValue + end + + return value == 'true' +end + return M diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index 108f919..608b8ac 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -29,6 +29,7 @@ insulate("lua_resty_netacea", function() get_body_data = spy.new(function() return "captcha-response" end) }, DEBUG = 7, + WARN = 4, ERR = 3 } @@ -121,9 +122,8 @@ insulate("lua_resty_netacea", function() local function new_ingest_enabled_netacea(options) options = options or {} - return Netacea:new({ + local config = { ingestEnabled = true, - mitigationEnabled = options.mitigationEnabled or false, mitigationType = options.mitigationType or '', mitigationEndpoint = options.mitigationEndpoint or '', apiKey = "test-api-key", @@ -135,9 +135,103 @@ insulate("lua_resty_netacea", function() aws_access_key = "test-access-key", aws_secret_key = "test-secret-key" } - }) + } + if options.mitigationEnabled ~= nil then + config.mitigationEnabled = options.mitigationEnabled + end + return Netacea:new(config) end + describe("startup logging", function() + it("should log ingest mode when only ingest is enabled", function() + new_ingest_enabled_netacea() + + assert.spy(ngx_mock.log).was.called_with( + ngx_mock.DEBUG, + "NETACEA CONFIG - integration mode: ", + "INGEST" + ) + end) + + it("should log mitigation mode when mitigation is enabled", function() + new_ingest_enabled_netacea({ + mitigationEnabled = true, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + cookieEncryptionKey = "test-cookie-encryption-key" + }) + + assert.spy(ngx_mock.log).was.called_with( + ngx_mock.DEBUG, + "NETACEA CONFIG - integration mode: ", + "MITIGATE" + ) + end) + + it("should log disabled mode when no integration paths are enabled", function() + Netacea:new({ + ingestEnabled = false, + mitigationEnabled = false, + mitigationEndpoint = "", + mitigationType = "", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key" + }) + + assert.spy(ngx_mock.log).was.called_with( + ngx_mock.DEBUG, + "NETACEA CONFIG - integration mode: ", + "DISABLED" + ) + end) + end) + + describe("protection mode config", function() + it("should disable mitigation when mitigationType is INGEST", function() + local netacea = Netacea:new({ + ingestEnabled = true, + mitigationType = "INGEST", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + kinesisProperties = { + stream_name = "test-stream", + region = "eu-west-1", + aws_access_key = "test-access-key", + aws_secret_key = "test-secret-key" + } + }) + + assert.are.equal("INGEST", netacea.mitigationType) + assert.is_false(netacea.mitigationEnabled) + assert.spy(protector_client_mock.new).was_not_called() + end) + + it("should treat mitigationEnabled false as deprecated ingest mode", function() + local netacea = Netacea:new({ + ingestEnabled = true, + mitigationEnabled = false, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + kinesisProperties = { + stream_name = "test-stream", + region = "eu-west-1", + aws_access_key = "test-access-key", + aws_secret_key = "test-secret-key" + } + }) + + assert.are.equal("INGEST", netacea.mitigationType) + assert.is_false(netacea.mitigationEnabled) + assert.spy(ngx_mock.log).was.called_with( + ngx_mock.WARN, + "NETACEA CONFIG - mitigationEnabled is deprecated; set mitigationType to INGEST instead" + ) + end) + end) + describe("cookie encryption key config", function() it("should pass realIpHeaderIndex to IP address lookup", function() local netacea = Netacea:new({ diff --git a/test/netacea_utils_spec.lua b/test/netacea_utils_spec.lua index 9e37288..27ee9fe 100644 --- a/test/netacea_utils_spec.lua +++ b/test/netacea_utils_spec.lua @@ -412,6 +412,79 @@ describe("netacea_utils", function() end) end) + describe("env", function() + local original_getenv + local env_values + + before_each(function() + original_getenv = os.getenv + env_values = {} + os.getenv = function(name) + return env_values[name] + end + end) + + after_each(function() + os.getenv = original_getenv + end) + + it("should return an environment variable when it is set", function() + env_values.NETACEA_TEST_VALUE = "configured-value" + + local result = utils.env("NETACEA_TEST_VALUE", "default-value") + + assert.is.equal("configured-value", result) + end) + + it("should return the default value when an environment variable is not set", function() + local result = utils.env("NETACEA_TEST_VALUE", "default-value") + + assert.is.equal("default-value", result) + end) + end) + + describe("envEnabled", function() + local original_getenv + local env_values + + before_each(function() + original_getenv = os.getenv + env_values = {} + os.getenv = function(name) + return env_values[name] + end + end) + + after_each(function() + os.getenv = original_getenv + end) + + it("should return the default value when an environment variable is not set", function() + local result = utils.envEnabled("NETACEA_INGEST_ENABLED", true) + + assert.is_true(result) + end) + + it("should return true when an environment variable is exactly true", function() + env_values.NETACEA_INGEST_ENABLED = "true" + + local result = utils.envEnabled("NETACEA_INGEST_ENABLED", false) + + assert.is_true(result) + end) + + it("should return false when an environment variable is set to anything else", function() + env_values.NETACEA_INGEST_ENABLED = "True" + assert.is_false(utils.envEnabled("NETACEA_INGEST_ENABLED", true)) + + env_values.NETACEA_INGEST_ENABLED = "false" + assert.is_false(utils.envEnabled("NETACEA_INGEST_ENABLED", true)) + + env_values.NETACEA_INGEST_ENABLED = "" + assert.is_false(utils.envEnabled("NETACEA_INGEST_ENABLED", true)) + end) + end) + describe("buildRandomString edge cases", function() it("should handle negative length gracefully", function() -- The current implementation doesn't check for negative values From bf27ee653a8575fd650c2f7d7176307a5cb4966a Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Fri, 22 May 2026 15:30:24 +0100 Subject: [PATCH 8/9] fix inject mode --- src/lua_resty_netacea.lua | 38 ++++++++++++++++ test/lua_resty_netacea_spec.lua | 80 ++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index f803efb..f4fa344 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -20,6 +20,27 @@ local function getIntegrationMode(n) return 'DISABLED' end +local function setInjectHeaders(protector_result) + local idType = Constants['idTypes'].NONE + local mitigationType = Constants['mitigationTypes'].NONE + local captchaState = Constants['captchaStates'].NONE + + if protector_result and protector_result.match then + idType = protector_result.match + end + if protector_result and protector_result.mitigate then + mitigationType = protector_result.mitigate + end + if protector_result and protector_result.captcha then + captchaState = protector_result.captcha + end + + ngx.req.set_header('x-netacea-match', idType) + ngx.req.set_header('x-netacea-mitigate', mitigationType) + ngx.req.set_header('x-netacea-captcha', captchaState) + return idType, mitigationType, captchaState +end + function _N:new(options) local n = {} setmetatable(n, self) @@ -266,6 +287,16 @@ function _N:mitigate() ngx.log(ngx.DEBUG, "NETACEA MITIGATE - protector result: ", cjson.encode(ngx.ctx.NetaceaState)) + if self.mitigationType == 'INJECT' then + local injectedIdType, injectedMitigationType, injectedCaptchaState = setInjectHeaders(protector_result) + ngx.log(ngx.DEBUG, + "NETACEA INJECT - setting recommendation headers: match=", injectedIdType, + ", mitigate=", injectedMitigationType, + ", captcha=", injectedCaptchaState) + self:refreshSession(parsed_cookie.reason) + return + end + local best_mitigation = mitigation.getBestMitigation(protector_result) if best_mitigation == 'captcha' then @@ -307,6 +338,13 @@ function _N:mitigate() mitigate = parsed_cookie.data.mit, captcha = parsed_cookie.data.cap } + if self.mitigationType == 'INJECT' then + ngx.log(ngx.DEBUG, + "NETACEA INJECT - setting recommendation headers from session: match=", parsed_cookie.data.mat, + ", mitigate=", parsed_cookie.data.mit, + ", captcha=", parsed_cookie.data.cap) + setInjectHeaders(ngx.ctx.NetaceaState.protector_result) + end end end return _N diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index 608b8ac..30d02cf 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -12,6 +12,7 @@ insulate("lua_resty_netacea", function() local protector_client_mock local protector_client_instance local decode_base64url_mock + local mitigation_mock before_each(function() ngx_mock = { @@ -26,7 +27,8 @@ insulate("lua_resty_netacea", function() exit = spy.new(function() end), req = { read_body = spy.new(function() end), - get_body_data = spy.new(function() return "captcha-response" end) + get_body_data = spy.new(function() return "captcha-response" end), + set_header = spy.new(function() end) }, DEBUG = 7, WARN = 4, @@ -99,7 +101,14 @@ insulate("lua_resty_netacea", function() 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'] = {} + mitigation_mock = { + getBestMitigation = spy.new(function() return nil end), + serveCaptcha = spy.new(function() end), + serveBlock = spy.new(function() end), + serveMonetisationRedirect = spy.new(function() end), + serveMonetisationFallback = spy.new(function() end) + } + package.loaded['lua_resty_netacea_mitigation'] = mitigation_mock package.loaded['cjson'] = { encode = function() return "{}" end } @@ -230,6 +239,73 @@ insulate("lua_resty_netacea", function() "NETACEA CONFIG - mitigationEnabled is deprecated; set mitigationType to INGEST instead" ) end) + + it("should inject the recommendation headers without serving mitigation", function() + protector_client_instance.checkReputation = spy.new(function() + return { + match = "2", + mitigate = "1", + captcha = "1", + response = { + body = "captcha" + } + } + end) + + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationType = "INJECT", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key" + }) + + netacea:mitigate() + + assert.spy(ngx_mock.req.set_header).was.called_with("x-netacea-match", "2") + assert.spy(ngx_mock.req.set_header).was.called_with("x-netacea-mitigate", "1") + assert.spy(ngx_mock.req.set_header).was.called_with("x-netacea-captcha", "1") + assert.spy(cookies_mock.generateNewCookieValue).was.called(1) + assert.spy(mitigation_mock.getBestMitigation).was_not_called() + assert.spy(mitigation_mock.serveCaptcha).was_not_called() + assert.spy(mitigation_mock.serveBlock).was_not_called() + assert.spy(mitigation_mock.serveMonetisationRedirect).was_not_called() + assert.spy(mitigation_mock.serveMonetisationFallback).was_not_called() + assert.spy(ngx_mock.exit).was_not_called() + end) + + it("should inject the recommendation headers from a valid session", function() + cookies_mock.parseMitataCookie = spy.new(function() + return { + valid = true, + user_id = "existing-user-id", + data = { + mat = "2", + mit = "4", + cap = "0" + } + } + end) + ngx_mock.var.cookie__mitata = "existing-session-cookie" + + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationType = "INJECT", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key" + }) + + netacea:mitigate() + + assert.spy(ngx_mock.req.set_header).was.called_with("x-netacea-match", "2") + assert.spy(ngx_mock.req.set_header).was.called_with("x-netacea-mitigate", "4") + assert.spy(ngx_mock.req.set_header).was.called_with("x-netacea-captcha", "0") + assert.spy(protector_client_instance.checkReputation).was_not_called() + assert.spy(cookies_mock.generateNewCookieValue).was_not_called() + assert.spy(mitigation_mock.getBestMitigation).was_not_called() + assert.spy(ngx_mock.exit).was_not_called() + end) end) describe("cookie encryption key config", function() From d6cd39393fb10ec1f66e43034d491fdda737e222 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Fri, 22 May 2026 15:48:10 +0100 Subject: [PATCH 9/9] rm secret key from example --- src/conf/nginx.conf | 1 - 1 file changed, 1 deletion(-) diff --git a/src/conf/nginx.conf b/src/conf/nginx.conf index ccee215..3af4d25 100644 --- a/src/conf/nginx.conf +++ b/src/conf/nginx.conf @@ -43,7 +43,6 @@ http { mitigationEndpoint = env('NETACEA_PROTECTOR_API_URL', ''), apiKey = env('NETACEA_API_KEY', ''), cookieEncryptionKey = env('NETACEA_COOKIE_ENCRYPTION_KEY', ''), - secretKey = env('NETACEA_SECRET_KEY', ''), ingestEnabled = envEnabled('NETACEA_INGEST_ENABLED', true), mitigationType = env('NETACEA_PROTECTION_MODE', ''), cookieName = env('NETACEA_COOKIE_NAME', ''),