Sindbad~EG File Manager
--[[
Improved JS Challenge
--]]
local _M = {}
local o2debug = require "lib/o2switch_debug"
local o2config = require "lib/o2switch_config"
local o2redis = require "lib/o2switch_redis"
local o2obfuscate = require "lib/o2switch_obfuscate_js"
local o2utils = require "lib/o2switch_utils"
local hashWrapper = require "lib/o2switch_hash_wrapper"
local json = require "cjson"
local md5 = ngx.md5
local today = ngx.today
local time = ngx.time
local cookieTime = ngx.cookie_time
local salt = o2config.cookieSecret
local table = table
local math = math
local string = string
local tostring = tostring
local pcall = pcall
local type = type
local pairs = pairs
local gsub = string.gsub
local CHALLENGE_REDIS_TTL = 120 -- The redis TTL for the challenge response. After that, it's deleted.
local ONGOING_CHALLENGE_SUFFIX = ':chl:o' -- Redis key suffix used to store ongoing challenge data
-- Those will never be used by the js challenge, so we can search replace for them just 1 time
local function removeUselessPattern(challengeTemplate)
return ((challengeTemplate:gsub('#(%w+)', {
['chlType'] = 'js',
['captchaKey'] = '',
['captchaProvider'] = '',
['captchaProviderJsInclusion'] = '',
['captchaDiv'] = '',
})))
end
-- Local copie for the performance boost
local challengeTemplate = removeUselessPattern(challengeTemplate)
-- Running a gsub on multiple pattern on the template is not efficient and king of slow
-- Since the template doesn't change for each requets, we can calculate the replacement position and use thoses
-- static position instead. We'll need to do the search/replace in reverse, starting by the last pattern found on the
-- challenge template.
local function getPositions()
local positions = {}
local item = {"#hashValue", "#requestId"}
for i=1, #item do
positions[i] = {
item[i],
string.find(challengeTemplate, item[i], 1)
}
end
table.sort(positions, function(a,b)
return a[2] > b[2]
end)
return positions
end
local positions = getPositions()
-- Fast search/replace of pattern using the position
-- @return string
local function fastReplace(template, start, stop, replacement)
return template:sub(1, start - 1)
.. replacement
.. template:sub(stop + 1, #template)
end
-- Fast search/replace to get the template
local function fastTemplate(replacementValues)
local response = challengeTemplate
for i =1, #positions do
response = fastReplace(
response,
positions[i][2],
positions[i][3],
replacementValues[positions[i][1]]
)
end
return response
end
-- Generate a hash with the client IP address, UA, challenge type and a secret, to avoid tampering of the challenge send.
-- For instance, to avoid a smartass to ask the JS challenge instead of captcha
-- @return md5 hash
function _M.getAntiTamperingHash(ip, ua)
return md5('ANTI-TAMPERING' .. ip .. (ua or '-') .. 'js' .. o2config.jsSecret .. today())
end
-- Send the HTML code for the JS Challenge
-- @antiTamperingHash the hash from getAntiTamperingHash
function _M.sendChallengeHtmlPage(antiTamperingHash, requestId)
ngx.status = 503
ngx.header.content_type = "text/html; charset=UTF-8"
ngx.header.cache_control = "private, max-age=0, no-store, no-cache, must-revalidate, post-check=0, pre-check=0"
ngx.header.expires = "Thu, 01 Jan 1970 00:00:01 GMT"
ngx.header.referer_policy = "same-origin"
ngx.header.tiger_protect_security = "https://faq.o2switch.fr/hebergement-mutualise/tutoriels-cpanel/tiger-protect"
ngx.say(fastTemplate({
['#hashValue'] = antiTamperingHash,
['#requestId'] = requestId or '-',
}))
ngx.exit(200)
end
-- Return a random number between the cost limit configured
-- @return {numberToGuess, costUpperLimit}
function _M.getRandomNumber()
math.randomseed(math.random(1,999999999) + time())
return tostring(math.random(o2config.minCost, o2config.maxCost))
end
-- Return a collection of hashing methods to use
-- @return {'first hash to use', 'second hash to use', '...'}
function _M.getHashingMethod()
math.randomseed(math.random(1,999999999) + time())
local hashingMethods = {'md5', 'sha256', 'sha1'}
local returnValue = {}
local iteration = math.random(1,4)
for i=1, iteration do
table.insert(returnValue, hashingMethods[math.random(#hashingMethods)])
end
return returnValue
end
-- Return the hash to guess
-- @return final hash or nil in case of error
function _M.getHash(numberToGuess, hashMethodCollection)
if type(numberToGuess) ~= 'number' then
numberToGuess = tostring(numberToGuess)
end
local returnValue = numberToGuess
for k, hashMethod in pairs(hashMethodCollection) do
local hash, err
if hashMethod == 'md5' then
hash, err = hashWrapper.md5(returnValue)
elseif hashMethod == 'sha1' then
hash, err = hashWrapper.sha1(returnValue)
elseif hashMethod == 'sha256' then
hash, err = hashWrapper.sha256(returnValue)
else
o2debug.debugErr('Unsupported hashing method')
return nil
end
if not hash or err ~= nil then
o2debug.debugErr('Hashing error')
return nil
end
returnValue = hash
end
return returnValue
end
-- Return a string that will be used as a challenge ID
-- The challenge ID is used to be extra safe in the case were there's a lot of people behind a public IP address
-- @return string
function _M.generateChallengeId(nginxUniqueId)
if type(nginxUniqueId) == 'string' then
return string.sub(nginxUniqueId, 1, 6)
end
math.randomseed(math.random(1,999999999) + time())
return tostring(math.random(100000,999999))
end
-- Persist the challenge details to Redis. It will be used to check the response / solution of the challenge later.
-- @domain The domain name for which the security was triggered
-- @ip The remote_addr
-- @numberToGuess The number to guess (the solution)
-- @return boolean, nil|error
function _M.persist(domain, ip, numberToGuess, challengeId)
if(type(domain) ~= 'string') then
o2debug.debugErr("The domain is not valid, not a string.")
return false, "The domain is not valid, not a string."
end
if(type(ip) ~= 'string') then
o2debug.debugErr("The domain IP address is not valid, not a string")
return false, "The domain IP address is not valid, not a string"
end
if(type(challengeId) ~= 'string') then
o2debug.debugErr("The challengeId is not valid, not a string : " .. type(challengeId))
return false, "The challengeId is not valid, not a string"
end
if(type(numberToGuess) == 'number') then
numberToGuess = tostring(numberToGuess)
end
if(type(numberToGuess) ~= 'string') then
o2debug.debugErr("The domain numberToGuess is not valid, not a string, neither a number")
return false, "The domain hashMethodCollection is not valid, not a string, neither a number"
end
local ok, json = pcall(json.encode, {
["i"] = ip,
["n"] = numberToGuess,
})
if not ok or type(json) ~= 'string' then
o2debug.debugErr("Json encode error")
return false, "Json encode error"
end
local ok, err = o2redis.hset(domain .. ONGOING_CHALLENGE_SUFFIX, challengeId, json, CHALLENGE_REDIS_TTL)
if not ok or err ~= nil then
o2debug.debugErr("Persisting error : " .. err)
return false, "Persisting error : " .. err
end
return true, nil
end
-- Retrieve the data of a previously send JS Challenge
-- @return table|nil, errMsg|nil
function _M.retrieve(domain, challengeId)
if(type(domain) ~= 'string') then
o2debug.debugErr("The domain is not valid, not a string.")
return false, "The domain is not valid, not a string."
end
if type(challengeId) == 'number' then
challengeId = tostring(challengeId)
end
if(type(challengeId) ~= 'string') then
o2debug.debugErr("The challengeId is not valid, not a string")
return false, "The challengeId is not valid, not a string"
end
local data, err = o2redis.hget(domain .. ONGOING_CHALLENGE_SUFFIX, challengeId)
if err ~= nil then
o2debug.debugErr("Error while retrieving data with hget : " .. err)
return false, "Persisting error : " .. err
end
if type(data) ~= 'string' then
return nil, nil
end
local ok, decoded = pcall(json.decode, data)
if not ok then
o2debug.debugErr("Json decode error")
return false, "Json decode error"
end
-- TODO : See to remove the hash collection
if type(decoded) ~= 'table' or decoded['n'] == nil or decoded['i'] == nil then
return nil, nil
end
return decoded, nil
end
-- Return a challenge JS payload. The JS code will be obfuscated.
-- @hash The hash to guess
-- @hashMethodCollection The collection of hash function name to use
-- @challengeId The challenge ID. It's the equivalent of a session, it's used to store the result in the backend.
function _M.getChallengeJsPayload(hash, hashMethodCollection, challengeId)
return o2obfuscate.getChallengeJsPayload(hash, hashMethodCollection, challengeId)
end
-- Generate and return the value of the bypass cookie for the HS challenge.
-- When this cookie is passed by the browser, the challenge is not triggered anymore.
-- @clientip
-- @ua
-- @domain
-- @return string
function _M.getBypassCookieValue(clientip, ua, domain)
return md5('JS-CHL-BYPASS' .. clientip .. (ua or '-') .. domain .. salt .. today())
end
function _M.sendSuccessRedirectResponse(url, domain, cookieName, bypassCookieValue)
ngx.status = 302 -- TODO: try with 307 one day to see if we are able to keep the POST content
ngx.header.content_type = "text/html; charset=UTF-8"
ngx.header.cache_control = "private, max-age=0, no-store, no-cache, must-revalidate, post-check=0, pre-check=0"
ngx.header.expires = "Thu, 01 Jan 1970 00:00:01 GMT"
ngx.header.referer_policy = "same-origin"
ngx.header.set_cookie = cookieName .. '=' .. bypassCookieValue .. '; domain=.'.. domain ..'; expires='.. cookieTime(time() + 86400) ..'; path=/; SameSite=Lax; HttpOnly'
ngx.header.tiger_protect_security = "https://faq.o2switch.fr/hebergement-mutualise/tutoriels-cpanel/tiger-protect"
ngx.header.location = url
ngx.say(((gsub([=[
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="#url">here</A>.
</BODY></HTML>
]=], '#url', url))))
ngx.exit(ngx.HTTP_OK)
end
return _M
Sindbad File Manager Version 1.0, Coded By Sindbad EG ~ The Terrorists