Cross-Site Request Forgery is an attack that allows an attacker to make authenticated calls to any websites the victim may be logged into. Here we run through an example scenario.
Let's suppose the victim wants to log into his banking website. Our (simple) banking webserver has a GET endpoint for the home page and a POST endpoint for transfers. We use express to create this bank webserver.
HI
constexpress=require('express')constcookieParser=require('cookie-parser')constbodyParser=require('body-parser')constapp=express()constport=3000// We need cookies and body paramsapp.use(cookieParser())app.use(bodyParser())// Middleware function to authenticate into the webserverfunctionis_authenticated(req,res,next){console.log('Came here') // Check cookies to determine if logged inif (req.cookies['session'] =='secret_token_placed_here'){next()}else{res.redirect('/auth')}}app.get('/auth',(req,res)=>{console.log('Authenticating',req.query.username,req.query.password) // User has valid loginif (req.query.username=='username'&&req.query.password=='password'){ // Add cookie certifying user has authenticatedres.cookie('session','secret_token_placed_here',{maxAge:1000*60*15,// would expire after 15 minuteshttpOnly:false,// The cookie only accessible by the web server/not javascriptsigned:false// Indicates if the cookie should be signed})res.redirect('/')} // Login was badelse{res.set('Content-Type','text/html');res.status(200).send('Click <a href="/auth?username=username&password=password">here to login</a>')}})app.get('/',is_authenticated,(req,res,next)=>{console.log('Logged into banking website')res.set('Content-Type','text/html');res.status(200).send("<h3>Welcome to your bank!</h3><form action='/transfer' method='post'><input id='POST-name' placeholder='recipient' name='recipient'><input id='POST-amount' placeholder='$20' name='amount'><input type='submit' value='Save'></form>")})app.post('/transfer',is_authenticated,(req,res)=>{console.log('Transferring money to',req.body.recipient,req.body.amount)res.set('Content-Type','text/html');res.status(200).send('<h3>Success! Go back to <a href="/">home</a></h3>')})app.listen(port,()=>console.log(`Bank app listening on port ${port}!`))
Serve this webserver with node bank.js and check that everything works:
GET /
POST /transfer
Great! Our bank works as expected. Now let's see how a hacker could exploit our design to transfer money to himself.
Attacking Webserver
Let's take a look at how our bank checks that requests are originating from an authenticated user.
User Logs in: When the user logs in, the server asks that the browser stores a cookie named session. We see this happen in the handler for the /auth route
2. User makes request:The GET '/' & POST '/transfer' routes are protected by the is_authenticated middleware. This middleware checks that the HTTP request came with a valid session cookie. If not, the server redirects them to GET /auth
This authentication strategy works because the browser always sends the cookies set by an origin. In this case, any request to localhost:3000 will always have all the cookies set by that origin sent along by the browser. This property of cookies is exactly what a CSRF attack exploits to cause state changes.
In this case, any request to localhost:3000 will always have all the cookies set by that origin sent along by the browser. This property of cookies is exactly what a CSRF attack exploits to cause state changes.
All the attacker needs to do now is to trick a victim into clicking on a link to a different webpage. Once this happens, a form on that webpage could easily POST to the bank web-server. The browser will send along the session cookie stored in the browser. This type of request is indistinguishable from one intentionally initiated by the user from the perspective of the bank web server and thus succeeds.
Let's construct this attacking webserver
Run the webserver with node attacker.jsNavigate to localhost:5000 to see our attack in action.
Protection against CSRF
The most common protection measure against this vulnerability is to enrich the form with a hidden token that is submitted along with the form. The server must do the added work of checking that this token is valid, but this will prevent against the CSRF attack. Let's see it in action. We modify the GET & POST routes in bank.js to include a csrf_token from the session
If we navigate to localhost:5000 we see that our attack no longer works. Success!
What if the attacker used AJAX to get the hidden csrf_token?
This is a good question. What if the attacker simply included some AJAX in his webpage to retrieve the csrf associated with the session. Why is he not able to do this?
This brings us to the Same Origin Policy (SOP) of browsers. SOP prevents sites from making AJAX requests across origins. This means the webpage would not succeed in making the AJAX call mentioned previously. Let's add this to our attacker webpage script:
We see that the request is denied. What is interesting to note is that the request still goes through (check this in the bank webserver) logs, but the response is blocked from the requestor.
CORS
Finally, we want to lax this policy in certain situations. For example, a REST API needs to be accessed across origins. We can permit this behavior by adding a Cross Origin Request Sharing header to our API. In express, the ''cors' package allows us to easily enable this.
...
res.cookie('session', 'secret_token_placed_here', {
maxAge: 1000 * 60 * 15, // would expire after 15 minutes
httpOnly: false, // The cookie only accessible by the web server/not javascript
signed: false // Indicates if the cookie should be signed
})
...
...
// Middleware function to authenticate into the webserver
function is_authenticated(req, res, next) {
console.log('Came here')
// Check cookies to determine if logged in
if (req.cookies['session'] == 'secret_token_placed_here'){
next()
}
else{
res.redirect('/auth')
}
}
...
attacker.js
const express = require('express')
const app = express()
const port = 5000
app.get('/', (req, res) =>
{
console.log('I am a vicious website')
res.set('Content-Type', 'text/html');
// Malicious code
res.status(200).send("<h3>I am an innocent website!</h3><form action='http://localhost:3000/transfer' method='post'><input id='POST-name' placeholder='recipient' name='recipient' value='hacker'><input id='POST-amount' placeholder='$20' name='amount' value='10000'><input type='submit' value='Save'></form><script>document.forms[0].submit()</script>")
}
)
app.listen(port, () => console.log(`Example app listening on port ${port}!`))
bank.js
...
const hash = require('crypto-js').SHA256
function generate_csrf_token_from(session){
return hash(session).toString()
}
app.get('/', is_authenticated, (req, res, next) => {
console.log('Logged into banking website')
res.set('Content-Type', 'text/html');
// Send csrf token in hidden input
let csrf_token = generate_csrf_token_from(req.cookies['session'])
res.status(200).send("<h3>Welcome to your bank!</h3><form action='/transfer' method='post'><input id='POST-name' placeholder='recipient' name='recipient'><input id='POST-amount' placeholder='$20' name='amount'><input id='POST-csrf' value='" + csrf_token + "' name='csrf_token' type='hidden'><input type='submit' value='Save'></form>")
})
app.post('/transfer', is_authenticated, (req, res) => {
// if csrf token in form doesn'tvalidate - FUCK EVERYTHING UP
if (req.body.csrf_token != generate_csrf_token_from(req.cookies['session'])){
res.status(403).send('Bad request!')
return
}
console.log('Transferring money to', req.body.recipient, req.body.amount)
res.set('Content-Type', 'text/html');
res.status(200).send('<h3>Success! Go back to <a href="/">home</a></h3>')
})
...
...
<script>
...
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState == XMLHttpRequest.DONE) {
alert(xhr.responseText);
}
}
xhr.open('GET', 'http://localhost:3000', true);
xhr.send(null);
</script>
...
var cors = require('cors')
var app = express()
app.use(cors())