Skip to content

Instantly share code, notes, and snippets.

@rda0
Last active November 20, 2025 12:31
Show Gist options
  • Select an option

  • Save rda0/48558b54fb5a1a41b6605b4577b9b992 to your computer and use it in GitHub Desktop.

Select an option

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

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

allow/deny list and rate limit based on the origin (server name). 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

            -- Get Authorization header (case-insensitive)
            local headers = ngx.req.get_headers()
            local auth = headers["Authorization"] or headers["authorization"]

            local origin

            -- Extract origin from the "Authorization: X-Matrix ..." header
            -- Examples:
            -- Authorization: X-Matrix origin="matrix.org",key="ed25519:abc",...
            -- Authorization: X-Matrix key="ed25519:abc",origin=matrix.org,...
            if auth then
                local m, err = ngx.re.match(auth, [[\borigin="?([^",\s]+)"?]], "ijo")
                if m then
                    origin = m[1]
                end
            end

            -- Fallback to body (v1 invite PDUs may have "origin" in body)
            if not origin then
                ngx.req.read_body()  -- explicitly read the req body
                local data = ngx.req.get_body_data()

                -- If body was buffered to disk (too large for memory buffer), read a small chunk.
                if not data then
                    local body_file = ngx.req.get_body_file()
                    if body_file then
                        -- blocking file I/O. Keep it small to reduce impact.
                        local f = io.open(body_file, "rb")
                        if f then
                            data = f:read(8192)  -- read only first 8KB to avoid heavy blocking
                            f:close()
                        end
                    end
                end

                if data then
                    local cjson = require "cjson.safe"
                    local obj = cjson.decode(data)
                    if obj then
                        -- Try common spots (legacy PDUs)
                        origin = obj.origin or (obj.event and obj.event.origin)
                    end
                end
            end

            if not origin then
                ngx.log(ngx.ERR, "federation_invite_no_origin_found")
                return
            end

            -- normalize: strip a trailing :port and lowercase the hostname
            do
                local m = ngx.re.match(origin, [[^([^:]+)]], "jo")
                origin = (m and m[1] or origin):lower()
            end

            local allow = {  -- no rate limit
                ["safe.org"] = true,
                ["safe.net"] = true,
            }
            local deny = {  -- reject immediately
                ["evil.org"] = true,
                ["evil.net"] = true,
            }

            if deny[origin] then
                ngx.log(ngx.WARN, "federation_invite_access_denied_by_origin: ", origin)
                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."}')
                return ngx.exit(ngx.HTTP_FORBIDDEN)
            end

            if allow[origin] then
                ngx.log(ngx.NOTICE, "federation_invite_access_allowed_by_origin: ", origin)
                return
            end

            -- Rate limit by origin
            ngx.log(ngx.NOTICE, "federation_invite_access_allowed_but_check_rate: ", origin)
            -- 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)
            local lim, err = limit_req.new("matrix_federation_invite", 2, 10)
            if not lim then
                ngx.log(ngx.ERR, "failed to instantiate resty.limit.req: ", err)
                return ngx.exit(500)
            end

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

            if delay >= 0.001 then
                -- delay when between rate and rate+burst
                ngx.log(ngx.WARN, "federation_invite_rate_limit_request_delayed: ", origin, " ", delay)
                ngx.sleep(delay)
            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