Securing static resources with cookies, nginx, and Lua

I’ve been working with one of my clients the last month on migrating his iron- based architecture to a cloud-based provider. In this transition, we are going from one or two physical servers to multiple cloud servers and separating out parts to better scale each individual service.

As part of this, we are moving a significant library of images and videos away from being served off the same web server as the application and to a server tuned to handle requests for these static assets. The problem is that a lot of these assets (the videos and full-size images) are for paying members only. We need a way to secure those resources across physical servers.

My first inclination was to use the secure URL functionality in nginx. But this is sub-optimal for a few reasons. The big one is that it generates a unique URL for each requests, which completely negates any browser caching for subsequent requests. It also requires you to either generate all the URLs at page time, or use redirects.

Secure URLs work great if you have to make a secure requests across different domains. But, from the browser’s point of view, we will be under the same domain. Instead of www.example.com the assets will be stored under assets.example.com. But we’re still under the example.com domain. So that gives us another option: cookies. We can set a some cookies on login, and use nginx and Lua to verify the cookie signature on the other server before serving a static asset.

The first thing we need to do is set up a simple algorithm that determines what our bounds are for serving an asset. An example would be three pieces of information:

  1. A user identifier.

  2. An expiration time.

  3. A secret token that is shared between both the application server and the asset server.

So you might do something like this (in pseudocode):

var secret_token = "your secret token here";
var expire = time() + 3600; // Expire in 1 hour.
var asset_hash = md5(secret_token + user_id + expiration_time);

setcookie("user_id", user_id, expire, "/", "example.com");
setcookie("expire", expire, expire, "/", "example.com");
setcookie("asset_hash", asset_hash, expire, "/", "example.com");

So what we’re doing here is setting three cookies: the user_id, the expiration timestamp, and the asset hash, which is an md5 hash of the three pieces of information, only two of which are also set as cookies. The third piece of information, the secret token, is only known on the servers.

Quick note here, I’m using md5 because it’s fast, but you can use any hashing algorithm you’d like as long as you do it the same in both places. md5 is insecure, but this method should be sufficient to stop all but the most determined adversary who would try to take the hash against a rainbow table.

So, now we have these cookies set on our application server, we need to jump over to our asset server and make some changes. This is where Lua comes into play.

nginx is a very stripped down HTTP server that is very fast. But, nicely, it provides you with the ability to implement some things within nginx using Lua. And we can use this functionality to verify our cookies before serving an asset.

--- Returns an error page
function throw_error(error)
    local f = io.open("/usr/share/nginx/html/" .. error .. ".html", "rb")
    local content = f:read("*all")
    f:close()

    ngx.header.content_type = 'text/html';
    ngx.status = error
    ngx.print(content)
    ngx.exit(ngx.HTTP_OK)
end

local secret = "you secret token here"

if ngx.var.cookie_user_id == nil or ngx.var.cookie_expire == nil or ngx.var.cookie_asset_hash == nil then
    ngx.log(ngx.INFO, "Throwing 410 because of missing cookies.")
    throw_error(ngx.HTTP_GONE)
end

local cookie_user_id = ngx.var.cookie_user_id
local cookie_expire = ngx.var.cookie_expire
local cookie_asset_hash = ngx.var.cookie_asset_hash

local testhash = ngx.md5(secret .. cookie_user_id .. cookie_expire)

if testhash ~= cookie_asset_hash then
    ngx.log(ngx.INFO, "Throwing 410 because of a bad hash. Has was " .. hash .. " expected " .. testhash)
    throw_error(ngx.HTTP_GONE)
end

if tonumber(cookie_expire) > 0 and ngx.time() > tonumber(cookie_expire) then
    ngx.log(ngx.INFO, "Throwing 410 because of timestamps.")
    throw_error(ngx.HTTP_GONE)
end

Notice what we’re reading here. ngx is provided by the nginx Lua machine, and cookies are available using ngx.var.cookie_<cookie_name>. So we first check that all the cookies are present, then we check if the hash is valid, then we check if the expire time has passed. If any of these conditions fail, we exit by setting the proper responses on the ngx object.

So the last thing to do is to tell nginx to run this script before serving any protected assets. We do that by using the access_by_lua_file nginx directive:

location /attachments/fullsize {
    access_by_lua_file /etc/nginx/lua/access_asset.lua;
    expire 1h;
    add_header Cache-Control public;
    add_header Vary Accept-Encoding;
    root /var/media/;
}

Now, if the user edits any of those cookies using the browser console, the computed hash won’t match and nginx won’t serve the asset. And they can’t recompute the hash on their own because they lack the secret token. But from the browser’s perspective, it’s just a standard HTTP request with the appropriate caching headers. So it will happily cache the asset for the specified period of time, thus reducing subsequent requests.

And, as an added bonus, the URL cannot be shared at all with non-members. With “secure URLs” that use a hash as part of the URL, for as long as that URL is valid anyone can use it. This method requres the approprate cookies on the request and anyone wanting to share the URL would have to have either create a page that sets the appropriate cookies before redirecting a user, or have the user manually enter the cookies.

Did something I wrote help you out?

That's great! I don't earn any money from this site - I run no ads, sell no products and participate in no affiliate programs. I do this solely because it's fun; I enjoy writing and sharing what I learn.

COVID-19 has taken the world by storm and left a lot of brokenness in its wake. A lot of people are suffering. If you feel so inclined, please make a donation to your local food bank or medical charity. Order take-out from your local Chinese restaurant. Help buy groceries for an unemployed friend. Help people make it through to the other side.

But if you found this article helpful and you really feel like donating to me specifically, you can do so below.

Read More

Wallpaper Swapping with Hammerspoon

Hammerspoon is a pretty nifty tool. It’s kind of difficult to explain what it does, but the best I can do is that it allows you to use Lua to script actions on your Mac and, crucially, respond to events. For instance, I use Hammerspoon to lauch all my applications when I get to work and lay them out on the screen in the order that I like. I can do this because I was able to attach a location listener to work’s location, and execute Lua code on arrival. The amount of things that you can do with this tool is pretty stunning. It’s become an indespensible part of my macOS experience.

Making Native WebDAV Actually Work on nginx with Finder and Explorer

So my long march away from Apache has been coming to an end, and I am finally migrating some of the more esoteric parts of my Apache setup to nginx. I have a side domain that I use to share files with some friends and, for ease of use, I have configured it with WebDAV so that they can simply mount it using Finder or Explorer, just like a shared drive. The problem? nginx’s WebDAV support … sucks. First, the ngx_http_dav_module module is not included in most distributions from the package managers. Even the ones that are, it’s usually pretty out of date. And, perhaps worst of all, it is a partial implementation of WebDAV. It doesn’t support some of the things (PROPFIND, OPTIONS, LOCK, and UNLOCK) that are needed to work with modern clients. So what can we do?