⬆️ ⬇️

LUA in nginx: a slightly intelligent firewall



This post is a continuation of the use of lua in nginx .



Caching in memory was discussed there, and here lua will be used to filter incoming requests as a sort of firewall on an nginx balancer. Something similar was at 2GIS . We have our own bike :) In which we share the dynamics and statics, we try to take into account NAT and the white list. And, of course, you can always screw up specific logic, which will not work when using ready-made modules.

This scheme is now calm and relaxed (almost no effect on the use of cpu) processes about 1200 requests / sec. Limit values ​​were not tested. Perhaps fortunately :)





I want to process all incoming requests immediately upon admission, and not on the fact of a line in access_log (which I suppose is turned off for the same statics). Not a question, hang the handler globally for the entire http:

http { include lua/req.conf; } #  lua/req.conf #      ( ,      LRU ) lua_shared_dict req_limit 1024m; #      (   ,    ) lua_shared_dict ban_list 128m; #  .   ,     geo $lua_req_whitelist { default 0; 12.34.56.78/24 1; } #  init_by_lua ' --      lua_req_priv_key = "secretpassphrase" --    lua_req_cookie_name = "reqcookiename" --      lua_req_ban_log = "/path/to/log/file" --     ( ) --     lua_req_d_one = 42 --    URI lua_req_d_mul = 84 --    URI lua_req_s_one = 100 --    URI lua_req_s_mul = 200 --    URI lua_req_d_ip = 200 --    IP lua_req_s_ip = 400 --    IP --   10  lua_req_ban_ttl = 600 --  math.randomseed(math.floor(ngx.now()*1000)) '; #   ,   access    access_by_lua_file /path/to/nginx/lua/req.lua; 


Now all requests coming to nginx will go through our req.lua script.

At the same time, we have two tables req_limit and ban_list for storing the query history and the list of those already banned, respectively (more details below).

And for the implementation of whitelist over IP, the geo nginx module was used instead of bicycles, putting down the value of the variable lua_req_whitelist, which is used like this:

 if ngx.var.lua_req_whitelist ~= '1' then -- IP    ,   end 


')

