VCL file that describes my JWT implementation in Varnish for Drupal. Part of my DrupalCon Vienna 2017 presentation.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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