Implementing Geo-Blocking with OpenResty and LUA

Whilst going through WAF exceptions recently, I found a period where my services were getting hammered with exploit attempts by an overseas IP (the graphs in that section only show part of the activity).

Probes like that are just a fact of life on the internet, but some of the targets being hit didn't actually need to be accessible for this IP to probe in the first place, because some of the services are only accessed in certain regions and don't actually need to be globally accessible.

In this post, I'll detail how to build an OpenResty docker image with Geo-IP support so that it can be used for geo-fencing and geo-blocking, whilst still allowing some subnets to bypass the checks (allowlisting).


Geo-fencing?

The term geoblocking is probably familiar and relatively self-explanatory: it's blocking of geographical regions.

Geofencing is the opposite of this: restricting access to everyone except specific regions - it's an allowlist approach rather than a blocklist one.


Getting Maxmind Credentials

A few years ago, Maxmind switched to requiring registration to download the Geolite2 DBs. So, to acquire a copy of the database, you'll need to Register for a free account.

Once you've created the account, you should be able to visit https://www.maxmind.com/en/accounts/current/license-key?lang=en to create a license key (if you don't see the page, there's an option on the left):

Maxmind Menu

At the bottom of the License Keys page is a button labelled Generate new license key.

Provide a description, and choose "No"

Maxmind Create License Key

When you click Confirm, you'll be provided with an Account ID and a License Key, make a note of the key.


The Docker Image

It's impossible for me to distribute a pre-built Docker Image and comply with Maxmind's Geolite2 license: the license requires that copies of the DB be deleted within 30 days of a new version being released.

However, building the image from scratch is straightforward

Clone down a copy of my repo

git clone https://github.com/bentasker/docker_openresty_geoip.git

Change working directory

cd docker_openresty_geoip

Set your maxmind license key (remember to replace with your license key)

echo GEO_IP_LICENSE="<maxmind license>" | tee geo_ip_license 

Ensure you've got the latest OpenResty image for use as a base

docker pull openresty/openresty:latest

Use Buildkit to make the license/secret available, and trigger the build

export DOCKER_BUILDKIT=1
docker build --secret id=maxmindsecret,src=geo_ip_license -t openresty_geoip .

This will

  • Take the latest OpenResty image
  • Build ngx_http_geoip2_module against that version
  • Install the module and update nginx.conf to load it
  • Fetch a copy of the Maxmind database (otherwise OpenResty will fail to start unless you export one in)

Because the license is being provided as a secret, it will not be visible in your image's history (unlike using COPY on a file).


Exporting the image

You may want to copy the image to other machines.

There are two ways that you can achieve this

  • Push to a registry
  • Export and load

If you've your own registry to push to, then you probably don't need any guidance on how to do that.

Export and load is simple.

On the system you built the image on

docker save openresty_geoip -o openresty_geoip.tar

You can then scp the file openresty_geoip.tar to other machines and (on those) run

docker load -i openresty_geoip.tar

Running the image

Once you've got an image built, it's time to get up and running

You'll (presumably) want to make some kind of site specific config file available, so create a file on the host

vi mysite.conf

For sake of example in the documentation, we'll insert

server {
        listen   80; 

        root /usr/local/openresty/nginx/html/;
        index index.php index.html index.htm;

        server_name example.invalid; 

        location / {
              return 200 "Hello World\n";
              add_header X-Clacks-Overhead "GNU Terry Pratchett";
        }
}

Run the image, mapping the file into /etc/nginx/conf.d within the image

docker run -d \
--name=openresty \
-v $PWD/mysite.conf:/etc/nginx/conf.d/mysite.conf \
-p 80:80 \
-p 443:443 \
openresty_geoip

The image should spin up, and you should be able to place a request against it

curl -H "Host: example.invalid" http://127.0.0.1

You should receive OpenResty's default page.


Subnet Allowlisting

The first thing to do, is to create a geo-map which can be used to allow specific subnets (such as LAN addresses) to bypass the geo checks.

