— firebase, nodejs, javascript, time-wasted — 5 min read
Bottom-line. Wasted a week(4/Jan/2021 - 9/Jan/2021) on Cross-Site Request Forgery (CSRF). It all started with me working on Firebase server-side authentication and in the example i was following, it had CSRF setup. So, i started investigating and studying CSRF and found it interesting wanted to try it out. So, i'd setup firebase hosting
, csurf
and finally got it work for all GET requests. But, when i introduced POST requests, it started failing with invalid csrf token
errors. Did a lot of attempts with solutions posted in S.O, nothing worked. So finally, i came across a post in S.O and decided CSRF is not required for firebase hosting. I proceeded to try out csurf
without using firebase hosting and finally gave up.
Hmmm... Curiosity kills time.
This is how it all started.
Cross-Site Request Forgery (CSRF) is an attack that forces an end user to execute unwanted actions on a web application in which they’re currently authenticated. With a little help of social engineering (such as sending a link via email or chat), an attacker may trick the users of a web application into executing actions of the attacker’s choosing. If the victim is a normal user, a successful CSRF attack can force the user to perform state changing requests like transferring funds, changing their email address, and so forth. If the victim is an administrative account, CSRF can compromise the entire web application.
csurf
?csurf
is a middleware which creates token via req.csrfToken()
and this token will be generated and added to all requests and makes sure that the request comes from a legitimate client. This generated token can be added as a Cookie or via templating in meta
tags or form
hidden fields whichever suits your needs. Here, i am storing it in a cookie, so additionally i am using cookie-parser
, another middleware. Why this choice, if you ask, basically i am using firebase and i am developing this based on examples from Firebase : Managing Cookies.
How CSRF works ? When the server sends a form/page to the client, it attaches a unique random value (the CSRF token) to it that the client needs to send back. When the server receives the request from the client, it compares the received token value with the previously generated value. If they match, it assumes that the request is valid.
Here are the steps to implement, this has nothing to do with actual firebase, here we are just makingt the site more secure.
npm install cookie-parser csurf --save
index.js
add the below code in the middleware section. In line 5, we are just stating we will be using cookie object to store the secret for the user. Other notable default value is name of the cookie will be _csrf
1...2const cookieParser = require("cookie-parser");3const csrf = require("csurf"); // protect from Cross site forgery attacks 4...5app.use(cookieParser());6const csrfMiddleware = csrf({ cookie: true });7app.use(csrfMiddleware);
XSRF_TOKEN
. In our example, just triggered the route /login
from the browser.1/** Routes2**********************************************************/3//This executes first and set the cookie to XSRF-TOKEN4app.all("*", (req, res, next) => {5 const XSRF_TOKEN = req.csrfToken();6 console.log(`In (*), XSRF_TOKEN=${XSRF_TOKEN}`);7 res.cookie("XSRF-TOKEN", XSRF_TOKEN);8 next();9});10
11app.get('/login', function(req, res){12 console.log(`In ${req.path}, req.cookies=${JSON.stringify(req.cookies)}`);13 res.clearCookie('__session');14 res.render('login.ejs', { title: "login - Sushanth Tests", pageID: "login"})15});
You can see the same XSRF_TOKEN
and _crsf
cookies in the browser.
In the browser, when i enter the email and password and click login following code in firebase gets triggered. Here i am doing following things,
getIdToken()
method. 1// Check AUTH state change2 firebase.auth().onAuthStateChanged(firebaseUser => { 3 if(firebaseUser){ 4 setUserDetails(firebaseUser).then( (results) => {5 firebaseUser.getIdToken().then(function(idToken) { 6 console.log(idToken); // It shows the Firebase token now7
8 const csrfToken = Cookies.get("XSRF-TOKEN");9 console.log("Cookie Get : ",csrfToken);10
11 return fetch("/sessionLogin", {12 method: "POST",13 headers: {14 Accept: "application/json",15 "Content-Type": "application/json",16 "CSRF-Token": Cookies.get("XSRF-TOKEN"),17 },18 body: JSON.stringify({ idToken }),19 }); 20 }).then( response => {21 firebase.auth().signOut();22 if(!response.ok){23 console.log(response);24 throw new Error(response.status+", "+response.statusText);25 }else{26 window.location.assign("/"); 27 }28 }).catch( error => {29 window.location.assign("/login"); 30 });31 32 }).catch( (error) => { 33 34 console.log('Status : ',error);35 setTimeout( () => {36 const auth = firebase.auth();37 auth.signOut(); 38 }, 5000); 39 });40
41 }42 });
/sessionLogin
is called by fetch below code gets triggered. Before executing the code in the route, csurf validates the tokens automatically and once its successful only, route code is executed. In the first part of the code, i am just displaying the idToken
, req.cookies
, req.headers
and req.body
and in the second part of the code, we are create a firebase session cookie with the name __session
. This is special because firebase hosting, functions and cloud run permits only this cookie to pass through to the execution of your app and rest are all stripped from incoming requests. This is mentioned in Manage cache behavior. 1//Login Setup and Check2app.post("/sessionLogin", (req, res) => {3 const idToken = req.body.idToken.toString();4 console.log(`In ${req.path}, idToken=${idToken}`);5 console.log(`In ${req.path}, req.cookies=${JSON.stringify(req.cookies)}=`);6 console.log(`In ${req.path}, req.headers=${JSON.stringify(req.headers)}=`);7 // console.log(`In ${req.path}, req.body=${JSON.stringify(req.body)}=`);8
9 const expiresIn = 60 * 60 * 24 * 5 * 1000; 10
11 admin.auth().createSessionCookie(idToken, { expiresIn }).then( (sessionCookie) => {12 console.log(`In ${req.path} : And in createSessionCookie()` );13 const options = { maxAge: expiresIn, httpOnly: true };14 res.cookie("__session", sessionCookie, options);15 res.end(JSON.stringify({ status: "success" }));16 }, (error) => {17 res.status(403).send("UNAUTHORIZED REQUEST!");18 }19 );20});
When the route /sessionLogin
is executed, we get the following output,
1i functions: Beginning execution of "app"2> In (*), XSRF_TOKEN=c8xlEuv3-I3Dc_dseJdNCgqLwVdy-ctrJ5r83> In /sessionLogin, idToken=eyJhbGciOiJSUzI1NiIsImtpZCI6ImUwOGI0NzM0YjYxNmE0...............VQ-g75TTw4> In /sessionLogin, req.cookies={"_csrf":"_FKcfg_uQ3UCd8x2ct1iQp5c","XSRF-TOKEN":"KNneQQhf-H1roUFVXyP-80qUZk9IUqwyUdtM"}=5> In /sessionLogin, req.headers={"x-forwarded-host":"localhost:5000","x-original-url":"/sessionLogin","pragma":"no-cache","cache-control":"no-cache, no-store","host":"localhost:5001","connection":"keep-alive","content-length":"930","sec-ch-ua":"\"Google Chrome\";v=\"87\", \" Not;A Brand\";v=\"99\", \"Chromium\";v=\"87\"","accept":"application/json","csrf-token":"KNneQQhf-H1roUFVXyP-80qUZk9IUqwyUdtM","sec-ch-ua-mobile":"?0","user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36","content-type":"application/json","origin":"http://localhost:5000","sec-fetch-site":"same-origin","sec-fetch-mode":"cors","sec-fetch-dest":"empty","referer":"http://localhost:5000/login","accept-encoding":"gzip, deflate, br","accept-language":"en-US,en;q=0.9","cookie":"_csrf=_FKcfg_uQ3UCd8x2ct1iQp5c; XSRF-TOKEN=KNneQQhf-H1roUFVXyP-80qUZk9IUqwyUdtM"}=
csrf
tokens. Below is the logout route and do note, if you don't clear the cookie __session
, you sort of get invalid csrf token
errors. So i am clearing that cookie in signout and login(it took two days to figure that out). 1app.get('/signout', async function(req, res){2 var sessionCookie = req.cookies.__session;3 console.log(`In ${req.path}, sessionCookie=${sessionCookie}`);4
5 let fbUser = await admin.auth().verifySessionCookie(sessionCookie, true/** checkRevoked */);6 console.log(`In ${req.path}, uid=${fbUser.uid}, email=${fbUser.email}, auth_time=${fbUser.auth_time}`);7
8 res.clearCookie('__session');9 res.redirect('/login');10});
Below are the steps, i am performing.
Click signout
Go to login page and clear storage
Enter the login route http://localhost:5000/login
again to you will get the new tokens now.
Entered email, password and manually updated the XSRF-TOKEN
. Just added an extra s
.
Click SIGN-IN WITH EMAIL
Below are the responses,
1[hosting] Rewriting /sessionLogin to http://localhost:5001/api-project-333122123186/us-central1/app for local Function app2i functions: Beginning execution of "app"3> EBADCSRFTOKEN invalid csrf token4i functions: Finished "app" in ~1s5i hosting: 127.0.0.1 - - [06/Jan/2021:07:30:20 +0000] "POST /sessionLogin HTTP/1.1" 403 42 "http://localhost:5000/login" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
You will be similar responses, if _csrf
token is changed as well.
Two things are handled here,
1app.use(function (err, req, res, next) {2 // res.clearCookie('__session'); //It is found that it is found that removing __session from cookie resolves3 // the EBADCSRFTOKEN error which kept reoccuring during testing 2 days wasted. After removing this __session cookie4 // was able to proceed.5 console.log(err.code, err.message);6 if (err.code !== 'EBADCSRFTOKEN'){7
8 //Checking the request origin9 const referer = (req.headers.referer? new URL(req.headers.referer).host : req.headers.host);10 const origin = (req.headers.origin? new URL(req.headers.origin).host : null);11 console.log("Orgin Checks");12 console.log(`req.headers.host=${req.headers.host}`);13 console.log(`req.headers.referer=${req.headers.referer}, ${new URL(req.headers.referer).host}`);14 console.log(`req.headers.origin=${req.headers.origin}, ${new URL(req.headers.origin).host}`);15 16 if (req.headers.host == (origin || referer)) {17 next();18 } else {19 return next(new Error('Unallowed origin'));20 }21 // return next(err);22 } 23
24 // handle CSRF token errors here25 res.status(403).send({message:"CSURF code has been tampered"});26});
Everything was fine till here and when i introduced POST requests, started getting invalid csrf token
and after sometime, i found this S.O response from bojeil
CSRF becomes an issue when you are saving a session cookie. Firebase Auth currently persists the Auth State in web storage (localStorage/indexedDB) and are not transmitted along the requests. You are expected to run client side code to get the Firebase ID token and pass it along the request via header, or POST body, etc. On your backend, you would verify the ID token before serving restricted content or processing authenticated requests. This is why in its current form, CSRF is not a problem since Javascript is needed to get the ID token from local storage and local storage is single host origin making it not accessible from different origins.
If you plan to save the ID token in a cookie or set your own session cookie after Firebase Authentication, you should then look into guarding against CSRF attacks.
bojeil response to another S.O question on same subject
getCookie is a basically a cookie getter. You can write it yourself or lookup the implementation online. As for the CSRF check, this is a basic defense against CSRF attacks. The CSRF token is set in a cookie and then returned back in the post body. The backend will confirm that the CSRF token in the cookie matches the token in the POST body. Basically the idea here is that only requests coming from your website can read the cookie and pass it in the request in the POST body. If the request is coming from another website, they will not be able to read the cookie and pass it in the POST body. While the CSRF token cookie will be always be passed along the request even when coming from other origins, the token will not be available in the POST body.
It's unique per protocol://host:port combination.
After reading this, i decided not go with CSRF and Firebase combination but wanted to try out csurf
in a regular ExpressJS app and i was getting the same error.
And at this point, i quit with csurf
. Thinking about what it does,
_csrf
or XSRF-TOKEN
input
hidden field in the form (or) pass it to a meta tag. Attempted to do the above without using csurf, just to have a little satisfaction that i didn't waste a entire week on this.
1...2function attachCsrfToken(url, cookie, value) {3 return function(req, res, next) {4 console.log(`in attachCsrfToken(), cookie=${cookie}, value=${value}, req.url=${req.url}, url=${url}`);5 res.clearCookie(cookie);6 res.cookie(cookie, value);7 app.set(cookie, value);8 /*9 if (req.url == url) {10 res.cookie(cookie, value);11 }12 */13 next();14 }15}16
17...18/** Middleware19**********************************************************/20...21app.use(cookieParser());22
23// Attach CSRF token on each request 24// - Funny thing about this Math.random() most of the time its the same value in a session25app.use(attachCsrfToken('/', 'csrfToken', (Math.random()* 100000000000000000).toString()));26
27app.post('/sessionLogin', function(req, res){28 console.log(`In ${req.path}`);29 const email = req.body.email.toString();30 const password = req.body.pass.toString();31 console.log(`In ${req.path}, ${email}, ${password}`);32
33 //If cookies.csrfToken not found, it will set as -1 otherwise it gets the value from cookie34 const csrfToken = !req.cookies.csrfToken ? -1 : req.body.csrfToken.toString(); 35
36 // Guard against CSRF attacks.37 if (!req.cookies || csrfToken !== req.cookies.csrfToken) {38 console.log(`In ${req.path}, csrfToken=${csrfToken}, req.cookies.csrfToken=${req.cookies.csrfToken}`); 39 res.status(401).send('UNAUTHORIZED REQUEST!');40 return;41 }42 43 // Set session expiration to 5 days.44 const expiresIn = 60 * 60 * 24 * 5 * 1000; 45 const options = { maxAge: expiresIn, httpOnly: true };46
47 if(email=="abc@xyz.com" && password=="123789"){ 48 res.cookie("userpass", "authorized", options);49
50 res.writeHead(200, {"Content-Type": "application/json"});51 res.end(JSON.stringify({ ok:true, status:200,message:'Success' })); 52 // res.redirect("/");53 }else{54 res.cookie("userpass", "unauthorized", options);55
56 res.writeHead(401, {"Content-Type": "application/json"});57 res.end(JSON.stringify({ ok:false, status:401,message:'Unauthorized' })); 58 }59});60
61// - home62app.get('/', isAuthenticated, function(req, res){63 console.log(`In ${req.path}`);64 // console.log(`In ${req.path}, ${JSON.stringify(req.headers)}`);65 var csrfToken = req.app.get('csrfToken');66 res.render('home.ejs', { title: "home", csrfToken})67});
1<meta name="csrf-token" content="<%= csrfToken %>">
Client side login.js script
1document.addEventListener('DOMContentLoaded', event => {2
3 /**** Elements4 **********************************************************/5 const txtEmail = document.getElementById('txtEmail');6 const txtPassword = document.getElementById('txtPassword');7 const btnLogin = document.getElementById('btnLogin');8
9 // Read the CSRF token from the <meta> tag10 var token = document.querySelector('meta[name="csrf-token"]').getAttribute('content')11
12 /**** Event Listeners13 **********************************************************/14 btnLogin.addEventListener('click', async (e) => {15 let email = txtEmail.value;16 let pass = txtPassword.value;17 console.log(token);18
19 let url = '/sessionLogin';20 let options = {21 method: 'POST',22 url: url,23 headers: { 'Accept': 'application/json', 'Content-Type': 'application/json;charset=UTF-8' },24 data: {email, pass, csrfToken: token }25 }; 26
27 await axios(options).then((response) => {28 // Success29 if(response.status==200 && response.statusText=="OK"){30 console.log(response.config);31 console.log(response.data);32 window.location = '/';33 }else{34 console.log(response);35 } 36 }).catch((error) => {37 // Error38 console.log("Error=",error);39 if (error.response) {40 // The request was made and the server responded with a status code41 // that falls out of the range of 2xx42 // console.log(error.response.data);43 // console.log(error.response.status);44 // console.log(error.response.headers);45 } else if (error.request) {46 // The request was made but no response was received47 // `error.request` is an instance of XMLHttpRequest in the 48 // browser and an instance of49 // http.ClientRequest in node.js50 console.log(error.request);51 } else {52 // Something happened in setting up the request that triggered an Error53 console.log('Error', error.message);54 }55 console.log("Error Config=",error.config);56 }); 57 58 });59
60});