bunkerweb 1.4.0

This commit is contained in:
bunkerity
2022-06-03 17:24:14 +02:00
parent 3a078326c5
commit a9f886804a
5245 changed files with 1432051 additions and 27894 deletions

375
core/antibot/antibot.lua Normal file
View File

@@ -0,0 +1,375 @@
local _M = {}
_M.__index = _M
local utils = require "utils"
local datastore = require "datastore"
local logger = require "logger"
local cjson = require "cjson"
local session = require "resty.session"
local captcha = require "antibot.captcha"
local base64 = require "base64"
local sha256 = require "resty.sha256"
local str = require "resty.string"
local http = require "resty.http"
function _M.new()
local self = setmetatable({}, _M)
return self, nil
end
function _M:init()
-- Check if init is needed
local init_needed, err = utils.has_not_variable("USE_ANTIBOT", "no")
if init_needed == nil then
return false, err
end
if not init_needed then
return true, "no service uses Antibot, skipping init"
end
-- Load templates
local templates = {}
for i, template in ipairs({"javascript", "captcha", "recaptcha", "hcaptcha"}) do
local f, err = io.open("/opt/bunkerweb/core/antibot/files/" .. template .. ".html")
if not f then
return false, "error while loading " .. template .. ".html : " .. err
end
templates[template] = f:read("*all")
f:close()
end
local ok, err = datastore:set("plugin_antibot_templates", cjson.encode(templates))
if not ok then
return false, "can't save templates to datastore : " .. err
end
return true, "success"
end
function _M:access()
-- Check if access is needed
local antibot, err = utils.get_variable("USE_ANTIBOT")
if antibot == nil then
return false, err, nil, nil
end
if antibot == "no" then
return true, "Antibot not activated", nil, nil
end
-- Get challenge URI
local challenge_uri, err = utils.get_variable("ANTIBOT_URI")
if not challenge_uri then
return false, "can't get Antibot URI from datastore : " .. err, nil, nil
end
-- Don't go further if client resolved the challenge
local resolved, err, original_uri = self:challenge_resolved(antibot)
if resolved == nil then
return false, "can't check if challenge is resolved : " .. err, nil, nil
end
if resolved then
if ngx.var.uri == challenge_uri then
return true, "client already resolved the challenge", true, ngx.redirect(original_uri)
end
return true, "client already resolved the challenge", nil, nil
end
-- Redirect to challenge page
if ngx.var.uri ~= challenge_uri then
local ok, err = self:prepare_challenge(antibot, challenge_uri)
if not ok then
return false, "can't prepare challenge : " .. err, true, ngx.HTTP_INTERNAL_SERVER_ERROR
end
return true, "redirecting client to the challenge uri", true, ngx.redirect(challenge_uri)
end
-- Display challenge
if ngx.var.request_method == "GET" then
local ok, err = self:display_challenge(antibot, challenge_uri)
if not ok then
if err == "can't open session" then
local ok, err = self:prepare_challenge(antibot, challenge_uri)
if not ok then
return false, "can't prepare challenge : " .. err, true, ngx.HTTP_INTERNAL_SERVER_ERROR
end
return true, "redirecting client to the challenge uri", true, ngx.redirect(challenge_uri)
end
return false, "display challenge error : " .. err, true, ngx.HTTP_INTERNAL_SERVER_ERROR
end
return true, "displaying challenge to client", true, ngx.HTTP_OK
end
-- Check challenge
if ngx.var.request_method == "POST" then
local ok, err, redirect = self:check_challenge(antibot)
if ok == nil then
if err == "can't open session" then
local ok, err = self:prepare_challenge(antibot, challenge_uri)
if not ok then
return false, "can't prepare challenge : " .. err, true, ngx.HTTP_INTERNAL_SERVER_ERROR
end
return true, "redirecting client to the challenge uri", true, ngx.redirect(challenge_uri)
end
return false, "check challenge error : " .. err, true, ngx.HTTP_INTERNAL_SERVER_ERROR
end
if redirect then
return true, "check challenge redirect : " .. redirect, true, ngx.redirect(redirect)
end
local ok, err = self:display_challenge(antibot)
if not ok then
if err == "can't open session" then
local ok, err = self:prepare_challenge(antibot, challenge_uri)
if not ok then
return false, "can't prepare challenge : " .. err, true, ngx.HTTP_INTERNAL_SERVER_ERROR
end
return true, "redirecting client to the challenge uri", true, ngx.redirect(challenge_uri)
end
return false, "display challenge error : " .. err, true, ngx.HTTP_INTERNAL_SERVER_ERROR
end
return true, "displaying challenge to client", true, ngx.HTTP_OK
end
-- Method is suspicious, let's deny the request
return true, "unsupported HTTP method for Antibot", true, ngx.HTTP_FORBIDDEN
end
function _M:challenge_resolved(antibot)
local chall_session, present, reason = session.open()
if present and chall_session.data.resolved and chall_session.data.type == antibot then
return true, "challenge " .. antibot .. " resolved", chall_session.data.original_uri
end
return false, "challenge " .. antibot .. " not resolved", nil
end
function _M:prepare_challenge(antibot, challenge_uri)
local chall_session, present, reason = session.open()
if not present then
local chall_session, present, reason = chall_session:start()
if not chall_session then
return false, "can't start session", nil
end
chall_session.data.type = antibot
chall_session.data.resolved = false
if ngx.var.request_uri == challenge_uri then
chall_session.data.original_uri = "/"
else
chall_session.data.original_uri = ngx.var.request_uri
end
if antibot == "cookie" then
chall_session.data.resolved = true
end
local saved, err = chall_session:save()
if not saved then
return false, "error while saving session : " .. err
end
end
return true, antibot .. " challenge prepared"
end
function _M:display_challenge(antibot, challenge_uri)
-- Open session
local chall_session, present, reason = session.open()
if not present then
return false, "can't open session"
end
-- Check if session type is equal to antibot type
if antibot ~= chall_session.data.type then
return false, "session type is different from antibot type"
end
-- Compute challenges
if antibot == "javascript" then
chall_session:start()
chall_session.data.random = utils.rand(20)
chall_session:save()
elseif antibot == "captcha" then
chall_session:start()
local chall_captcha = captcha.new()
chall_captcha:font("/opt/bunkerweb/core/antibot/files/font.ttf")
chall_captcha:generate()
chall_session.data.image = base64.encode(chall_captcha:jpegStr(70))
chall_session.data.text = chall_captcha:getStr()
chall_session:save()
end
-- Load HTML templates
local str_templates, err = datastore:get("plugin_antibot_templates")
if not str_templates then
return false, "can't get templates from datastore : " .. err
end
local templates = cjson.decode(str_templates)
local html = ""
-- Javascript case
if antibot == "javascript" then
html = templates.javascript:format(challenge_uri, chall_session.data.random)
end
-- Captcha case
if antibot == "captcha" then
html = templates.captcha:format(challenge_uri, chall_session.data.image)
end
-- reCAPTCHA case
if antibot == "recaptcha" then
local recaptcha_sitekey, err = utils.get_variable("ANTIBOT_RECAPTCHA_SITEKEY")
if not recaptcha_sitekey then
return false, "can't get reCAPTCHA sitekey variable : " .. err
end
html = templates.recaptcha:format(recaptcha_sitekey, challenge_uri, recaptcha_sitekey)
end
-- hCaptcha case
if antibot == "hcaptcha" then
local hcaptcha_sitekey, err = utils.get_variable("ANTIBOT_HCAPTCHA_SITEKEY")
if not hcaptcha_sitekey then
return false, "can't get hCaptcha sitekey variable : " .. err
end
html = templates.hcaptcha:format(challenge_uri, hcaptcha_sitekey)
end
ngx.header["Content-Type"] = "text/html"
ngx.say(html)
return true, "displayed challenge"
end
function _M:check_challenge(antibot)
-- Open session
local chall_session, present, reason = session.open()
if not present then
return nil, "can't open session", nil
end
-- Check if session type is equal to antibot type
if antibot ~= chall_session.data.type then
return nil, "session type is different from antibot type", nil
end
local resolved = false
local err = ""
local redirect = nil
-- Javascript case
if antibot == "javascript" then
ngx.req.read_body()
local args, err = ngx.req.get_post_args(1)
if err == "truncated" or not args or not args["challenge"] then
return false, "missing challenge arg", nil
end
local hash = sha256:new()
hash:update(chall_session.data.random .. args["challenge"])
local digest = hash:final()
resolved = str.to_hex(digest):find("^0000") ~= nil
if not resolved then
return false, "wrong value", nil
end
chall_session:start()
chall_session.data.resolved = true
chall_session:save()
return true, "resolved", chall_session.data.original_uri
end
-- Captcha case
if antibot == "captcha" then
ngx.req.read_body()
local args, err = ngx.req.get_post_args(1)
if err == "truncated" or not args or not args["captcha"] then
return false, "missing challenge arg", nil
end
if chall_session.data.text ~= args["captcha"] then
return false, "wrong value", nil
end
chall_session:start()
chall_session.data.resolved = true
chall_session:save()
return true, "resolved", chall_session.data.original_uri
end
-- reCAPTCHA case
if antibot == "recaptcha" then
ngx.req.read_body()
local args, err = ngx.req.get_post_args(1)
if err == "truncated" or not args or not args["token"] then
return false, "missing challenge arg", nil
end
local recaptcha_secret, err = utils.get_variable("ANTIBOT_RECAPTCHA_SECRET")
if not recaptcha_secret then
return nil, "can't get reCAPTCHA secret variable : " .. err, nil
end
local httpc, err = http.new()
if not httpc then
return false, "can't instantiate http object : " .. err, nil, nil
end
local res, err = httpc:request_uri("https://www.google.com/recaptcha/api/siteverify", {
method = "POST",
body = "secret=" .. recaptcha_secret .. "&response=" .. args["token"] .. "&remoteip=" .. ngx.var.remote_addr,
headers = {
["Content-Type"] = "application/x-www-form-urlencoded"
}
})
httpc:close()
if not res then
return nil, "can't send request to reCAPTCHA API : " .. err, nil
end
local ok, data = pcall(cjson.decode, res.body)
if not ok then
return nil, "error while decoding JSON from reCAPTCHA API : " .. data, nil
end
local recaptcha_score, err = utils.get_variable("ANTIBOT_RECAPTCHA_SCORE")
if not recaptcha_score then
return nil, "can't get reCAPTCHA score variable : " .. err, nil
end
if not data.success or data.score < tonumber(recaptcha_score) then
return false, "client failed challenge with score " .. tostring(data.score), nil
end
chall_session:start()
chall_session.data.resolved = true
chall_session:save()
return true, "resolved", chall_session.data.original_uri
end
-- hCaptcha case
if antibot == "hcaptcha" then
ngx.req.read_body()
local args, err = ngx.req.get_post_args(1)
if err == "truncated" or not args or not args["token"] then
return false, "missing challenge arg", nil
end
local hcaptcha_secret, err = utils.get_variable("ANTIBOT_HCAPTCHA_SECRET")
if not hcaptcha_secret then
return nil, "can't get hCaptcha secret variable : " .. err, nil
end
local httpc, err = http.new()
if not httpc then
return false, "can't instantiate http object : " .. err, nil, nil
end
local res, err = httpc:request_uri("https://hcaptcha.com/siteverify", {
method = "POST",
body = "secret=" .. hcaptcha_secret .. "&response=" .. args["token"] .. "&remoteip=" .. ngx.var.remote_addr,
headers = {
["Content-Type"] = "application/x-www-form-urlencoded"
}
})
httpc:close()
if not res then
return nil, "can't send request to hCaptcha API : " .. err, nil
end
local ok, data = pcall(cjson.decode, res.body)
if not ok then
return nil, "error while decoding JSON from hCaptcha API : " .. data, nil
end
if not data.success then
return false, "client failed challenge", nil
end
chall_session:start()
chall_session.data.resolved = true
chall_session:save()
return true, "resolved", chall_session.data.original_uri
end
return nil, "unknown", nil
end
return _M

