Skip to content

Instantly share code, notes, and snippets.

@ThijsFeryn
Created September 27, 2017 20:02
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ThijsFeryn/53e69553d5ca8f9f56ce1b7414c0931e to your computer and use it in GitHub Desktop.
Save ThijsFeryn/53e69553d5ca8f9f56ce1b7414c0931e to your computer and use it in GitHub Desktop.
VCL file that describes my JWT implementation in Varnish for Drupal. Part of my DrupalCon Vienna 2017 presentation.
vcl 4.0;
import std;
import var;
import cookie;
import digest;
acl internal {
"192.168.20.0"/24;
}
backend default {
.host = "127.0.0.1";
.port = "8080";
.connect_timeout = 5s;
.first_byte_timeout = 60s;
.between_bytes_timeout = 60s;
}
# Respond to incoming requests.
sub vcl_recv {
#JWT private key
var.set("key",std.fileread("/var/www/html/jwt.key"));
#Sort querystring alphabetically
set req.url = std.querysort(req.url);
#Remove anchors
if(req.url ~ "\#"){
set req.url = regsub(req.url, "\#.*$","");
}
#Remove trailing question mark
if(req.url ~ "\?$"){
set req.url = regsub(req.url, "\?$","");
}
#Announce ESI support
set req.http.Surrogate-Capability="key=ESI/1.0";
#Only cache GET and HEAD
if ((req.method != "GET" && req.method != "HEAD") || req.http.Authorization) {
return (pass);
}
# Do not allow outside access to cron.php or install.php.
if (req.url ~ "^/(cron|install|update)\.php$" && !client.ip ~ internal) {
# Have Varnish throw the error directly.
return (synth(404, "Page not found."));
# Use a custom error page that you've defined in Drupal at the path "404".
# set req.url = "/404";
}
# Handle compression correctly. Different browsers send different
# "Accept-Encoding" headers, even though they mostly all support the same
# compression mechanisms. By consolidating these compression headers into
# a consistent format, we can reduce the size of the cache and get more hits.=
# @see: http:// varnish.projects.linpro.no/wiki/FAQ/Compression
if (req.http.Accept-Encoding) {
if (req.http.Accept-Encoding ~ "gzip") {
# If the browser supports it, we'll use gzip.
set req.http.Accept-Encoding = "gzip";
}
else if (req.http.Accept-Encoding ~ "deflate") {
# Next, try deflate if it is supported.
set req.http.Accept-Encoding = "deflate";
}
else {
# Unknown algorithm. Remove it and send unencoded.
unset req.http.Accept-Encoding;
}
}
# Always cache the following file types for all users.
if (req.url ~ "(?i)\.(png|gif|jpeg|jpg|ico|swf|css|js|html|htm|woff)(\?[wd=.-]+)?$") {
unset req.http.Cookie;
return(hash);
}
#Check for cookies
if (req.http.Cookie) {
#Parse cookies
cookie.parse(req.http.cookie);
#Look for session
if(req.http.cookie ~ ".*(SESS[a-z0-9]+)=.*") {
var.set("sessionCookie",regsub(req.http.cookie,".*(SESS[a-z0-9]+)=.*","\1"));
}
#Remove all but some cookies
cookie.filter_except("PHPSESSID,NO_CACHE,ci_session,CI_SESSION,authtoken,jwt_cookie,"+var.get("sessionCookie"));
#Write cookies back to header
set req.http.cookie = cookie.get_string();
#Remove cookie header if empty
if (req.http.Cookie ~ "^\s*$") {
unset req.http.Cookie;
}
}
call jwt;
if(var.get("roles") ~ "administrator") {
std.log("Administrators bypass the cache");
return(pass);
}
#Logged in users cannot access /login and should be redirected to their user profile page
if(req.url == "/login" && req.http.X-login =="true") {
return(synth(302,"/user"));
}
#Exception: cache /user page for anonymous users
if(req.url == "/user" && !cookie.isset("jwt_cookie")) {
return (hash);
}
# Exception: cache password reset form and user registration form
if (req.url == "/user/password" || req.url == "/user/register") {
return(hash);
}
#Cache login redirection page
if(req.url ~ "^/user/login") {
return(hash);
}
# Do not cache these paths.
if (req.url == "/status.php" ||
req.url == "/ooyala/ping" ||
req.url == "/user" ||
req.url ~ "^/admin/.*$" ||
req.url ~ "^/info/.*$" ||
req.url ~ "^/flag/.*$" ||
req.url ~ "^.*/ajax/.*$" ||
req.url ~ "^.*/ahah/.*$" ||
req.url ~ "^/rest/.*$" ||
req.url ~ "^/users?/.*$"
) {
return (pass);
}
#Cache everything else
return(hash);
}
sub vcl_deliver {
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
unset resp.http.x-url; # Optional
unset resp.http.x-host; # Optional
unset resp.http.Server;
unset resp.http.X-Generator;
unset resp.http.X-Powered-By;
unset resp.http.X-Drupal-Cache;
set resp.http.vary="Accept-Encoding";
}
# Routine used to determine the cache key if storing/retrieving a cached page.
sub vcl_hash {
if (req.http.X-Forwarded-Proto) {
hash_data(req.http.X-Forwarded-Proto);
}
}
sub vcl_backend_response {
#Custom headers to store request information in the response object. Facilitates lurker-friendly bans
set beresp.http.x-url = bereq.url;
set beresp.http.x-host = bereq.http.host;
if (bereq.url ~ "^/admin/content/backup_migrate/export") {
set beresp.do_stream = true;
}
# Don't allow static files to set cookies.
if (bereq.url ~ "(?i)\.(png|gif|jpeg|jpg|ico|swf|css|js|html|htm)(\?[wd=.-]+)?$") {
# beresp == Back-end response from the web server.
unset beresp.http.set-cookie;
}
# Allow items to be stale if needed.
set beresp.grace = 6h;
#Perform ESI parsing
if(beresp.http.Surrogate-Control~"ESI/1.0") {
unset beresp.http.Surrogate-Control;
set beresp.do_esi=true;
return(deliver);
}
}
sub vcl_synth {
#301 & 302 synths should be actual redirects
if (resp.status == 301 || resp.status == 302) {
set resp.http.location = resp.reason;
set resp.reason = "Moved";
return (deliver);
}
}
sub jwt {
std.log("Ready to perform some JWT magic");
if(cookie.isset("jwt_cookie")) {
#Extract header data from JWT
var.set("token", cookie.get("jwt_cookie"));
var.set("header", regsub(var.get("token"),"([^\.]+)\.[^\.]+\.[^\.]+","\1"));
var.set("type", regsub(digest.base64url_decode(var.get("header")),{"^.*?"typ"\s*:\s*"(\w+)".*?$"},"\1"));
var.set("algorithm", regsub(digest.base64url_decode(var.get("header")),{"^.*?"alg"\s*:\s*"(\w+)".*?$"},"\1"));
#Don't allow invalid JWT header
if(var.get("type") == "JWT" && var.get("algorithm") == "HS256") {
#Extract signature & payload data from JWT
var.set("rawPayload",regsub(var.get("token"),"[^\.]+\.([^\.]+)\.[^\.]+$","\1"));
var.set("signature",regsub(var.get("token"),"^[^\.]+\.[^\.]+\.([^\.]+)$","\1"));
var.set("currentSignature",digest.base64url_nopad_hex(digest.hmac_sha256(var.get("key"),var.get("header") + "." + var.get("rawPayload"))));
var.set("payload", digest.base64url_decode(var.get("rawPayload")));
var.set("exp",regsub(var.get("payload"),{"^.*?"exp"\s*:\s*([0-9]+).*?$"},"\1"));
var.set("jti",regsub(var.get("payload"),{"^.*?"jti"\s*:\s*"([a-z0-9A-Z_\-]+)".*?$"},"\1"));
var.set("userId",regsub(var.get("payload"),{"^.*?"uid"\s*:\s*"([0-9]+)".*?$"},"\1"));
var.set("roles",regsub(var.get("payload"),{"^.*?"roles"\s*:\s*"([a-z0-9A-Z_\-, ]+)".*?$"},"\1"));
#Only allow valid userId
if(var.get("userId") ~ "^\d+$") {
#Don't allow expired JWT
if(std.time(var.get("exp"),now) >= now) {
#SessionId should match JTI value from JWT
if(cookie.get(var.get("sessionCookie")) == var.get("jti")) {
#Don't allow invalid JWT signature
if(var.get("signature") == var.get("currentSignature")) {
#The sweet spot
set req.http.X-login="true";
} else {
std.log("JWT: signature doesn't match. Received: " + var.get("signature") + ", expected: " + var.get("currentSignature"));
}
} else {
std.log("JWT: session cookie doesn't match JTI." + var.get("sessionCookie") + ": " + cookie.get(var.get("sessionCookie")) + ", JTI:" + var.get("jti"));
}
} else {
std.log("JWT: token has expired");
}
} else {
std.log("UserId '"+ var.get("userId") +"', is not numeric");
}
} else {
std.log("JWT: type is not JWT or algorithm is not HS256");
}
std.log("JWT processing finished. UserId: " + var.get("userId") + ". X-Login: " + req.http.X-login);
}
#Look for full private content
if(req.url ~ "/node/2" && req.url !~ "^/user/login") {
if(req.http.X-login != "true") {
return(synth(302,"/user/login?destination=" + req.url));
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment