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/README.md b/README.md index a0c6ec6..e6e8810 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` | `text/plain` | +| `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/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/conf/nginx.conf b/src/conf/nginx.conf index 0554f66..41a57e9 100644 --- a/src/conf/nginx.conf +++ b/src/conf/nginx.conf @@ -15,6 +15,9 @@ 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_BLOCKED_RESPONSE_BODY; +env NETACEA_BLOCKED_RESPONSE_CONTENT_TYPE; env NETACEA_KINESIS_ACCESS_KEY; env NETACEA_KINESIS_SECRET_KEY; env NETACEA_KINESIS_STREAM_NAME; @@ -56,6 +59,9 @@ 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'), + 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 cb3ec02..850dff6 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' @@ -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 @@ -51,6 +60,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) @@ -123,6 +157,12 @@ 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 = normalizeBlockedResponseStatus(utils.parseOption(options.blockedResponseStatus, nil)) + -- 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 @@ -257,8 +297,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 @@ -305,8 +344,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 @@ -328,8 +374,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) @@ -390,7 +436,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, self.blockedResponseBody, self.blockedResponseContentType) return end diff --git a/src/lua_resty_netacea_mitigation.lua b/src/lua_resty_netacea_mitigation.lua index 439c89f..e632b27 100644 --- a/src/lua_resty_netacea_mitigation.lua +++ b/src/lua_resty_netacea_mitigation.lua @@ -120,11 +120,19 @@ function _M.serveCaptcha(captchaBody, options) return ngx.exit(ngx.HTTP_OK) end -function _M.serveBlock() - ngx.status = ngx.HTTP_FORBIDDEN; +function _M.serveBlock(blockedResponseStatus, blockedResponseBody, blockedResponseContentType) + 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 + ngx.header["content-type"] = blockedResponseContentType + end 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(body); + 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..06ecc27 100644 --- a/test/lua_resty_netacea_mitigation_spec.lua +++ b/test/lua_resty_netacea_mitigation_spec.lua @@ -161,6 +161,21 @@ 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 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"]) @@ -171,6 +186,21 @@ 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 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 a0141fe..28d8616 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -29,8 +29,10 @@ 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) }, + HTTP_FORBIDDEN = 403, DEBUG = 7, WARN = 4, ERR = 3 @@ -154,6 +156,9 @@ insulate("lua_resty_netacea", function() apiKey = "test-api-key", 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", @@ -212,6 +217,101 @@ 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 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, + 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, @@ -463,6 +563,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 { @@ -493,6 +604,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) @@ -926,6 +1038,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)