diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d5a35cc --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +NETACEA_INGEST_ENABLED=true +NETACEA_PROTECTION_MODE=INGEST +NETACEA_API_KEY= +NETACEA_COOKIE_ENCRYPTION_KEY= +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_KINESIS_ACCESS_KEY= +NETACEA_KINESIS_BATCH_SIZE= +NETACEA_KINESIS_BATCH_TIMEOUT= +NETACEA_KINESIS_REGION=eu-west-1 +NETACEA_KINESIS_SECRET_KEY= +NETACEA_KINESIS_STREAM_NAME= +NETACEA_PROTECTOR_API_URL= +NETACEA_REAL_IP_HEADER_INDEX= +NETACEA_REAL_IP_HEADER= +NETACEA_SECRET_KEY= diff --git a/.gitignore b/.gitignore index 4361458..3b76bdf 100644 --- a/.gitignore +++ b/.gitignore @@ -44,8 +44,11 @@ luac.out tags +# Local environment config +.env + # luacov reports luacov.report luacov.report.* luacov.stats.out -luacov.stats.out.* \ No newline at end of file +luacov.stats.out.* diff --git a/Dockerfile b/Dockerfile index 382c399..0fb304b 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.0-0.rockspec ./ +COPY ./lua_resty_netacea-1.2.0-0.rockspec ./ COPY ./src ./src -RUN /usr/local/openresty/luajit/bin/luarocks make ./lua_resty_netacea-1.0-0.rockspec +RUN /usr/local/openresty/luajit/bin/luarocks make ./lua_resty_netacea-1.2.0-0.rockspec FROM build AS test diff --git a/README.md b/README.md index 8108241..77a42d1 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,24 @@ The Dockerfile contains a multi-stage build, including: The docker compose file is used to mount local files to the right place in the image to support development. +### Environment variables + +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: + +```sh +cp .env.example .env +``` + +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. Update `./src/conf/nginx.conf` to include Netacea configuration and server configuration. Default is the NGINX instance will just return a static "Hello world" page. See "Configuration" below -2. `docker compose up resty` -3. Access [](http://localhost:8080) +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 @@ -36,107 +49,96 @@ With coverage report (sent to stdout) `export LUACOV_REPORT=1 && ./run_lua_tests ##### Docker compose -Without coverage report: `docker compose run --build test` +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 -`docker compose run --build lint` +`docker compose run --rm --build lint` ## Configuration -### nginx.conf - mitigate - -```conf -worker_processes 1; - -events { - worker_connections 1024; -} - -http { - lua_package_path "/usr/local/share/lua/5.1/?.lua;;"; - lua_max_running_timers 2048; - lua_max_pending_timers 4096; - lua_socket_pool_size 1024; - lua_need_request_body on; - resolver 8.8.8.8 ipv6=off; - lua_ssl_verify_depth 2; - lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; - init_worker_by_lua_block { - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = 'ingest-endpoint', - mitigationEndpoint = 'mitigation-endpoint', - apiKey = 'your-api-key', - secretKey = 'your-secret-key', - realIpHeader = 'realip-header', - ingestEnabled = true, - mitigationEnabled = true, - mitigationType = 'MITIGATE' - }) - } - log_by_lua_block { - netacea:ingest() - } - access_by_lua_block { - netacea:run() - } - - server { - listen 80; - server_name localhost; - location / { - default_type text/html; - content_by_lua 'ngx.say("

hello, world

")'; - } - } -} +### .env - ingest only + +Use ingest-only mode when you want to send request data to the ingest pipeline without calling the Mitigation Endpoint. + +Ingest is enabled by default. Set `NETACEA_PROTECTION_MODE` to `INGEST`. + +Kinesis properties must be provided for ingest to remain enabled. + +When `realIpHeaderIndex` is set, `realIpHeader` is parsed as a comma-separated list and the indexed value is used. Indexing starts at `0`; negative indexes count from the end, so `-1` selects the last value. +This is useful for, though not limited to, parsing `X-Forwarded-For` values. + +```dotenv +NETACEA_PROTECTION_MODE=INGEST +NETACEA_API_KEY=your-api-key +NETACEA_COOKIE_ENCRYPTION_KEY=your-cookie-encryption-key +NETACEA_COOKIE_NAME=your-session-cookie-name +NETACEA_CAPTCHA_COOKIE_NAME=your-captcha-cookie-name +NETACEA_REAL_IP_HEADER=X-Forwarded-For +NETACEA_REAL_IP_HEADER_INDEX=0 +NETACEA_KINESIS_ACCESS_KEY=your-aws-access-key +NETACEA_KINESIS_SECRET_KEY=your-aws-secret-key +NETACEA_KINESIS_STREAM_NAME=your-kinesis-stream ``` -### nginx.conf - inject - -```conf -worker_processes 1; - -events { - worker_connections 1024; -} - -http { - lua_package_path "/usr/local/share/lua/5.1/?.lua;;"; - lua_max_running_timers 2048; - lua_max_pending_timers 4096; - lua_socket_pool_size 1024; - lua_need_request_body on; - resolver 8.8.8.8 ipv6=off; - lua_ssl_verify_depth 2; - lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; - init_worker_by_lua_block { - netacea = (require 'lua_resty_netacea'):new({ - ingestEndpoint = 'ingest-endpoint', - mitigationEndpoint = 'mitigation-endpoint', - apiKey = 'your-api-key', - secretKey = 'your-secret-key', - realIpHeader = 'realip-header', - ingestEnabled = true, - mitigationEnabled = true, - mitigationType = 'INJECT' - }) - } - log_by_lua_block { - netacea:ingest() - } - access_by_lua_block { - netacea:run() - } - - server { - listen 80; - server_name localhost; - location / { - default_type text/html; - content_by_lua 'ngx.say("

hello, world

