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..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.0-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.0-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 7f0ed3c..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-0.2-2.rockspec ./ +COPY ./lua_resty_netacea-1.3.0-0.rockspec ./ COPY ./src ./src -RUN luarocks make ./lua_resty_netacea-0.2-2.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/README.md b/README.md index 77a42d1..3c89a88 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 @@ -136,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/lua_resty_netacea-1.2.0-0.rockspec b/lua_resty_netacea-1.3.0-0.rockspec similarity index 96% rename from lua_resty_netacea-1.2.0-0.rockspec rename to lua_resty_netacea-1.3.0-0.rockspec index 22b76c0..6522b39 100644 --- a/lua_resty_netacea-1.2.0-0.rockspec +++ b/lua_resty_netacea-1.3.0-0.rockspec @@ -1,5 +1,5 @@ package = "lua_resty_netacea" -version = "1.2.0-0" +version = "1.3.0-0" source = { url = "git://github.com/Netacea/lua_resty_netacea", branch = "master" 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..280f534 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' @@ -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 @@ -201,33 +210,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() @@ -240,13 +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.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() @@ -276,6 +291,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 +325,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 +336,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 @@ -329,6 +366,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 @@ -338,9 +382,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_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..bbc10fb 100644 --- a/src/lua_resty_netacea_mitigation.lua +++ b/src/lua_resty_netacea_mitigation.lua @@ -11,22 +11,47 @@ 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 - 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/src/lua_resty_netacea_protector_client.lua b/src/lua_resty_netacea_protector_client.lua index d11a993..e389524 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() @@ -102,24 +103,18 @@ 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 - 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 - } + return { + response = { + status = res.status, + body = res.body, + headers = res.headers + }, + match = idType, + mitigate = mitigationType, + captcha = captchaState, + exit_status = res.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_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_protector_client_spec.lua b/test/lua_resty_netacea_protector_client_spec.lua index aa3fbb7..2d33926 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({ @@ -286,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, @@ -303,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) @@ -323,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 30d02cf..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), @@ -274,6 +275,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 { @@ -306,6 +351,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() @@ -488,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() @@ -522,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)