├── README.md ├── puzzle.html └── puzzle.lua /README.md: -------------------------------------------------------------------------------- 1 | ## OpenResty Javascript challenge 2 | This is a OpenResty Lua and Redis powered puzzle for browsers to mitigate DDOS attacks 3 | 4 | ### OpenResty Prerequisite 5 | * You need cJSON lua module, [lua-resty-cookie](https://github.com/cloudflare/lua-resty-cookie) and [lua-resty-reids](https://github.com/openresty/lua-resty-redis) 6 | 7 | 8 | 9 | ### How it works 10 | 1. Client asks for content, lua asks for cookie 11 | 2. Cookie is checked and if valid then pass, if not then ... 12 | 3. Lua creates SEED (random string) 13 | 4. Lua picks number between 6000 and difficulty 14 | 5. Lua creates SHA1 with the number and SHA1 15 | 6. Then send the SHA1 and SEED and ask for the number 16 | 7. Browser javascript uses forloop to find out the number 17 | 8. Javascript sends result and gets back a cookie 18 | 19 | ### Example OpenResty Site Config 20 | ``` 21 | # Location of this Lua package 22 | lua_package_path "/opt/lua-resty-rate-limit/lib/?.lua;;"; 23 | 24 | server { 25 | listen 80; 26 | server_name api.dev; 27 | 28 | access_log /var/log/nginx/website.com-access.log; 29 | error_log /var/log/nginx/website.com-error.log; 30 | 31 | location / { 32 | 33 | # All keys have default value. 34 | access_by_lua ' 35 | local puzzle = require "resty.puzzle" 36 | puzzle.challenge { 37 | log_level = ngx.INFO, 38 | cookie_lifetime = 604800 39 | difficulty = 100, 40 | min_difficulty = 0, 41 | seed_lengt = 30, 42 | seed_lifetime = 60, 43 | target = "___", 44 | cookie_name = "_cuid", 45 | template = '/location/to/the/puzzle.html', 46 | client_key = ngx.var.remote_addr, 47 | redis_config = { 48 | timeout = 1, 49 | host = "127.0.0.1", 50 | port = 6379 51 | } 52 | } 53 | '; 54 | 55 | proxy_set_header Host $host; 56 | proxy_set_header X-Real-IP $remote_addr; 57 | proxy_set_header X-Forwarded-For $remote_addr; 58 | proxy_pass https://github.com; 59 | } 60 | location /__ { 61 | content_by_lua ' 62 | local puzzle = require "resty.puzzle" 63 | puzzle.response { 64 | log_level = ngx.INFO, 65 | cookie_lifetime = 604800 66 | target = "___", 67 | cookie_name = "_cuid", 68 | client_key = ngx.var.remote_addr, 69 | timezone = "GMT", 70 | http_only_cookie = false, 71 | cookie_secure = false, 72 | cookie_domain = ngx.var.host, 73 | cookie_path = "/", 74 | min_time = 2, 75 | redis_config = { 76 | timeout = 1, 77 | host = "127.0.0.1", 78 | port = 6379 79 | } 80 | } 81 | '; 82 | } 83 | } 84 | ``` 85 | 86 | ### Config Values 87 | You can customize the puzzle options by changing the following values: 88 | 89 | * key: The value to use as a unique identifier in Redis, COOKIE_ or SEED_ is prepended 90 | * cookie_lifetime: For how long you want the browser and redis to store the cookie? 91 | * difficulty: How many interaction to you want the browser to perform, this is the upper limit of a range 92 | * seed_lengt: How long you want the SEED to be, just for the random string generator 93 | * seed_lifetime: For how long do you want the SEED to be stored in redis 94 | * target: Path for AJAX to request to with answer 95 | * cookie_name : Name of the cookie 96 | * template : Path to the HTML template 97 | * client_key : How do you want to identify users, can be anything, IP is just fine 98 | * timezone : Timezone appended to Set-Cookie Expires 99 | * http_only_cookie : Can the cookie be used with AJAX? 100 | * cookie_secure : HTTPS only cookie? 101 | * cookie_domain : For what domain is the cookie? 102 | * cookie_domain = "/", 103 | * min_time : Minimun time needed before users can send there results 104 | * log_level: Set an Nginx log level. All errors from this plugin will be dumped here 105 | * redis_config: The Redis host, port, timeout and pool size 106 | 107 | 108 | ### Template 109 | There is a demo template with this module, you can use it or edit it.. 110 | In the file there are few variables 111 | * ::SEED:: will be replaced for the SEED 112 | * ::HASH:: will be replaced for the expected result sha1 hash 113 | * ::TARGET:: Where the ajax should send the result 114 | * ::URL:: User original URL 115 | 116 | 117 | ### Possible flaws 118 | In theory users can brute force the api with flooding integers, but rate limit should stop that.. easyer just to puzzle :) 119 | -------------------------------------------------------------------------------- /puzzle.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Just a moment, checking if you are RD-D2 6 | 7 | 8 | 9 | 41 | 48 | 49 | 50 |
51 |
52 |