193
core/antibot/captcha.lua Normal file
View File

@@ -0,0 +1,193 @@
-- Copyright startx <startx@plentyfact.org>
-- Modifications copyright mrDoctorWho <mrdoctorwho@gmail.com>
-- Published under the MIT license
local _M = {}
local gd = require 'gd'
local logger = require "logger"
local mt = { __index = {} }
function _M.new()
local cap = {}
local f = setmetatable({ cap = cap}, mt)
return f
end
local function urandom()
local seed = 1
local devurandom = io.open("/dev/urandom", "r")
local urandom = devurandom:read(32)
devurandom:close()
for i=1,string.len(urandom) do
local s = string.byte(urandom,i)
seed = seed + s
end
return seed
end
local function random_char(length)
local set, char, uid
-- local set = [[1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ]]
local set = [[abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ]]
local captcha_t = {}
math.randomseed(urandom())
for c=1,length do
local i = math.random(1, string.len(set))
table.insert(captcha_t, string.sub(set,i,i))
end
return captcha_t
end
local function random_angle()
math.randomseed(urandom())
return math.random(-20, 40)
end
local function scribble(w,h)
math.randomseed(urandom())
local x1 = math.random(5, w - 5)
local x2 = math.random(5, w - 5)
return x1, x2
end
function mt.__index:string(s)
self.cap.string = s
end
function mt.__index:scribble(n)
self.cap.scribble = n or 20
end
function mt.__index:length(l)
self.cap.length = l
end
function mt.__index:bgcolor(r,g,b)
self.cap.bgcolor = { r = r , g = g , b = b}
end
function mt.__index:fgcolor(r,g,b)
self.cap.fgcolor = { r = r , g = g , b = b}
end
function mt.__index:line(line)
self.cap.line = line
end
function mt.__index:font(font)
self.cap.font = font
end
function mt.__index:generate()
--local self.captcha = {}
local captcha_t = {}
if not self.cap.string then
if not self.cap.length then
self.cap.length = 6
end
captcha_t = random_char(self.cap.length)
self:string(table.concat(captcha_t))
else
for i=1, #self.cap.string do
table.insert(captcha_t, string.sub(self.cap.string, i, i))
end
end
self.im = gd.createTrueColor(#captcha_t * 40, 45)
local black = self.im:colorAllocate(0, 0, 0)
local white = self.im:colorAllocate(255, 255, 255)
local bgcolor
if not self.cap.bgcolor then
bgcolor = white
else
bgcolor = self.im:colorAllocate(self.cap.bgcolor.r , self.cap.bgcolor.g, self.cap.bgcolor.b )
end
local fgcolor
if not self.cap.fgcolor then
fgcolor = black
else
fgcolor = self.im:colorAllocate(self.cap.fgcolor.r , self.cap.fgcolor.g, self.cap.fgcolor.b )
end
self.im:filledRectangle(0, 0, #captcha_t * 40, 45, bgcolor)
local offset_left = 10
for i=1, #captcha_t do
local angle = random_angle()
local llx, lly, lrx, lry, urx, ury, ulx, uly = self.im:stringFT(fgcolor, self.cap.font, 25, math.rad(angle), offset_left, 35, captcha_t[i])
self.im:polygon({ {llx, lly}, {lrx, lry}, {urx, ury}, {ulx, uly} }, bgcolor)
offset_left = offset_left + 40
end
if self.cap.line then
self.im:line(10, 10, ( #captcha_t * 40 ) - 10 , 40, fgcolor)
self.im:line(11, 11, ( #captcha_t * 40 ) - 11 , 41, fgcolor)
self.im:line(12, 12, ( #captcha_t * 40 ) - 12 , 42, fgcolor)
end
if self.cap.scribble then
for i=1,self.cap.scribble do
local x1,x2 = scribble( #captcha_t * 40 , 45 )
self.im:line(x1, 5, x2, 40, fgcolor)
end
end
end
-- Perhaps it's not the best solution
-- Writes the generated image to a jpeg file
function mt.__index:jpeg(outfile, quality)
self.im:jpeg(outfile, quality)
end
-- Writes the generated image to a png file
function mt.__index:png(outfile)
self.im:png(outfile)
end
-- Allows to get the image data in PNG format
function mt.__index:pngStr()
return self.im:pngStr()
end
-- Allows to get the image data in JPEG format
function mt.__index:jpegStr(quality)
return self.im:jpegStr(quality)
end
-- Allows to get the image text
function mt.__index:getStr()
return self.cap.string
end
-- Writes the image to a file
function mt.__index:write(outfile, quality)
if self.cap.string == nil then
self:generate()
end
self:jpeg(outfile, quality)
-- Compatibility
return self:getStr()
end
return _M

View File

@@ -0,0 +1,9 @@
map "{{ ANTIBOT_SESSION_SECRET }}" $session_secret {
default "{{ ANTIBOT_SESSION_SECRET }}";
"random" "{{ random(32) }}";
}
map "{{ ANTIBOT_SESSION_NAME }}" $session_name {
default "{{ ANTIBOT_SESSION_NAME }}";
"random" "{{ random(16) }}";
}

File diff suppressed because one or more lines are too long

BIN
core/antibot/files/font.ttf Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

98
core/antibot/plugin.json Normal file
View File

@@ -0,0 +1,98 @@
{
"id": "antibot",
"order": 4,
"name": "Antibot",
"description": "Bot detection by using a challenge.",
"version": "0.1",
"settings": {
"USE_ANTIBOT": {
"context": "multisite",
"default": "no",
"help": "Activate antibot feature.",
"id": "use-antibot",
"label": "Antibot challenge",
"regex": "^(no|cookie|javascript|captcha|recaptcha|hcaptcha)$",
"type": "select",
"select": [
"no",
"cookie",
"javascript",
"captcha",
"recaptcha",
"hcaptcha"
]
},
"ANTIBOT_URI": {
"context": "multisite",
"default": "/challenge",
"help": "Unused URI that clients will be redirected to solve the challenge.",
"id": "antibot-uri",
"label": "Antibot URL",
"regex": "^.*$",
"type": "text"
},
"ANTIBOT_SESSION_SECRET": {
"context": "global",
"default": "random",
"help": "Secret used to encrypt sessions variables for storing data related to challenges.",
"id": "antibot-session-secret",
"label": "Session secret",
"regex": "^.*$",
"type": "text"
},
"ANTIBOT_SESSION_NAME": {
"context": "global",
"default": "random",
"help": "Name of the cookie used by the antibot feature.",
"id": "antibot-session-name",
"label": "Session name",
"regex": "^.*$",
"type": "text"
},
"ANTIBOT_RECAPTCHA_SCORE": {
"context": "multisite",
"default": "0.7",
"help": "Minimum score required for reCAPTCHA challenge.",
"id": "antibot-recaptcha-score",
"label": "reCAPTCHA score",
"regex": "^.*$",
"type": "text"
},
"ANTIBOT_RECAPTCHA_SITEKEY": {
"context": "multisite",
"default": "",
"help": "Sitekey for reCAPTCHA challenge.",
"id": "antibot-recaptcha-sitekey",
"label": "reCAPTCHA sitekey",
"regex": "^.*$",
"type": "text"
},
"ANTIBOT_RECAPTCHA_SECRET": {
"context": "multisite",
"default": "",
"help": "Secret for reCAPTCHA challenge.",
"id": "antibot-recaptcha-secret",
"label": "reCAPTCHA secret",
"regex": "^.*$",
"type": "text"
},
"ANTIBOT_HCAPTCHA_SITEKEY": {
"context": "multisite",
"default": "",
"help": "Sitekey for hCaptcha challenge.",
"id": "antibot-hcaptcha-sitekey",
"label": "hCaptcha sitekey",
"regex": "^.*$",
"type": "text"
},
"ANTIBOT_HCAPTCHA_SECRET": {
"context": "multisite",
"default": "",
"help": "Secret for hCaptcha challenge.",
"id": "antibot-hcaptcha-secret",
"label": "hCaptcha secret",
"regex": "^.*$",
"type": "text"
}
}
}