├── 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 |
--------------------------------------------------------------------------------