vi geomap.conf

Insert the following

geo $whitelist_ip {
  default 0;
  192.168.5.0/24 1;
}

In this example, 192.168.5.0/24 has been whitelisted and won't be subject to our geo restrictions.

Ensure this file is mapped into conf.d within the image:

docker run -d \
--name=openresty \
-v $PWD/mysite.conf:/etc/nginx/conf.d/mysite.conf \
-v $PWD/geomap.conf:/etc/nginx/conf.d/geomap.conf \
-p 80:80 \
-p 443:443 \
openresty_geoip

Geofencing

The simplest way to enforce geo-fencing is with a small access_by_lua_block.

Edit mysite.conf so that it reads as follows

server {
        listen   80; 

        root /usr/local/openresty/nginx/html/;
        index index.php index.html index.htm;

        server_name example.invalid; 

        location / {
            # Ban any non-UK IP unless whitelisted
            access_by_lua_block {
                if (ngx.var.whitelist_ip == "0" and ngx.var.geoip2_data_country_code ~= "GB")
                then
                    ngx.exit(403)
                end
            }

            add_header X-Clacks-Overhead "GNU Terry Pratchett";
        }
}

If you now restart and run curl against it, you should get a 403

docker restart openresty
curl -H "Host: example.invalid" http://127.0.0.1

This is because your source IP will not have geolocated into the UK (country code GB).


Geo-Blocking

Geo-blocking uses more or less exactly the same approach.

However, it's more likely that you'll want to geo-block multiple countries, so to achieve this we're going to create a table, and then check whether that table contains a value

server {
        listen   80; 

        root /usr/local/openresty/nginx/html/;
        index index.php index.html index.htm;

        server_name example.invalid; 

        location / {
            # Ban any non-whitelisted IP from the listed countries
            access_by_lua_block {
                function table_contains(tbl, x)
                    found = false
                    for _, v in pairs(tbl) do
                        if v == x then 
                            found = true 
                        end
                    end
                    return found
                end


                -- Barred country codes
                local barred = {"NK", "CN", "RU"}

                if (ngx.var.whitelist_ip == "0" and table_contains(barred, ngx.var.geoip2_data_country_code))
                then
                    ngx.exit(403)
                end
            }

            add_header X-Clacks-Overhead "GNU Terry Pratchett";
        }
}

If you now restart and run curl against it, you should get a 403

docker restart openresty
curl -H "Host: example.invalid" http://127.0.0.1

This is because your local IP will fail to geolocate and so default to NK (Not Known).

Remove NK from the blacklist

server {
        listen   80; 

        root /usr/local/openresty/nginx/html/;
        index index.php index.html index.htm;

        server_name example.invalid; 

        location / {
            # Ban any non-UK IP unless whitelisted
            access_by_lua_block {
                function table_contains(tbl, x)
                    found = false
                    for _, v in pairs(tbl) do
                        if v == x then 
                            found = true 
                        end
                    end
                    return found
                end


                -- Barred country codes
                local barred = {"NK", "CN", "RU"}

                if (ngx.var.whitelist_ip == "0" and table_contains(barred, ngx.var.geoip2_data_country_code))
                then
                    ngx.exit(403)
                end
            }

            add_header X-Clacks-Overhead "GNU Terry Pratchett";
        }
}

If you now restart and run curl against it

docker restart openresty
curl -H "Host: example.invalid" http://127.0.0.1

And you should get the OpenResty default page back.


Automating Database Updates

At some point the database will go out of date, and IP's that should be allowed will be blocked (or vice-versa), so you'll likely want to periodically update the database.

If you're using CI or CD, you can do this by rebuilding the image. If you're not using CI though, that's a bit cumbersome, so it's probably preferable to manage the database from the host.

Create a script to fetch the database (maxmind_update.sh or whatever suits) and edit the variables at the top

#!/bin/bash
#
# Maxmind database fetcher
#
# Fetch the Geolite2 country db from maxmind

