HTTPS friendly "walled garden" page for DNS Firewall - Homas/ioc2rpz GitHub Wiki

HTTPS friendly "walled garden" page for DNS Firewall

Overview

DNS Firewall can easily not only block requests but also redirect a client to a "walled garden" (block) page. On this page you can show why information that the request was blocked and provide additional information how to remediate a threat.

Sounds attractive? I think yes and it is straight forward if there is a just http connection. Nowadays https became a standard (which is obviously good) but it that case your client will see an annoying untrusted certificate warning page. If you deployed DNS Firewall in an enterprise the IT users will definitely start complaining about the issue.

So what can we do here?

Of course you can try to ignore them (joke :) but as a better option you can configure a proxy or a reverse proxy which can automatically generate certificates signed with a private root CA. The root CA should be installed as a trusted CA on all your client machines. Dynamically generated certificate

Solution and pre-requirements

To solve that I've came up with a solution using OpenResty. OpenResty is a NGINX fork which supports LUA for certificates handling. OpenResty has a very skinny footprint and nicely runs on Raspberry Pi Zero W. This solution also allows you to inspect and filtrate SSL traffic.
I've also used lua-resty-openssl library to generate certificates. You can use other libraries (e.g. luaossl) which provide an interface to OpenSSL.
Kudos to remco_verhoef who 5 years ago posted this article. His solution is obviously outdated (based on old versions) and really hard to find the referred OpenResty branch but it gave me an idea that it is easy possible.
BTW this was my first LUA script I've ever created so it may be a bit ugly and not optimized :)

In this article I'll not cover how to install software and generate certificates but you can easily google it. Before you start please be sure that you have:

  • Installed OpenResty 1.15.8.2
  • Installed lua-resty-openssl 0.4.2 library. "0.4.2" right now is the most recent and minimum required version because the previous releases do not support setting certificate extensions
  • Private root CA certificate (ioc2rpzCA.crt in the script). Keep the private key in a safe place.
  • Intermediate CA certificate (ioc2rpzInt.crt) and a private key (ioc2rpzInt.pkey). The certificate must be signed by the private root CA.

You also should install the root CA to your browsers or OS storage as a trusted CA.

To configure OpenResty create a file with a sample configuration provided below and adjust a few settings:

  • paths
  • user name
  • error log output
  • daemon mode
  • action (e.g. send a static file, serve content on nginx, proxy the request or redirect to a new location)

To start the service for debug purposes you can use this CLI command: sudo nginx -p `pwd` -c nginx.conf

Workflow

The LUA script implements the following workflow:

  1. Trying to load a certificate and a private key for a requested domain from a file cache;
  2. If the certificate and the key are exist sets them for the SSL session and exit;
  3. If the certificate and the key are not exist it will:
  • generate ECC public and private keys. ECC keys are smaller and generated very fast so you can easily run OpenResty on Raspberry PI Zero;
  • create a certificate and set it's params;
  • sign the certificate with the intermediate private key;
  • adds the intermediate certificate and the root CA into the certificate chain;
  • save the certificate chain and the private key to the certificates file cache;
  • set the certificate chain and private key for the SSL session.

OpenResty configuration

Sample nginx.conf for OpenResty

#adjust based on your HW and load
worker_processes  1;

#error logs
#error_log  logs/error.log;
error_log /dev/stdout debug;

#pid        logs/nginx.pid;

daemon off; #daemon mode

user pi; #user under which the process will be executed

#adjust based on your HW and load
events {
    worker_connections  1024;
}

