Cloudflare is a Content Delivery Network (CDN) company that provides various network and security services. In March 2018, they released "Cloudflare Workers" feature for public. Cloudflare Workers allow us to write JavaScript code and run them in Cloudflare edges. This is helpful when we want to pre-process requests before forwarding them to the origin. In this post, we will explain how we implemented HMAC authentication while caching all files in Cloudflare edges.
We have a bunch of files hosted in S3 which are served through CloudFront. To reduce the CloudFront bandwidth cost and to make use of a global CDN (we use Price Class 100 in CloudFront), we decided to use Cloudflare for file downloads. This would help us cache files in Cloudflare edges and will eventually reduce the bandwidth costs at origin (CloudFront). But to do this, we had to solve a few problems.
We had been signing CloudFront download URLs to restrict their usage after a period of time. This means the file download URLs are always unique. Since Cloudflare caches files based on URLs, caching will not work when the URLs are signed. We had to remove the URL signing to get it working with Cloudflare, but we can't allow people to continuously use the same download URL. Cloudflare Workers helped us with this.
We negotiated a deal with Cloudflare and upgraded the subscription to Enterprise plan. Enterprise plan helps us define a Custom Cache Key using which we can configure Cloudflare to cache based on user defined key. Enterprise plan also increased cache file size limits. We wrote following Worker code which configures a custom cache key and authenticates URLs using HMAC.
Cloudflare worker starts with attaching a method to "fetch" event.
1addEventListener("fetch", event => { 2 event.respondWith(verifyAndCache(event.request)); 3});
verifyAndCache function can be defined as follows.
1async function verifyAndCache(request) { 2 /** 3 source: 4 5 https://jameshfisher.com/2017/10/31/web-cryptography-api-hmac.html 6 https://github.com/diafygi/webcrypto-amples#hmac-verify 7 https://stackoverflow.com/questions/17191945/conversion-between-utf-8-arraybuffer-and-string 8 **/ 9 10 // Convert the string to array of its ASCII values 11 function str2ab(str) { 12 let uintArray = new Uint8Array( 13 str.split("").map(function (char) { 14 return char.charCodeAt(0); 15 }) 16 ); 17 return uintArray; 18 } 19 20 // Retrieve to token from query string which is in the format "<time>-<auth_code>" 21 function getFullToken(url, query_string_key) { 22 let full_token = url.split(query_string_key)[1]; 23 return full_token; 24 } 25 26 // Fetch the authentication code from token 27 function getAuthCode(full_token) { 28 let token = full_token.split("-"); 29 return token[1].split("/")[0]; 30 } 31 32 // Fetch timestamp from token 33 function getExpiryTimestamp(full_token) { 34 let timestamp = full_token.split("-"); 35 return timestamp[0]; 36 } 37 38 // Fetch file path from URL 39 function getFilePath(url) { 40 let url_obj = new URL(url); 41 return decodeURI(url_obj.pathname); 42 } 43 44 const full_token = getFullToken(request.url, "&verify="); 45 const token = getAuthCode(full_token); 46 const str = 47 getFilePath(encodeURI(request.url)) + "/" + getExpiryTimestamp(full_token); 48 const secret = "< HMAC KEY >"; 49 50 // Generate the SHA-256 hash from the secret string 51 let key = await crypto.subtle.importKey( 52 "raw", 53 str2ab(secret), 54 { name: "HMAC", hash: { name: "SHA-256" } }, 55 false, 56 ["sign", "verify"] 57 ); 58 59 // Sign the "str" with the key generated previously 60 let sig = await crypto.subtle.sign({ name: "HMAC" }, key, str2ab(str)); 61 62 // convert the Arraybuffer "sig" in string and then, in Base64 digest, and then URLencode it 63 let verif = encodeURIComponent( 64 btoa(String.fromCharCode.apply(null, new Uint8Array(sig))) 65 ); 66 67 // Get time in Unix epoch 68 let time = Math.floor(Date.now() / 1000); 69 70 if (time > getExpiryTimestamp(full_token) || verif != token) { 71 // Render error response 72 const init = { 73 status: 403, 74 }; 75 const modifiedResponse = new Response(`Invalid token`, init); 76 return modifiedResponse; 77 } else { 78 let url = new URL(request.url); 79 80 // Generate a cache key from URL excluding the unique query string 81 let cache_key = url.host + url.pathname; 82 83 let headers = new Headers(request.headers); 84 85 /** 86 Set an optional header/auth token for additional security in origin. 87 For example, using AWS Web Application Firewall (WAF), it is possible to create a filter 88 that allows requests only with a custom header to pass through CloudFront distribution. 89 **/ 90 headers.set("X-Auth-token", "< Optional Auth Token >"); 91 92 /** 93 Fetch the file using cache_key. File will be served from cache if it's already there, 94 or it will send the request to origin. Please note 'cacheKey' is available only in 95 Enterprise plan. 96 **/ 97 98 const response = await fetch(request, { 99 cf: { cacheKey: cache_key }, 100 headers: headers, 101 }); 102 return response; 103 } 104}
Once the worker is added, configure an associated route in "Workers -> Routes -> Add Route" in Cloudflare.
Now, all requests will go through the configured Cloudflare worker. Each request will be verified using HMAC authentication and all files will be cached in Cloudflare edges. This would reduce bandwidth costs at the origin.