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:
A user identifier.
An expiration time.
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.