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):
At the bottom of the License Keys page is a button labelled Generate new license key
.
Provide a description, and choose "No"
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.