# Replace this with your license key
GEO_IP_LICENSE="<replace me>"

# Path to save the DB into
DEST_PATH="/home/ben/docker_files/nginx/maxmind"

tmpd=`mktemp -d`

# Fetch the DB
cd $tmpd
wget "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${GEO_IP_LICENSE}&suffix=tar.gz" -O maxmind.tar.gz

# Extract it and move into place
tar xzf maxmind.tar.gz
mv GeoLite2-Country*/* $DEST_PATH

cd
rm -rf $tmpd

Make executable and then run the script to get a copy of the Maxmind database

chmod +x maxmind_update.sh
./maxmind_update.sh

Add it to cron

crontab -e

The following will schedule it to run daily at midnight

0 0 * * * /path/to/maxmind_update.sh

Finally, you need to export the database file into the container for OpenResty to use

docker run -d \
--name=openresty \
-v $PWD/mysite.conf:/etc/nginx/conf.d/mysite.conf \
-v $PWD/geomap.conf:/etc/nginx/conf.d/geomap.conf \
-v $PWD/maxmind/GeoLite2-Country.mmdb:/usr/local/openresty/nginx/maxmind/GeoLite2-Country.mmdb \
-p 80:80 \
-p 443:443 \
openresty_geoip

Practical Example

So far, we've used a simple example config. Whilst that shows how to implement geo-restrictions it doesn't necessarily show why you might want to do this.

As a simple example, imagine you've got a Joomla based website.

If you're putting Nginx in front of it, you might have config a little like this

server {
        listen   80;

        root /var/www/vhosts/example.com/public_html;
        index index.php index.html index.htm;

        server_name www.example.com;

        location / {
            try_files $uri $uri/ /index.php;
        }

        location ~ \.php$ {
            proxy_set_header X-Real-IP  $remote_addr;
            proxy_set_header X-Forwarded-For $remote_addr;
            proxy_set_header Host $host;
            proxy_pass http://apache:8080;

         }
         location ~ /\.ht {
            deny all;
        }
}

Whilst that's perfectly functional, it does mean that the administrator interface (at /administrator/) is also available to the entire world.

If you'll only ever access it from a static set of IPs, it's trivial to restrict access to those, but if you can't guarantee that, you might instead decided that you want to geo-fence access:

server {
        listen   80;

        root /var/www/vhosts/example.com/public_html;
        index index.php index.html index.htm;

        server_name www.example.com;

        location / {
            try_files $uri $uri/ /index.php;
        }

        # Only allow from France
        location /administrator/ {
            access_by_lua_block {
                if (ngx.var.whitelist_ip == "0" and ngx.var.geoip2_data_country_code ~= "FR")
                then
                    ngx.exit(403)
                end
            }  

            try_files $uri $uri/ /index.php;
        }


        location ~ \.php$ {
            proxy_set_header X-Real-IP  $remote_addr;
            proxy_set_header X-Forwarded-For $remote_addr;
            proxy_set_header Host $host;
            proxy_pass http://apache:8080;

         }
         location ~ /\.ht {
            deny all;
        }
}

In this example, only users in France would be permitted to access /administrator/ - whilst that's still a lot of people, it's significantly fewer than before the change.


City Database

It is also possible to have the image load the City database.

There are examples of this in ngx_http_geoip2_module's README.

Essentially, you'll need to map a copy of that DB into the container and then map a .conf file into /etc/nginx/conf.d/ containing something like the following

    geoip2 /etc/maxmind-city.mmdb {
        $geoip2_data_city_name default=London city names en;
    }

Conclusion

Surprisingly, neither Nginx or OpenResty have GeoIP support built into their vanilla builds.

However, it's relatively easy to build a docker image that adds GeopIP2 support - most of the effort involved, really, is working around the barriers that Maxmind have put in the way of downloading the free Geolite2 database.

Once the image is built, it's absolutely trivial to geo-block or geo-fence paths in order to exercise control over who can access your content.