Wait ...

53 | 54 |

Your browser is computing access to .

55 |

This can take up to 5 sec, you will be redirected to your requested content

56 |

57 | 58 |

59 |
60 |
61 | 62 | 63 | 76 | 77 | 78 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /puzzle.lua: -------------------------------------------------------------------------------- 1 | _M = { _VERSION = "1.0" } 2 | 3 | local redis = require "resty.redis" 4 | local ck = require "resty.cookie" 5 | 6 | 7 | local cjson = require "cjson" 8 | local clientIP = ngx.var.remote_addr 9 | 10 | local sha1 = require "sha1" 11 | 12 | local function CreatePow(min,max) 13 | math.randomseed(os.time()); 14 | -- Start from 6000, so it wont be to easy 15 | return math.random(min, max); 16 | end 17 | 18 | local function render(template, obj) 19 | local str = "" 20 | for key, value in pairs(obj) do 21 | str = "::" .. key .. "::" 22 | template = string.gsub(template,str, value) 23 | end 24 | return template 25 | end 26 | 27 | 28 | local function RandomString(length) 29 | length = length or 1 30 | if length < 1 then return nil end 31 | 32 | math.randomseed(os.time()); 33 | local chars = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM"; 34 | local charlength = string.len(chars); 35 | local array = {} 36 | for i = 1, length do 37 | local rand = math.random(0, charlength) 38 | array[i] = string.sub(chars,rand, rand + 1); 39 | end 40 | return table.concat(array) 41 | end 42 | 43 | local function Fetch(redis_connection, key, log_level) 44 | local json, err = redis_connection:get(key) 45 | if not json then 46 | -- ngx.say("failed to get ipaddr ", err) 47 | ngx.log(log_level, "failed to get key ", err) 48 | ngx.exit(nginx.HTTP_INTERNAL_SERVER_ERROR) 49 | return 50 | end 51 | 52 | if json == ngx.null then 53 | -- Nothing in the DB, return false 54 | return nil 55 | else 56 | return cjson.decode(json) 57 | end 58 | end 59 | 60 | local function Del(redis_connection, key, log_level) 61 | local json, err = redis_connection:del(key) 62 | if not json then 63 | -- ngx.say("failed to get ipaddr ", err) 64 | ngx.log(log_level, "failed to delete key ", err) 65 | ngx.exit(nginx.HTTP_INTERNAL_SERVER_ERROR) 66 | return 67 | end 68 | return true 69 | end 70 | 71 | 72 | local function Set(redis_connection, key, data, ttl, log_level) 73 | ok, err = redis_connection:set(key, cjson.encode(data)) 74 | if not ok then 75 | ngx.log(log_level, "failed to set key ", err) 76 | ngx.exit(nginx.HTTP_INTERNAL_SERVER_ERROR) 77 | return 78 | end 79 | 80 | -- Set lifetime of key 81 | ok, err = redis_connection:expire(key, ttl) 82 | if not ok then 83 | ngx.log(log_level, "failed to set key expire ", err) 84 | ngx.exit(nginx.HTTP_INTERNAL_SERVER_ERROR) 85 | return 86 | end 87 | 88 | return true 89 | end 90 | 91 | function _M.challenge(config) 92 | 93 | local redis_config = config.redis_config or {} 94 | 95 | -- Basic config, with default values 96 | local LOG_LEVEL = config.log_level or ngx.NOTICE 97 | 98 | local COKKIE_LIFETIME = config.session_lifetime or 604800 99 | local BASIC_DIFFICULTY = config.difficulty or 100 100 | local MIN_DIFFICULTY = config.min_difficulty or 0 101 | local SEED_LENGTH = config.seed_lengt or 30 102 | local SEED_LIFETIME = config.lifetime or 60 103 | local RESPONSE_TARGET = config.target or "___" 104 | local COOKIE_NAME = config.cookie or "_cuid" 105 | local PUZZLE_TEMPLATE_LOCATION = config.template or '/etc/nginx/html/puzzle.html' 106 | local CLIENT_KEY = config.client_key or ngx.var.remote_addr 107 | 108 | 109 | 110 | -- Redis Config 111 | local REDIS_TIMEOUT = redis_config.timeout or 1 112 | local REDIS_SERVER = redis_config.host or "127.0.0.1" 113 | local REDIS_PORT = redis_config.port or 6379 114 | 115 | local COOKIE_FETCH_KEY = "COOKIE_" .. CLIENT_KEY 116 | 117 | local SEED_FETCH_KEY = "SEED_" .. CLIENT_KEY 118 | 119 | local authenticated = false 120 | 121 | local field = false 122 | -- Create URL 123 | local URL = ngx.var.scheme .. "://" .. ngx.var.host .. ngx.var.request_uri; 124 | 125 | local cookie, err = ck:new() 126 | if not cookie then 127 | ngx.log(LOG_LEVEL, err) 128 | ngx.exit(503) 129 | return 130 | end 131 | 132 | local REDIS_CONNECTION = redis:new() 133 | REDIS_CONNECTION:set_timeout(REDIS_TIMEOUT * 1000) 134 | 135 | local ok, error = REDIS_CONNECTION:connect(REDIS_SERVER, REDIS_PORT) 136 | if not ok then 137 | ngx.log(LOG_LEVEL, "failed to connect to redis: ", error) 138 | ngx.exit(503) 139 | return 140 | end 141 | 142 | field, err = cookie:get(COOKIE_NAME) 143 | if field then 144 | local redis_fetch = Fetch(REDIS_CONNECTION, COOKIE_FETCH_KEY, LOG_LEVEL) 145 | if redis_fetch ~= nil then 146 | if redis_fetch == field then 147 | authenticated = true 148 | local ok, err = REDIS_CONNECTION:close() 149 | ngx.header.cache_control = "no-store"; 150 | return true 151 | end 152 | end 153 | end 154 | 155 | 156 | if ngx.var.request_method ~= 'GET' then 157 | if not authenticated then 158 | --ngx.exit(ngx.HTTP_FORBIDDEN) 159 | ngx.exit(405) 160 | end 161 | end 162 | 163 | -- Set client key for SEED 164 | 165 | 166 | local TRYS = 1 167 | 168 | local SEED = "" 169 | local POW = "" 170 | local reuse = false 171 | 172 | 173 | local DIFF = BASIC_DIFFICULTY * TRYS 174 | 175 | local redis_fetch = Fetch(REDIS_CONNECTION, SEED_FETCH_KEY, LOG_LEVEL) 176 | 177 | -- If not set in REDIS, then do some work 178 | 179 | local obj = {} 180 | 181 | local now = os.time(); 182 | 183 | if redis_fetch == nil then 184 | SEED = RandomString(30) 185 | -- Create Proof Of Work integer 186 | POW=CreatePow(MIN_DIFFICULTY,DIFF); 187 | 188 | -- Create string for SHA1 189 | local sha1_string = SEED .. POW 190 | 191 | -- SHA1 string 192 | local HASH = sha1(sha1_string) 193 | 194 | -- Get time NOW in epoch 195 | 196 | 197 | obj = { 198 | POW = POW , 199 | SEED = SEED, 200 | HASH = HASH, 201 | TRYS = TRYS, 202 | DIFF = DIFF, 203 | TIME = now, 204 | TARGET = RESPONSE_TARGET, 205 | URL = URL 206 | } 207 | -- Set to REDIS, so it can be fetched 208 | local redis_set = Set(REDIS_CONNECTION, SEED_FETCH_KEY, obj, SEED_LIFETIME, LOG_LEVEL) 209 | else 210 | -- Bump trys 211 | TRYS = tonumber(redis_fetch['TRYS']) + 1 212 | 213 | -- Make it harder 214 | DIFF = BASIC_DIFFICULTY * TRYS 215 | obj = { 216 | POW = redis_fetch['POW'] , 217 | SEED = redis_fetch['SEED'], 218 | HASH = redis_fetch['HASH'], 219 | TRYS = TRYS, 220 | DIFF = DIFF, 221 | TIME = now, 222 | TARGET = redis_fetch['TARGET'], 223 | URL = redis_fetch['URL'] 224 | } 225 | 226 | -- Set to REDIS, so trycount we can bump trycount and Time 227 | local redis_set = Set(REDIS_CONNECTION, SEED_FETCH_KEY, obj, SEED_LIFETIME, LOG_LEVEL) 228 | --obj = redis_fetch 229 | end 230 | 231 | 232 | -- For debugging , output JSON 233 | --ngx.say(cjson.encode(obj)) 234 | 235 | 236 | -- Set template as string 237 | local PUZZLE_TEMPLATE = "" 238 | 239 | -- Open file 240 | local f = io.open(PUZZLE_TEMPLATE_LOCATION,'r') 241 | 242 | 243 | -- If file not open, then throw error 244 | if f~=nil then 245 | 246 | -- Read all file 247 | PUZZLE_TEMPLATE = f:read('*all') 248 | io.close(f) 249 | else 250 | -- Log if error and exit with error code 251 | ngx.log(LOG_LEVEL, 'Could not find template') 252 | ngx.exit(503) 253 | end 254 | 255 | local puzzle_html = render(PUZZLE_TEMPLATE, obj) 256 | 257 | -- Render the template to users 258 | -- ngx.header["Cache-Control"] = "no-cache, no-store, must-revalidate" 259 | -- ngx.header["Cache-Control"] = "max-age: 0" 260 | -- ngx.header["Pragma"] = "no-cache" 261 | -- ngx.header["Expires"] = "0" 262 | 263 | local ok, err = REDIS_CONNECTION:close() 264 | ngx.header['Content-Type'] = 'text/html; charset=UTF-8' 265 | ngx.say(puzzle_html) 266 | ngx.exit(ngx.HTTP_OK) 267 | 268 | 269 | -- ngx.exit(405) 270 | end 271 | 272 | function _M.response(config) 273 | 274 | 275 | local redis_config = config.redis_config or {} 276 | 277 | -- Basic config, with default values 278 | local LOG_LEVEL = config.log_level or ngx.NOTICE 279 | 280 | local COKKIE_LIFETIME = config.session_lifetime or 604800 281 | local BASIC_DIFFICULTY = config.difficulty or 300000 282 | local SEED_LENGTH = config.seed_lengt or 30 283 | local SEED_LIFETIME = config.lifetime or 60 284 | local RESPONSE_TARGET = config.target or "___" 285 | local COOKIE_NAME = config.cookie or "_cuid" 286 | local PUZZLE_TEMPLATE_LOCATION = config.template or '/etc/nginx/html/puzzle.html' 287 | local CLIENT_KEY = config.client_key or ngx.var.remote_addr 288 | local TIMEZONE = config.timezone or "GMT" 289 | local HTTP_ONLY = config.http_only_cookie or false 290 | local SECURE = config.cookie_secure or false 291 | local COOKIE_DOMAIN = config.cookie_domain or ngx.var.host 292 | local COOKIE_PATH = config.cookie_domain or "/" 293 | 294 | local MIN_TIME = config.min_time or 2 295 | 296 | 297 | -- Redis Config 298 | local REDIS_TIMEOUT = redis_config.timeout or 1 299 | local REDIS_SERVER = redis_config.host or "127.0.0.1" 300 | local REDIS_PORT = redis_config.port or 6379 301 | 302 | -- Ger all args as Lua object 303 | local args = ngx.req.get_uri_args() 304 | 305 | local SEED = args.SEED 306 | local POW = tonumber(args.POW) 307 | local RD_POW = 0 308 | local TIMEDIFF = 0 309 | local req_headers = ngx.req.get_headers() 310 | local COOKIE_EXPIRES = "" 311 | local COOKIE_VALUE = RandomString(20) 312 | 313 | TIMEZONE = " " .. TIMEZONE 314 | 315 | local now = os.time(); 316 | 317 | if not SEED then 318 | ngx.exit(ngx.HTTP_FORBIDDEN) 319 | return 320 | end 321 | 322 | if not POW then 323 | ngx.exit(ngx.HTTP_FORBIDDEN) 324 | return 325 | end 326 | 327 | local COOKIE_FETCH_KEY = "COOKIE_" .. CLIENT_KEY; 328 | 329 | local SEED_FETCH_KEY = "SEED_" .. CLIENT_KEY; 330 | 331 | -- expecting an Ajax GET 332 | if req_headers.x_requested_with ~= "XMLHttpRequest" then 333 | ngx.log(ngx.ERR, "Not XMLHttpReq") 334 | ngx.exit(405) 335 | return 336 | end 337 | 338 | ----- Authentication checks done -- 339 | 340 | local cookie, err = ck:new() 341 | if not cookie then 342 | ngx.log(LOG_LEVEL, err) 343 | return 344 | end 345 | 346 | local output = {} 347 | 348 | output.status="fail" 349 | local REDIS_CONNECTION = redis:new() 350 | REDIS_CONNECTION:set_timeout(REDIS_TIMEOUT * 1000) 351 | 352 | local ok, error = REDIS_CONNECTION:connect(REDIS_SERVER, REDIS_PORT) 353 | if not ok then 354 | ngx.log(LOG_LEVEL, "failed to connect to redis: ", error) 355 | return 356 | end 357 | 358 | 359 | local redis_fetch = Fetch(REDIS_CONNECTION, SEED_FETCH_KEY, LOG_LEVEL) 360 | 361 | if redis_fetch == nil then 362 | -- Not found in REDIS. No further proccessing needed 363 | else 364 | -- Found, check if valid 365 | RD_POW = redis_fetch["POW"] 366 | 367 | TIMEDIFF = now - redis_fetch['TIME'] 368 | 369 | if (POW == RD_POW) then 370 | if TIMEDIFF >= MIN_TIME then 371 | 372 | 373 | 374 | COOKIE_EXPIRES = os.date('%a, %d %b %Y %X', os.time() + COKKIE_LIFETIME ) .. TIMEZONE 375 | local ok, err = cookie:set({ 376 | key = COOKIE_NAME, value = COOKIE_VALUE, path = COOKIE_PATH, 377 | domain = COOKIE_DOMAIN, secure = SECURE, httponly = HTTP_ONLY, 378 | expires = COOKIE_EXPIRES, max_age = COKKIE_LIFETIME 379 | }) 380 | 381 | -- Log to redis with long lifetime 382 | local redis_set = Set(REDIS_CONNECTION, COOKIE_FETCH_KEY, COOKIE_VALUE, COKKIE_LIFETIME, LOG_LEVEL) 383 | if redis_set == nil then 384 | output.message="Server error" 385 | else 386 | output.status="success" 387 | output.redirect=redis_fetch['URL'] 388 | Del(REDIS_CONNECTION, SEED_FETCH_KEY, LOG_LEVEL) 389 | end 390 | if not ok then 391 | ngx.log(LOG_LEVEL, err) 392 | return 393 | end 394 | else 395 | output.message="To fast !" 396 | output.time = TIMEDIFF 397 | end 398 | 399 | end 400 | 401 | end 402 | 403 | local ok, err = REDIS_CONNECTION:close() 404 | ngx.header.cache_control = "no-store"; 405 | -- ngx.header["Cache-Control"] = "no-cache, no-store, must-revalidate" 406 | -- ngx.header["Cache-Control"] = "max-age: 0" 407 | -- ngx.header["Pragma"] = "no-cache" 408 | -- ngx.header["Expires"] = "0" 409 | 410 | --output.data=redis_fetch 411 | ngx.say(cjson.encode(output)) 412 | 413 | end 414 | 415 | 416 | return _M 417 | --------------------------------------------------------------------------------