From 1b94f880a6686e0bae3368468c0fef54ee3dfa84 Mon Sep 17 00:00:00 2001 From: iqbal rifai Date: Mon, 28 May 2018 08:27:10 +0200 Subject: [PATCH] Initial Commit --- LinkShim.dev.js | 41 +++++++++++++ LinkShim.min.js | 1 + README.md | 61 ++++++++++++++++++- example.html | 30 ++++++++++ server.py | 151 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 LinkShim.dev.js create mode 100644 LinkShim.min.js create mode 100644 example.html create mode 100644 server.py diff --git a/LinkShim.dev.js b/LinkShim.dev.js new file mode 100644 index 0000000..a56ece1 --- /dev/null +++ b/LinkShim.dev.js @@ -0,0 +1,41 @@ +var LinkShim = { + + //set options + redirect_url: "http://yoururl.com:8888/r?", + tracker_attr: "data-track", + params: {}, + + //called on mousedown event + changeHref: function(a) { + var $a = $(a); + var params = this.params; + var url = this.redirect_url + $a.attr(this.tracker_attr); + + //build params + params.href = $a.attr('href'); + for (var f in params) { + url += "&" + f + "=" + encodeURIComponent(params[f]); + } + + //change href of click + $a.attr('href', url); + }, + + //manually call to add PageParams + addPageParams: function(new_params) { + if (typeof new_params == 'object') { + for (var f in new_params) { + this.params[f] = new_params[f]; + } + } + }, + + //call to listen to clicks on objects that contain the tracker_attr par + init: function() { + var obj = this; //added b/c 'this' will end up refering to a tag + $("a[" + obj.tracker_attr + "]").bind('mousedown', function(e) { + e.preventDefault(); + obj.changeHref(this); + }); + } +}; \ No newline at end of file diff --git a/LinkShim.min.js b/LinkShim.min.js new file mode 100644 index 0000000..9e931f4 --- /dev/null +++ b/LinkShim.min.js @@ -0,0 +1 @@ +var LinkShim={redirect_url:"http://yoururl.com:8888/r?",tracker_attr:"data-track",params:{},changeHref:function(a){var b=$(a);var c=this.params;var d=this.redirect_url+b.attr(this.tracker_attr);c.href=b.attr("href");for(var e in c){d+="&"+e+"="+encodeURIComponent(c[e])}b.attr("href",d)},addPageParams:function(a){if(typeof a=="object"){for(var b in a){this.params[b]=a[b]}}},init:function(){var a=this;$("a["+a.tracker_attr+"]").bind("mousedown",function(b){b.preventDefault();a.changeHref(this)})}} \ No newline at end of file diff --git a/README.md b/README.md index d07ce04..def13b4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,60 @@ -# LinkShim +Notice: +====== +This is a proof of concept, and not a production-ready script. Use it as a reference. -l.facebook.com clone \ No newline at end of file + +LinkShim +======== + +Replicates Facebook Functionality of their LinkShim + +When you click on a link on Facebook to an external url, they take you to a script on Facebook that redirects you to link you requested. This is an important security feature, for the following reasons: + +### Protects People +Creates opportunity to stop malicious and spammy sites in real-time. + +### Protect Privacy +Websites know where you came from by the referrer attribute in the header. On most pages, this might not be an issue. But if I clicked on a link that was on my profile, the website could glean the fact that my facebook user +name is "iqbalrifaii" because my referrer would be "http://www.facebook.com/iqbalrifaii". But when we use a redirect script, the referrer is simply "http://www.facebook.com/l.php" + +### Gather Analytics +A successful web company should know what's be linked to, shared by who, clicked by who, trends, etc. A redirect script creates that opportunity. + + +### Learn More About Facebook's (& thus this) LinkShim +Matt Jones, an engineer at Facebook, wrote an excellent explanation of their LinkShim +https://www.facebook.com/note.php?note_id=10150492832835766 + +How to Setup +======== +This project is meant to be a framework you can use to quickly set a LinkShim. Thus, it is not comprehensive (for example, there is no user specific logging, which would be necessary in production). Follow these steps to setup: + +### 1. Install Redis & Tornado +LinkShim uses [Redis](http://redis.io), a [NoSQL](http://en.wikipedia.org/wiki/NoSQL) technology, to maintain a spam watchlist, an analytics container, and a set of valid hashes to prevent becoming an [OpenRedirector](https://www.owasp.org/index.php/Open_redirect) + +It also uses the python (Tornado Framework)[http://www.tornadoweb.org/], a scalable, non-blocking web server. I implemented this in python rather than PHP so we can keep settings and database connections open between calls. In redirect engines, speed is of utmost importantance (behind security, of course.) + +### 2. Change Settings +Download these files, place them where you want, and open server.py. Change the `admin_token` to something random/secure, and `listen_on_port` to the port you want to listen to, and templates_dir to the absolute path of your templates. (duh.) + +### 3. Start er' Up. +`python server.py` will work for testing. In production, you'll want to use a daemon. + +### 4. Create some Hashes +Have your frontend guys/gals hit `/hash?admin_token=YOUR_TOKEN&num=10` to create some hashes when they need them. By Default, tokens are valid for 6 hours. This endpoint should really be only avaiable internally for security reasons. It's on the same port for now just for the demo. + +### 5. Include the JS and place the Hashes +Include the JS Script on your page. Place one of the hashes in JS like so: + +```javascript + + + + + + +This link is outbound, and will be tracked!​ + + +

+This link will also save placement=2 within redis​ +
+This will save placement=2&version=2 as well!​ + + +

+This link is outbound, and will not be tracked!​ + + + \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000..c97f66b --- /dev/null +++ b/server.py @@ -0,0 +1,151 @@ +import tornado.ioloop +import tornado.web +import tornado.template +from urlparse import urlparse +import redis +import datetime +import string +import random +import time + + + +#Admin Handler +class HashHandler(tornado.web.RedirectHandler): + def initialize(self, cache, admin_token): + self.cache = cache + self.admin_token = admin_token + self.life_of_hash = 60*60*6 + + def get(self): + #simple admin check. Best to move this endpoint to an internal IP for security on production + if self.get_argument("admin_token") != self.admin_token: + self.write({"error":1,"errorMsg":"Are you the admin?"}) + return 1 + + #get the number of hashes they want. Default to 10. + num = int(self.get_argument("num",10)) + if num > 1000000000000: + self.write({"error":1,"errorMsg":"No more than 100 at a time, please."}) + return 1 + + + expiration = int(time.mktime(time.gmtime())) + self.life_of_hash + tokens = [] + for i in range(num): + #create token and add it to the database + token = self.generateRandomToken() + self.cache.zadd("linkshim:hashes", expiration, token) + tokens.append(token) + + self.write({"error":0,"tokens": tokens,"expiration":expiration}) + return 1 + + + def generateRandomToken(self, size=25, chars=string.ascii_uppercase + string.ascii_lowercase + string.digits): + return "".join(random.choice(chars) for x in range(size)) + + + + +#Handle requests to redirect +class RedirectHandler(tornado.web.RequestHandler): + def initialize(self, cache, templates_dir): + self.cache = cache + self.loader = tornado.template.Loader(templates_dir) + + def get(self): + href = self.get_argument("href") + + current_timestamp = int(time.mktime(time.gmtime())) + + #add the url to today's set + today = datetime.date.today().strftime("%Y-%m-%d") + self.cache.zincrby("linkshim:outbound:" + today , href, 1) + + #check domain for watch_list + domain = self.getDomain(href) + ismember = self.cache.sismember("linkshim:watchlist",domain) + + #if it is in the watchlist, lets totally block that domain by displaying watchlist.html + if ismember: + self.writeConfirmationMessage("watchlist.html",href) + return 1 + + #get expiration of the hash provided + h = str(self.get_argument("h")) + expiration = self.cache.zscore("linkshim:hashes", h) + + #if hash doesn't exist, or its expired, display the warning message + if expiration == None or expiration < time.mktime(time.gmtime()): + self.writeConfirmationMessage("warning.html", href) + return 1 + + #we're all good! [domain not marked as spam, and hash is valid] + self.smartRedirect(href) + return 1 + + #write a simple HTML page to stop user from being linked, or to warning them (you edit the HTML in watchlist.html or warning.html) + def writeConfirmationMessage(self, file, href): + html = self.loader.load(file).generate(href=href) + self.write(html) + return 1 + + + #using this rather than a simple header redirect makes the referrer from this page, to protect privacy of the user + def smartRedirect(self, href): + #we set a refresh header just to be safe, but hopefully we won't use it. + self.set_header("Refresh","1") + self.set_header("URL", href) + + #IE requires a special solution. + if self.isIE(): + self.write("") + else: + self.write("") + + return 1 + + + #IE sucks. Let's use a helper function to detect it + def isIE(self): + if "User-Agent" not in self.request.headers: + return False + + user_agent = self.request.headers["User-Agent"] + if user_agent.find("MSIE") != -1: + return True + + return False + + #get raw domain name, stripped of subdomains and ports + def getDomain(self, href): + url_parts = urlparse(href) + domain = '.'.join(url_parts.netloc.split('.')[-2:]) + index = domain.find(":") + if index != -1: + domain = domain[:index] + + return domain + + + + + +if __name__ == "__main__": + #connect to redis! + redis_cache = redis.StrictRedis(host='localhost', port=6379, db=0) + + #change to random string. Strong authentication with IP checks, etc. should be required on production scripts. + admin_token = "JTCyFFO7OMWRxlnLCp6gp4fcJaLj2234tv3U0AabE7iQ" + listen_on_port = 8888 + + #absolute path that our templates are located in + templates_dir = "/var/www/linkshim/templates" + + application = tornado.web.Application([ + (r"/r", RedirectHandler, dict(cache=redis_cache, templates_dir=templates_dir)), + (r"/hash", HashHandler, dict(cache=redis_cache,admin_token=admin_token)), + ]) + application.listen(listen_on_port) + tornado.ioloop.IOLoop.instance().start()