Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions Dockerfile.nginx_lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 15 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 4 additions & 0 deletions src/conf/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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', ''),
Expand Down
49 changes: 45 additions & 4 deletions src/lua_resty_netacea.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
60 changes: 59 additions & 1 deletion src/lua_resty_netacea_mitigation.lua
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions src/lua_resty_netacea_protector_client.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 '',
Expand Down Expand Up @@ -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
21 changes: 21 additions & 0 deletions src/netacea_utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading