From c18c7de49786b67b94c5db2670fdc5182896ba3d Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Wed, 25 Mar 2026 14:37:21 +0000 Subject: [PATCH 1/6] serve monetisation redirect --- src/lua_resty_netacea.lua | 32 +++++++++++++++++++++- src/lua_resty_netacea_constants.lua | 4 ++- src/lua_resty_netacea_protector_client.lua | 3 +- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 9beb36f..c8a1e51 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -28,6 +28,21 @@ local function serveBlock() return ngx.exit(ngx.HTTP_FORBIDDEN); end +local function serveMonetisationRedirect(location) + ngx.status = 303; + ngx.header["Location"] = location + ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" + ngx.print("303 See Other"); + return ngx.exit(303); +end + +local function serveMonetisationFallback() + ngx.status = 402; + ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" + ngx.print("402 See Other"); + return ngx.exit(402); +end + function _N:new(options) local n = {} setmetatable(n, self) @@ -132,6 +147,10 @@ function _N:getBestMitigation(protector_result) return 'captcha' end + if (mitigate == Constants.mitigationTypes.MONETISED) then + return 'monetise' + end + return 'block' end @@ -259,6 +278,17 @@ function _N:mitigate() self:refreshSession(parsed_cookie.reason) serveBlock() return + elseif best_mitigation == 'monetise' then + ngx.log(ngx.DEBUG, "NETACEA MITIGATE - serving monetise") + ngx.ctx.NetaceaState.grace_period = -1000 + self:refreshSession(parsed_cookie.reason) + if protector_result.redirectHost then + local redirect_location = "https://" .. protector_result.redirectHost .. ngx.var.request_uri + serveMonetisationRedirect(redirect_location) + else + serveMonetisationFallback() + end + return else ngx.log(ngx.DEBUG, "NETACEA MITIGATE - no mitigation applied") self:refreshSession(parsed_cookie.reason) @@ -272,4 +302,4 @@ function _N:mitigate() } end end -return _N \ No newline at end of file +return _N diff --git a/src/lua_resty_netacea_constants.lua b/src/lua_resty_netacea_constants.lua index 31d0fb0..475d0df 100644 --- a/src/lua_resty_netacea_constants.lua +++ b/src/lua_resty_netacea_constants.lua @@ -20,7 +20,9 @@ Constants['mitigationTypes'] = { NONE = '0', BLOCKED = '1', ALLOW = '2', - HARDBLOCKED = '3' + HARDBLOCKED = '3', + FLAGGED = '4', + MONETISED = '5' } Constants['captchaStatesText'] = {} diff --git a/src/lua_resty_netacea_protector_client.lua b/src/lua_resty_netacea_protector_client.lua index 6e1e9d8..27f5595 100644 --- a/src/lua_resty_netacea_protector_client.lua +++ b/src/lua_resty_netacea_protector_client.lua @@ -71,7 +71,8 @@ function ProtectorClient:checkReputation() }, 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 + captcha = res['headers']['x-netacea-captcha'] or constants['captchaStates'].NONE, + redirectHost = res['headers']['x-netacea-redirect-host'] or nil } return result end From f3e66227c6abd89b87924ef95ca41f76067aa9c8 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Wed, 25 Mar 2026 16:01:03 +0000 Subject: [PATCH 2/6] move mitigation actions to src/lua_resty_netacea_mitigation.lua --- docker-compose.yml | 9 +- src/lua_resty_netacea.lua | 82 +--- src/lua_resty_netacea_mitigation.lua | 62 +++ test/lua_resty_netacea_mitigation_spec.lua | 231 ++++++++++ ...ua_resty_netacea_protector_client_spec.lua | 399 ++++++++++++++++++ 5 files changed, 709 insertions(+), 74 deletions(-) create mode 100644 src/lua_resty_netacea_mitigation.lua create mode 100644 test/lua_resty_netacea_mitigation_spec.lua create mode 100644 test/lua_resty_netacea_protector_client_spec.lua diff --git a/docker-compose.yml b/docker-compose.yml index e7f4200..73e9dec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,14 +12,7 @@ services: - "443:443" 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" - + - "./src:/opt/netacea/src" test: build: diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index c8a1e51..1da1fb2 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -5,6 +5,7 @@ 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 mitigation = require("lua_resty_netacea_mitigation") local _N = {} _N._VERSION = '0.2.2' @@ -13,36 +14,6 @@ _N._TYPE = 'nginx' local ngx = require 'ngx' local cjson = require 'cjson' -local function serveCaptcha(captchaBody) - ngx.status = ngx.HTTP_FORBIDDEN - ngx.header["content-type"] = "text/html" - ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" - ngx.print(captchaBody) - return ngx.exit(ngx.HTTP_OK) -end - -local function serveBlock() - ngx.status = ngx.HTTP_FORBIDDEN; - ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" - ngx.print("403 Forbidden"); - return ngx.exit(ngx.HTTP_FORBIDDEN); -end - -local function serveMonetisationRedirect(location) - ngx.status = 303; - ngx.header["Location"] = location - ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" - ngx.print("303 See Other"); - return ngx.exit(303); -end - -local function serveMonetisationFallback() - ngx.status = 402; - ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" - ngx.print("402 See Other"); - return ngx.exit(402); -end - function _N:new(options) local n = {} setmetatable(n, self) @@ -128,32 +99,6 @@ function _N:new(options) return n end -function _N:getBestMitigation(protector_result) - if not protector_result then return nil end - - local mitigate = protector_result.mitigate - local captcha = protector_result.captcha - - if (mitigate == Constants.mitigationTypes.NONE) then return nil end - if (not Constants.mitigationTypesText[mitigate]) 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 (mitigate == Constants.mitigationTypes.BLOCKED - and (captcha == Constants.captchaStates.SERVE - or captcha == Constants['captchaStates'].COOKIEFAIL)) then - return 'captcha' - end - - if (mitigate == Constants.mitigationTypes.MONETISED) then - return 'monetise' - end - - return 'block' -end - function _N:setBcType(match, mitigate, captcha) local UNKNOWN = 'unknown' local mitigationApplied = '' @@ -264,35 +209,40 @@ function _N:mitigate() ngx.log(ngx.DEBUG, "NETACEA MITIGATE - protector result: ", cjson.encode(ngx.ctx.NetaceaState)) - local best_mitigation = self:getBestMitigation(protector_result) + local best_mitigation = mitigation.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) + mitigation.serveCaptcha(captchaBody) return - elseif best_mitigation == 'block' then + end + + if best_mitigation == 'block' then ngx.log(ngx.DEBUG, "NETACEA MITIGATE - serving block") ngx.ctx.NetaceaState.grace_period = -1000 self:refreshSession(parsed_cookie.reason) - serveBlock() + mitigation.serveBlock() return - elseif best_mitigation == 'monetise' then + end + + if best_mitigation == 'monetise' then ngx.log(ngx.DEBUG, "NETACEA MITIGATE - serving monetise") ngx.ctx.NetaceaState.grace_period = -1000 self:refreshSession(parsed_cookie.reason) if protector_result.redirectHost then local redirect_location = "https://" .. protector_result.redirectHost .. ngx.var.request_uri - serveMonetisationRedirect(redirect_location) + mitigation.serveMonetisationRedirect(redirect_location) else - serveMonetisationFallback() + mitigation.serveMonetisationFallback() end return - else - ngx.log(ngx.DEBUG, "NETACEA MITIGATE - no mitigation applied") - self:refreshSession(parsed_cookie.reason) end + + ngx.log(ngx.DEBUG, "NETACEA MITIGATE - no mitigation applied") + self:refreshSession(parsed_cookie.reason) else ngx.log(ngx.DEBUG, "NETACEA MITIGATE - valid cookie found, skipping mitigation") ngx.ctx.NetaceaState.protector_result = { diff --git a/src/lua_resty_netacea_mitigation.lua b/src/lua_resty_netacea_mitigation.lua new file mode 100644 index 0000000..91068df --- /dev/null +++ b/src/lua_resty_netacea_mitigation.lua @@ -0,0 +1,62 @@ +local ngx = require 'ngx' +local Constants = require("lua_resty_netacea_constants") + +local _M = {} + +function _M.getBestMitigation(protector_result) + if not protector_result then return nil end + + local mitigate = protector_result.mitigate + local captcha = protector_result.captcha + + if (mitigate == Constants.mitigationTypes.NONE) then return nil end + if (not Constants.mitigationTypesText[mitigate]) 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 (mitigate == Constants.mitigationTypes.BLOCKED + and (captcha == Constants.captchaStates.SERVE + or captcha == Constants['captchaStates'].COOKIEFAIL)) then + return 'captcha' + end + + if (mitigate == Constants.mitigationTypes.MONETISED) then + return 'monetise' + end + + return 'block' +end + +function _M.serveCaptcha(captchaBody) + ngx.status = ngx.HTTP_FORBIDDEN + ngx.header["content-type"] = "text/html" + ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" + ngx.print(captchaBody) + return ngx.exit(ngx.HTTP_OK) +end + +function _M.serveBlock() + ngx.status = ngx.HTTP_FORBIDDEN; + ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" + ngx.print("403 Forbidden"); + return ngx.exit(ngx.HTTP_FORBIDDEN); +end + +function _M.serveMonetisationRedirect(location) + ngx.status = 303; + ngx.header["Location"] = location + ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" + ngx.print("303 See Other"); + return ngx.exit(303); +end + +function _M.serveMonetisationFallback() + ngx.status = 402; + ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" + ngx.print("402 See Other"); + return ngx.exit(402); +end + +return _M diff --git a/test/lua_resty_netacea_mitigation_spec.lua b/test/lua_resty_netacea_mitigation_spec.lua new file mode 100644 index 0000000..e5e4ee0 --- /dev/null +++ b/test/lua_resty_netacea_mitigation_spec.lua @@ -0,0 +1,231 @@ +require("silence_g_write_guard") +require 'busted.runner'() + +package.path = "../src/?.lua;" .. package.path + +describe("lua_resty_netacea_mitigation", function() + local mitigation + local ngx_mock + local Constants + + before_each(function() + ngx_mock = { + HTTP_FORBIDDEN = 403, + HTTP_OK = 200, + status = 0, + header = {}, + print = spy.new(function() end), + exit = spy.new(function() end) + } + + package.loaded['ngx'] = ngx_mock + package.loaded['lua_resty_netacea_mitigation'] = nil + mitigation = require('lua_resty_netacea_mitigation') + Constants = require('lua_resty_netacea_constants') + end) + + after_each(function() + package.loaded['lua_resty_netacea_mitigation'] = nil + package.loaded['ngx'] = nil + end) + + describe("serveCaptcha", function() + it("should set status to HTTP_FORBIDDEN", function() + mitigation.serveCaptcha("captcha") + assert.are.equal(403, ngx_mock.status) + end) + + it("should set content-type to text/html", function() + mitigation.serveCaptcha("captcha") + assert.are.equal("text/html", ngx_mock.header["content-type"]) + end) + + it("should set Cache-Control to no-cache", function() + mitigation.serveCaptcha("captcha") + assert.are.equal("max-age=0, no-cache, no-store, must-revalidate", ngx_mock.header["Cache-Control"]) + end) + + it("should print the captcha body", function() + mitigation.serveCaptcha("captcha") + assert.spy(ngx_mock.print).was.called_with("captcha") + end) + + it("should exit with HTTP_OK", function() + mitigation.serveCaptcha("captcha") + assert.spy(ngx_mock.exit).was.called_with(200) + end) + end) + + describe("serveBlock", function() + it("should set status to HTTP_FORBIDDEN", function() + mitigation.serveBlock() + assert.are.equal(403, ngx_mock.status) + end) + + it("should set Cache-Control to no-cache", function() + mitigation.serveBlock() + assert.are.equal("max-age=0, no-cache, no-store, must-revalidate", ngx_mock.header["Cache-Control"]) + end) + + it("should print 403 Forbidden", function() + mitigation.serveBlock() + assert.spy(ngx_mock.print).was.called_with("403 Forbidden") + end) + + it("should exit with HTTP_FORBIDDEN", function() + mitigation.serveBlock() + assert.spy(ngx_mock.exit).was.called_with(403) + end) + end) + + describe("serveMonetisationRedirect", function() + it("should set status to 303", function() + mitigation.serveMonetisationRedirect("https://example.com/path") + assert.are.equal(303, ngx_mock.status) + end) + + it("should set Location header", function() + mitigation.serveMonetisationRedirect("https://example.com/path") + assert.are.equal("https://example.com/path", ngx_mock.header["Location"]) + end) + + it("should set Cache-Control to no-cache", function() + mitigation.serveMonetisationRedirect("https://example.com/path") + assert.are.equal("max-age=0, no-cache, no-store, must-revalidate", ngx_mock.header["Cache-Control"]) + end) + + it("should print 303 See Other", function() + mitigation.serveMonetisationRedirect("https://example.com/path") + assert.spy(ngx_mock.print).was.called_with("303 See Other") + end) + + it("should exit with 303", function() + mitigation.serveMonetisationRedirect("https://example.com/path") + assert.spy(ngx_mock.exit).was.called_with(303) + end) + end) + + describe("serveMonetisationFallback", function() + it("should set status to 402", function() + mitigation.serveMonetisationFallback() + assert.are.equal(402, ngx_mock.status) + end) + + it("should set Cache-Control to no-cache", function() + mitigation.serveMonetisationFallback() + assert.are.equal("max-age=0, no-cache, no-store, must-revalidate", ngx_mock.header["Cache-Control"]) + end) + + it("should print 402 See Other", function() + mitigation.serveMonetisationFallback() + assert.spy(ngx_mock.print).was.called_with("402 See Other") + end) + + it("should exit with 402", function() + mitigation.serveMonetisationFallback() + assert.spy(ngx_mock.exit).was.called_with(402) + end) + end) + + describe("getBestMitigation", function() + it("should return nil when protector_result is nil", function() + assert.is_nil(mitigation.getBestMitigation(nil)) + end) + + it("should return nil when mitigate is NONE", function() + local result = { + mitigate = Constants.mitigationTypes.NONE, + captcha = Constants.captchaStates.NONE + } + assert.is_nil(mitigation.getBestMitigation(result)) + end) + + it("should return nil when mitigate is not a known type", function() + local result = { + mitigate = 'unknown_value', + captcha = Constants.captchaStates.NONE + } + assert.is_nil(mitigation.getBestMitigation(result)) + end) + + it("should return nil when mitigate is ALLOW", function() + local result = { + mitigate = Constants.mitigationTypes.ALLOW, + captcha = Constants.captchaStates.NONE + } + assert.is_nil(mitigation.getBestMitigation(result)) + end) + + it("should return nil when captcha is PASS", function() + local result = { + mitigate = Constants.mitigationTypes.BLOCKED, + captcha = Constants.captchaStates.PASS + } + assert.is_nil(mitigation.getBestMitigation(result)) + end) + + it("should return nil when captcha is COOKIEPASS", function() + local result = { + mitigate = Constants.mitigationTypes.BLOCKED, + captcha = Constants.captchaStates.COOKIEPASS + } + assert.is_nil(mitigation.getBestMitigation(result)) + end) + + it("should return captcha when mitigate is BLOCKED and captcha is SERVE", function() + local result = { + mitigate = Constants.mitigationTypes.BLOCKED, + captcha = Constants.captchaStates.SERVE + } + assert.are.equal('captcha', mitigation.getBestMitigation(result)) + end) + + it("should return captcha when mitigate is BLOCKED and captcha is COOKIEFAIL", function() + local result = { + mitigate = Constants.mitigationTypes.BLOCKED, + captcha = Constants.captchaStates.COOKIEFAIL + } + assert.are.equal('captcha', mitigation.getBestMitigation(result)) + end) + + it("should return monetise when mitigate is MONETISED", function() + local result = { + mitigate = Constants.mitigationTypes.MONETISED, + captcha = Constants.captchaStates.NONE + } + assert.are.equal('monetise', mitigation.getBestMitigation(result)) + end) + + it("should return block when mitigate is BLOCKED and captcha is NONE", function() + local result = { + mitigate = Constants.mitigationTypes.BLOCKED, + captcha = Constants.captchaStates.NONE + } + assert.are.equal('block', mitigation.getBestMitigation(result)) + end) + + it("should return block when mitigate is BLOCKED and captcha is FAIL", function() + local result = { + mitigate = Constants.mitigationTypes.BLOCKED, + captcha = Constants.captchaStates.FAIL + } + assert.are.equal('block', mitigation.getBestMitigation(result)) + end) + + it("should return block when mitigate is HARDBLOCKED", function() + local result = { + mitigate = Constants.mitigationTypes.HARDBLOCKED, + captcha = Constants.captchaStates.NONE + } + assert.are.equal('block', mitigation.getBestMitigation(result)) + end) + + it("should return block when mitigate is FLAGGED", function() + local result = { + mitigate = Constants.mitigationTypes.FLAGGED, + captcha = Constants.captchaStates.NONE + } + assert.are.equal('block', mitigation.getBestMitigation(result)) + end) + end) +end) diff --git a/test/lua_resty_netacea_protector_client_spec.lua b/test/lua_resty_netacea_protector_client_spec.lua new file mode 100644 index 0000000..aa3fbb7 --- /dev/null +++ b/test/lua_resty_netacea_protector_client_spec.lua @@ -0,0 +1,399 @@ +require("silence_g_write_guard") +require 'busted.runner'() + +package.path = "../src/?.lua;" .. package.path + +describe("lua_resty_netacea_protector_client", function() + local ProtectorClient + local ngx_mock + local http_mock_instance + local http_mock + local constants + + before_each(function() + constants = require('lua_resty_netacea_constants') + + ngx_mock = { + ctx = { + NetaceaState = { + client = "192.168.1.1", + user_agent = "Test-Agent/1.0", + UserId = "user123", + captcha_cookie = nil + } + }, + log = spy.new(function() end), + ERR = 3, + HTTP_FORBIDDEN = 403, + HTTP_OK = 200 + } + + http_mock_instance = { + set_timeouts = spy.new(function() end), + request_uri = spy.new(function(self, url, opts) + return { + status = 200, + body = "response", + headers = { + ['x-netacea-match'] = constants.idTypes.IP, + ['x-netacea-mitigate'] = constants.mitigationTypes.BLOCKED, + ['x-netacea-captcha'] = constants.captchaStates.SERVE, + ['x-netacea-redirect-host'] = nil + } + }, nil + end) + } + + http_mock = { + new = function() + return http_mock_instance + end + } + + package.loaded['ngx'] = ngx_mock + package.loaded['resty.http'] = http_mock + package.loaded['cjson'] = { + encode = function(obj) return '{}' end + } + package.loaded['lua_resty_netacea_protector_client'] = nil + + ProtectorClient = require('lua_resty_netacea_protector_client') + end) + + after_each(function() + package.loaded['lua_resty_netacea_protector_client'] = nil + package.loaded['ngx'] = nil + package.loaded['resty.http'] = nil + package.loaded['cjson'] = nil + end) + + describe("new", function() + it("should create a new instance with provided options", function() + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + assert.are.equal("test-api-key", client.apiKey) + assert.are.same({ "https://endpoint1.example.com" }, client.mitigationEndpoint) + assert.are.equal(0, client.endpointIndex) + end) + + it("should default mitigationEndpoint to empty table", function() + local client = ProtectorClient:new({ apiKey = "key" }) + assert.are.same({}, client.mitigationEndpoint) + end) + end) + + describe("getMitigationRequestHeaders", function() + it("should return headers with api key and client info", function() + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local headers = client:getMitigationRequestHeaders() + assert.are.equal("test-api-key", headers["x-netacea-api-key"]) + assert.are.equal("application/x-www-form-urlencoded", headers["content-type"]) + assert.are.equal("Test-Agent/1.0", headers["user-agent"]) + assert.are.equal("192.168.1.1", headers["x-netacea-client-ip"]) + assert.are.equal("user123", headers["x-netacea-userid"]) + end) + + it("should include captcha cookie when present", function() + ngx_mock.ctx.NetaceaState.captcha_cookie = "captcha_value_123" + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local headers = client:getMitigationRequestHeaders() + assert.are.equal("_mitatacaptcha=captcha_value_123", headers["cookie"]) + end) + + it("should set empty cookie when captcha cookie is nil", function() + ngx_mock.ctx.NetaceaState.captcha_cookie = nil + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local headers = client:getMitigationRequestHeaders() + assert.are.equal("", headers["cookie"]) + end) + + it("should default user_agent to empty string when nil", function() + ngx_mock.ctx.NetaceaState.user_agent = nil + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local headers = client:getMitigationRequestHeaders() + assert.are.equal("", headers["user-agent"]) + end) + + it("should default client to empty string when nil", function() + ngx_mock.ctx.NetaceaState.client = nil + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local headers = client:getMitigationRequestHeaders() + assert.are.equal("", headers["x-netacea-client-ip"]) + end) + + it("should default UserId to empty string when nil", function() + ngx_mock.ctx.NetaceaState.UserId = nil + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local headers = client:getMitigationRequestHeaders() + assert.are.equal("", headers["x-netacea-userid"]) + end) + end) + + describe("checkReputation", function() + it("should make a GET request to the mitigation endpoint", function() + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + client:checkReputation() + assert.spy(http_mock_instance.request_uri).was.called(1) + local call_args = http_mock_instance.request_uri.calls[1] + assert.are.equal("https://endpoint1.example.com", call_args.vals[2]) + assert.are.equal("GET", call_args.vals[3].method) + end) + + it("should return parsed response with mitigation headers", function() + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local result = client:checkReputation() + assert.are.equal(200, result.response.status) + assert.are.equal("response", result.response.body) + assert.are.equal(constants.idTypes.IP, result.match) + assert.are.equal(constants.mitigationTypes.BLOCKED, result.mitigate) + assert.are.equal(constants.captchaStates.SERVE, result.captcha) + assert.is_nil(result.redirectHost) + end) + + it("should include redirectHost when present in response", function() + http_mock_instance.request_uri = spy.new(function() + return { + status = 200, + body = "", + headers = { + ['x-netacea-match'] = constants.idTypes.IP, + ['x-netacea-mitigate'] = constants.mitigationTypes.MONETISED, + ['x-netacea-captcha'] = constants.captchaStates.NONE, + ['x-netacea-redirect-host'] = 'redirect.example.com' + } + }, nil + end) + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local result = client:checkReputation() + assert.are.equal("redirect.example.com", result.redirectHost) + end) + + it("should default missing headers to NONE constants", function() + http_mock_instance.request_uri = spy.new(function() + return { + status = 200, + body = "", + headers = {} + }, nil + end) + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local result = client:checkReputation() + assert.are.equal(constants.idTypes.NONE, result.match) + assert.are.equal(constants.mitigationTypes.NONE, result.mitigate) + assert.are.equal(constants.captchaStates.NONE, result.captcha) + end) + + it("should return nil on HTTP error", function() + http_mock_instance.request_uri = spy.new(function() + return nil, "connection refused" + end) + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local result = client:checkReputation() + assert.is_nil(result) + end) + + it("should round-robin across multiple endpoints", function() + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { + "https://endpoint1.example.com", + "https://endpoint2.example.com" + } + }) + client:checkReputation() + local first_url = http_mock_instance.request_uri.calls[1].vals[2] + assert.are.equal("https://endpoint2.example.com", first_url) + + client:checkReputation() + local second_url = http_mock_instance.request_uri.calls[2].vals[2] + assert.are.equal("https://endpoint1.example.com", second_url) + + client:checkReputation() + local third_url = http_mock_instance.request_uri.calls[3].vals[2] + assert.are.equal("https://endpoint2.example.com", third_url) + end) + end) + + describe("validateCaptcha", function() + it("should make a POST request to the captcha endpoint", function() + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + client:validateCaptcha("captcha_response_data") + assert.spy(http_mock_instance.request_uri).was.called(1) + local call_args = http_mock_instance.request_uri.calls[1] + assert.are.equal("https://endpoint1.example.com/AtaVerifyCaptcha", call_args.vals[2]) + assert.are.equal("POST", call_args.vals[3].method) + assert.are.equal("captcha_response_data", call_args.vals[3].body) + end) + + it("should return exit_status HTTP_OK when captcha passes", function() + http_mock_instance.request_uri = spy.new(function() + return { + status = 200, + body = "", + headers = { + ['x-netacea-match'] = constants.idTypes.IP, + ['x-netacea-mitigate'] = constants.mitigationTypes.BLOCKED, + ['x-netacea-captcha'] = constants.captchaStates.PASS, + ['X-Netacea-MitATACaptcha-Value'] = 'captcha_cookie_val' + } + }, nil + end) + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local result = client:validateCaptcha("captcha_data") + assert.are.equal(ngx_mock.HTTP_OK, result.exit_status) + assert.are.equal(constants.captchaStates.PASS, result.captcha) + assert.are.equal("captcha_cookie_val", result.captcha_cookie) + end) + + it("should return exit_status HTTP_FORBIDDEN when captcha fails", function() + http_mock_instance.request_uri = spy.new(function() + return { + status = 200, + body = "", + headers = { + ['x-netacea-match'] = constants.idTypes.IP, + ['x-netacea-mitigate'] = constants.mitigationTypes.BLOCKED, + ['x-netacea-captcha'] = constants.captchaStates.FAIL + } + }, nil + end) + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local result = client:validateCaptcha("captcha_data") + assert.are.equal(ngx_mock.HTTP_FORBIDDEN, result.exit_status) + assert.are.equal(constants.captchaStates.FAIL, result.captcha) + end) + + it("should default missing headers to NONE constants", function() + http_mock_instance.request_uri = spy.new(function() + return { + status = 200, + body = "", + headers = {} + }, nil + end) + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local result = client:validateCaptcha("captcha_data") + assert.are.equal(constants.idTypes.NONE, result.match) + assert.are.equal(constants.mitigationTypes.NONE, result.mitigate) + assert.are.equal(constants.captchaStates.NONE, result.captcha) + assert.are.equal(ngx_mock.HTTP_FORBIDDEN, result.exit_status) + end) + + it("should return nil captcha_cookie when header is missing", function() + http_mock_instance.request_uri = spy.new(function() + return { + status = 200, + body = "", + headers = { + ['x-netacea-match'] = constants.idTypes.NONE, + ['x-netacea-mitigate'] = constants.mitigationTypes.NONE, + ['x-netacea-captcha'] = constants.captchaStates.FAIL + } + }, nil + end) + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local result = client:validateCaptcha("captcha_data") + assert.is_nil(result.captcha_cookie) + end) + + it("should return nil on HTTP error", function() + http_mock_instance.request_uri = spy.new(function() + return nil, "timeout" + end) + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local result = client:validateCaptcha("captcha_data") + assert.is_nil(result) + end) + + it("should round-robin endpoints for captcha validation", function() + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { + "https://endpoint1.example.com", + "https://endpoint2.example.com" + } + }) + client:validateCaptcha("data1") + local first_url = http_mock_instance.request_uri.calls[1].vals[2] + assert.are.equal("https://endpoint2.example.com/AtaVerifyCaptcha", first_url) + + client:validateCaptcha("data2") + local second_url = http_mock_instance.request_uri.calls[2].vals[2] + assert.are.equal("https://endpoint1.example.com/AtaVerifyCaptcha", second_url) + end) + + it("should include full response in result", function() + http_mock_instance.request_uri = spy.new(function() + return { + status = 200, + body = "response body", + headers = { + ['x-netacea-match'] = constants.idTypes.IP, + ['x-netacea-mitigate'] = constants.mitigationTypes.BLOCKED, + ['x-netacea-captcha'] = constants.captchaStates.SERVE + } + }, nil + end) + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local result = client:validateCaptcha("captcha_data") + assert.are.equal(200, result.response.status) + assert.are.equal("response body", result.response.body) + end) + end) +end) From fdc7dc85e6ead0fbf8061fcadb5ae7a33392e5f6 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Mon, 30 Mar 2026 13:55:15 +0100 Subject: [PATCH 3/6] update volume mount directory --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 73e9dec..eaf4006 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: - "443:443" volumes: - "./src/conf/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf" - - "./src:/opt/netacea/src" + - "./src:/usr/local/openresty/site/lualib/" test: build: From 31f23816b3fa42dd755718f225f52196b45059d0 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Wed, 1 Apr 2026 09:55:10 +0100 Subject: [PATCH 4/6] update mitigateBcTypes --- src/lua_resty_netacea_constants.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lua_resty_netacea_constants.lua b/src/lua_resty_netacea_constants.lua index 475d0df..48232f7 100644 --- a/src/lua_resty_netacea_constants.lua +++ b/src/lua_resty_netacea_constants.lua @@ -45,7 +45,6 @@ Constants['issueReasons'] = { CAPTCHA_GET = 'captcha_get', } - Constants['matchBcTypes'] = { ['1'] = 'ua', ['2'] = 'ip', @@ -63,7 +62,8 @@ Constants['mitigateBcTypes'] = { ['1'] = 'blocked', ['2'] = 'allow', ['3'] = 'hardblocked', - ['4'] = 'block' + ['4'] = 'flagged', + ['5'] = 'monetised' } Constants['captchaBcTypes'] = { From fad35193dc6541263077a3710fdfdbb3fdec6850 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Wed, 1 Apr 2026 12:18:36 +0100 Subject: [PATCH 5/6] print 'payment required' alongside 402 --- src/lua_resty_netacea_mitigation.lua | 2 +- test/lua_resty_netacea_mitigation_spec.lua | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lua_resty_netacea_mitigation.lua b/src/lua_resty_netacea_mitigation.lua index 91068df..483d232 100644 --- a/src/lua_resty_netacea_mitigation.lua +++ b/src/lua_resty_netacea_mitigation.lua @@ -55,7 +55,7 @@ end function _M.serveMonetisationFallback() ngx.status = 402; ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" - ngx.print("402 See Other"); + ngx.print("402 Payment Required"); return ngx.exit(402); end diff --git a/test/lua_resty_netacea_mitigation_spec.lua b/test/lua_resty_netacea_mitigation_spec.lua index e5e4ee0..72fa5cc 100644 --- a/test/lua_resty_netacea_mitigation_spec.lua +++ b/test/lua_resty_netacea_mitigation_spec.lua @@ -116,9 +116,9 @@ describe("lua_resty_netacea_mitigation", function() assert.are.equal("max-age=0, no-cache, no-store, must-revalidate", ngx_mock.header["Cache-Control"]) end) - it("should print 402 See Other", function() + it("should print 402 Payment Required", function() mitigation.serveMonetisationFallback() - assert.spy(ngx_mock.print).was.called_with("402 See Other") + assert.spy(ngx_mock.print).was.called_with("402 Payment Required") end) it("should exit with 402", function() From 858e614c02679ad407675e915df70ef914aece9b Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Wed, 1 Apr 2026 15:07:50 +0100 Subject: [PATCH 6/6] version bump to 1.1.0 --- src/lua_resty_netacea.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 1da1fb2..5c9b6ea 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 = '0.2.2' +_N._VERSION = '1.1.0' _N._TYPE = 'nginx' local ngx = require 'ngx'