From 025ce0380e163f8c293fcc4e9934ad12dfdc6b29 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Fri, 12 Jun 2026 15:58:32 +0100 Subject: [PATCH 1/6] update package version and contribution guidelines --- CONTRIBUTING.md | 81 +++++++++++++++++++ Dockerfile | 4 +- Dockerfile.nginx_lua | 4 +- README.md | 66 +++++++-------- ...spec => lua_resty_netacea-1.2.1-0.rockspec | 2 +- 5 files changed, 115 insertions(+), 42 deletions(-) create mode 100644 CONTRIBUTING.md rename lua_resty_netacea-1.2.0-0.rockspec => lua_resty_netacea-1.2.1-0.rockspec (96%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2a4957a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,81 @@ +# Contributing to `lua_resty_netacea` + +## Before you start + +This repository is for developing the Lua package that is published +through Luarocks. The Netacea team handles publishing. + +Use the existing Docker-based workflow unless you have a specific reason not to: + +1. Create `./.env` from `./.env.example`. +2. Fill in the Netacea environment variables required for the mode you want to test. +3. Update `./src/conf/nginx.conf` if you need a local server configuration. + +## Local development + +Start the development container with: + +```sh +docker compose up --build resty +``` + +The module will be available on `http://localhost:8080`. + +## Testing + +Run unit tests in the dev container with: + +```sh +./run_lua_tests.sh +``` + +To include coverage output: + +```sh +export LUACOV_REPORT=1 && ./run_lua_tests.sh +``` + +You can also run tests through Docker Compose: + +```sh +docker compose run --rm --build test +``` + +For coverage through Docker Compose: + +```sh +docker compose run -e LUACOV_REPORT=1 --build test +``` + +## Linting + +Run the linter with: + +```sh +docker compose run --rm --build lint +``` + +## Code style and changes + +- Keep changes focused and consistent with the existing Lua style in `src/` and `test/`. +- Add or update tests when behavior changes. +- Prefer small, reviewable changes over broad refactors unless a refactor is part of the task. + +## Updating the version number + +When releasing a new version, update all version references together: + +1. Update [`src/lua_resty_netacea.lua`](/home/user/github/lua_resty_netacea/src/lua_resty_netacea.lua) and change `_N._VERSION` to the new library version, for example `1.2.2`. +2. Rename the rockspec file to match the new release, for example `lua_resty_netacea-1.2.2-0.rockspec`. +3. Update the `version = "..."` field inside the rockspec to the same value. +4. Update any build files that reference the rockspec filename: + - [`Dockerfile`](/home/user/github/lua_resty_netacea/Dockerfile) + - [`Dockerfile.nginx_lua`](/home/user/github/lua_resty_netacea/Dockerfile.nginx_lua) +5. Update any other hardcoded version references you introduce in future changes. + +The package version and the rockspec version should stay in sync, with the rockspec using the `-0` release suffix. + +## Security issues + +Report security issues by email to [security@netacea.com](mailto:security@netacea.com). +Include a description of the issue, the steps to reproduce it, affected versions, and any known mitigations. diff --git a/Dockerfile b/Dockerfile index 0fb304b..af91105 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.2.0-0.rockspec ./ +COPY ./lua_resty_netacea-1.2.1-0.rockspec ./ COPY ./src ./src -RUN /usr/local/openresty/luajit/bin/luarocks make ./lua_resty_netacea-1.2.0-0.rockspec +RUN /usr/local/openresty/luajit/bin/luarocks make ./lua_resty_netacea-1.2.1-0.rockspec FROM build AS test diff --git a/Dockerfile.nginx_lua b/Dockerfile.nginx_lua index 7f0ed3c..8db66e6 100644 --- a/Dockerfile.nginx_lua +++ b/Dockerfile.nginx_lua @@ -69,9 +69,9 @@ RUN cd /usr/src && \ make install # Set up Netacea module -COPY ./lua_resty_netacea-0.2-2.rockspec ./ +COPY ./lua_resty_netacea-1.2.1-0.rockspec ./ COPY ./src ./src -RUN luarocks make ./lua_resty_netacea-0.2-2.rockspec +RUN luarocks make ./lua_resty_netacea-1.2.1-0.rockspec # Link CA certs so they match expected filename RUN ln -s /etc/ssl/certs/ca-bundle.crt /etc/ssl/certs/ca-certificates.crt diff --git a/README.md b/README.md index 77a42d1..a5f80ac 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,52 @@ # lua_resty_netacea -An Openresty module for easy integration of Netacea services. This repo is for developing the package. The package can be accessed by the Luarocks package management platform. See the Netacea documentation for making use of the module. +An Openresty module for easy integration of Netacea services. This repo is for +developing the package. The package can be accessed by the Luarocks package +management platform. See the Netacea documentation for making use of the module. ## Published package -The Netacea package is available on the Luarocks package manager. Publishing is handled by the Netacea team. +The Netacea package is available on the Luarocks package manager. +Publishing is handled by the Netacea team. + +## Contributing to this Repository + +If you wish to make a contribution to this repository, please see +[CONTRIBUTING.md](/home/user/github/lua_resty_netacea/CONTRIBUTING.md). ## Docker images -The Dockerfile contains a multi-stage build, including: +This repository includes Dockerfiles and Docker Compose services for running the +existing code without making any changes. -| Stage name | Based on | Description | -| -- | -- | -- | -| base | openresty/openresty:noble | Base image of Openresty with updated packages around openSSL | -| build | base | Working Openresty instance with Netacea plugin installed using luarocks and rockspec file | -| test | build | Lua packages installed for testing and linting. Command overridden to run unit tests | -| lint | test | Command overridden to run luacheck linter and output results | +### Prerequisites -The docker compose file is used to mount local files to the right place in the image to support development. +- Docker Engine or Docker Desktop +- Docker Compose +- A local `.env` file if you want to supply Netacea runtime values -### Environment variables +### Build the image -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: +To build the OpenResty image used by the `resty` service: ```sh -cp .env.example .env +docker compose build resty ``` -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. 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 +This uses [`Dockerfile`](/home/user/github/lua_resty_netacea/Dockerfile) and +builds the package from the checked-in rockspec and source files. -#### Unit tests +### Run the module locally -##### In dev container +The `resty` service starts OpenResty with the module loaded and exposes it on +`http://localhost:8080`: -Without coverage report: `./run_lua_tests.sh` -With coverage report (sent to stdout) `export LUACOV_REPORT=1 && ./run_lua_tests.sh` - -##### Docker compose - -Without coverage report: `docker compose run --rm --build test` -With coverage report (sent to stdout) `docker compose run -e LUACOV_REPORT=1 --build test [> output.html]` - -#### Linter +```sh +docker compose up --build resty +``` -`docker compose run --rm --build lint` +Before starting it, create a `.env` file from `.env.example` and set the +runtime values required by your selected protection mode. ## Configuration diff --git a/lua_resty_netacea-1.2.0-0.rockspec b/lua_resty_netacea-1.2.1-0.rockspec similarity index 96% rename from lua_resty_netacea-1.2.0-0.rockspec rename to lua_resty_netacea-1.2.1-0.rockspec index 22b76c0..1ef680b 100644 --- a/lua_resty_netacea-1.2.0-0.rockspec +++ b/lua_resty_netacea-1.2.1-0.rockspec @@ -1,5 +1,5 @@ package = "lua_resty_netacea" -version = "1.2.0-0" +version = "1.2.1-0" source = { url = "git://github.com/Netacea/lua_resty_netacea", branch = "master" From 0da04d9271d321b0e35782cdb1f7f2b2771529b9 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Wed, 17 Jun 2026 10:24:15 +0100 Subject: [PATCH 2/6] implement checkpoint challenge support --- README.md | 1 + docker-compose.yml | 2 - src/conf/nginx.conf | 2 + src/lua_resty_netacea.lua | 88 +++++++++++++------ src/lua_resty_netacea_constants.lua | 23 ++++- src/lua_resty_netacea_cookies_v3.lua | 2 + src/lua_resty_netacea_mitigation.lua | 15 +++- src/lua_resty_netacea_protector_client.lua | 38 ++++---- ...ua_resty_netacea_protector_client_spec.lua | 15 +++- test/lua_resty_netacea_spec.lua | 44 ++++++++++ 10 files changed, 184 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index a5f80ac..3c89a88 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ NETACEA_PROTECTOR_API_URL=https://your-protector-api-url | `NETACEA_CAPTCHA_COOKIE_ATTRIBUTES` | `Max-Age=86400; Path=/;` | | `NETACEA_REAL_IP_HEADER` | `""` | | `NETACEA_REAL_IP_HEADER_INDEX` | unset | +| `NETACEA_CHECKPOINT_SIGNAL_PATH` | unset | | `NETACEA_KINESIS_ACCESS_KEY` | `""` | | `NETACEA_KINESIS_SECRET_KEY` | `""` | | `NETACEA_KINESIS_STREAM_NAME` | `""` | diff --git a/docker-compose.yml b/docker-compose.yml index 21a21ed..39c2a06 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.5' - services: resty: build: diff --git a/src/conf/nginx.conf b/src/conf/nginx.conf index 3af4d25..e4b9b18 100644 --- a/src/conf/nginx.conf +++ b/src/conf/nginx.conf @@ -12,6 +12,7 @@ env NETACEA_SECRET_KEY; env NETACEA_INGEST_ENABLED; env NETACEA_REAL_IP_HEADER; env NETACEA_REAL_IP_HEADER_INDEX; +env NETACEA_CHECKPOINT_SIGNAL_PATH; env NETACEA_KINESIS_ACCESS_KEY; env NETACEA_KINESIS_SECRET_KEY; env NETACEA_KINESIS_STREAM_NAME; @@ -51,6 +52,7 @@ http { captchaCookieAttributes = env('NETACEA_CAPTCHA_COOKIE_ATTRIBUTES', ''), realIpHeader = env('NETACEA_REAL_IP_HEADER', ''), realIpHeaderIndex = tonumber(env('NETACEA_REAL_IP_HEADER_INDEX', '')), + checkpointSignalPath = env('NETACEA_CHECKPOINT_SIGNAL_PATH'), kinesisProperties = { region = env('NETACEA_KINESIS_REGION', 'eu-west-1'), stream_name = env('NETACEA_KINESIS_STREAM_NAME', ''), diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 2d7684d..ff172af 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -109,6 +109,8 @@ function _N:new(options) n.realIpHeaderIndex = utils.parseOption(options.realIpHeaderIndex, nil) -- global:optional:userIdKey n.userIdKey = utils.parseOption(options.userIdKey, '') + -- global:optional:checkpointSignalPath + n.checkpointSignalPath = utils.parseOption(options.checkpointSignalPath, nil) -- global:required:apiKey n.apiKey = utils.parseOption(options.apiKey, nil) if not n.apiKey then @@ -148,7 +150,14 @@ function _N:setBcType(match, mitigate, captcha) mitigationApplied = mitigationApplied .. (Constants.mitigateBcTypes[mitigate] or UNKNOWN) end if (captcha ~= '0') then - mitigationApplied = mitigationApplied .. ',' .. (Constants.captchaBcTypes[captcha] or UNKNOWN) + if mitigationApplied ~= '' then + mitigationApplied = mitigationApplied .. ',' + end + mitigationApplied = mitigationApplied .. ( + Constants.captchaBcTypes[captcha] or + Constants.checkpointBcTypes[captcha] or + UNKNOWN + ) end return mitigationApplied end @@ -192,6 +201,10 @@ function _N:handleSession() end function _N:refreshSession(reason) + if shouldSetCookies == nil then + shouldSetCookies = true + end + local protector_result = ngx.ctx.NetaceaState.protector_result or { match = Constants['idTypes'].NONE, mitigate = Constants['mitigationTypes'].NONE, @@ -201,33 +214,33 @@ function _N:refreshSession(reason) local grace_period = ngx.ctx.NetaceaState.grace_period or 60 local new_cookie = netacea_cookies.generateNewCookieValue( + self.cookieEncryptionKey, + ngx.ctx.NetaceaState.client, + ngx.ctx.NetaceaState.UserId, + netacea_cookies.newUserId(), + reason, + os.time(), + grace_period, + protector_result.match, + protector_result.mitigate, + protector_result.captcha, + {} + ) + local cookies = { + self.cookieName .. '=' .. new_cookie.mitata_jwe .. ';' .. 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.cookieEncryptionKey, - ngx.ctx.NetaceaState.client, - ngx.ctx.NetaceaState.UserId, - netacea_cookies.newUserId(), - reason, - os.time(), - grace_period, - protector_result.match, - protector_result.mitigate, - protector_result.captcha, - {} + protector_result.captcha_cookie ) - 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.cookieEncryptionKey, - protector_result.captcha_cookie - ) - table.insert(cookies, - self.captchaCookieName .. '=' .. captcha_cookie_encrypted .. ';'.. self.captchaCookieAttributes) - end + table.insert(cookies, + self.captchaCookieName .. '=' .. captcha_cookie_encrypted .. ';'.. self.captchaCookieAttributes) + end - ngx.header['Set-Cookie'] = cookies + ngx.header['Set-Cookie'] = cookies end function _N:handleCaptcha() @@ -243,6 +256,9 @@ function _N:handleCaptcha() if protector_result.captcha == Constants['captchaStates'].PASS then self:refreshSession(Constants['issueReasons'].CAPTCHA_POST) end + if protector_result.captcha == Constants['checkpointStates'].PASS then + self:refreshSession(Constants['issueReasons'].CAPTCHA_POST) + end ngx.exit(protector_result.exit_status) end @@ -276,6 +292,18 @@ function _N:mitigate() end local parsed_cookie = self:handleSession() + -- Return early on requests to the checkpoint signal path + local signalPathEnabled = (self.checkpointSignalPath or '') ~= '' + if signalPathEnabled and ngx.var.uri == self.checkpointSignalPath then + ngx.ctx.NetaceaState.bc_type = self:setBcType( + parsed_cookie.data.mat or nil, + parsed_cookie.data.mit or nil, + Constants['checkpointStates'].SIGNAL + ) + ngx.exit(ngx.OK) + return + end + if not parsed_cookie.valid then if not ngx.ctx.NetaceaState.UserId then ngx.ctx.NetaceaState.UserId = netacea_cookies.newUserId() @@ -298,6 +326,7 @@ function _N:mitigate() end local best_mitigation = mitigation.getBestMitigation(protector_result) + ngx.log(ngx.DEBUG, "NETACEA MITIGATE best mitigation: " .. (best_mitigation or "none")) if best_mitigation == 'captcha' then ngx.log(ngx.DEBUG, "NETACEA MITIGATE - serving captcha") @@ -308,6 +337,15 @@ function _N:mitigate() return end + if best_mitigation == 'checkpoint' then + ngx.log(ngx.DEBUG, "NETACEA MITIGATE - serving checkpoint") + local checkpointBody = protector_result.response.body + ngx.ctx.NetaceaState.grace_period = -1000 + self:refreshSession(parsed_cookie.reason) + mitigation.serveCaptcha(checkpointBody) + return + end + if best_mitigation == 'block' then ngx.log(ngx.DEBUG, "NETACEA MITIGATE - serving block") ngx.ctx.NetaceaState.grace_period = -1000 diff --git a/src/lua_resty_netacea_constants.lua b/src/lua_resty_netacea_constants.lua index 48232f7..2908da4 100644 --- a/src/lua_resty_netacea_constants.lua +++ b/src/lua_resty_netacea_constants.lua @@ -35,6 +35,17 @@ Constants['captchaStates'] = { COOKIEFAIL = '5' } +Constants['checkpointStates'] = { + NONE = '0', + SIGNAL = '6', + POST = '7', + SERVE = 'a', + PASS = 'b', + FAIL = 'c', + COOKIEPASS = 'd', + COOKIEFAIL = 'e' +} + Constants['issueReasons'] = { NO_SESSION = 'no_session', EXPIRED_SESSION = 'expired_session', @@ -74,6 +85,16 @@ Constants['captchaBcTypes'] = { ['5'] = 'captcha_cookiefail' } +Constants['checkpointBcTypes'] = { + ['6'] = 'checkpoint_signal', + ['7'] = 'checkpoint_post', + ['a'] = 'checkpoint_serve', + ['b'] = 'checkpoint_pass', + ['c'] = 'checkpoint_fail', + ['d'] = 'checkpoint_cookiepass', + ['e'] = 'checkpoint_cookiefail' +} + local function reversifyTable(table) for k,v in pairs(Constants[table]) do Constants[table .. 'Text'][v] = k end end @@ -82,4 +103,4 @@ reversifyTable('idTypes') reversifyTable('mitigationTypes') reversifyTable('captchaStates') -return Constants \ No newline at end of file +return Constants diff --git a/src/lua_resty_netacea_cookies_v3.lua b/src/lua_resty_netacea_cookies_v3.lua index 642cdf4..37101cb 100644 --- a/src/lua_resty_netacea_cookies_v3.lua +++ b/src/lua_resty_netacea_cookies_v3.lua @@ -129,6 +129,7 @@ function NetaceaCookies.parseMitataCookie(cookie, cookieEncryptionKey) if ist + grp < ngx.time() then return { valid = false, + data = decoded, user_id = decoded.uid, reason = constants['issueReasons'].EXPIRED_SESSION } @@ -137,6 +138,7 @@ function NetaceaCookies.parseMitataCookie(cookie, cookieEncryptionKey) if decoded.cip ~= ngx.ctx.NetaceaState.client then return { valid = false, + data = decoded, user_id = decoded.uid, reason = constants['issueReasons'].IP_CHANGE } diff --git a/src/lua_resty_netacea_mitigation.lua b/src/lua_resty_netacea_mitigation.lua index 483d232..64fbb64 100644 --- a/src/lua_resty_netacea_mitigation.lua +++ b/src/lua_resty_netacea_mitigation.lua @@ -11,17 +11,30 @@ function _M.getBestMitigation(protector_result) 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 + + -- Handle captcha pass if (captcha == Constants.captchaStates.PASS) then return nil end if (captcha == Constants.captchaStates.COOKIEPASS) then return nil end + -- Handle checkpoint pass + if (captcha == Constants.checkpointStates.PASS) then return nil end + if (captcha == Constants.checkpointStates.COOKIEPASS) then return nil end + + -- Handle captcha serve if (mitigate == Constants.mitigationTypes.BLOCKED and (captcha == Constants.captchaStates.SERVE or captcha == Constants['captchaStates'].COOKIEFAIL)) then return 'captcha' end + -- handle checkpoint serve + if (mitigate == Constants.mitigationTypes.BLOCKED + and (captcha == Constants.checkpointStates.SERVE + or captcha == Constants['checkpointStates'].COOKIEFAIL)) then + return 'checkpoint' + end + if (mitigate == Constants.mitigationTypes.MONETISED) then return 'monetise' end diff --git a/src/lua_resty_netacea_protector_client.lua b/src/lua_resty_netacea_protector_client.lua index d11a993..3024059 100644 --- a/src/lua_resty_netacea_protector_client.lua +++ b/src/lua_resty_netacea_protector_client.lua @@ -30,6 +30,7 @@ end function ProtectorClient:getMitigationRequestHeaders() local NetaceaState = ngx.ctx.NetaceaState + local content_type = ngx.var and ngx.var.http_content_type or nil local cookie = '' if NetaceaState ~= nil and NetaceaState.captcha_cookie ~= nil then @@ -38,7 +39,7 @@ function ProtectorClient:getMitigationRequestHeaders() local headers = { ["x-netacea-api-key"] = self.apiKey, - ["content-type"] = 'application/x-www-form-urlencoded', + ["content-type"] = content_type or 'application/x-www-form-urlencoded', ["cookie"] = cookie, ["user-agent"] = NetaceaState.user_agent or '', ["x-netacea-client-ip"] = NetaceaState.client or '', @@ -78,7 +79,7 @@ function ProtectorClient:checkReputation() end function ProtectorClient:validateCaptcha(captcha_data) - local hc = createHttpConnection() + local hc = createHttpConnection() local headers = self:getMitigationRequestHeaders() @@ -105,21 +106,26 @@ function ProtectorClient:validateCaptcha(captcha_data) local exit_status = ngx.HTTP_FORBIDDEN if (captchaState == constants['captchaStates'].PASS) then exit_status = ngx.HTTP_OK - end - return { - response = { - status = res.status, - body = res.body, - headers = res.headers - }, - match = idType, - mitigate = mitigationType, - captcha = captchaState, - exit_status = exit_status, - captcha_cookie = res.headers['X-Netacea-MitATACaptcha-Value'] or nil - } + if (captchaState == constants['checkpointStates'].POST) then + exit_status = ngx.HTTP_OK + end + if (captchaState == constants['checkpointStates'].PASS) then + exit_status = ngx.HTTP_OK + end + return { + response = { + status = res.status, + body = res.body, + headers = res.headers + }, + match = idType, + mitigate = mitigationType, + captcha = captchaState, + exit_status = exit_status, + captcha_cookie = res.headers['X-Netacea-MitATACaptcha-Value'] or nil + } end -return ProtectorClient \ No newline at end of file +return ProtectorClient diff --git a/test/lua_resty_netacea_protector_client_spec.lua b/test/lua_resty_netacea_protector_client_spec.lua index aa3fbb7..be2c019 100644 --- a/test/lua_resty_netacea_protector_client_spec.lua +++ b/test/lua_resty_netacea_protector_client_spec.lua @@ -22,6 +22,9 @@ describe("lua_resty_netacea_protector_client", function() captcha_cookie = nil } }, + var = { + http_content_type = "application/json" + }, log = spy.new(function() end), ERR = 3, HTTP_FORBIDDEN = 403, @@ -92,12 +95,22 @@ describe("lua_resty_netacea_protector_client", function() }) 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("application/json", 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 default content-type to application/x-www-form-urlencoded when missing", function() + ngx_mock.var.http_content_type = nil + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + local headers = client:getMitigationRequestHeaders() + assert.are.equal("application/x-www-form-urlencoded", headers["content-type"]) + end) + it("should include captcha cookie when present", function() ngx_mock.ctx.NetaceaState.captcha_cookie = "captcha_value_123" local client = ProtectorClient:new({ diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index 30d02cf..3d98d22 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -274,6 +274,50 @@ insulate("lua_resty_netacea", function() assert.spy(ngx_mock.exit).was_not_called() end) + it("should return HTTP_OK for checkpointSignalPath without proxying to origin", function() + cookies_mock.parseMitataCookie = spy.new(function() + return { + valid = true, + user_id = "existing-user-id", + data = { + mat = "2", + mit = "4", + cap = "0" + } + } + end) + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + checkpointSignalPath = "/CustomCheck" + }) + ngx_mock.var.uri = "/CustomCheck" + + netacea:mitigate() + + assert.spy(ngx_mock.exit).was.called_with(ngx_mock.OK) + assert.are.equal("ip_flagged,checkpoint_signal", ngx_mock.ctx.NetaceaState.bc_type) + assert.spy(protector_client_instance.checkReputation).was_not_called() + assert.spy(mitigation_mock.getBestMitigation).was_not_called() + assert.spy(cookies_mock.generateNewCookieValue).was_not_called() + assert.is_nil(ngx_mock.header["Set-Cookie"]) + end) + + it("should keep checkpointSignalPath unset when omitted", function() + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key" + }) + + assert.is_nil(netacea.checkpointSignalPath) + end) + it("should inject the recommendation headers from a valid session", function() cookies_mock.parseMitataCookie = spy.new(function() return { From c8342e2654e8f841f336e03246aec08b4e8251a7 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Wed, 17 Jun 2026 10:32:01 +0100 Subject: [PATCH 3/6] version bump to 1.3.0 --- Dockerfile | 4 ++-- Dockerfile.nginx_lua | 4 ++-- ...cea-1.2.1-0.rockspec => lua_resty_netacea-1.3.0-0.rockspec | 2 +- src/lua_resty_netacea.lua | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) rename lua_resty_netacea-1.2.1-0.rockspec => lua_resty_netacea-1.3.0-0.rockspec (96%) diff --git a/Dockerfile b/Dockerfile index af91105..2aef76e 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.2.1-0.rockspec ./ +COPY ./lua_resty_netacea-1.3.0-0.rockspec ./ COPY ./src ./src -RUN /usr/local/openresty/luajit/bin/luarocks make ./lua_resty_netacea-1.2.1-0.rockspec +RUN /usr/local/openresty/luajit/bin/luarocks make ./lua_resty_netacea-1.3.0-0.rockspec FROM build AS test diff --git a/Dockerfile.nginx_lua b/Dockerfile.nginx_lua index 8db66e6..513831d 100644 --- a/Dockerfile.nginx_lua +++ b/Dockerfile.nginx_lua @@ -69,9 +69,9 @@ RUN cd /usr/src && \ make install # Set up Netacea module -COPY ./lua_resty_netacea-1.2.1-0.rockspec ./ +COPY ./lua_resty_netacea-1.3.0-0.rockspec ./ COPY ./src ./src -RUN luarocks make ./lua_resty_netacea-1.2.1-0.rockspec +RUN luarocks make ./lua_resty_netacea-1.3.0-0.rockspec # Link CA certs so they match expected filename RUN ln -s /etc/ssl/certs/ca-bundle.crt /etc/ssl/certs/ca-certificates.crt diff --git a/lua_resty_netacea-1.2.1-0.rockspec b/lua_resty_netacea-1.3.0-0.rockspec similarity index 96% rename from lua_resty_netacea-1.2.1-0.rockspec rename to lua_resty_netacea-1.3.0-0.rockspec index 1ef680b..6522b39 100644 --- a/lua_resty_netacea-1.2.1-0.rockspec +++ b/lua_resty_netacea-1.3.0-0.rockspec @@ -1,5 +1,5 @@ package = "lua_resty_netacea" -version = "1.2.1-0" +version = "1.3.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 ff172af..4846305 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.2.1' +_N._VERSION = '1.3.0' _N._TYPE = 'nginx' local ngx = require 'ngx' From 83f88fec22e89c54b811959d5ad5616ad71eeb00 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Wed, 17 Jun 2026 10:35:31 +0100 Subject: [PATCH 4/6] rm unused variable --- src/lua_resty_netacea.lua | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 4846305..1585322 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -201,10 +201,6 @@ function _N:handleSession() end function _N:refreshSession(reason) - if shouldSetCookies == nil then - shouldSetCookies = true - end - local protector_result = ngx.ctx.NetaceaState.protector_result or { match = Constants['idTypes'].NONE, mitigate = Constants['mitigationTypes'].NONE, From 9045f948db5413f0e900d221fb49617e29f7e712 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Thu, 18 Jun 2026 11:52:29 +0100 Subject: [PATCH 5/6] inject headers for flagged requests --- src/lua_resty_netacea.lua | 13 +++- src/lua_resty_netacea_mitigation.lua | 14 ++++- test/lua_resty_netacea_mitigation_spec.lua | 4 +- test/lua_resty_netacea_spec.lua | 69 ++++++++++++++++++++++ 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 1585322..d244976 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -363,6 +363,13 @@ function _N:mitigate() return end + if best_mitigation == 'flag' then + ngx.log(ngx.DEBUG, "NETACEA MITIGATE - flagging request with recommendation headers") + setInjectHeaders(protector_result) + self:refreshSession(parsed_cookie.reason) + return + end + ngx.log(ngx.DEBUG, "NETACEA MITIGATE - no mitigation applied") self:refreshSession(parsed_cookie.reason) else @@ -372,9 +379,11 @@ function _N:mitigate() mitigate = parsed_cookie.data.mit, captcha = parsed_cookie.data.cap } - if self.mitigationType == 'INJECT' then + local isFlagged = parsed_cookie.data.mit == Constants['mitigationTypes'].FLAGGED + if self.mitigationType == 'INJECT' or isFlagged then ngx.log(ngx.DEBUG, - "NETACEA INJECT - setting recommendation headers from session: match=", parsed_cookie.data.mat, + "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) diff --git a/src/lua_resty_netacea_mitigation.lua b/src/lua_resty_netacea_mitigation.lua index 64fbb64..bbc10fb 100644 --- a/src/lua_resty_netacea_mitigation.lua +++ b/src/lua_resty_netacea_mitigation.lua @@ -39,7 +39,19 @@ function _M.getBestMitigation(protector_result) return 'monetise' end - return 'block' + if (mitigate == Constants.mitigationTypes.FLAGGED) then + return 'flag' + end + + if (mitigate == Constants.mitigationTypes.BLOCKED) then + return 'block' + end + + if (mitigate == Constants.mitigationTypes.HARDBLOCKED) then + return 'block' + end + + return nil end function _M.serveCaptcha(captchaBody) diff --git a/test/lua_resty_netacea_mitigation_spec.lua b/test/lua_resty_netacea_mitigation_spec.lua index 72fa5cc..92d4e45 100644 --- a/test/lua_resty_netacea_mitigation_spec.lua +++ b/test/lua_resty_netacea_mitigation_spec.lua @@ -220,12 +220,12 @@ describe("lua_resty_netacea_mitigation", function() assert.are.equal('block', mitigation.getBestMitigation(result)) end) - it("should return block when mitigate is FLAGGED", function() + it("should return flag when mitigate is FLAGGED", function() local result = { mitigate = Constants.mitigationTypes.FLAGGED, captcha = Constants.captchaStates.NONE } - assert.are.equal('block', mitigation.getBestMitigation(result)) + assert.are.equal('flag', mitigation.getBestMitigation(result)) end) end) end) diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index 3d98d22..3bc8627 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -350,6 +350,75 @@ insulate("lua_resty_netacea", function() assert.spy(mitigation_mock.getBestMitigation).was_not_called() assert.spy(ngx_mock.exit).was_not_called() end) + + it("should inject the recommendation headers when best mitigation is flag", function() + protector_client_instance.checkReputation = spy.new(function() + return { + match = "2", + mitigate = "4", + captcha = "0", + response = { + body = "" + } + } + end) + mitigation_mock.getBestMitigation = spy.new(function() + return "flag" + end) + + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationType = "MITIGATE", + 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(cookies_mock.generateNewCookieValue).was.called(1) + 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 flagged session without calling the protector api", 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 = "MITIGATE", + 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 2b41e943475477b83b77f3714f0417da0710bf28 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Fri, 19 Jun 2026 16:48:10 +0100 Subject: [PATCH 6/6] fix checkpoint status codes and cookie usage --- src/lua_resty_netacea.lua | 11 +++++---- src/lua_resty_netacea_protector_client.lua | 13 +--------- ...ua_resty_netacea_protector_client_spec.lua | 6 ++--- test/lua_resty_netacea_spec.lua | 24 ++++++++++++++++--- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index d244976..280f534 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -249,16 +249,19 @@ function _N:handleCaptcha() ngx.ctx.NetaceaState.grace_period = -1000 ngx.log(ngx.DEBUG, "NETACEA CAPTCHA - protector result: ", cjson.encode(ngx.ctx.NetaceaState)) - if protector_result.captcha == Constants['captchaStates'].PASS then + -- Refresh session only when the captcha verification succeeded and this wasn't a checkpoint post + local isCheckpointPost = protector_result.captcha ~= Constants['checkpointStates'].POST + if protector_result.exit_status == 200 and isCheckpointPost then self:refreshSession(Constants['issueReasons'].CAPTCHA_POST) end - if protector_result.captcha == Constants['checkpointStates'].PASS then - self:refreshSession(Constants['issueReasons'].CAPTCHA_POST) + + if protector_result.response and protector_result.response.body then + ngx.print(protector_result.response.body) end + ngx.exit(protector_result.exit_status) end - function _N:refreshIngestSession() local parsed_cookie = self:handleSession() diff --git a/src/lua_resty_netacea_protector_client.lua b/src/lua_resty_netacea_protector_client.lua index 3024059..e389524 100644 --- a/src/lua_resty_netacea_protector_client.lua +++ b/src/lua_resty_netacea_protector_client.lua @@ -103,16 +103,6 @@ function ProtectorClient:validateCaptcha(captcha_data) 'Netacea captcha validation response: match=' .. idType .. ', mitigate=' .. mitigationType .. ', captcha=' .. captchaState) - local exit_status = ngx.HTTP_FORBIDDEN - if (captchaState == constants['captchaStates'].PASS) then - exit_status = ngx.HTTP_OK - end - if (captchaState == constants['checkpointStates'].POST) then - exit_status = ngx.HTTP_OK - end - if (captchaState == constants['checkpointStates'].PASS) then - exit_status = ngx.HTTP_OK - end return { response = { status = res.status, @@ -122,10 +112,9 @@ function ProtectorClient:validateCaptcha(captcha_data) match = idType, mitigate = mitigationType, captcha = captchaState, - exit_status = exit_status, + exit_status = res.status, captcha_cookie = res.headers['X-Netacea-MitATACaptcha-Value'] or nil } end - return ProtectorClient diff --git a/test/lua_resty_netacea_protector_client_spec.lua b/test/lua_resty_netacea_protector_client_spec.lua index be2c019..2d33926 100644 --- a/test/lua_resty_netacea_protector_client_spec.lua +++ b/test/lua_resty_netacea_protector_client_spec.lua @@ -299,7 +299,7 @@ describe("lua_resty_netacea_protector_client", function() assert.are.equal("captcha_cookie_val", result.captcha_cookie) end) - it("should return exit_status HTTP_FORBIDDEN when captcha fails", function() + it("should return the upstream status when captcha fails", function() http_mock_instance.request_uri = spy.new(function() return { status = 200, @@ -316,7 +316,7 @@ describe("lua_resty_netacea_protector_client", function() mitigationEndpoint = { "https://endpoint1.example.com" } }) local result = client:validateCaptcha("captcha_data") - assert.are.equal(ngx_mock.HTTP_FORBIDDEN, result.exit_status) + assert.are.equal(200, result.exit_status) assert.are.equal(constants.captchaStates.FAIL, result.captcha) end) @@ -336,7 +336,7 @@ describe("lua_resty_netacea_protector_client", function() 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) + assert.are.equal(200, result.exit_status) end) it("should return nil captcha_cookie when header is missing", function() diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index 3bc8627..2688136 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -24,6 +24,7 @@ insulate("lua_resty_netacea", function() }, header = {}, log = spy.new(function() end), + print = spy.new(function() end), exit = spy.new(function() end), req = { read_body = spy.new(function() end), @@ -601,27 +602,43 @@ insulate("lua_resty_netacea", function() }) end - it("should not set session or captcha cookies when captcha fails", function() + it("should NOT refresh 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" + captcha_cookie = "failed-captcha-cookie", + response = { + body = "Unauthorized" + } } end) local netacea = new_mitigation_enabled_netacea() netacea:handleCaptcha() - assert.is_nil(ngx_mock.header["Set-Cookie"]) + assert.are.same({}, ngx_mock.header["Set-Cookie"] or {}) assert.spy(cookies_mock.generateNewCookieValue).was_not_called() assert.spy(cookies_mock.encrypt).was_not_called() + assert.spy(ngx_mock.print).was.called_with("Unauthorized") assert.spy(ngx_mock.exit).was.called_with(403) end) it("should refresh session and captcha cookies when captcha passes", function() + protector_client_instance.validateCaptcha = spy.new(function() + return { + match = "1", + mitigate = "1", + captcha = "2", + exit_status = 200, + captcha_cookie = "captcha-cookie-value", + response = { + body = "Captcha OK" + } + } + end) local netacea = new_mitigation_enabled_netacea() netacea:handleCaptcha() @@ -635,6 +652,7 @@ insulate("lua_resty_netacea", function() "decoded-test-cookie-encryption-key", "captcha-cookie-value" ) + assert.spy(ngx_mock.print).was.called_with("Captcha OK") assert.spy(ngx_mock.exit).was.called_with(200) end) end)