Cloudfront functions, open redirects, unit testing and footguns
Cloudfront, AWS’s CDN, lets you write JS to pre-process requests, and do things like URL rewriting (as I've seen in the wild). That JavaScript comes with semi-surprising constraints: some things like const/let are explicitly not working, you're forced into strict mode, some bonus methods on String, a few timing features are off (I assume it's for security reasons, it's probably running in a shared something) and so on.
But the thing making me write this post is its lack of exports, whether in the context of CommonJS or ES6 modules. Their syntax check will actively reject anything it doesn't like, and since module
isn't defined and export
is not a keyword, tough luck. That doesn’t mesh well with Jest or similar frameworks: if you want to write a JS file respecting these constraints, making it work in a standard test runner is not trivial.
You’re left with a few solutions that are not the ones you’d like to have in your code (cf https://www.uglydirtylittlestrawberry.co.uk/posts/unit-testing-cloudfront-functions/). Stuart Forrest’s blog post presents a few solutions, but they involve generating code or building franken-blobs out of partial files.
Facing the same problem, I started looking around for answers, and found yet another trick that I ended up using. Add at the end of your function:
var module = module || {};
module.exports = {/** your exports **/};
And behold, you can then use the exports in Jest without preprocessor trickery or generating code. Kudos to the folks behind the Closure JS Compiler, who generates that kind of stuff often enough to make me remember it.
Why am I bothering with a blog post?
My trade is finding vulnerabilities and preventing them, and these particular lines of code came to my attention in a bug bounty in an open redirect report. The position of CloudFront functions in front of everything in your app makes them surprisingly important security-wise, especially if the behaviors it tries to activate are based on error-prone patterns.
The example I encountered was canonicalization, for SEO purposes. To avoid confusing search engines (I assume), making sure folders look like folders is in your URLs is a common step. In my case, this was done by adding slashes at the end of directory-looking path components, and instinctively, this can be implemented by looking at whether the path requested ends with an extension (/abc/def
becomes /abc/def/
, but /abc/def.html
should stay untouched). Throw your requested path in a regexp like s/(/[^\/\.]+$)/$1\//
, and if that matched, use a HTTP 301 to redirect the user to the "clean" version of the URL. You'll then try to unit-test it, hit the same wall I did, and just ship it because it's a one-liner and if AWS intended this to be testable, they would have designed it otherwise.
And you've created an open redirect.
Sure, https://conquerirlemon.de/abc/def.html
stays the same, and https://conquerirlemon.de/abc/def
301's to Location: /abc/def/
, understood as expected as https://conquerirlemon.de/abc/def/
by the client browser. When you write a redirect in Cloudfront, you are only given the path of the request as a string (/abc/def
for https://conquerirlemon.de/abc/def
), but your redirect is a 301, where the Location field you set is not just a path. Request https://conquerirlemon.de//hello
with those two slashes, and you have a 301 with Location: //hello
, directing your browser to https://hello
, which is not my server. If you try and use dots and slashes, the regexp will eat part of your payload, so a "regular" URL won't work.
And here's the part where I show off cool tricks:
- IP writing shenanigans. IPv4 have dots, so
https://conquerirlemon.de//1.1.1.1
won't redirect per our regexp. However,1.1.1.1
can also be written as hex or decimal:https://1.1.1.1/
is the same ashttps://0x01010101/
, orhttps://16843009/
. Those have no dots. IPv6 have no dots either. - CJK writers have a specific period character, 。, that browsers understand as a dot. Try it:
https://example。org/
. It's a period for URL purposes, but not for regexp purposes.
The naive way to implement that SEO optimization is therefore an open redirect:
https://conquerirlemon.de///0x01010101/
(that respond to HTTPS with a valid cert, which is non-trivial to get if you're not Cloudflare), and https://conquerirlemon.de//evil。com/
for domains.
Types and rant
I'm a big proponent of typing JS (through TS or whatever you want). If something is a path, explicitly say so, and if something is a URL, explicitly say so too. Assigning a path to a URL is an unsafe cast, same way as putting uint_32 into a char. To express such constraints, or at least give a hint that this requires special care, types are the tool of choice. Were it my code, I'd probably try to funnel the developer through a choice of either rewritePath
or redirectToArbitraryUrl
, forcing them to state explicitly what they're trying to accomplish.
Going back to unit testing, I hit the wall mentioned above when trying to unit test my "is this a safe relative URL to redirect to" code. This isn't complex code, but it's not trivial either, and I felt unit testing was necessary as I wouldn't necessarily get it right in one go, nor trust the people coming after me to keep it safe when changing it.
One textbook footgun if I've ever seen one.