From 612572bb15ae4fa23473fddc1ddaebf63bc5c35e Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Thu, 25 Jun 2026 13:51:56 +0100 Subject: [PATCH 1/9] read large request bodies from disk when needed --- src/lua_resty_netacea.lua | 28 +++++++++++++- test/lua_resty_netacea_spec.lua | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index ffa3829..93fd96d 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -51,6 +51,31 @@ local function serveCaptchaFailOpen(body, options) return true end +local function readRequestBody() + ngx.req.read_body() + + local body = ngx.req.get_body_data() + if body ~= nil then + return body + end + + local body_file = ngx.req.get_body_file() + if not body_file then + return nil + end + + local file, err = io.open(body_file, "rb") + if not file then + ngx.log(ngx.WARN, "NETACEA CAPTCHA - unable to read request body file: ", err) + return nil + end + + local data = file:read("*a") + file:close() + + return data +end + function _N:new(options) local n = {} setmetatable(n, self) @@ -257,8 +282,7 @@ end function _N:handleCaptcha() self:handleSession() - ngx.req.read_body() - local captcha_data = ngx.req.get_body_data() + local captcha_data = readRequestBody() local protector_result = self.protectorClient:validateCaptcha(captcha_data) ngx.ctx.NetaceaState.protector_result = protector_result ngx.ctx.NetaceaState.grace_period = -1000 diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index a0141fe..e0c3635 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -29,6 +29,7 @@ insulate("lua_resty_netacea", function() req = { read_body = spy.new(function() end), get_body_data = spy.new(function() return "captcha-response" end), + get_body_file = spy.new(function() return nil end), set_header = spy.new(function() end) }, DEBUG = 7, @@ -926,6 +927,72 @@ insulate("lua_resty_netacea", function() assert.spy(ngx_mock.print).was.called_with("Captcha OK") assert.spy(ngx_mock.exit).was.called_with(200) end) + + it("should read captcha request bodies from the temporary file when needed", function() + local body_file = os.tmpname() + local file = assert(io.open(body_file, "wb")) + file:write("captcha-from-file") + file:close() + + ngx_mock.req.get_body_data = spy.new(function() + return nil + end) + ngx_mock.req.get_body_file = spy.new(function() + return body_file + end) + + local captured_body + protector_client_instance.validateCaptcha = spy.new(function(_, body) + captured_body = body + return { + match = "1", + mitigate = "1", + captcha = "2", + exit_status = 200, + captcha_cookie = nil, + response = { + body = "Captcha OK" + } + } + end) + + local netacea = new_mitigation_enabled_netacea() + + netacea:handleCaptcha() + + assert.are.equal("captcha-from-file", captured_body) + os.remove(body_file) + end) + + it("should return nil captcha body when the body file cannot be read", function() + ngx_mock.req.get_body_data = spy.new(function() + return nil + end) + ngx_mock.req.get_body_file = spy.new(function() + return "/tmp/definitely-not-a-real-body-file" + end) + + local captured_body + protector_client_instance.validateCaptcha = spy.new(function(_, body) + captured_body = body + return { + match = "1", + mitigate = "1", + captcha = "2", + exit_status = 200, + captcha_cookie = nil, + response = { + body = "Captcha OK" + } + } + end) + + local netacea = new_mitigation_enabled_netacea() + + netacea:handleCaptcha() + + assert.is_nil(captured_body) + end) end) end) end) From 9ac84ad658d631b89f4612011daadcb8095726ad Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Fri, 26 Jun 2026 12:32:43 +0100 Subject: [PATCH 2/9] add NETACEA_BLOCKED_RESPONSE_STATUS config option --- src/conf/nginx.conf | 2 ++ src/lua_resty_netacea.lua | 7 ++++++- src/lua_resty_netacea_mitigation.lua | 9 +++++---- test/lua_resty_netacea_mitigation_spec.lua | 10 ++++++++++ test/lua_resty_netacea_spec.lua | 20 ++++++++++++++++++++ 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/conf/nginx.conf b/src/conf/nginx.conf index 0554f66..c884fc8 100644 --- a/src/conf/nginx.conf +++ b/src/conf/nginx.conf @@ -15,6 +15,7 @@ env NETACEA_REAL_IP_HEADER_INDEX; env NETACEA_CHECKPOINT_SIGNAL_PATH; env NETACEA_CAPTCHA_PATH; env NETACEA_ENABLE_CAPTCHA_CONTENT_NEGOTIATION; +env NETACEA_BLOCKED_RESPONSE_STATUS; env NETACEA_KINESIS_ACCESS_KEY; env NETACEA_KINESIS_SECRET_KEY; env NETACEA_KINESIS_STREAM_NAME; @@ -56,6 +57,7 @@ http { realIpHeaderIndex = tonumber(env('NETACEA_REAL_IP_HEADER_INDEX', '')), checkpointSignalPath = env('NETACEA_CHECKPOINT_SIGNAL_PATH'), netaceaCaptchaPath = env('NETACEA_CAPTCHA_PATH'), + blockedResponseStatus = env('NETACEA_BLOCKED_RESPONSE_STATUS'), enableCaptchaContentNegotiation = envEnabled('NETACEA_ENABLE_CAPTCHA_CONTENT_NEGOTIATION', false), kinesisProperties = { region = env('NETACEA_KINESIS_REGION', 'eu-west-1'), diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 93fd96d..ed6d517 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -148,6 +148,11 @@ function _N:new(options) n.checkpointSignalPath = utils.parseOption(options.checkpointSignalPath, nil) -- global:optional:netaceaCaptchaPath n.netaceaCaptchaPath = utils.normalizeRelativePath(utils.parseOption(options.netaceaCaptchaPath, nil)) + -- global:optional:blockedResponseStatus + n.blockedResponseStatus = tonumber(utils.parseOption(options.blockedResponseStatus, nil)) + if n.blockedResponseStatus then + n.blockedResponseStatus = math.floor(n.blockedResponseStatus) + end -- global:optional:enableCaptchaContentNegotiation n.enableCaptchaContentNegotiation = options.enableCaptchaContentNegotiation == true -- global:required:apiKey @@ -414,7 +419,7 @@ function _N:mitigate() ngx.log(ngx.DEBUG, "NETACEA MITIGATE - serving block") ngx.ctx.NetaceaState.grace_period = -1000 self:refreshSession(parsed_cookie.reason) - mitigation.serveBlock() + mitigation.serveBlock(self.blockedResponseStatus) return end diff --git a/src/lua_resty_netacea_mitigation.lua b/src/lua_resty_netacea_mitigation.lua index 439c89f..6345b6f 100644 --- a/src/lua_resty_netacea_mitigation.lua +++ b/src/lua_resty_netacea_mitigation.lua @@ -120,11 +120,12 @@ function _M.serveCaptcha(captchaBody, options) return ngx.exit(ngx.HTTP_OK) end -function _M.serveBlock() - ngx.status = ngx.HTTP_FORBIDDEN; +function _M.serveBlock(blockedResponseStatus) + local status = tonumber(blockedResponseStatus) or ngx.HTTP_FORBIDDEN + ngx.status = status; ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" - ngx.print("403 Forbidden"); - return ngx.exit(ngx.HTTP_FORBIDDEN); + ngx.print(tostring(status) .. " Forbidden"); + return ngx.exit(status); end function _M.serveMonetisationRedirect(location) diff --git a/test/lua_resty_netacea_mitigation_spec.lua b/test/lua_resty_netacea_mitigation_spec.lua index 091c479..3e5d99a 100644 --- a/test/lua_resty_netacea_mitigation_spec.lua +++ b/test/lua_resty_netacea_mitigation_spec.lua @@ -161,6 +161,11 @@ describe("lua_resty_netacea_mitigation", function() assert.are.equal(403, ngx_mock.status) end) + it("should use the configured blocked response status", function() + mitigation.serveBlock(429) + assert.are.equal(429, 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"]) @@ -171,6 +176,11 @@ describe("lua_resty_netacea_mitigation", function() assert.spy(ngx_mock.print).was.called_with("403 Forbidden") end) + it("should print the configured blocked response status", function() + mitigation.serveBlock(429) + assert.spy(ngx_mock.print).was.called_with("429 Forbidden") + end) + it("should exit with HTTP_FORBIDDEN", function() mitigation.serveBlock() assert.spy(ngx_mock.exit).was.called_with(403) diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index e0c3635..ab20afe 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -155,6 +155,7 @@ insulate("lua_resty_netacea", function() apiKey = "test-api-key", cookieEncryptionKey = options.cookieEncryptionKey, secretKey = options.secretKey or "test-secret-key", + blockedResponseStatus = options.blockedResponseStatus, kinesisProperties = { stream_name = "test-stream", region = "eu-west-1", @@ -213,6 +214,25 @@ insulate("lua_resty_netacea", function() end) describe("protection mode config", function() + it("should store the configured blocked response status", function() + local netacea = Netacea:new({ + ingestEnabled = true, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + blockedResponseStatus = "429", + kinesisProperties = { + stream_name = "test-stream", + region = "eu-west-1", + aws_access_key = "test-access-key", + aws_secret_key = "test-secret-key" + } + }) + + assert.are.equal(429, netacea.blockedResponseStatus) + end) + it("should disable mitigation when mitigationType is INGEST", function() local netacea = Netacea:new({ ingestEnabled = true, From 47219b6a424664b1fd9a7803481219338bbf4836 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Fri, 26 Jun 2026 14:27:40 +0100 Subject: [PATCH 3/9] options for blocked response body & content-type --- README.md | 49 ++++++++++++---------- src/conf/nginx.conf | 4 ++ src/lua_resty_netacea.lua | 6 ++- src/lua_resty_netacea_mitigation.lua | 8 +++- test/lua_resty_netacea_mitigation_spec.lua | 10 +++++ test/lua_resty_netacea_spec.lua | 40 ++++++++++++++++++ 6 files changed, 91 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index a0c6ec6..ae3a9a1 100644 --- a/README.md +++ b/README.md @@ -114,26 +114,29 @@ NETACEA_PROTECTOR_API_URL=https://your-protector-api-url ### Environment variable default values reference -| Environment variable | Default | -| ----------------------------------- | ------------------------ | -| `NETACEA_API_KEY` | none | -| `NETACEA_CAPTCHA_COOKIE_ATTRIBUTES` | `Max-Age=86400; Path=/;` | -| `NETACEA_CAPTCHA_COOKIE_NAME` | `_mitatacaptcha` | -| `NETACEA_ENABLE_CAPTCHA_CONTENT_NEGOTIATION` | `false` | -| `NETACEA_CAPTCHA_PATH` | unset | -| `NETACEA_CHECKPOINT_SIGNAL_PATH` | unset | -| `NETACEA_COOKIE_ATTRIBUTES` | `Max-Age=86400; Path=/;` | -| `NETACEA_COOKIE_ENCRYPTION_KEY` | none | -| `NETACEA_COOKIE_NAME` | `_mitata` | -| `NETACEA_INGEST_ENABLED` | `true` | -| `NETACEA_KINESIS_ACCESS_KEY` | `""` | -| `NETACEA_KINESIS_BATCH_SIZE` | `25` | -| `NETACEA_KINESIS_BATCH_TIMEOUT` | `1.0` | -| `NETACEA_KINESIS_REGION` | `eu-west-1` | -| `NETACEA_KINESIS_SECRET_KEY` | `""` | -| `NETACEA_KINESIS_STREAM_NAME` | `""` | -| `NETACEA_PROTECTION_MODE` | `INGEST` | -| `NETACEA_PROTECTOR_API_URL` | `""` | -| `NETACEA_REAL_IP_HEADER_INDEX` | unset | -| `NETACEA_REAL_IP_HEADER` | `""` | -| `NETACEA_SECRET_KEY` | none | +| Environment variable | Default | +| --------------------------------------------- | ------------------------ | +| `NETACEA_API_KEY` | none | +| `NETACEA_BLOCKED_RESPONSE_BODY` | unset | +| `NETACEA_BLOCKED_RESPONSE_CONTENT_TYPE` | unset | +| `NETACEA_BLOCKED_RESPONSE_STATUS` | `403` | +| `NETACEA_CAPTCHA_COOKIE_ATTRIBUTES` | `Max-Age=86400; Path=/;` | +| `NETACEA_CAPTCHA_COOKIE_NAME` | `_mitatacaptcha` | +| `NETACEA_CAPTCHA_PATH` | unset | +| `NETACEA_CHECKPOINT_SIGNAL_PATH` | unset | +| `NETACEA_COOKIE_ATTRIBUTES` | `Max-Age=86400; Path=/;` | +| `NETACEA_COOKIE_ENCRYPTION_KEY` | none | +| `NETACEA_COOKIE_NAME` | `_mitata` | +| `NETACEA_ENABLE_CAPTCHA_CONTENT_NEGOTIATION` | `false` | +| `NETACEA_INGEST_ENABLED` | `true` | +| `NETACEA_KINESIS_ACCESS_KEY` | `""` | +| `NETACEA_KINESIS_BATCH_SIZE` | `25` | +| `NETACEA_KINESIS_BATCH_TIMEOUT` | `1.0` | +| `NETACEA_KINESIS_REGION` | `eu-west-1` | +| `NETACEA_KINESIS_SECRET_KEY` | `""` | +| `NETACEA_KINESIS_STREAM_NAME` | `""` | +| `NETACEA_PROTECTION_MODE` | `INGEST` | +| `NETACEA_PROTECTOR_API_URL` | `""` | +| `NETACEA_REAL_IP_HEADER_INDEX` | unset | +| `NETACEA_REAL_IP_HEADER` | `""` | +| `NETACEA_SECRET_KEY` | none | diff --git a/src/conf/nginx.conf b/src/conf/nginx.conf index c884fc8..41a57e9 100644 --- a/src/conf/nginx.conf +++ b/src/conf/nginx.conf @@ -16,6 +16,8 @@ env NETACEA_CHECKPOINT_SIGNAL_PATH; env NETACEA_CAPTCHA_PATH; env NETACEA_ENABLE_CAPTCHA_CONTENT_NEGOTIATION; env NETACEA_BLOCKED_RESPONSE_STATUS; +env NETACEA_BLOCKED_RESPONSE_BODY; +env NETACEA_BLOCKED_RESPONSE_CONTENT_TYPE; env NETACEA_KINESIS_ACCESS_KEY; env NETACEA_KINESIS_SECRET_KEY; env NETACEA_KINESIS_STREAM_NAME; @@ -58,6 +60,8 @@ http { checkpointSignalPath = env('NETACEA_CHECKPOINT_SIGNAL_PATH'), netaceaCaptchaPath = env('NETACEA_CAPTCHA_PATH'), blockedResponseStatus = env('NETACEA_BLOCKED_RESPONSE_STATUS'), + blockedResponseBody = env('NETACEA_BLOCKED_RESPONSE_BODY'), + blockedResponseContentType = env('NETACEA_BLOCKED_RESPONSE_CONTENT_TYPE'), enableCaptchaContentNegotiation = envEnabled('NETACEA_ENABLE_CAPTCHA_CONTENT_NEGOTIATION', false), kinesisProperties = { region = env('NETACEA_KINESIS_REGION', 'eu-west-1'), diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index ed6d517..08a1488 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -153,6 +153,10 @@ function _N:new(options) if n.blockedResponseStatus then n.blockedResponseStatus = math.floor(n.blockedResponseStatus) end + -- global:optional:blockedResponseBody + n.blockedResponseBody = utils.parseOption(options.blockedResponseBody, nil) + -- global:optional:blockedResponseContentType + n.blockedResponseContentType = utils.parseOption(options.blockedResponseContentType, nil) -- global:optional:enableCaptchaContentNegotiation n.enableCaptchaContentNegotiation = options.enableCaptchaContentNegotiation == true -- global:required:apiKey @@ -419,7 +423,7 @@ function _N:mitigate() ngx.log(ngx.DEBUG, "NETACEA MITIGATE - serving block") ngx.ctx.NetaceaState.grace_period = -1000 self:refreshSession(parsed_cookie.reason) - mitigation.serveBlock(self.blockedResponseStatus) + mitigation.serveBlock(self.blockedResponseStatus, self.blockedResponseBody, self.blockedResponseContentType) return end diff --git a/src/lua_resty_netacea_mitigation.lua b/src/lua_resty_netacea_mitigation.lua index 6345b6f..0a3396a 100644 --- a/src/lua_resty_netacea_mitigation.lua +++ b/src/lua_resty_netacea_mitigation.lua @@ -120,11 +120,15 @@ function _M.serveCaptcha(captchaBody, options) return ngx.exit(ngx.HTTP_OK) end -function _M.serveBlock(blockedResponseStatus) +function _M.serveBlock(blockedResponseStatus, blockedResponseBody, blockedResponseContentType) local status = tonumber(blockedResponseStatus) or ngx.HTTP_FORBIDDEN + local body = blockedResponseBody or (tostring(status) .. " Forbidden") ngx.status = status; + if blockedResponseContentType then + ngx.header["content-type"] = blockedResponseContentType + end ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" - ngx.print(tostring(status) .. " Forbidden"); + ngx.print(body); return ngx.exit(status); end diff --git a/test/lua_resty_netacea_mitigation_spec.lua b/test/lua_resty_netacea_mitigation_spec.lua index 3e5d99a..dd5d6d0 100644 --- a/test/lua_resty_netacea_mitigation_spec.lua +++ b/test/lua_resty_netacea_mitigation_spec.lua @@ -181,6 +181,16 @@ describe("lua_resty_netacea_mitigation", function() assert.spy(ngx_mock.print).was.called_with("429 Forbidden") end) + it("should print the configured blocked response body verbatim", function() + mitigation.serveBlock(429, "Too many requests") + assert.spy(ngx_mock.print).was.called_with("Too many requests") + end) + + it("should set the configured blocked response content type", function() + mitigation.serveBlock(429, "Too many requests", "text/plain; charset=utf-8") + assert.are.equal("text/plain; charset=utf-8", ngx_mock.header["content-type"]) + end) + it("should exit with HTTP_FORBIDDEN", function() mitigation.serveBlock() assert.spy(ngx_mock.exit).was.called_with(403) diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index ab20afe..2e64db2 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -156,6 +156,8 @@ insulate("lua_resty_netacea", function() cookieEncryptionKey = options.cookieEncryptionKey, secretKey = options.secretKey or "test-secret-key", blockedResponseStatus = options.blockedResponseStatus, + blockedResponseBody = options.blockedResponseBody, + blockedResponseContentType = options.blockedResponseContentType, kinesisProperties = { stream_name = "test-stream", region = "eu-west-1", @@ -233,6 +235,44 @@ insulate("lua_resty_netacea", function() assert.are.equal(429, netacea.blockedResponseStatus) end) + it("should store the configured blocked response body", function() + local netacea = Netacea:new({ + ingestEnabled = true, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + blockedResponseBody = "Too many requests", + kinesisProperties = { + stream_name = "test-stream", + region = "eu-west-1", + aws_access_key = "test-access-key", + aws_secret_key = "test-secret-key" + } + }) + + assert.are.equal("Too many requests", netacea.blockedResponseBody) + end) + + it("should store the configured blocked response content type", function() + local netacea = Netacea:new({ + ingestEnabled = true, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + blockedResponseContentType = "text/plain; charset=utf-8", + kinesisProperties = { + stream_name = "test-stream", + region = "eu-west-1", + aws_access_key = "test-access-key", + aws_secret_key = "test-secret-key" + } + }) + + assert.are.equal("text/plain; charset=utf-8", netacea.blockedResponseContentType) + end) + it("should disable mitigation when mitigationType is INGEST", function() local netacea = Netacea:new({ ingestEnabled = true, From ea0dcc2825424d08d456a77eecb60caba03a19c0 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Fri, 26 Jun 2026 14:55:24 +0100 Subject: [PATCH 4/9] set bc_type for captcha-on-demand path --- src/lua_resty_netacea.lua | 11 +++++++++-- test/lua_resty_netacea_spec.lua | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 08a1488..ee61de6 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -338,8 +338,15 @@ function _N:mitigate() return nil end local parsed_cookie = self:handleSession() + local parsed_cookie_data = parsed_cookie.data or {} if self.netaceaCaptchaPath and ngx.var.uri == self.netaceaCaptchaPath then + ngx.ctx.NetaceaState.bc_type = self:setBcType( + parsed_cookie_data.mat or nil, + parsed_cookie_data.mit or nil, + Constants['captchaStates'].SERVE + ) + ngx.log(ngx.DEBUG, "NETACEA MITIGATE - serving configured captcha path") local trackingId = ngx.var.arg_trackingId --TODO: make this more lenient to all JWE tokens if not utils.isSafeTrackingId(trackingId) then @@ -361,8 +368,8 @@ function _N:mitigate() 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, + parsed_cookie_data.mat or nil, + parsed_cookie_data.mit or nil, Constants['checkpointStates'].SIGNAL ) ngx.exit(ngx.OK) diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index 2e64db2..ecb6921 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -554,6 +554,7 @@ insulate("lua_resty_netacea", function() captchaPath = "/captcha", trackingId = "e334cc64-6cc2-4193-92dd-237e38bab4a7" }) + assert.are.equal("ip_flagged,captcha_serve", ngx_mock.ctx.NetaceaState.bc_type) assert.spy(ngx_mock.exit).was_not_called() end) From 431d97b64e1a680eb7e1d144c0561bc4e5828c01 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Fri, 26 Jun 2026 15:11:57 +0100 Subject: [PATCH 5/9] fix test assertion --- test/lua_resty_netacea_spec.lua | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index ecb6921..bacc781 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -524,6 +524,17 @@ insulate("lua_resty_netacea", function() end) it("should serve captcha on the configured captcha path with valid trackingId", function() + cookies_mock.parseMitataCookie = spy.new(function() + return { + valid = true, + user_id = "existing-user-id", + data = { + mat = "2", + mit = "4", + cap = "0" + } + } + end) protector_client_instance.getCaptchaPage = spy.new(function(_, trackingId) assert.are.equal("e334cc64-6cc2-4193-92dd-237e38bab4a7", trackingId) return { From 2b14be8be717539006aca7cfb9cf0f500d7ba67a Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Fri, 26 Jun 2026 15:17:20 +0100 Subject: [PATCH 6/9] version bump to 1.5.0 --- Dockerfile | 4 ++-- Dockerfile.nginx_lua | 4 ++-- ...cea-1.4.0-0.rockspec => lua_resty_netacea-1.5.0-0.rockspec | 2 +- src/lua_resty_netacea.lua | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) rename lua_resty_netacea-1.4.0-0.rockspec => lua_resty_netacea-1.5.0-0.rockspec (96%) diff --git a/Dockerfile b/Dockerfile index 1567069..37f0923 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.4.0-0.rockspec ./ +COPY ./lua_resty_netacea-1.5.0-0.rockspec ./ COPY ./src ./src -RUN /usr/local/openresty/luajit/bin/luarocks make ./lua_resty_netacea-1.4.0-0.rockspec +RUN /usr/local/openresty/luajit/bin/luarocks make ./lua_resty_netacea-1.5.0-0.rockspec FROM build AS test diff --git a/Dockerfile.nginx_lua b/Dockerfile.nginx_lua index 8e404df..8b55e44 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.4.0-0.rockspec ./ +COPY ./lua_resty_netacea-1.5.0-0.rockspec ./ COPY ./src ./src -RUN luarocks make ./lua_resty_netacea-1.4.0-0.rockspec +RUN luarocks make ./lua_resty_netacea-1.5.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.4.0-0.rockspec b/lua_resty_netacea-1.5.0-0.rockspec similarity index 96% rename from lua_resty_netacea-1.4.0-0.rockspec rename to lua_resty_netacea-1.5.0-0.rockspec index 523cc0d..8c771f4 100644 --- a/lua_resty_netacea-1.4.0-0.rockspec +++ b/lua_resty_netacea-1.5.0-0.rockspec @@ -1,5 +1,5 @@ package = "lua_resty_netacea" -version = "1.4.0-0" +version = "1.5.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 d352465..23f822a 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.4.0' +_N._VERSION = '1.5.0' _N._TYPE = 'nginx' local ngx = require 'ngx' From 79d5367523e92c6f4328cf8db90d5e5e5574d226 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Mon, 29 Jun 2026 13:53:41 +0100 Subject: [PATCH 7/9] validate blockedResponseStatus --- README.md | 2 +- src/lua_resty_netacea.lua | 10 ++++--- src/lua_resty_netacea_mitigation.lua | 5 +++- test/lua_resty_netacea_spec.lua | 39 ++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ae3a9a1..e6e8810 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ NETACEA_PROTECTOR_API_URL=https://your-protector-api-url | --------------------------------------------- | ------------------------ | | `NETACEA_API_KEY` | none | | `NETACEA_BLOCKED_RESPONSE_BODY` | unset | -| `NETACEA_BLOCKED_RESPONSE_CONTENT_TYPE` | unset | +| `NETACEA_BLOCKED_RESPONSE_CONTENT_TYPE` | `text/plain` | | `NETACEA_BLOCKED_RESPONSE_STATUS` | `403` | | `NETACEA_CAPTCHA_COOKIE_ATTRIBUTES` | `Max-Age=86400; Path=/;` | | `NETACEA_CAPTCHA_COOKIE_NAME` | `_mitatacaptcha` | diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 23f822a..5c2a29a 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -149,9 +149,13 @@ function _N:new(options) -- global:optional:netaceaCaptchaPath n.netaceaCaptchaPath = utils.normalizeRelativePath(utils.parseOption(options.netaceaCaptchaPath, nil)) -- global:optional:blockedResponseStatus - n.blockedResponseStatus = tonumber(utils.parseOption(options.blockedResponseStatus, nil)) - if n.blockedResponseStatus then - n.blockedResponseStatus = math.floor(n.blockedResponseStatus) + do + local blockedResponseStatus = tonumber(utils.parseOption(options.blockedResponseStatus, nil)) + if blockedResponseStatus and blockedResponseStatus >= 100 and blockedResponseStatus <= 599 and blockedResponseStatus % 1 == 0 then + n.blockedResponseStatus = blockedResponseStatus + else + n.blockedResponseStatus = ngx.HTTP_FORBIDDEN + end end -- global:optional:blockedResponseBody n.blockedResponseBody = utils.parseOption(options.blockedResponseBody, nil) diff --git a/src/lua_resty_netacea_mitigation.lua b/src/lua_resty_netacea_mitigation.lua index 0a3396a..e632b27 100644 --- a/src/lua_resty_netacea_mitigation.lua +++ b/src/lua_resty_netacea_mitigation.lua @@ -121,7 +121,10 @@ function _M.serveCaptcha(captchaBody, options) end function _M.serveBlock(blockedResponseStatus, blockedResponseBody, blockedResponseContentType) - local status = tonumber(blockedResponseStatus) or ngx.HTTP_FORBIDDEN + local status = tonumber(blockedResponseStatus) + if not status or status < 100 or status > 599 or status % 1 ~= 0 then + status = ngx.HTTP_FORBIDDEN + end local body = blockedResponseBody or (tostring(status) .. " Forbidden") ngx.status = status; if blockedResponseContentType then diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index bacc781..28d8616 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -32,6 +32,7 @@ insulate("lua_resty_netacea", function() get_body_file = spy.new(function() return nil end), set_header = spy.new(function() end) }, + HTTP_FORBIDDEN = 403, DEBUG = 7, WARN = 4, ERR = 3 @@ -235,6 +236,44 @@ insulate("lua_resty_netacea", function() assert.are.equal(429, netacea.blockedResponseStatus) end) + it("should default blocked response status to HTTP_FORBIDDEN when invalid", function() + local netacea = Netacea:new({ + ingestEnabled = true, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + blockedResponseStatus = "700", + kinesisProperties = { + stream_name = "test-stream", + region = "eu-west-1", + aws_access_key = "test-access-key", + aws_secret_key = "test-secret-key" + } + }) + + assert.are.equal(403, netacea.blockedResponseStatus) + end) + + it("should default blocked response status to HTTP_FORBIDDEN when not an integer", function() + local netacea = Netacea:new({ + ingestEnabled = true, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + blockedResponseStatus = "429.5", + kinesisProperties = { + stream_name = "test-stream", + region = "eu-west-1", + aws_access_key = "test-access-key", + aws_secret_key = "test-secret-key" + } + }) + + assert.are.equal(403, netacea.blockedResponseStatus) + end) + it("should store the configured blocked response body", function() local netacea = Netacea:new({ ingestEnabled = true, From 2427a45d3de1f92482dfde47e60cc3c8a84067af Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Mon, 29 Jun 2026 14:05:12 +0100 Subject: [PATCH 8/9] commit missing test --- test/lua_resty_netacea_mitigation_spec.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/lua_resty_netacea_mitigation_spec.lua b/test/lua_resty_netacea_mitigation_spec.lua index dd5d6d0..06ecc27 100644 --- a/test/lua_resty_netacea_mitigation_spec.lua +++ b/test/lua_resty_netacea_mitigation_spec.lua @@ -166,6 +166,16 @@ describe("lua_resty_netacea_mitigation", function() assert.are.equal(429, ngx_mock.status) end) + it("should default to HTTP_FORBIDDEN when the blocked response status is invalid", function() + mitigation.serveBlock(700) + assert.are.equal(403, ngx_mock.status) + end) + + it("should default to HTTP_FORBIDDEN when the blocked response status is not an integer", function() + mitigation.serveBlock(429.5) + 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"]) From 17fb7062379ab9c34fb449dbbd800688b8e60ee1 Mon Sep 17 00:00:00 2001 From: Richard Walkden Date: Mon, 29 Jun 2026 15:20:16 +0100 Subject: [PATCH 9/9] fix linter warning --- src/lua_resty_netacea.lua | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 5c2a29a..850dff6 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -41,6 +41,15 @@ local function setInjectHeaders(protector_result) return idType, mitigationType, captchaState end +local function normalizeBlockedResponseStatus(value) + local status = tonumber(value) + if not status or status < 100 or status > 599 or status % 1 ~= 0 then + return ngx.HTTP_FORBIDDEN + end + + return status +end + local function serveCaptchaFailOpen(body, options) local ok, err = pcall(mitigation.serveCaptcha, body, options) if not ok then @@ -149,14 +158,7 @@ function _N:new(options) -- global:optional:netaceaCaptchaPath n.netaceaCaptchaPath = utils.normalizeRelativePath(utils.parseOption(options.netaceaCaptchaPath, nil)) -- global:optional:blockedResponseStatus - do - local blockedResponseStatus = tonumber(utils.parseOption(options.blockedResponseStatus, nil)) - if blockedResponseStatus and blockedResponseStatus >= 100 and blockedResponseStatus <= 599 and blockedResponseStatus % 1 == 0 then - n.blockedResponseStatus = blockedResponseStatus - else - n.blockedResponseStatus = ngx.HTTP_FORBIDDEN - end - end + n.blockedResponseStatus = normalizeBlockedResponseStatus(utils.parseOption(options.blockedResponseStatus, nil)) -- global:optional:blockedResponseBody n.blockedResponseBody = utils.parseOption(options.blockedResponseBody, nil) -- global:optional:blockedResponseContentType