To check the statics / dynamics (request by file on disk / backend server) we do a simple check on the name of the requested file (here you can complicate the implementation, adjusting to your business logic):

 function string.endswith(haystack, needle) return (needle == '') or (needle == string.sub(haystack, -string.len(needle))) end local function path_is_static(path) local exts = {'js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'xml', 'ico', 'swf'} path = path:lower() for _,ext in ipairs(exts) do if path:endswith(ext) then return true end end return false end local uri_path = ngx.var.request_uri if ngx.var.is_args == '?' then uri_path = uri_path:gsub('^([^?]+)\\?.*$', '%1') end local is_static = path_is_static(uri_path) 




For at least some NAT processing, in addition to IP clients, their UserAgent is also taken into account and a special cookie is affixed. All three elements in general constitute the user ID. If a villain hollows the server, ignoring the transmitted cookie, then in the worst case its IP / subnet will simply be banned. At the same time, those users from this subnet who have already received a cookie before will work quietly further (except for the case of a ban by IP). The solution is not perfect, but still better than counting half the country / mobile operator as a single user.

Generation and verification of cookies:

 local function gen_cookie_rand() return tostring(math.random(2147483647)) end local function gen_cookie(prefix, rnd) return ngx.encode_base64( --       IP    UserAgent,     ngx.sha1_bin(ngx.today() .. prefix .. lua_req_priv_key .. rnd) ) end local uri = ngx.var.request_uri --  URI local host = ngx.var.http_host --      (   nginx   ) local ip = ngx.var.remote_addr local user_agent = ngx.var.http_user_agent or '' if user_agent:len() > 0 then user_agent = ngx.encode_base64(ngx.sha1_bin(user_agent)) end local key_prefix = ip .. ':' .. user_agent --    local user_cookie = ngx.unescape_uri(ngx.var['cookie_' .. lua_req_cookie_name]) or '' local rnd = gen_cookie_rand() local p = user_cookie:find('_') if p then rnd = user_cookie:sub(p+1) user_cookie = user_cookie:sub(1, p-1) end local control_cookie = gen_cookie(key_prefix, rnd) if user_cookie ~= control_cookie then user_cookie = '' rnd = gen_cookie_rand() control_cookie = gen_cookie(key_prefix, rnd) end key_prefix = key_prefix .. ':' .. user_cookie ngx.header['Set-Cookie'] = string.format('%s=%s; path=/; expires=%s', lua_req_cookie_name, ngx.escape_uri(control_cookie .. '_' .. rnd), ngx.cookie_time(ngx.time()+24*3600) ) 


Now the key_prefix contains the identifier of the client whose request we are processing. If this client is already banned, then no further processing is necessary:

 local ban_key = key_prefix..':ban' if ban_list:get(ban_key) or ban_list:get(ip..':ban') then --          IP return ngx.exit(ngx.HTTP_FORBIDDEN) end 


They got the key, they checked the ban, now you can calculate if this query does not exceed which of the limits:

 --   :   URI    URI local limits = { [false] = { [false] = lua_req_d_mul, --    URI [true] = lua_req_d_one, --    URI }, [true] = { [false] = lua_req_s_mul, --    URI [true] = lua_req_s_one, --    URI } } for _,one_path in ipairs({true, false}) do local limit = limits[is_static][one_path] local key = {key_prefix} --        if is_static then table.insert(key, 'S') else table.insert(key, 'D') end --          (  API   ) if one_path then table.insert(key, host..uri) end --    "12.34.56.78:useragentsha1base64:cookiesha1base64:S:site.com/path/to/file" key = table.concat(key, ':') local exhaust = check_limit_exhaust(key, limit, ban_ttl) if exhaust then return ngx.exit(ngx.HTTP_FORBIDDEN) end end 


We check 4 versions of the counters: static / dynamics, one way / according to different ones. Direct checks are performed in check_limit_exhaust ():

 local function check_limit_exhaust(key, limit, cnt_ttl) local key_ts = key..':ts' local cnt, _ = req_limit:incr(key, 1) --   ,     --        if cnt == nil then if req_limit:add(key, 1, cnt_ttl) then req_limit:set(key_ts, ngx.now(), cnt_ttl) end return false end --     (    ) if cnt <= limit then return false end --     (  ), --              local key_lock = key..':lock' local key_lock_ttl = 0.5 local ts local try_until = ngx.now() + key_lock_ttl local locked while true do locked = req_limit:add(key_lock, 1, key_lock_ttl) cnt = req_limit:get(key) ts = req_limit:get(key_ts) if locked or (try_until < ngx.now()) then break end ngx.sleep(0.01) end --            - , ,  . --      IP  blacklist --       if (not locked) and ((not cnt) or (not ts)) then return true, 'lock_failed' end --    ( )   local ts_diff = math.max(0.001, ngx.now() - ts) --      local cnt_norm = math.floor(cnt / ts_diff) --        if cnt_norm <= limit then --  ts  cnt (    set'  -        ) req_limit:set(key, cnt_norm, cnt_ttl) req_limit:set(key_ts, ngx.now() - 1, cnt_ttl) --  ;  blacklist  ;    if locked then req_limit:delete(key_lock) end return false end --  . ,  ,    req_limit:delete(key) req_limit:delete(key_ts) if locked then req_limit:delete(key_lock) end return true, cnt_norm end 


In addition to the direct ban on lua_req_ban_ttl seconds, you can implement persistent storage, and at the same time screw logging and forwarding banned by IP in iptables / analogs. This is already off the topic of the post.



All this, of course, is only an example, not a copy-paste silver bullet. Moreover, the given numbers of limits are indicated from the ceiling.



The image in the cap is taken from here .

Source: https://habr.com/ru/post/215235/



All Articles