http {
    include       conf/mime.types;
    default_type  text/html;
    server_tokens off;

    lua_shared_dict ioc2rpz_locks 5m;

    resolver 192.168.43.26; #DNS server

    sendfile        on;
    keepalive_timeout  65;

    init_by_lua '
    ';

    server {
        listen 0.0.0.0:80;
	listen [::]:80;	
        server_name  _;
        location / {
	  #respond with the default block page
          root /home/pi/www;
	  rewrite ^.*$ /index.html break;
        }
    }

    server {
        listen 0.0.0.0:443 ssl;
	listen [::]:443 ssl;

        server_name _;

        ssl_session_cache  builtin:1000  shared:SSL:10m;
        ssl_protocols  TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
        ssl_ciphers HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4;
        ssl_prefer_server_ciphers on;

	#fallback certificates are used to start nginx
        ssl_certificate /home/pi/ssl/ioc2rpz.local.crt;
        ssl_certificate_key /home/pi/ssl/ioc2rpz.local.key;

        ssl_certificate_by_lua_block {
-- lua script to dynamically generate certificates
local ssl = require "ngx.ssl" -- ssl connection management
ssl.clear_certs() -- clear fallback certificates
local common_name = ssl.server_name() -- get requested domain
if common_name == nil then
   common_name = "unknown"
end

-- trying to load previously generated certificate and private key from files
local pkey_pem = nil;
local f = io.open(string.format("/home/pi/ssl_cache/%s.pkey", common_name), "r")
if f then
  pkey_pem = f:read("*a")
  f:close()
end

local cert_pem = nil;
local f = io.open(string.format("/home/pi/ssl_cache/%s.crt", common_name), "r")
if f then
   cert_pem = f:read("*a")
   f:close()
end

-- if the private key and the certificate were loaded, use them to establish HTTPS connection
if pkey_pem and cert_pem then
 assert(ssl.set_priv_key(assert(ssl.parse_pem_priv_key(pkey_pem))))
 assert(ssl.set_cert(assert(ssl.parse_pem_cert(cert_pem))))
 return -- script exists here if the key and the cert chain were successfully set
end 

-- if there were no files the script will generate a new private certificate and sign with an intermediate certificate
-- Load a private key for the intermediate cert
local IntKey = nil
local f = io.open("/home/pi/ssl/ioc2rpzInt.pkey", "r") -- change path and name based on your configuration
if f then
  IntKey =  assert(require("resty.openssl").pkey.new(f:read("*a"),"PEM"))
  f:close()
end

-- Load the intermidiate certificate
local f = io.open("/home/pi/ssl/ioc2rpzInt.crt", "r")
local IntCert = nil -- Intermediate certificate
local IntCert_pem="" -- Intermediate certificate in PEM format. We need it in PEM format to add to the certificate chain
if f then
   IntCert_pem=f:read("*a")
   IntCert = assert(require("resty.openssl.x509").new(IntCert_pem))
   f:close()
end

-- Load the CA certificate. CA certificate should be added to the certificate chain
local CAcert_pem = "";
local f = io.open("/home/pi/ssl/ioc2rpzCA.crt", "r")
if f then
   CAcert_pem=f:read("*a")
   f:close()
end

-- prevent generating the same certificate multiple times in parallel
local lock = require("resty.lock"):new("ioc2rpz_locks")
assert(lock:lock(common_name))
-- generate new keys (public and private)
local pk = assert(require("resty.openssl").pkey.new({type  = "EC", curve = "secp384r1",})) -- ECC keys are generated much faster in comparing with RSA which is very important on Raspberry Pi Zero

local x509, err = require("resty.openssl.x509").new() -- create a new certificate
local name, err = require("resty.openssl.x509.name").new() -- create name object. It is used to define the subject. We will generate certificate w/o CSR
local altname = assert(require("resty.openssl.x509.altname").new()) -- create alt name object. subjectAltName extension is required to pass Chrome security validation

-- add the subject and common names
assert(name:add("CN", common_name):add("C", "US"):add("ST", "California"):add("L", "San Jose"):add("O", "ioc2rpz Community"))
assert(altname:add("DNS", common_name):add("DNS", "*."..common_name))

assert(x509:set_version(3)) -- set certificate version
assert(x509:get_serial_number(42)) -- set serial. the certificate is fake so we don't care about the number
assert(x509:set_not_before(ngx.time())) -- set current time as the certificate's validity start date
assert(x509:set_not_after(ngx.time()+3650*86400)) -- the certificate will be valid for 10 years. It is recommended to periodically clear the certificate cache
assert(x509:set_subject_name(name)) -- set certificate's subject
local issuer = assert(IntCert:get_subject_name()) -- the intermediate's certificate subject is used as the certificate's issuer
assert(x509:set_issuer_name(issuer)) -- set the issuer
assert(x509:set_subject_alt_name(altname)) -- add subjectAltName extension
assert(x509:set_pubkey(pk)) -- set the certificate's public key
assert(x509:set_basic_constraints({ cA = false, pathlen = 0})) -- set constraints

assert(x509:sign(IntKey)) -- sign the certificate with the Intermediate's certificate private key

cert_pem=assert(x509:to_PEM()) -- convert the certificate to PEM format
local cert = assert(ssl.parse_pem_cert(cert_pem..IntCert_pem..CAcert_pem)) -- combine the domain, intermediate and CA certificates
pkey_pem = assert(pk:to_PEM("private")) -- convert the private key to PEM format

-- save certificates to a local cache
local f = assert(io.open(string.format("/home/pi/ssl_cache/%s.crt", common_name), "w"))
f:write(cert_pem)
f:write(IntCert_pem)
f:write(CAcert_pem)
f:close()

-- save the private key to a local file
local f = assert(io.open(string.format("/home/pi/ssl_cache/%s.pkey", common_name), "w"))
f:write(pkey_pem)
f:close()

-- set the private key for the session
assert(ssl.set_priv_key(ssl.parse_pem_priv_key(pkey_pem)))

-- set the certificate for the session
assert(ssl.set_cert(cert))

-- unlock the common name
assert(lock:unlock())
-- end lua script to dynamically generate certificates
        }

        #lua_need_request_body on;
        #client_max_body_size 100k;
        #client_body_buffer_size 100k;
				add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        #server_tokens off;
        location / {
	    #you may proxy request to another server or use nginx as a webserver
	    #proxy_set_header   X-Real-IP        $remote_addr;
            #proxy_ssl_verify off;
            #proxy_set_header Host $host;
            #proxy_pass_header Server;
            #proxy_pass http://$host:80;
						
	    #respond with the default block page
            root /home/pi/www;
	    rewrite ^.*$ /index.html break;
        }
	#  return 301 http://$http_host$request_uri;
    }
}

Questions or comments or bugs?

You can always contact me via github or ioc2rpz[at]ioc2rpz.net

Vadim