Skip to content

Instantly share code, notes, and snippets.

@rda0
Last active November 8, 2024 21:21
Show Gist options
  • Save rda0/48558b54fb5a1a41b6605b4577b9b992 to your computer and use it in GitHub Desktop.
Save rda0/48558b54fb5a1a41b6605b4577b9b992 to your computer and use it in GitHub Desktop.
nginx lua access/deny list and ratelimit for matrix federation invites

nginx lua

install lua

apt install libnginx-mod-http-lua

lua deny list based on request_body content

simple deny list based on the origin field (server name) in the $request_body:

server {
    location ~ ^/_matrix/federation/(v1|v2)/invite/ {
        access_by_lua_block {
            ngx.req.read_body()  -- explicitly read the req body
            local data = ngx.req.get_body_data()
            if data then
                ngx.log(ngx.INFO, "federation_invite_request_body: ", data)
            end
            local match, err = ngx.re.match(data, '"origin"\\s*:\\s*"([^"]+)"', "jou")
            local deny = {
                ["example.org"] = true,
                ["example.net"] = true,
            }
            if match then
                if deny[match[1]] then
                    ngx.log(ngx.WARN, "federation_invite_access_denied_by_origin: ", match[1])
                    --ngx.sleep(60)  -- wait a bit to waste spammers time?
                    ngx.status = ngx.HTTP_FORBIDDEN
                    ngx.header.content_type = 'application/json'
                    --ngx.header["Access-Control-Allow-Origin"] = '*'
                    --ngx.header["Access-Control-Allow-Methods"] = 'GET, HEAD, POST, PUT, DELETE, OPTIONS'
                    --ngx.header["Access-Control-Allow-Headers"] = 'X-Requested-With, Content-Type, Authorization, Date'
                    ngx.say('{"errcode":"M_FORBIDDEN","error":"User cannot invite the target user."}')
                    ngx.exit(ngx.HTTP_FORBIDDEN)
                else
                    ngx.log(ngx.NOTICE, "federation_invite_access_allowed_by_origin: ", match[1])
                end
            else
                ngx.log(ngx.ERR, "federation_invite_invalid_origin")
                return
            end
        }
        proxy_pass http://$workers;
        proxy_read_timeout 600s;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        client_max_body_size 125M;
    }
}

install lua rate limit module

git clone https://github.com/openresty/lua-resty-limit-traffic.git /usr/local/lib/lua-resty-limit-traffic

include in nginx.conf:

lua_package_path "/usr/local/lib/lua-resty-limit-traffic/lib/?.lua;;";

lua access/deny list and ratelimit based on request_body content

allow/deny list and rate limit based on the origin field (server name) in the $request_body. requests with server names in the deny list will be hard rejected. all others will be rate limited, except servers in the allow list.

lua_shared_dict matrix_federation_invite 10m;

server {
    location ~ ^/_matrix/federation/(v1|v2)/invite/ {
        access_by_lua_block {
            local method = ngx.req.get_method()
            if method ~= 'PUT' then  -- return if not PUT (lua '~=' means 'not equal')
                return
            end
            ngx.req.read_body()  -- explicitly read the req body
            local data = ngx.req.get_body_data()
            if data then
                ngx.log(ngx.INFO, "federation_invite_request_body: ", data)
            end
            local match, err = ngx.re.match(data, '"origin"\\s*:\\s*"([^"]+)"', "jou")
            local allow = {  -- no rate limit
                ["safe.org"] = true,
                ["safe.net"] = true,
            }
            local deny = {  -- reject immediately
                ["evil.org"] = true,
                ["evil.net"] = true,
            }
            if match then
                if deny[match[1]] then
                    ngx.log(ngx.WARN, "federation_invite_access_denied_by_origin: ", match[1])
                    --ngx.sleep(60)  -- wait a bit to waste spammers time?
                    ngx.status = ngx.HTTP_FORBIDDEN
                    ngx.header.content_type = 'application/json'
                    --ngx.header["Access-Control-Allow-Origin"] = '*'
                    --ngx.header["Access-Control-Allow-Methods"] = 'GET, HEAD, POST, PUT, DELETE, OPTIONS'
                    --ngx.header["Access-Control-Allow-Headers"] = 'X-Requested-With, Content-Type, Authorization, Date'
                    ngx.say('{"errcode":"M_FORBIDDEN","error":"User cannot invite the target user."}')
                    ngx.exit(ngx.HTTP_FORBIDDEN)
                else
                    if allow[match[1]] then
                        ngx.log(ngx.NOTICE, "federation_invite_access_allowed_by_origin: ", match[1])
                        return
                    else
                        ngx.log(ngx.NOTICE, "federation_invite_access_allowed_but_check_rate: ", match[1])
                        -- see docs for more info:
                        -- https://github.com/openresty/lua-resty-limit-traffic/tree/master?tab=readme-ov-file#synopsis
                        -- https://github.com/openresty/lua-resty-limit-traffic/blob/master/lib/resty/limit/req.md#methods
                        local limit_req = require "resty.limit.req"

                        -- obj, err = class.new(shdict_name, rate, burst)
                        -- rate: requests/second, burst: requests (allow a 10 request burst and then limit 2 per second)
                        local lim, err = limit_req.new("matrix_federation_invite", 2, 10)
                        if not lim then
                            ngx.log(ngx.ERR, "failed to instantiate a resty.limit.req object: ", err)
                            return ngx.exit(500)
                        end

                        local key = match[1]  -- origin server_name, must be unique in lua lua_shared_dict shm zone
                        -- delay, err = obj:incoming(key, commit)
                        local delay, err = lim:incoming(key, true)
                        if not delay then
                            -- reject when over rate+burst
                            if err == "rejected" then
                                ngx.log(ngx.WARN, "federation_invite_rate_limit_request_rejected: ", match[1])
                                return ngx.exit(503)
                            end
                            ngx.log(ngx.ERR, "failed to limit req: ", err)
                            return ngx.exit(500)
                        end

                        if delay >= 0.001 then
                            local excess = err
                            -- delay when between rate and rate+burst
                            ngx.log(ngx.WARN, "federation_invite_rate_limit_request_delayed: ", match[1], " ", delay)
                            ngx.sleep(delay)
                        end
                    end
                end
            else
                ngx.log(ngx.ERR, "federation_invite_invalid_origin")
                return
            end
        }
        proxy_pass http://$workers;
        proxy_read_timeout 600s;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        client_max_body_size 125M;
    }
}

caveats:

this could be abused by someone to rate limit some other server. putting well-known trusted servers in the allow list and maybe using fail2ban should help to avoid this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment