diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..88cb2de --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# AGENTS.md + +Before making any code changes: + +1. Read and follow `CONTRIBUTING.md`. +2. Follow all coding standards and workflows described there. +3. If `CONTRIBUTING.md` conflicts with these instructions, ask for clarification. +4. Prefer use of docker for running tests and linting. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a4957a..695b24f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,8 @@ To include coverage output: export LUACOV_REPORT=1 && ./run_lua_tests.sh ``` -You can also run tests through Docker Compose: +For ease of use, the recommended way to run tests outside of the dev container +is through Docker Compose: ```sh docker compose run --rm --build test diff --git a/Dockerfile b/Dockerfile index 2aef76e..1567069 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.3.0-0.rockspec ./ +COPY ./lua_resty_netacea-1.4.0-0.rockspec ./ COPY ./src ./src -RUN /usr/local/openresty/luajit/bin/luarocks make ./lua_resty_netacea-1.3.0-0.rockspec +RUN /usr/local/openresty/luajit/bin/luarocks make ./lua_resty_netacea-1.4.0-0.rockspec FROM build AS test diff --git a/Dockerfile.nginx_lua b/Dockerfile.nginx_lua index 513831d..8e404df 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.3.0-0.rockspec ./ +COPY ./lua_resty_netacea-1.4.0-0.rockspec ./ COPY ./src ./src -RUN luarocks make ./lua_resty_netacea-1.3.0-0.rockspec +RUN luarocks make ./lua_resty_netacea-1.4.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 3c89a88..a0c6ec6 100644 --- a/README.md +++ b/README.md @@ -116,22 +116,24 @@ NETACEA_PROTECTOR_API_URL=https://your-protector-api-url | Environment variable | Default | | ----------------------------------- | ------------------------ | -| `NETACEA_PROTECTION_MODE` | `INGEST` | -| `NETACEA_INGEST_ENABLED` | `true` | -| `NETACEA_PROTECTOR_API_URL` | `""` | | `NETACEA_API_KEY` | none | -| `NETACEA_COOKIE_ENCRYPTION_KEY` | none | -| `NETACEA_SECRET_KEY` | none | -| `NETACEA_COOKIE_NAME` | `_mitata` | -| `NETACEA_CAPTCHA_COOKIE_NAME` | `_mitatacaptcha` | -| `NETACEA_COOKIE_ATTRIBUTES` | `Max-Age=86400; Path=/;` | | `NETACEA_CAPTCHA_COOKIE_ATTRIBUTES` | `Max-Age=86400; Path=/;` | -| `NETACEA_REAL_IP_HEADER` | `""` | -| `NETACEA_REAL_IP_HEADER_INDEX` | unset | +| `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_SECRET_KEY` | `""` | -| `NETACEA_KINESIS_STREAM_NAME` | `""` | -| `NETACEA_KINESIS_REGION` | `eu-west-1` | | `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.3.0-0.rockspec b/lua_resty_netacea-1.4.0-0.rockspec similarity index 96% rename from lua_resty_netacea-1.3.0-0.rockspec rename to lua_resty_netacea-1.4.0-0.rockspec index 6522b39..523cc0d 100644 --- a/lua_resty_netacea-1.3.0-0.rockspec +++ b/lua_resty_netacea-1.4.0-0.rockspec @@ -1,5 +1,5 @@ package = "lua_resty_netacea" -version = "1.3.0-0" +version = "1.4.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 e4b9b18..0554f66 100644 --- a/src/conf/nginx.conf +++ b/src/conf/nginx.conf @@ -13,6 +13,8 @@ env NETACEA_INGEST_ENABLED; env NETACEA_REAL_IP_HEADER; env NETACEA_REAL_IP_HEADER_INDEX; env NETACEA_CHECKPOINT_SIGNAL_PATH; +env NETACEA_CAPTCHA_PATH; +env NETACEA_ENABLE_CAPTCHA_CONTENT_NEGOTIATION; env NETACEA_KINESIS_ACCESS_KEY; env NETACEA_KINESIS_SECRET_KEY; env NETACEA_KINESIS_STREAM_NAME; @@ -53,6 +55,8 @@ http { realIpHeader = env('NETACEA_REAL_IP_HEADER', ''), realIpHeaderIndex = tonumber(env('NETACEA_REAL_IP_HEADER_INDEX', '')), checkpointSignalPath = env('NETACEA_CHECKPOINT_SIGNAL_PATH'), + netaceaCaptchaPath = env('NETACEA_CAPTCHA_PATH'), + enableCaptchaContentNegotiation = envEnabled('NETACEA_ENABLE_CAPTCHA_CONTENT_NEGOTIATION', false), 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 280f534..cb3ec02 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.3.0' +_N._VERSION = '1.4.0' _N._TYPE = 'nginx' local ngx = require 'ngx' @@ -41,6 +41,16 @@ local function setInjectHeaders(protector_result) return idType, mitigationType, captchaState end +local function serveCaptchaFailOpen(body, options) + local ok, err = pcall(mitigation.serveCaptcha, body, options) + if not ok then + ngx.log(ngx.WARN, "NETACEA MITIGATE - captcha response failed open: ", err) + return false + end + + return true +end + function _N:new(options) local n = {} setmetatable(n, self) @@ -111,6 +121,10 @@ function _N:new(options) n.userIdKey = utils.parseOption(options.userIdKey, '') -- global:optional:checkpointSignalPath n.checkpointSignalPath = utils.parseOption(options.checkpointSignalPath, nil) + -- global:optional:netaceaCaptchaPath + n.netaceaCaptchaPath = utils.normalizeRelativePath(utils.parseOption(options.netaceaCaptchaPath, nil)) + -- global:optional:enableCaptchaContentNegotiation + n.enableCaptchaContentNegotiation = options.enableCaptchaContentNegotiation == true -- global:required:apiKey n.apiKey = utils.parseOption(options.apiKey, nil) if not n.apiKey then @@ -132,7 +146,8 @@ function _N:new(options) if n.mitigationEnabled then n.protectorClient = protector_client:new{ apiKey = n.apiKey, - mitigationEndpoint = n.mitigationEndpoint + mitigationEndpoint = n.mitigationEndpoint, + enableCaptchaContentNegotiation = n.enableCaptchaContentNegotiation } end @@ -291,6 +306,24 @@ function _N:mitigate() end local parsed_cookie = self:handleSession() + if self.netaceaCaptchaPath and ngx.var.uri == self.netaceaCaptchaPath then + local trackingId = ngx.var.arg_trackingId + --TODO: make this more lenient to all JWE tokens + if not utils.isSafeTrackingId(trackingId) then + trackingId = nil + end + local captcha_result = self.protectorClient:getCaptchaPage(trackingId) + if captcha_result then + serveCaptchaFailOpen(captcha_result.response.body, { + enableCaptchaContentNegotiation = self.enableCaptchaContentNegotiation, + netaceaCaptchaPath = self.netaceaCaptchaPath, + captchaPath = self.netaceaCaptchaPath, + trackingId = trackingId + }) + end + return + end + -- Return early on requests to the checkpoint signal path local signalPathEnabled = (self.checkpointSignalPath or '') ~= '' if signalPathEnabled and ngx.var.uri == self.checkpointSignalPath then @@ -332,7 +365,11 @@ function _N:mitigate() local captchaBody = protector_result.response.body ngx.ctx.NetaceaState.grace_period = -1000 self:refreshSession(parsed_cookie.reason) - mitigation.serveCaptcha(captchaBody) + serveCaptchaFailOpen(captchaBody, { + enableCaptchaContentNegotiation = self.enableCaptchaContentNegotiation, + netaceaCaptchaPath = self.netaceaCaptchaPath, + captchaPath = self.netaceaCaptchaPath + }) return end @@ -341,7 +378,11 @@ function _N:mitigate() local checkpointBody = protector_result.response.body ngx.ctx.NetaceaState.grace_period = -1000 self:refreshSession(parsed_cookie.reason) - mitigation.serveCaptcha(checkpointBody) + serveCaptchaFailOpen(checkpointBody, { + enableCaptchaContentNegotiation = self.enableCaptchaContentNegotiation, + netaceaCaptchaPath = self.netaceaCaptchaPath, + captchaPath = self.netaceaCaptchaPath + }) return end diff --git a/src/lua_resty_netacea_mitigation.lua b/src/lua_resty_netacea_mitigation.lua index bbc10fb..439c89f 100644 --- a/src/lua_resty_netacea_mitigation.lua +++ b/src/lua_resty_netacea_mitigation.lua @@ -1,8 +1,54 @@ local ngx = require 'ngx' +local cjson = require 'cjson' local Constants = require("lua_resty_netacea_constants") local _M = {} +local function shouldServeCaptchaAsJson(enableCaptchaContentNegotiation, netaceaCaptchaPath) + if not enableCaptchaContentNegotiation then return false end + if not netaceaCaptchaPath then return false end + + local accept = ngx.var and ngx.var.http_accept or nil + if type(accept) ~= "string" then return false end + + accept = accept:lower() + return accept:find("application/json", 1, true) ~= nil + and accept:find("text/html", 1, true) == nil +end + +local function buildCaptchaJson(captchaPath, trackingId) + local scheme = (ngx.var and ngx.var.scheme) or "http" + local host = (ngx.var and (ngx.var.http_host or ngx.var.host)) or "" + local relativeURL = captchaPath and captchaPath or "null" + local absoluteURL = captchaPath and (scheme .. "://" .. host .. captchaPath) or "null" + + if captchaPath and trackingId and trackingId ~= "" then + local separator = captchaPath:find("?", 1, true) and "&" or "?" + relativeURL = captchaPath .. separator .. "trackingId=" .. trackingId + absoluteURL = scheme .. "://" .. host .. relativeURL + end + + return string.format( + '{"captchaRelativeURL":%s,"captchaAbsoluteURL":%s}', + relativeURL == "null" and "null" or string.format("%q", relativeURL), + absoluteURL == "null" and "null" or string.format("%q", absoluteURL) + ) +end + +local function extractTrackingId(captchaBody) + if type(captchaBody) ~= "string" then return nil end + + local ok, decoded = pcall(cjson.decode, captchaBody) + if not ok or type(decoded) ~= "table" then return nil end + + local trackingId = decoded.trackingId + if type(trackingId) ~= "string" or trackingId == "" then + return nil + end + + return trackingId +end + function _M.getBestMitigation(protector_result) if not protector_result then return nil end @@ -54,8 +100,20 @@ function _M.getBestMitigation(protector_result) return nil end -function _M.serveCaptcha(captchaBody) +function _M.serveCaptcha(captchaBody, options) + options = options or {} ngx.status = ngx.HTTP_FORBIDDEN + if shouldServeCaptchaAsJson(options.enableCaptchaContentNegotiation, options.netaceaCaptchaPath) then + ngx.header["content-type"] = "application/json" + ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" + local trackingId = options.trackingId or extractTrackingId(captchaBody) + if not trackingId or trackingId == "" then + error("NETACEA CAPTCHA - missing trackingId for negotiated JSON response") + end + ngx.print(buildCaptchaJson(options.captchaPath, trackingId)) + return ngx.exit(ngx.HTTP_OK) + end + ngx.header["content-type"] = "text/html" ngx.header["Cache-Control"] = "max-age=0, no-cache, no-store, must-revalidate" ngx.print(captchaBody) diff --git a/src/lua_resty_netacea_protector_client.lua b/src/lua_resty_netacea_protector_client.lua index e389524..4c93012 100644 --- a/src/lua_resty_netacea_protector_client.lua +++ b/src/lua_resty_netacea_protector_client.lua @@ -17,20 +17,41 @@ local function createHttpConnection() return hc end +local function appendTrackingId(url, trackingId) + if not trackingId then return url end + local separator = url:find('?', 1, true) and '&' or '?' + return url .. separator .. "trackingId=" .. trackingId +end + function ProtectorClient:new(options) local n = {} setmetatable(n, self) n.apiKey = options.apiKey n.mitigationEndpoint = options.mitigationEndpoint or {} + n.enableCaptchaContentNegotiation = options.enableCaptchaContentNegotiation == true n.endpointIndex = 0 return n end +local function getCaptchaContentTypeHeader(enableCaptchaContentNegotiation) + if not enableCaptchaContentNegotiation then return nil end + + local accept = ngx.var and ngx.var.http_accept or nil + if type(accept) ~= "string" then return nil end + + accept = accept:lower() + if accept:find("application/json", 1, true) == nil then return nil end + if accept:find("text/html", 1, true) ~= nil then return nil end + + return "application/json" +end + function ProtectorClient:getMitigationRequestHeaders() local NetaceaState = ngx.ctx.NetaceaState local content_type = ngx.var and ngx.var.http_content_type or nil + local captcha_content_type = getCaptchaContentTypeHeader(self.enableCaptchaContentNegotiation) local cookie = '' if NetaceaState ~= nil and NetaceaState.captcha_cookie ~= nil then @@ -40,6 +61,7 @@ function ProtectorClient:getMitigationRequestHeaders() local headers = { ["x-netacea-api-key"] = self.apiKey, ["content-type"] = content_type or 'application/x-www-form-urlencoded', + ["x-netacea-captcha-content-type"] = captcha_content_type, ["cookie"] = cookie, ["user-agent"] = NetaceaState.user_agent or '', ["x-netacea-client-ip"] = NetaceaState.client or '', @@ -117,4 +139,28 @@ function ProtectorClient:validateCaptcha(captcha_data) } end +function ProtectorClient:getCaptchaPage(trackingId) + local hc = createHttpConnection() + + local headers = self:getMitigationRequestHeaders() + self.endpointIndex = (self.endpointIndex + 1) % table.getn(self.mitigationEndpoint) + + local res, err = hc:request_uri( + appendTrackingId(self.mitigationEndpoint[self.endpointIndex + 1] .. '/captcha', trackingId), + { + method = 'GET', + headers = headers + } + ) + if (err) then return nil end + + return { + response = { + status = res.status, + body = res.body, + headers = res.headers + } + } +end + return ProtectorClient diff --git a/src/netacea_utils.lua b/src/netacea_utils.lua index 1549f2d..67af01f 100644 --- a/src/netacea_utils.lua +++ b/src/netacea_utils.lua @@ -66,6 +66,27 @@ function M.parseOption(option, defaultValue) return option end +function M.normalizeRelativePath(path) + if type(path) ~= 'string' then return nil end + + path = path:match("^%s*(.-)%s*$") + if path == '' then return nil end + if path:sub(1, 1) ~= '/' then + path = '/' .. path + end + + if not path:match("^/[A-Za-z0-9/]*$") then + return nil + end + + return path +end + +function M.isSafeTrackingId(value) + if type(value) ~= 'string' then return false end + return value:match("^[A-Za-z0-9._~-]+$") ~= nil +end + function M.env(name, defaultValue) return os.getenv(name) or defaultValue end diff --git a/test/lua_resty_netacea_mitigation_spec.lua b/test/lua_resty_netacea_mitigation_spec.lua index 92d4e45..091c479 100644 --- a/test/lua_resty_netacea_mitigation_spec.lua +++ b/test/lua_resty_netacea_mitigation_spec.lua @@ -14,11 +14,21 @@ describe("lua_resty_netacea_mitigation", function() HTTP_OK = 200, status = 0, header = {}, + var = { + scheme = "https", + http_host = "example.com", + http_accept = "text/html" + }, print = spy.new(function() end), exit = spy.new(function() end) } package.loaded['ngx'] = ngx_mock + package.loaded['cjson.safe'] = { + encode = function(value) + return require("cjson").encode(value) + end + } package.loaded['lua_resty_netacea_mitigation'] = nil mitigation = require('lua_resty_netacea_mitigation') Constants = require('lua_resty_netacea_constants') @@ -27,6 +37,7 @@ describe("lua_resty_netacea_mitigation", function() after_each(function() package.loaded['lua_resty_netacea_mitigation'] = nil package.loaded['ngx'] = nil + package.loaded['cjson.safe'] = nil end) describe("serveCaptcha", function() @@ -54,6 +65,94 @@ describe("lua_resty_netacea_mitigation", function() mitigation.serveCaptcha("captcha") assert.spy(ngx_mock.exit).was.called_with(200) end) + + it("should serve json when html is not accepted but json is accepted", function() + ngx_mock.var.http_accept = "application/json" + + mitigation.serveCaptcha("captcha", { + enableCaptchaContentNegotiation = true, + netaceaCaptchaPath = "/captcha", + captchaPath = "/getCaptcha", + trackingId = "a1a16640-e1f0-4de4-a3a6-140c45181383" + }) + + assert.are.equal("application/json", ngx_mock.header["content-type"]) + assert.spy(ngx_mock.print).was.called_with( + '{"captchaRelativeURL":"/getCaptcha?trackingId=a1a16640-e1f0-4de4-a3a6-140c45181383","captchaAbsoluteURL":"https://example.com/getCaptcha?trackingId=a1a16640-e1f0-4de4-a3a6-140c45181383"}' + ) + end) + + it("should keep html when text/html is accepted", function() + ngx_mock.var.http_accept = "text/html,application/json" + + mitigation.serveCaptcha("captcha", { + enableCaptchaContentNegotiation = true, + captchaPath = "/getCaptcha", + trackingId = "a1a16640-e1f0-4de4-a3a6-140c45181383" + }) + + assert.are.equal("text/html", ngx_mock.header["content-type"]) + assert.spy(ngx_mock.print).was.called_with("captcha") + end) + + it("should keep html when negotiation is disabled", function() + ngx_mock.var.http_accept = "application/json" + + mitigation.serveCaptcha("captcha", { + enableCaptchaContentNegotiation = false, + captchaPath = "/getCaptcha", + trackingId = "a1a16640-e1f0-4de4-a3a6-140c45181383" + }) + + assert.are.equal("text/html", ngx_mock.header["content-type"]) + assert.spy(ngx_mock.print).was.called_with("captcha") + end) + + it("should keep html when no captcha path is configured", function() + ngx_mock.var.http_accept = "application/json" + + mitigation.serveCaptcha("captcha", { + enableCaptchaContentNegotiation = true, + trackingId = "a1a16640-e1f0-4de4-a3a6-140c45181383" + }) + + assert.are.equal("text/html", ngx_mock.header["content-type"]) + assert.spy(ngx_mock.print).was.called_with("captcha") + end) + + it("should build captcha urls from upstream trackingId json", function() + ngx_mock.var.http_accept = "application/json" + ngx_mock.var.scheme = "http" + ngx_mock.var.http_host = "localhost:8080" + + mitigation.serveCaptcha('{"trackingId":"b0343c30-a382-42ad-9d65-fdb005fef054"}', { + enableCaptchaContentNegotiation = true, + netaceaCaptchaPath = "/captcha", + captchaPath = "/getCaptcha" + }) + + assert.are.equal("application/json", ngx_mock.header["content-type"]) + assert.spy(ngx_mock.print).was.called_with( + '{"captchaRelativeURL":"/getCaptcha?trackingId=b0343c30-a382-42ad-9d65-fdb005fef054","captchaAbsoluteURL":"http://localhost:8080/getCaptcha?trackingId=b0343c30-a382-42ad-9d65-fdb005fef054"}' + ) + end) + + it("should error when negotiated json is missing trackingId", function() + ngx_mock.var.http_accept = "application/json" + + local ok, err = pcall(function() + mitigation.serveCaptcha('{"captchaURL":"https://example.com/captcha"}', { + enableCaptchaContentNegotiation = true, + netaceaCaptchaPath = "/captcha", + captchaPath = "/getCaptcha" + }) + end) + + assert.is_false(ok) + assert.is_truthy(tostring(err):find("missing trackingId", 1, true)) + assert.spy(ngx_mock.print).was_not_called() + assert.spy(ngx_mock.exit).was_not_called() + end) end) describe("serveBlock", function() diff --git a/test/lua_resty_netacea_protector_client_spec.lua b/test/lua_resty_netacea_protector_client_spec.lua index 2d33926..5c8d7ee 100644 --- a/test/lua_resty_netacea_protector_client_spec.lua +++ b/test/lua_resty_netacea_protector_client_spec.lua @@ -74,10 +74,12 @@ describe("lua_resty_netacea_protector_client", function() it("should create a new instance with provided options", function() local client = ProtectorClient:new({ apiKey = "test-api-key", - mitigationEndpoint = { "https://endpoint1.example.com" } + mitigationEndpoint = { "https://endpoint1.example.com" }, + enableCaptchaContentNegotiation = true }) assert.are.equal("test-api-key", client.apiKey) assert.are.same({ "https://endpoint1.example.com" }, client.mitigationEndpoint) + assert.is_true(client.enableCaptchaContentNegotiation) assert.are.equal(0, client.endpointIndex) end) @@ -160,6 +162,39 @@ describe("lua_resty_netacea_protector_client", function() local headers = client:getMitigationRequestHeaders() assert.are.equal("", headers["x-netacea-userid"]) end) + + it("should send captcha content type when negotiation is enabled and application/json is accepted", function() + ngx_mock.var.http_accept = "application/json" + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" }, + enableCaptchaContentNegotiation = true + }) + local headers = client:getMitigationRequestHeaders() + assert.are.equal("application/json", headers["x-netacea-captcha-content-type"]) + end) + + it("should not send captcha content type when text/html is accepted", function() + ngx_mock.var.http_accept = "text/html,application/json" + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" }, + enableCaptchaContentNegotiation = true + }) + local headers = client:getMitigationRequestHeaders() + assert.is_nil(headers["x-netacea-captcha-content-type"]) + end) + + it("should not send captcha content type when negotiation is disabled", function() + ngx_mock.var.http_accept = "application/json" + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" }, + enableCaptchaContentNegotiation = false + }) + local headers = client:getMitigationRequestHeaders() + assert.is_nil(headers["x-netacea-captcha-content-type"]) + end) end) describe("checkReputation", function() @@ -409,4 +444,31 @@ describe("lua_resty_netacea_protector_client", function() assert.are.equal("response body", result.response.body) end) end) + + describe("getCaptchaPage", function() + it("should make a GET request to the captcha endpoint", function() + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + client:getCaptchaPage("e334cc64-6cc2-4193-92dd-237e38bab4a7") + assert.spy(http_mock_instance.request_uri).was.called(1) + local call_args = http_mock_instance.request_uri.calls[1] + assert.are.equal( + "https://endpoint1.example.com/captcha?trackingId=e334cc64-6cc2-4193-92dd-237e38bab4a7", + call_args.vals[2] + ) + assert.are.equal("GET", call_args.vals[3].method) + end) + + it("should omit trackingId when not provided", function() + local client = ProtectorClient:new({ + apiKey = "test-api-key", + mitigationEndpoint = { "https://endpoint1.example.com" } + }) + client:getCaptchaPage(nil) + local call_args = http_mock_instance.request_uri.calls[1] + assert.are.equal("https://endpoint1.example.com/captcha", call_args.vals[2]) + end) + end) end) diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index 2688136..a0141fe 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -76,6 +76,21 @@ insulate("lua_resty_netacea", function() if value == nil then return default end return value end, + normalizeRelativePath = function(path) + if type(path) ~= 'string' then return nil end + path = path:match("^%s*(.-)%s*$") + if path == '' then return nil end + if path:sub(1, 1) ~= '/' then + path = '/' .. path + end + if not path:match("^/[A-Za-z0-9/]*$") then + return nil + end + return path + end, + isSafeTrackingId = function(value) + return type(value) == 'string' and value:match("^[A-Za-z0-9._~-]+$") ~= nil + end, getIpAddress = spy.new(function() return "127.0.0.1" end) @@ -319,6 +334,32 @@ insulate("lua_resty_netacea", function() assert.is_nil(netacea.checkpointSignalPath) end) + it("should normalize a valid netaceaCaptchaPath and keep matching case-sensitive", function() + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + netaceaCaptchaPath = "Captcha/Path" + }) + + assert.are.equal("/Captcha/Path", netacea.netaceaCaptchaPath) + end) + + it("should disable an invalid netaceaCaptchaPath", function() + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + netaceaCaptchaPath = "/captcha-path" + }) + + assert.is_nil(netacea.netaceaCaptchaPath) + end) + it("should inject the recommendation headers from a valid session", function() cookies_mock.parseMitataCookie = spy.new(function() return { @@ -420,6 +461,236 @@ insulate("lua_resty_netacea", function() assert.spy(mitigation_mock.getBestMitigation).was_not_called() assert.spy(ngx_mock.exit).was_not_called() end) + + it("should serve captcha on the configured captcha path with valid trackingId", function() + protector_client_instance.getCaptchaPage = spy.new(function(_, trackingId) + assert.are.equal("e334cc64-6cc2-4193-92dd-237e38bab4a7", trackingId) + return { + response = { + body = "captcha" + } + } + end) + ngx_mock.var.uri = "/captcha" + ngx_mock.var.arg_trackingId = "e334cc64-6cc2-4193-92dd-237e38bab4a7" + + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + netaceaCaptchaPath = "/captcha" + }) + + netacea:mitigate() + + assert.spy(protector_client_instance.checkReputation).was_not_called() + assert.spy(protector_client_instance.getCaptchaPage).was.called(1) + assert.spy(mitigation_mock.serveCaptcha).was.called_with("captcha", { + enableCaptchaContentNegotiation = false, + netaceaCaptchaPath = "/captcha", + captchaPath = "/captcha", + trackingId = "e334cc64-6cc2-4193-92dd-237e38bab4a7" + }) + assert.spy(ngx_mock.exit).was_not_called() + end) + + it("should serve json captcha when content negotiation is enabled and html is not accepted", function() + protector_client_instance.checkReputation = spy.new(function() + return { + match = "2", + mitigate = "4", + captcha = "1", + response = { + body = "captcha" + } + } + end) + ngx_mock.var.http_accept = "application/json" + + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + enableCaptchaContentNegotiation = true + }) + + mitigation_mock.getBestMitigation = spy.new(function() + return "captcha" + end) + netacea:mitigate() + + assert.spy(mitigation_mock.serveCaptcha).was.called_with("captcha", { + enableCaptchaContentNegotiation = true, + captchaPath = nil + }) + assert.spy(ngx_mock.exit).was_not_called() + end) + + it("should keep serving html captcha when netaceaCaptchaPath is configured", function() + protector_client_instance.checkReputation = spy.new(function() + return { + match = "2", + mitigate = "4", + captcha = "1", + response = { + body = "captcha" + } + } + end) + ngx_mock.var.http_accept = "application/json" + + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + enableCaptchaContentNegotiation = true, + netaceaCaptchaPath = "/captcha" + }) + + mitigation_mock.getBestMitigation = spy.new(function() + return "captcha" + end) + netacea:mitigate() + + assert.spy(mitigation_mock.serveCaptcha).was.called_with("captcha", { + enableCaptchaContentNegotiation = true, + netaceaCaptchaPath = "/captcha", + captchaPath = "/captcha" + }) + assert.spy(ngx_mock.exit).was_not_called() + end) + + it("should serve negotiated json for checkpoint responses when netaceaCaptchaPath is configured", function() + protector_client_instance.checkReputation = spy.new(function() + return { + match = "2", + mitigate = "4", + captcha = "1", + response = { + body = '{"trackingId":"b0343c30-a382-42ad-9d65-fdb005fef054"}' + } + } + end) + ngx_mock.var.http_accept = "application/json" + + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + enableCaptchaContentNegotiation = true, + netaceaCaptchaPath = "/captcha" + }) + + mitigation_mock.getBestMitigation = spy.new(function() + return "checkpoint" + end) + netacea:mitigate() + + assert.spy(mitigation_mock.serveCaptcha).was.called_with('{"trackingId":"b0343c30-a382-42ad-9d65-fdb005fef054"}', { + enableCaptchaContentNegotiation = true, + netaceaCaptchaPath = "/captcha", + captchaPath = "/captcha" + }) + assert.spy(ngx_mock.exit).was_not_called() + end) + + it("should not invent a captcha path when netaceaCaptchaPath is unset", function() + protector_client_instance.checkReputation = spy.new(function() + return { + match = "2", + mitigate = "4", + captcha = "1", + response = { + body = "captcha" + } + } + end) + ngx_mock.var.http_accept = "application/json" + + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + enableCaptchaContentNegotiation = true + }) + + mitigation_mock.getBestMitigation = spy.new(function() + return "captcha" + end) + netacea:mitigate() + + assert.spy(mitigation_mock.serveCaptcha).was.called_with("captcha", { + enableCaptchaContentNegotiation = true, + netaceaCaptchaPath = nil, + captchaPath = nil + }) + assert.spy(ngx_mock.exit).was_not_called() + end) + + it("should ignore invalid trackingId on the configured captcha path", function() + protector_client_instance.getCaptchaPage = spy.new(function(_, trackingId) + assert.is_nil(trackingId) + return { + response = { + body = "captcha" + } + } + end) + ngx_mock.var.uri = "/captcha" + ngx_mock.var.arg_trackingId = "not a uuid" + + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + netaceaCaptchaPath = "/captcha" + }) + + netacea:mitigate() + + assert.spy(protector_client_instance.checkReputation).was_not_called() + assert.spy(protector_client_instance.getCaptchaPage).was.called(1) + assert.spy(mitigation_mock.serveCaptcha).was.called_with("captcha", { + enableCaptchaContentNegotiation = false, + netaceaCaptchaPath = "/captcha", + captchaPath = "/captcha" + }) + assert.spy(ngx_mock.exit).was_not_called() + end) + + it("should only enable captcha content negotiation when set to true", function() + local enabled = Netacea:new({ + ingestEnabled = false, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + enableCaptchaContentNegotiation = true + }) + local disabled = Netacea:new({ + ingestEnabled = false, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + enableCaptchaContentNegotiation = "true" + }) + + assert.is_true(enabled.enableCaptchaContentNegotiation) + assert.is_false(disabled.enableCaptchaContentNegotiation) + end) end) describe("cookie encryption key config", function() diff --git a/test/netacea_utils_spec.lua b/test/netacea_utils_spec.lua index 27ee9fe..0bc83da 100644 --- a/test/netacea_utils_spec.lua +++ b/test/netacea_utils_spec.lua @@ -412,6 +412,52 @@ describe("netacea_utils", function() end) end) + describe("normalizeRelativePath", function() + it("should return a valid absolute path unchanged", function() + assert.are.equal("/captcha", utils.normalizeRelativePath("/captcha")) + end) + + it("should prepend a leading slash before validation", function() + assert.are.equal("/captcha", utils.normalizeRelativePath("captcha")) + end) + + it("should preserve case sensitivity", function() + assert.are.equal("/Captcha", utils.normalizeRelativePath("Captcha")) + end) + + it("should allow nested relative paths", function() + assert.are.equal("/my/captcha", utils.normalizeRelativePath("/my/captcha")) + end) + + it("should reject invalid characters", function() + assert.is_nil(utils.normalizeRelativePath("/captcha-page")) + assert.is_nil(utils.normalizeRelativePath("/captcha.html")) + assert.is_nil(utils.normalizeRelativePath("/captcha?")) + assert.is_nil(utils.normalizeRelativePath("/captcha#")) + assert.is_nil(utils.normalizeRelativePath("/captcha test")) + end) + + it("should reject empty and non-string values", function() + assert.is_nil(utils.normalizeRelativePath("")) + assert.is_nil(utils.normalizeRelativePath(nil)) + end) + end) + + describe("isSafeTrackingId", function() + it("should accept parser-safe tracking ids", function() + assert.is_true(utils.isSafeTrackingId("e334cc64-6cc2-4193-92dd-237e38bab4a7")) + assert.is_true(utils.isSafeTrackingId("abcDEF123._~-")) + end) + + it("should reject values that can break url or query parsing", function() + assert.is_false(utils.isSafeTrackingId("abc def")) + assert.is_false(utils.isSafeTrackingId("abc?def")) + assert.is_false(utils.isSafeTrackingId("abc&def")) + assert.is_false(utils.isSafeTrackingId("abc%def")) + assert.is_false(utils.isSafeTrackingId(nil)) + end) + end) + describe("env", function() local original_getenv local env_values