")'; - } - } -} +### .env - mitigate + +Use MITIGATE as the NETACEA_PROTECTION_MODE when you want the integration to +call the Protector API and enforce mitigation responses. + +```dotenv +NETACEA_PROTECTION_MODE=MITIGATE +NETACEA_API_KEY=your-api-key +NETACEA_COOKIE_ENCRYPTION_KEY=your-cookie-encryption-key +NETACEA_COOKIE_NAME=your-session-cookie-name +NETACEA_CAPTCHA_COOKIE_NAME=your-captcha-cookie-name +NETACEA_REAL_IP_HEADER=X-Forwarded-For +NETACEA_REAL_IP_HEADER_INDEX=0 +NETACEA_KINESIS_ACCESS_KEY=your-aws-access-key +NETACEA_KINESIS_SECRET_KEY=your-aws-secret-key +NETACEA_KINESIS_STREAM_NAME=your-kinesis-stream +NETACEA_PROTECTOR_API_URL=https://your-protector-api-url ``` + +### .env - inject + +Use INJECT as the NETACEA_PROTECTION_MODE when you want the integration to +call the Protector API but defer mitigation to downstream services. + +```dotenv +NETACEA_PROTECTION_MODE=INJECT +NETACEA_API_KEY=your-api-key +NETACEA_COOKIE_ENCRYPTION_KEY=your-cookie-encryption-key +NETACEA_COOKIE_NAME=your-session-cookie-name +NETACEA_CAPTCHA_COOKIE_NAME=your-captcha-cookie-name +NETACEA_REAL_IP_HEADER=X-Forwarded-For +NETACEA_REAL_IP_HEADER_INDEX=0 +NETACEA_KINESIS_ACCESS_KEY=your-aws-access-key +NETACEA_KINESIS_SECRET_KEY=your-aws-secret-key +NETACEA_KINESIS_STREAM_NAME=your-kinesis-stream +NETACEA_PROTECTOR_API_URL=https://your-protector-api-url +``` + +### Environment variable default values reference + +| 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_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` | diff --git a/docker-compose.yml b/docker-compose.yml index eaf4006..21a21ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: context: . target: build container_name: resty + env_file: + - ".env" ports: - "8080:80" - "443:443" @@ -39,6 +41,8 @@ services: build: dockerfile: Dockerfile.nginx_lua context: . + env_file: + - ".env" ports: - "80:80" volumes: diff --git a/lua_resty_netacea-1.0-0.rockspec b/lua_resty_netacea-1.2.0-0.rockspec similarity index 96% rename from lua_resty_netacea-1.0-0.rockspec rename to lua_resty_netacea-1.2.0-0.rockspec index e9c3a9e..22b76c0 100644 --- a/lua_resty_netacea-1.0-0.rockspec +++ b/lua_resty_netacea-1.2.0-0.rockspec @@ -1,5 +1,5 @@ package = "lua_resty_netacea" -version = "1.0-0" +version = "1.2.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 ef5ea78..ccee215 100644 --- a/src/conf/nginx.conf +++ b/src/conf/nginx.conf @@ -1,5 +1,24 @@ worker_processes 1; +env NETACEA_COOKIE_NAME; +env NETACEA_CAPTCHA_COOKIE_NAME; +env NETACEA_COOKIE_ATTRIBUTES; +env NETACEA_CAPTCHA_COOKIE_ATTRIBUTES; +env NETACEA_PROTECTOR_API_URL; +env NETACEA_PROTECTION_MODE; +env NETACEA_API_KEY; +env NETACEA_COOKIE_ENCRYPTION_KEY; +env NETACEA_SECRET_KEY; +env NETACEA_INGEST_ENABLED; +env NETACEA_REAL_IP_HEADER; +env NETACEA_REAL_IP_HEADER_INDEX; +env NETACEA_KINESIS_ACCESS_KEY; +env NETACEA_KINESIS_SECRET_KEY; +env NETACEA_KINESIS_STREAM_NAME; +env NETACEA_KINESIS_REGION; +env NETACEA_KINESIS_BATCH_SIZE; +env NETACEA_KINESIS_BATCH_TIMEOUT; + events { worker_connections 1024; } @@ -9,29 +28,41 @@ http { lua_max_running_timers 2048; lua_max_pending_timers 4096; lua_socket_pool_size 1024; - + lua_need_request_body on; resolver 8.8.8.8 ipv6=off; lua_ssl_verify_depth 2; lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; + init_worker_by_lua_block { + local utils = require("netacea_utils") + local env = utils.env + local envEnabled = utils.envEnabled + netacea = (require 'lua_resty_netacea'):new({ - mitigationEndpoint = '', - apiKey = '', - secretKey = '', - realIpHeader = '', - ingestEnabled = false, - mitigationEnabled = false, - mitigationType = '', - cookieName = '', - kinesisProperties = { - region = '', - stream_name = '', - aws_access_key = '', - aws_secret_key = '', + mitigationEndpoint = env('NETACEA_PROTECTOR_API_URL', ''), + apiKey = env('NETACEA_API_KEY', ''), + cookieEncryptionKey = env('NETACEA_COOKIE_ENCRYPTION_KEY', ''), + secretKey = env('NETACEA_SECRET_KEY', ''), + ingestEnabled = envEnabled('NETACEA_INGEST_ENABLED', true), + mitigationType = env('NETACEA_PROTECTION_MODE', ''), + cookieName = env('NETACEA_COOKIE_NAME', ''), + captchaCookieName = env('NETACEA_CAPTCHA_COOKIE_NAME', ''), + cookieAttributes = env('NETACEA_COOKIE_ATTRIBUTES', ''), + captchaCookieAttributes = env('NETACEA_CAPTCHA_COOKIE_ATTRIBUTES', ''), + realIpHeader = env('NETACEA_REAL_IP_HEADER', ''), + realIpHeaderIndex = tonumber(env('NETACEA_REAL_IP_HEADER_INDEX', '')), + kinesisProperties = { + region = env('NETACEA_KINESIS_REGION', 'eu-west-1'), + stream_name = env('NETACEA_KINESIS_STREAM_NAME', ''), + aws_access_key = env('NETACEA_KINESIS_ACCESS_KEY', ''), + aws_secret_key = env('NETACEA_KINESIS_SECRET_KEY', ''), + batch_size = tonumber(env('NETACEA_KINESIS_BATCH_SIZE', '')), + batch_timeout = tonumber(env('NETACEA_KINESIS_BATCH_TIMEOUT', '')), } }) } + log_by_lua_block { netacea:ingest() } diff --git a/src/lua_resty_netacea.lua b/src/lua_resty_netacea.lua index 5c9b6ea..f803efb 100644 --- a/src/lua_resty_netacea.lua +++ b/src/lua_resty_netacea.lua @@ -8,12 +8,18 @@ local Constants = require("lua_resty_netacea_constants") local mitigation = require("lua_resty_netacea_mitigation") local _N = {} -_N._VERSION = '1.1.0' +_N._VERSION = '1.2.0' _N._TYPE = 'nginx' local ngx = require 'ngx' local cjson = require 'cjson' +local function getIntegrationMode(n) + if n.mitigationEnabled then return n.mitigationType end + if n.ingestEnabled then return 'INGEST' end + return 'DISABLED' +end + function _N:new(options) local n = {} setmetatable(n, self) @@ -40,8 +46,14 @@ function _N:new(options) n.ingestEnabled = false end end - -- mitigate:optional:mitigationEnabled - n.mitigationEnabled = options.mitigationEnabled or false + -- mitigate:optional:mitigationType + n.mitigationType = utils.parseOption(options.mitigationType, 'INGEST') + -- mitigationEnabled is deprecated. Use mitigationType instead. + if options.mitigationEnabled == false then + n.mitigationType = 'INGEST' + ngx.log(ngx.WARN, "NETACEA CONFIG - mitigationEnabled is deprecated; set mitigationType to INGEST instead") + end + n.mitigationEnabled = n.mitigationType == 'MITIGATE' or n.mitigationType == 'INJECT' -- mitigate:required:mitigationEndpoint n.mitigationEndpoint = options.mitigationEndpoint if type(n.mitigationEndpoint) ~= 'table' then @@ -50,14 +62,16 @@ function _N:new(options) if not n.mitigationEndpoint[1] or n.mitigationEndpoint[1] == '' then n.mitigationEnabled = false end - -- mitigate:required:mitigationType - n.mitigationType = utils.parseOption(options.mitigationType, '') - if not n.mitigationType or (n.mitigationType ~= 'MITIGATE' and n.mitigationType ~= 'INJECT') then + if n.mitigationType ~= 'INGEST' and n.mitigationType ~= 'MITIGATE' and n.mitigationType ~= 'INJECT' then n.mitigationEnabled = false end - -- mitigate:required:secretKey - n.secretKey = b64.decode_base64url(options.secretKey) or '' - if not n.secretKey or n.secretKey == '' then + -- mitigate:required:cookieEncryptionKey + -- secretKey is kept as a backwards-compatible alias. + local encodedCookieEncryptionKey = options.cookieEncryptionKey or options.secretKey + n.cookieEncryptionKey = b64.decode_base64url(encodedCookieEncryptionKey) or '' + n.secretKey = n.cookieEncryptionKey + n.sessionEnabled = n.cookieEncryptionKey and n.cookieEncryptionKey ~= '' + if not n.cookieEncryptionKey or n.cookieEncryptionKey == '' then n.mitigationEnabled = false end -- global:optional:cookieName @@ -70,6 +84,8 @@ function _N:new(options) n.captchaCookieAttributes = utils.parseOption(options.captchaCookieAttributes, 'Max-Age=86400; Path=/;') -- global:optional:realIpHeader n.realIpHeader = utils.parseOption(options.realIpHeader, '') + -- global:optional:realIpHeaderIndex + n.realIpHeaderIndex = utils.parseOption(options.realIpHeaderIndex, nil) -- global:optional:userIdKey n.userIdKey = utils.parseOption(options.userIdKey, '') -- global:required:apiKey @@ -83,6 +99,7 @@ function _N:new(options) n._MODULE_TYPE = _N._TYPE n._MODULE_VERSION = _N._VERSION + ngx.log(ngx.DEBUG, "NETACEA CONFIG - integration mode: ", getIntegrationMode(n)) if n.ingestEnabled then n.ingestPipeline = Ingest:new(options.kinesisProperties or {}, n) @@ -118,22 +135,27 @@ end function _N:ingest() ngx.log(ngx.DEBUG, "NETACEA INGEST - in netacea:ingest(): ", self.ingestEnabled) if not self.ingestEnabled then return nil end - ngx.ctx.NetaceaState.bc_type = self:setBcType( - tostring(ngx.ctx.NetaceaState.protector_result.match or Constants['idTypes'].NONE), - tostring(ngx.ctx.NetaceaState.protector_result.mitigate or Constants['mitigationTypes'].NONE), - tostring(ngx.ctx.NetaceaState.protector_result.captcha or Constants['captchaStates'].NONE) - ) + local NetaceaState = ngx.ctx.NetaceaState + local protector_result = NetaceaState and NetaceaState.protector_result + if protector_result then + NetaceaState.bc_type = self:setBcType( + tostring(protector_result.match or Constants['idTypes'].NONE), + tostring(protector_result.mitigate or Constants['mitigationTypes'].NONE), + tostring(protector_result.captcha or Constants['captchaStates'].NONE) + ) + end return self.ingestPipeline:ingest() end function _N:handleSession() ngx.ctx.NetaceaState = {} - ngx.ctx.NetaceaState.client = utils:getIpAddress(ngx.var, self.realIpHeader) + ngx.ctx.NetaceaState.client = utils:getIpAddress(ngx.var, self.realIpHeader, self.realIpHeaderIndex) ngx.ctx.NetaceaState.user_agent = ngx.var.http_user_agent or '' -- Check cookie local cookie = ngx.var['cookie_' .. self.cookieName] or '' - local parsed_cookie = netacea_cookies.parseMitataCookie(cookie, self.secretKey) + ngx.ctx.mitata = cookie + local parsed_cookie = netacea_cookies.parseMitataCookie(cookie, self.cookieEncryptionKey) ngx.log(ngx.DEBUG, "NETACEA MITIGATE - parsed cookie: ", cjson.encode(parsed_cookie)) if parsed_cookie.user_id then ngx.ctx.NetaceaState.UserId = parsed_cookie.user_id @@ -141,7 +163,7 @@ function _N:handleSession() -- Get captcha cookie local captcha_cookie_raw = ngx.var['cookie_' .. self.captchaCookieName] or '' - local captcha_cookie = netacea_cookies.decrypt(self.secretKey, captcha_cookie_raw) + local captcha_cookie = netacea_cookies.decrypt(self.cookieEncryptionKey, captcha_cookie_raw) if captcha_cookie and captcha_cookie ~= '' then ngx.ctx.NetaceaState.captcha_cookie = captcha_cookie end @@ -149,12 +171,16 @@ function _N:handleSession() end function _N:refreshSession(reason) - local protector_result = ngx.ctx.NetaceaState.protector_result + local protector_result = ngx.ctx.NetaceaState.protector_result or { + match = Constants['idTypes'].NONE, + mitigate = Constants['mitigationTypes'].NONE, + captcha = Constants['captchaStates'].NONE + } local grace_period = ngx.ctx.NetaceaState.grace_period or 60 local new_cookie = netacea_cookies.generateNewCookieValue( - self.secretKey, + self.cookieEncryptionKey, ngx.ctx.NetaceaState.client, ngx.ctx.NetaceaState.UserId, netacea_cookies.newUserId(), @@ -169,9 +195,13 @@ function _N:refreshSession(reason) 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.secretKey, protector_result.captcha_cookie) + local captcha_cookie_encrypted = netacea_cookies.encrypt( + self.cookieEncryptionKey, + protector_result.captcha_cookie + ) table.insert(cookies, self.captchaCookieName .. '=' .. captcha_cookie_encrypted .. ';'.. self.captchaCookieAttributes) end @@ -189,13 +219,40 @@ function _N:handleCaptcha() ngx.ctx.NetaceaState.grace_period = -1000 ngx.log(ngx.DEBUG, "NETACEA CAPTCHA - protector result: ", cjson.encode(ngx.ctx.NetaceaState)) - self:refreshSession(Constants['issueReasons'].CAPTCHA_POST) + if protector_result.captcha == Constants['captchaStates'].PASS then + self:refreshSession(Constants['issueReasons'].CAPTCHA_POST) + end ngx.exit(protector_result.exit_status) end +function _N:refreshIngestSession() + local parsed_cookie = self:handleSession() + + if parsed_cookie.valid then + ngx.ctx.NetaceaState.protector_result = { + match = parsed_cookie.data.mat, + mitigate = parsed_cookie.data.mit, + captcha = parsed_cookie.data.cap + } + return parsed_cookie + end + + if not ngx.ctx.NetaceaState.UserId then + ngx.ctx.NetaceaState.UserId = netacea_cookies.newUserId() + end + + self:refreshSession(parsed_cookie.reason) + return parsed_cookie +end + function _N:mitigate() - if not self.mitigationEnabled then return nil end + if not self.mitigationEnabled then + if self.sessionEnabled then + return self:refreshIngestSession() + end + return nil + end local parsed_cookie = self:handleSession() if not parsed_cookie.valid then diff --git a/src/lua_resty_netacea_cookies_v3.lua b/src/lua_resty_netacea_cookies_v3.lua index d13d824..642cdf4 100644 --- a/src/lua_resty_netacea_cookies_v3.lua +++ b/src/lua_resty_netacea_cookies_v3.lua @@ -7,16 +7,16 @@ local NetaceaCookies = {} NetaceaCookies.__index = NetaceaCookies -function NetaceaCookies.decrypt(secretKey, value) - local decoded = jwt:verify(secretKey, value) - if not decoded.verified then +function NetaceaCookies.decrypt(cookieEncryptionKey, value) + local decoded = jwt:verify(cookieEncryptionKey, value) + if not decoded or not decoded.verified then return nil end return decoded.payload end -function NetaceaCookies.encrypt(secretKey, payload) - local encoded = jwt:sign(secretKey, { +function NetaceaCookies.encrypt(cookieEncryptionKey, payload) + local encoded = jwt:sign(cookieEncryptionKey, { header={ typ="JWE", alg="dir", enc="A128CBC-HS256" }, payload = payload }) @@ -49,7 +49,7 @@ end function NetaceaCookies.generateNewCookieValue( - secretKey, client, user_id, cookie_id, issue_reason, + cookieEncryptionKey, client, user_id, cookie_id, issue_reason, issue_timestamp, grace_period, match, mitigation, captcha, settings) settings = settings or {} local plaintext = ngx.encode_args({ @@ -65,7 +65,7 @@ function NetaceaCookies.generateNewCookieValue( fCAPR = settings.fCAPR or 0 }) - local encoded = NetaceaCookies.encrypt(secretKey, plaintext) + local encoded = NetaceaCookies.encrypt(cookieEncryptionKey, plaintext) return { mitata_jwe = encoded, @@ -73,7 +73,7 @@ function NetaceaCookies.generateNewCookieValue( } end -function NetaceaCookies.parseMitataCookie(cookie, secretKey) +function NetaceaCookies.parseMitataCookie(cookie, cookieEncryptionKey) if not cookie or cookie == '' then return { valid = false, @@ -81,7 +81,14 @@ function NetaceaCookies.parseMitataCookie(cookie, secretKey) } end - local decoded_str = NetaceaCookies.decrypt(secretKey, cookie) + local decoded_str = NetaceaCookies.decrypt(cookieEncryptionKey, cookie) + if not decoded_str then + return { + valid = false, + reason = constants['issueReasons'].INVALID_SESSION + } + end + local decoded = ngx.decode_args(decoded_str) if not decoded then return { @@ -142,4 +149,4 @@ function NetaceaCookies.parseMitataCookie(cookie, secretKey) } end -return NetaceaCookies \ No newline at end of file +return NetaceaCookies diff --git a/src/lua_resty_netacea_ingest.lua b/src/lua_resty_netacea_ingest.lua index 6cb8e08..c6fb30a 100644 --- a/src/lua_resty_netacea_ingest.lua +++ b/src/lua_resty_netacea_ingest.lua @@ -228,20 +228,21 @@ function Ingest:ingest() Request = vars.request_method .. " " .. vars.request_uri .. " " .. vars.server_protocol, TimeLocal = vars.time_local, TimeUnixMsUTC = vars.msec * 1000, - RealIp = NetaceaState.client or utils:getIpAddress(vars, self._N.realIpHeader), - UserAgent = vars.http_user_agent or "-", + RealIp = NetaceaState.client or utils:getIpAddress(vars, self._N.realIpHeader, self._N.realIpHeaderIndex), + XForwardedFor = vars.http_x_forwarded_for or "", + UserAgent = vars.http_user_agent or "", Status = vars.status, RequestTime = vars.request_time, BytesSent = vars.bytes_sent, - Referer = vars.http_referer or "-", + Referer = vars.http_referer or "", NetaceaUserIdCookie = mitata, - UserId = NetaceaState.UserId or "-", + UserId = NetaceaState.UserId or "", NetaceaMitigationApplied = NetaceaState.bc_type, IntegrationType = self._N._MODULE_TYPE, IntegrationVersion = self._N._MODULE_VERSION, Query = vars.query_string or "", - RequestHost = vars.host or "-", - RequestId = vars.request_id or "-", + RequestHost = vars.host or "", + RequestId = vars.request_id or "", ProtectionMode = self._N.mitigationType or "ERROR", -- TODO BytesReceived = vars.bytes_received or 0, -- Doesn't seem to work @@ -259,4 +260,4 @@ function Ingest:ingest() end -return Ingest \ No newline at end of file +return Ingest diff --git a/src/netacea_utils.lua b/src/netacea_utils.lua index a20f901..1549f2d 100644 --- a/src/netacea_utils.lua +++ b/src/netacea_utils.lua @@ -19,12 +19,40 @@ function M.buildRandomString(length) return randomString end -function M:getIpAddress(vars, realIpHeader) +local function normalizeHeaderName(headerName) + if type(headerName) ~= 'string' then return headerName end + return headerName:lower():gsub('-', '_') +end + +local function getIndexedHeaderValue(realIpHeaderValue, realIpHeaderIndex) + if type(realIpHeaderIndex) ~= 'number' then return realIpHeaderValue end + if realIpHeaderIndex % 1 ~= 0 then return realIpHeaderValue end + + local headerValues = {} + for value in string.gmatch(realIpHeaderValue, '([^,]+)') do + table.insert(headerValues, value:match("^%s*(.-)%s*$")) + end + + local luaIndex = realIpHeaderIndex + 1 + if realIpHeaderIndex < 0 then + luaIndex = #headerValues + realIpHeaderIndex + 1 + end + + local headerValue = headerValues[luaIndex] + if headerValue == '' then return nil end + return headerValue +end + +function M:getIpAddress(vars, realIpHeader, realIpHeaderIndex) if not realIpHeader then return vars.remote_addr end - local realIpHeaderValue = vars['http_' .. realIpHeader] + local normalizedRealIpHeader = normalizeHeaderName(realIpHeader) + local realIpHeaderValue = vars['http_' .. normalizedRealIpHeader] if not realIpHeaderValue or realIpHeaderValue == '' then return vars.remote_addr end + if realIpHeaderIndex ~= nil then + return getIndexedHeaderValue(realIpHeaderValue, realIpHeaderIndex) or vars.remote_addr + end return realIpHeaderValue or vars.remote_addr end @@ -38,5 +66,18 @@ function M.parseOption(option, defaultValue) return option end +function M.env(name, defaultValue) + return os.getenv(name) or defaultValue +end + +function M.envEnabled(name, defaultValue) + local value = os.getenv(name) + if value == nil then + return defaultValue + end + + return value == 'true' +end + -return M \ No newline at end of file +return M diff --git a/test/lua_resty_netacea_cookies_v3_spec.lua b/test/lua_resty_netacea_cookies_v3_spec.lua index 644cdc6..4849750 100644 --- a/test/lua_resty_netacea_cookies_v3_spec.lua +++ b/test/lua_resty_netacea_cookies_v3_spec.lua @@ -74,7 +74,7 @@ describe("lua_resty_netacea_cookies_v3", function() return nil end if not str then - return nil + error("bad argument #1 to 'decode_args' (string expected, got nil)") end local result = {} for pair in str:gmatch("[^&]+") do @@ -560,6 +560,7 @@ describe("lua_resty_netacea_cookies_v3", function() assert.is_false(result.valid) assert.is.equal('invalid_session', result.reason) assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "invalid_token") + assert.spy(ngx_mock.decode_args).was_not_called() end) it("should return invalid result for invalid payload format", function() @@ -792,6 +793,20 @@ describe("lua_resty_netacea_cookies_v3", function() assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "invalid_token") end) + it("should return nil when JWT decryption fails without a decoded token", function() + jwt_mock.verify = spy.new(function() + return nil + end) + + local ok, result = pcall(function() + return NetaceaCookies.decrypt("secret_key", "invalid_token") + end) + + assert.is_true(ok) + assert.is_nil(result) + assert.spy(jwt_mock.verify).was.called_with(match.is_not_nil(), "secret_key", "invalid_token") + end) + it("should return nil for unverified JWT token", function() jwt_mock.verify = spy.new(function(self, secretKey, token) return { verified = false } diff --git a/test/lua_resty_netacea_ingest_spec.lua b/test/lua_resty_netacea_ingest_spec.lua index 061c494..0a64cf6 100644 --- a/test/lua_resty_netacea_ingest_spec.lua +++ b/test/lua_resty_netacea_ingest_spec.lua @@ -33,6 +33,7 @@ describe("lua_resty_netacea_ingest", function() request_time = "0.123", bytes_sent = "1024", http_referer = "https://example.com", + http_x_forwarded_for = "203.0.113.1, 203.0.113.2", query_string = "param=value", host = "test.example.com", request_id = "req-12345", @@ -377,6 +378,7 @@ describe("lua_resty_netacea_ingest", function() _MODULE_TYPE = "nginx", _MODULE_VERSION = "2.1.0", realIpHeader = "x_forwarded_for", + realIpHeaderIndex = -1, mitigationType = "monitor" }) end) @@ -392,6 +394,7 @@ describe("lua_resty_netacea_ingest", function() assert.is.equal("01/Jan/2022:00:00:00 +0000", queued_item.TimeLocal) assert.is.equal(1640995200123, queued_item.TimeUnixMsUTC) assert.is.equal("192.168.1.1", queued_item.RealIp) + assert.is.equal("203.0.113.1, 203.0.113.2", queued_item.XForwardedFor) assert.is.equal("Test-Agent/1.0", queued_item.UserAgent) assert.is.equal("200", queued_item.Status) assert.is.equal("0.123", queued_item.RequestTime) @@ -419,12 +422,13 @@ describe("lua_resty_netacea_ingest", function() it("should handle missing NetaceaState gracefully", function() ngx_mock.ctx.NetaceaState = nil + ngx_mock.var.http_x_forwarded_for = nil ingest:ingest() local queued_item = ingest.data_queue:pop() assert.is.equal("127.0.0.1", queued_item.RealIp) -- default from utils mock - assert.is.equal("-", queued_item.UserId) + assert.is.equal("", queued_item.UserId) assert.is_nil(queued_item.NetaceaMitigationApplied) end) @@ -435,13 +439,19 @@ describe("lua_resty_netacea_ingest", function() ingest:ingest() assert.spy(utils_mock.getIpAddress).was.called() - -- Verify the call was made with correct number of arguments + assert.spy(utils_mock.getIpAddress).was.called_with( + utils_mock, + ngx_mock.var, + "x_forwarded_for", + -1 + ) assert.is.equal(1, #utils_mock.getIpAddress.calls) end) it("should handle missing optional fields with defaults", function() ngx_mock.var.http_user_agent = nil ngx_mock.var.http_referer = nil + ngx_mock.var.http_x_forwarded_for = nil ngx_mock.var.query_string = nil ngx_mock.var.host = nil ngx_mock.var.request_id = nil @@ -450,11 +460,12 @@ describe("lua_resty_netacea_ingest", function() ingest:ingest() local queued_item = ingest.data_queue:pop() - assert.is.equal("-", queued_item.UserAgent) - assert.is.equal("-", queued_item.Referer) + assert.is.equal("", queued_item.UserAgent) + assert.is.equal("", queued_item.Referer) + assert.is.equal("", queued_item.XForwardedFor) assert.is.equal("", queued_item.Query) - assert.is.equal("-", queued_item.RequestHost) - assert.is.equal("-", queued_item.RequestId) + assert.is.equal("", queued_item.RequestHost) + assert.is.equal("", queued_item.RequestId) assert.is.equal(0, queued_item.BytesReceived) end) @@ -674,4 +685,4 @@ describe("lua_resty_netacea_ingest", function() assert.spy(kinesis_mock.new).was.called_with("eu_stream", "eu-west-1", "key", "secret") end) end) -end) \ No newline at end of file +end) diff --git a/test/lua_resty_netacea_spec.lua b/test/lua_resty_netacea_spec.lua index 9edf206..608b8ac 100644 --- a/test/lua_resty_netacea_spec.lua +++ b/test/lua_resty_netacea_spec.lua @@ -1,12 +1,453 @@ require("silence_g_write_guard") require 'busted.runner'() --- Test file for lua_resty_netacea + +package.path = "../src/?.lua;" .. package.path insulate("lua_resty_netacea", function() - describe("lua_resty_netacea", function() - it("should always pass", function() - assert.is_true(true) + local Netacea + local ngx_mock + local ingest_instance + local cookies_mock + local protector_client_mock + local protector_client_instance + local decode_base64url_mock + + before_each(function() + ngx_mock = { + ctx = {}, + var = { + remote_addr = "127.0.0.1", + http_user_agent = "Test-Agent", + cookie__mitata = "" + }, + header = {}, + log = spy.new(function() end), + exit = spy.new(function() end), + req = { + read_body = spy.new(function() end), + get_body_data = spy.new(function() return "captcha-response" end) + }, + DEBUG = 7, + WARN = 4, + ERR = 3 + } + + ingest_instance = { + start_timers = spy.new(function() end), + ingest = spy.new(function() return "ingested" end) + } + + package.loaded['ngx'] = ngx_mock + decode_base64url_mock = spy.new(function(value) + if value == nil or value == "" then return nil end + if value == "invalid-cookie-encryption-key" then return nil end + return "decoded-" .. value + end) + package.loaded['ngx.base64'] = { + decode_base64url = decode_base64url_mock + } + package.loaded['lua_resty_netacea_ingest'] = { + new = spy.new(function() return ingest_instance end) + } + cookies_mock = { + parseMitataCookie = spy.new(function() + return { + valid = false, + reason = "no_session" + } + end), + generateNewCookieValue = spy.new(function() + return { + mitata_jwe = "new-session-cookie", + mitata_plaintext = "plaintext" + } + end), + newUserId = spy.new(function() return "new-user-id" end), + decrypt = spy.new(function() return nil end), + encrypt = spy.new(function() return "encrypted" end) + } + package.loaded['lua_resty_netacea_cookies_v3'] = cookies_mock + package.loaded['netacea_utils'] = { + parseOption = function(value, default) + if value == nil then return default end + return value + end, + getIpAddress = spy.new(function() + return "127.0.0.1" + end) + } + protector_client_instance = { + checkReputation = spy.new(function() + return { + match = "0", + mitigate = "0", + captcha = "0" + } + end), + validateCaptcha = spy.new(function() + return { + match = "0", + mitigate = "0", + captcha = "2", + exit_status = 200, + captcha_cookie = "captcha-cookie-value" + } + end) + } + protector_client_mock = { + new = spy.new(function() return protector_client_instance end) + } + package.loaded['lua_resty_netacea_protector_client'] = protector_client_mock + package.loaded['lua_resty_netacea_mitigation'] = {} + package.loaded['cjson'] = { + encode = function() return "{}" end + } + package.loaded['lua_resty_netacea'] = nil + + Netacea = require('lua_resty_netacea') + end) + + after_each(function() + package.loaded['lua_resty_netacea'] = nil + package.loaded['ngx'] = nil + package.loaded['ngx.base64'] = nil + package.loaded['lua_resty_netacea_ingest'] = nil + package.loaded['lua_resty_netacea_cookies_v3'] = nil + package.loaded['netacea_utils'] = nil + package.loaded['lua_resty_netacea_protector_client'] = nil + package.loaded['lua_resty_netacea_mitigation'] = nil + package.loaded['cjson'] = nil + end) + + local function new_ingest_enabled_netacea(options) + options = options or {} + local config = { + ingestEnabled = true, + mitigationType = options.mitigationType or '', + mitigationEndpoint = options.mitigationEndpoint or '', + apiKey = "test-api-key", + cookieEncryptionKey = options.cookieEncryptionKey, + secretKey = options.secretKey or "test-secret-key", + kinesisProperties = { + stream_name = "test-stream", + region = "eu-west-1", + aws_access_key = "test-access-key", + aws_secret_key = "test-secret-key" + } + } + if options.mitigationEnabled ~= nil then + config.mitigationEnabled = options.mitigationEnabled + end + return Netacea:new(config) + end + + describe("startup logging", function() + it("should log ingest mode when only ingest is enabled", function() + new_ingest_enabled_netacea() + + assert.spy(ngx_mock.log).was.called_with( + ngx_mock.DEBUG, + "NETACEA CONFIG - integration mode: ", + "INGEST" + ) + end) + + it("should log mitigation mode when mitigation is enabled", function() + new_ingest_enabled_netacea({ + mitigationEnabled = true, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + cookieEncryptionKey = "test-cookie-encryption-key" + }) + + assert.spy(ngx_mock.log).was.called_with( + ngx_mock.DEBUG, + "NETACEA CONFIG - integration mode: ", + "MITIGATE" + ) + end) + + it("should log disabled mode when no integration paths are enabled", function() + Netacea:new({ + ingestEnabled = false, + mitigationEnabled = false, + mitigationEndpoint = "", + mitigationType = "", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key" + }) + + assert.spy(ngx_mock.log).was.called_with( + ngx_mock.DEBUG, + "NETACEA CONFIG - integration mode: ", + "DISABLED" + ) + end) + end) + + describe("protection mode config", function() + it("should disable mitigation when mitigationType is INGEST", function() + local netacea = Netacea:new({ + ingestEnabled = true, + mitigationType = "INGEST", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + kinesisProperties = { + stream_name = "test-stream", + region = "eu-west-1", + aws_access_key = "test-access-key", + aws_secret_key = "test-secret-key" + } + }) + + assert.are.equal("INGEST", netacea.mitigationType) + assert.is_false(netacea.mitigationEnabled) + assert.spy(protector_client_mock.new).was_not_called() + end) + + it("should treat mitigationEnabled false as deprecated ingest mode", function() + local netacea = Netacea:new({ + ingestEnabled = true, + mitigationEnabled = false, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + kinesisProperties = { + stream_name = "test-stream", + region = "eu-west-1", + aws_access_key = "test-access-key", + aws_secret_key = "test-secret-key" + } + }) + + assert.are.equal("INGEST", netacea.mitigationType) + assert.is_false(netacea.mitigationEnabled) + assert.spy(ngx_mock.log).was.called_with( + ngx_mock.WARN, + "NETACEA CONFIG - mitigationEnabled is deprecated; set mitigationType to INGEST instead" + ) + end) + end) + + describe("cookie encryption key config", function() + it("should pass realIpHeaderIndex to IP address lookup", function() + local netacea = Netacea:new({ + ingestEnabled = false, + mitigationEnabled = false, + mitigationEndpoint = "", + mitigationType = "", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key", + realIpHeader = "x_forwarded_for", + realIpHeaderIndex = -1 + }) + + netacea:mitigate() + + assert.spy(package.loaded['netacea_utils'].getIpAddress).was.called_with( + package.loaded['netacea_utils'], + ngx_mock.var, + "x_forwarded_for", + -1 + ) + end) + + it("should prefer cookieEncryptionKey as the internal key name", function() + local netacea = new_ingest_enabled_netacea({ + cookieEncryptionKey = "test-cookie-encryption-key" + }) + + assert.are.equal("decoded-test-cookie-encryption-key", netacea.cookieEncryptionKey) + assert.are.equal("decoded-test-cookie-encryption-key", netacea.secretKey) + assert.spy(decode_base64url_mock).was.called_with("test-cookie-encryption-key") + end) + + it("should keep secretKey as a backwards-compatible alias", function() + local netacea = new_ingest_enabled_netacea({ + secretKey = "test-secret-key" + }) + + assert.are.equal("decoded-test-secret-key", netacea.cookieEncryptionKey) + assert.are.equal("decoded-test-secret-key", netacea.secretKey) + assert.spy(decode_base64url_mock).was.called_with("test-secret-key") + end) + + it("should ignore secretKey when cookieEncryptionKey is also configured", function() + local netacea = new_ingest_enabled_netacea({ + cookieEncryptionKey = "test-cookie-encryption-key", + secretKey = "ignored-secret-key" + }) + + assert.are.equal("decoded-test-cookie-encryption-key", netacea.cookieEncryptionKey) + assert.are.equal("decoded-test-cookie-encryption-key", netacea.secretKey) + assert.spy(decode_base64url_mock).was.called(1) + assert.spy(decode_base64url_mock).was.called_with("test-cookie-encryption-key") + end) + + it("should disable sessions and mitigation when the configured key cannot be decoded", function() + local netacea = new_ingest_enabled_netacea({ + cookieEncryptionKey = "invalid-cookie-encryption-key", + mitigationEnabled = true, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example" + }) + + assert.are.equal("", netacea.cookieEncryptionKey) + assert.are.equal("", netacea.secretKey) + assert.is_false(netacea.sessionEnabled) + assert.is_false(netacea.mitigationEnabled) + assert.spy(decode_base64url_mock).was.called(1) + assert.spy(decode_base64url_mock).was.called_with("invalid-cookie-encryption-key") + assert.spy(protector_client_mock.new).was_not_called() + end) + + it("should use cookieEncryptionKey for session cookie operations", function() + local netacea = new_ingest_enabled_netacea({ + cookieEncryptionKey = "test-cookie-encryption-key" + }) + + netacea:mitigate() + + assert.spy(cookies_mock.parseMitataCookie).was.called_with( + "", + "decoded-test-cookie-encryption-key" + ) + assert.spy(cookies_mock.decrypt).was.called_with( + "decoded-test-cookie-encryption-key", + "" + ) + end) + end) + + describe("ingest", function() + it("should support ingest-only mode when NetaceaState is missing", function() + local netacea = new_ingest_enabled_netacea() + ngx_mock.ctx.NetaceaState = nil + + local result = netacea:ingest() + + assert.are.equal("ingested", result) + assert.spy(ingest_instance.ingest).was.called(1) + end) + + it("should support ingest-only mode when protector_result is missing", function() + local netacea = new_ingest_enabled_netacea() + ngx_mock.ctx.NetaceaState = {} + + netacea:ingest() + + assert.is_nil(ngx_mock.ctx.NetaceaState.bc_type) + assert.spy(ingest_instance.ingest).was.called(1) + end) + + it("should set bc_type when mitigation state is available", function() + local netacea = new_ingest_enabled_netacea() + ngx_mock.ctx.NetaceaState = { + protector_result = { + match = "2", + mitigate = "1", + captcha = "0" + } + } + + netacea:ingest() + + assert.are.equal("ip_blocked", ngx_mock.ctx.NetaceaState.bc_type) + assert.spy(ingest_instance.ingest).was.called(1) + end) + end) + + describe("session cookie in ingest-only mode", function() + it("should set a session cookie when mitigation is disabled", function() + local netacea = new_ingest_enabled_netacea() + + netacea:mitigate() + + assert.are.same({ + "_mitata=new-session-cookie;Max-Age=86400; Path=/;" + }, ngx_mock.header["Set-Cookie"]) + assert.are.equal("new-session-cookie", ngx_mock.ctx.mitata) + assert.are.equal("new-user-id", ngx_mock.ctx.NetaceaState.UserId) + assert.spy(cookies_mock.generateNewCookieValue).was.called(1) + assert.spy(protector_client_instance.checkReputation).was_not_called() + end) + + it("should not refresh a valid session cookie when mitigation is disabled", function() + cookies_mock.parseMitataCookie = spy.new(function() + return { + valid = true, + user_id = "existing-user-id", + data = { + mat = "0", + mit = "0", + cap = "0" + } + } + end) + ngx_mock.var.cookie__mitata = "existing-session-cookie" + local netacea = new_ingest_enabled_netacea() + + netacea:mitigate() + + assert.is_nil(ngx_mock.header["Set-Cookie"]) + assert.are.equal("existing-session-cookie", ngx_mock.ctx.mitata) + assert.are.equal("existing-user-id", ngx_mock.ctx.NetaceaState.UserId) + assert.spy(cookies_mock.generateNewCookieValue).was_not_called() + assert.spy(protector_client_instance.checkReputation).was_not_called() + end) + end) + + describe("captcha handling", function() + local function new_mitigation_enabled_netacea() + return Netacea:new({ + ingestEnabled = false, + mitigationEnabled = true, + mitigationType = "MITIGATE", + mitigationEndpoint = "https://mitigation.example", + apiKey = "test-api-key", + cookieEncryptionKey = "test-cookie-encryption-key" + }) + end + + it("should not set session or captcha 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" + } + end) + local netacea = new_mitigation_enabled_netacea() + + netacea:handleCaptcha() + + assert.is_nil(ngx_mock.header["Set-Cookie"]) + assert.spy(cookies_mock.generateNewCookieValue).was_not_called() + assert.spy(cookies_mock.encrypt).was_not_called() + assert.spy(ngx_mock.exit).was.called_with(403) + end) + + it("should refresh session and captcha cookies when captcha passes", function() + local netacea = new_mitigation_enabled_netacea() + + netacea:handleCaptcha() + + assert.are.same({ + "_mitata=new-session-cookie;Max-Age=86400; Path=/;", + "_mitatacaptcha=encrypted;Max-Age=86400; Path=/;" + }, ngx_mock.header["Set-Cookie"]) + assert.spy(cookies_mock.generateNewCookieValue).was.called(1) + assert.spy(cookies_mock.encrypt).was.called_with( + "decoded-test-cookie-encryption-key", + "captcha-cookie-value" + ) + assert.spy(ngx_mock.exit).was.called_with(200) + end) end) end) -end) \ No newline at end of file +end) diff --git a/test/netacea_utils_spec.lua b/test/netacea_utils_spec.lua index f6ea611..27ee9fe 100644 --- a/test/netacea_utils_spec.lua +++ b/test/netacea_utils_spec.lua @@ -111,6 +111,56 @@ describe("netacea_utils", function() assert.is.equal("203.0.113.1", result) end) + it("should return the indexed header value when index is positive", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "203.0.113.1, 203.0.113.2, 203.0.113.3" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for", 1) + assert.is.equal("203.0.113.2", result) + end) + + it("should return the indexed header value when index is zero", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "203.0.113.1, 203.0.113.2" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for", 0) + assert.is.equal("203.0.113.1", result) + end) + + it("should return the indexed header value when index is negative", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "203.0.113.1, 203.0.113.2, 203.0.113.3" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for", -2) + assert.is.equal("203.0.113.2", result) + end) + + it("should fall back to remote_addr when header index is out of range", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "203.0.113.1, 203.0.113.2" + } + + local result = utils:getIpAddress(vars, "x_forwarded_for", -3) + assert.is.equal("192.168.1.1", result) + end) + + it("should apply realIpHeaderIndex to non x-forwarded-for headers", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_custom_ip = "203.0.113.1, 203.0.113.2" + } + + local result = utils:getIpAddress(vars, "x_custom_ip", 1) + assert.is.equal("203.0.113.2", result) + end) + it("should return remote_addr when real IP header doesn't exist", function() local vars = { remote_addr = "192.168.1.1" @@ -175,6 +225,36 @@ describe("netacea_utils", function() assert.is.equal("203.0.113.1", result) end) + it("should normalize realIpHeader dashes to nginx variable underscores", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "203.0.113.1" + } + + local result = utils:getIpAddress(vars, "x-forwarded-for") + assert.is.equal("203.0.113.1", result) + end) + + it("should normalize realIpHeader case", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "203.0.113.1" + } + + local result = utils:getIpAddress(vars, "X-Forwarded-For") + assert.is.equal("203.0.113.1", result) + end) + + it("should apply header index after normalizing the header name", function() + local vars = { + remote_addr = "192.168.1.1", + http_x_forwarded_for = "203.0.113.1, 203.0.113.2" + } + + local result = utils:getIpAddress(vars, "X-Forwarded-For", -1) + assert.is.equal("203.0.113.2", result) + end) + it("should handle missing remote_addr gracefully", function() local vars = { http_x_forwarded_for = "203.0.113.1" @@ -332,6 +412,79 @@ describe("netacea_utils", function() end) end) + describe("env", function() + local original_getenv + local env_values + + before_each(function() + original_getenv = os.getenv + env_values = {} + os.getenv = function(name) + return env_values[name] + end + end) + + after_each(function() + os.getenv = original_getenv + end) + + it("should return an environment variable when it is set", function() + env_values.NETACEA_TEST_VALUE = "configured-value" + + local result = utils.env("NETACEA_TEST_VALUE", "default-value") + + assert.is.equal("configured-value", result) + end) + + it("should return the default value when an environment variable is not set", function() + local result = utils.env("NETACEA_TEST_VALUE", "default-value") + + assert.is.equal("default-value", result) + end) + end) + + describe("envEnabled", function() + local original_getenv + local env_values + + before_each(function() + original_getenv = os.getenv + env_values = {} + os.getenv = function(name) + return env_values[name] + end + end) + + after_each(function() + os.getenv = original_getenv + end) + + it("should return the default value when an environment variable is not set", function() + local result = utils.envEnabled("NETACEA_INGEST_ENABLED", true) + + assert.is_true(result) + end) + + it("should return true when an environment variable is exactly true", function() + env_values.NETACEA_INGEST_ENABLED = "true" + + local result = utils.envEnabled("NETACEA_INGEST_ENABLED", false) + + assert.is_true(result) + end) + + it("should return false when an environment variable is set to anything else", function() + env_values.NETACEA_INGEST_ENABLED = "True" + assert.is_false(utils.envEnabled("NETACEA_INGEST_ENABLED", true)) + + env_values.NETACEA_INGEST_ENABLED = "false" + assert.is_false(utils.envEnabled("NETACEA_INGEST_ENABLED", true)) + + env_values.NETACEA_INGEST_ENABLED = "" + assert.is_false(utils.envEnabled("NETACEA_INGEST_ENABLED", true)) + end) + end) + describe("buildRandomString edge cases", function() it("should handle negative length gracefully", function() -- The current implementation doesn't check for negative values