Skip to main content

02 - XSS, CSRF and CSP: Essential Frontend Security

XSS, CSRF and CSP reproduced on a test app: why HTML encoding is not enough for URLs, how to stop a CSRF and how to issue a CSP with a nonce.

What do you think about this article?

02 - XSS, CSRF and CSP: Essential Frontend Security
Sicurezza WebPhoto:Amina FilkinsPexels

At a Glance

XSS, CSRF and CSP reproduced on a test app: why HTML encoding is not enough for URLs, how to stop a CSRF and how to issue a CSP with a nonce.

The browser runs everything your server hands it and attaches cookies to almost everything your domain receives: XSS and CSRF are born from these two habits, and CSP is the net that catches what the earlier defences let through. They are three different problems with three different defences, and none replaces the others. Here you reproduce them on a test application, you see that the most obvious fix (encoding the HTML) repairs only two cases out of three, and you can say what the browser does for you and what you have to write yourself.

XSS: untrusted data becomes code

According to OWASP's guide, cross-site scripting happens when the application puts untrusted data in a page in such a way that the browser treats it as code: a comment that was meant to stay text and instead runs JavaScript. Untrusted data is everything the application did not create itself: form fields, address parameters, headers, the content of a database filled by others. The variants are told apart by their path: reflected (the payload comes back in the response to the same request), stored (it sits in the database and hits whoever opens the page) and DOM-based (the page's script writes it into the document without going through the server). In the 2025 Top 10 all of this sits in A05, together with SQL injection.

The main defence is context-dependent output encoding, and the context matters. The following script exposes /search, which inserts the same parameter in three places: the text of a paragraph, the value of an attribute and the href of a link. The same program runs at three levels: no defence, only the escapeHtml function (which replaces & < > " '), and escapeHtml plus an allowlist of schemes for URLs. The application is a test one, no port is open: Fastify's inject() hands the request directly to the application.

import Fastify from 'fastify';

const escapeHtml = (text) =>
  String(text).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);

// A link is safe only if its scheme is in an allowlist.
function safeUrl(value) {
  try {
    const u = new URL(value);
    return ['http:', 'https:'].includes(u.protocol) ? u.href : '#';
  } catch {
    return '#';
  }
}

function build(level) {
  // level 0: nothing; 1: HTML encoding only; 2: encoding plus an allowlist on URL schemes
  const app = Fastify();
  app.get('/search', async (req, reply) => {
    const { q = '', site = '' } = req.query;
    const text = level >= 1 ? escapeHtml(q) : q;
    const attrValue = level >= 1 ? escapeHtml(q) : q;
    const href = level === 2 ? escapeHtml(safeUrl(site)) : level === 1 ? escapeHtml(site) : site;
    reply.type('text/html');
    return `<p>Results for: ${text}</p><input value="${attrValue}"><a href="${href}">site</a>`;
  });
  return app;
}

const tests = [
  ['text context', { q: '<img src=x onerror=alert(1)>' }, (b) => b.includes('<img src=x onerror=alert(1)>')],
  ['attribute context', { q: '" autofocus onfocus="alert(1)' }, (b) => b.includes('value="" autofocus onfocus="alert(1)"')],
  ['URL context', { site: 'javascript:alert(1)' }, (b) => b.includes('href="javascript:alert(1)"')],
];
const labels = ['no defences', 'HTML encoding only', 'HTML encoding and scheme allowlist'];
for (const level of [0, 1, 2]) {
  console.log('--- ' + labels[level]);
  const app = build(level);
  for (const [name, query, injected] of tests) {
    const r = await app.inject({ url: '/search', query });
    console.log(`${name}: payload active in the document = ${injected(r.body)}`);
  }
}
const r = await build(2).inject({ url: '/search', query: { q: '<img src=x onerror=alert(1)>' } });
console.log('text made safe:', r.body.match(/<p>.*?<\/p>/)[0]);

// Output:
// --- no defences
// text context: payload active in the document = true
// attribute context: payload active in the document = true
// URL context: payload active in the document = true
// --- HTML encoding only
// text context: payload active in the document = false
// attribute context: payload active in the document = false
// URL context: payload active in the document = true
// --- HTML encoding and scheme allowlist
// text context: payload active in the document = false
// attribute context: payload active in the document = false
// URL context: payload active in the document = false
// text made safe: <p>Results for: &lt;img src=x onerror=alert(1)&gt;</p>

The result holds a surprise: HTML encoding repairs the text and the attribute, but href="javascript:alert(1)" goes through intact, because inside an href attribute there is no special character to encode: the danger is the URL's scheme. The fix is safeUrl, which parses the value with new URL and accepts only http: and https:; anything else, or a value that does not parse, becomes #. The escapeHtml function includes double and single quotes precisely for the attribute: without them, the payload " autofocus onfocus="... closes the attribute and opens another. A limit to declare: these tests look at the HTML text produced, not at execution in a browser, which I did not start here; the criterion ("the payload is active in the document") is the one OWASP points to as the cause.

What Angular does for you

With a modern framework you start with an advantage. In Angular the interpolation {{ value }} is always encoded: special characters appear as text. A value bound to [innerHTML] is sanitised, that is, stripped of whatever runs code, and the guide distinguishes four security contexts (HTML, style, URL, resource URL), because a value that is harmless in one can be dangerous in another: it is the same reasoning as in the test above. The danger comes back when you switch sanitisation off: the bypassSecurityTrust* functions tell Angular "I verified this value myself", and the guide itself warns that marking as trusted a value that can be malicious introduces a vulnerability; OWASP too lists them among the places where frameworks are bypassed. The practical rule: never apply them to text that comes from a user.

CSRF: the browser attaches the cookie without asking you

In cross-site request forgery a page on another site fires a request at your application, and the victim's browser attaches the session cookies: the server sees an authenticated request and does not know the user did not want it. There is no need to steal anything or to read the response; it is enough that the action is a state change obtained with a predictable request. For OWASP, CSRF (CWE-352) sits in A01, because it is access granted without knowing whether it is wanted.

The script builds a small bank with a cookie session and a POST /transfer route, and tests it with three defences: none, an anti-CSRF token (the synchronizer token model: the server generates a value per session and requires it in the x-csrf header, compared with timingSafeEqual), and a Fetch Metadata check (the browser declares in Sec-Fetch-Site where the request starts from, and the server rejects everything that is not same-origin). The "forged" request carries the cookie and the origin evil.test, but not the token: it is what the attacker's page can obtain, because it can neither read the token nor set arbitrary headers.

import Fastify from 'fastify';
import { randomBytes, timingSafeEqual } from 'node:crypto';

function build(defence) {
  const sessions = new Map(); // sid -> { user, csrf, balance }
  const app = Fastify();
  app.post('/login', async (req, reply) => {
    const sid = randomBytes(16).toString('hex');
    const csrf = randomBytes(16).toString('hex');
    sessions.set(sid, { user: 'alice', csrf, balance: 1000 });
    reply.header('set-cookie', `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`);
    return { csrf };
  });
  app.post('/transfer', async (req, reply) => {
    const sid = /sid=([0-9a-f]+)/.exec(req.headers.cookie ?? '')?.[1];
    const s = sessions.get(sid);
    if (!s) return reply.code(401).send({ error: 'not authenticated' });
    if (defence === 'token') {
      const given = Buffer.from(String(req.headers['x-csrf'] ?? ''));
      const expected = Buffer.from(s.csrf);
      if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
        return reply.code(403).send({ error: 'CSRF token missing or wrong' });
      }
    }
    if (defence === 'fetch-metadata') {
      const site = req.headers['sec-fetch-site'];
      if (site !== undefined && site !== 'same-origin') {
        return reply.code(403).send({ error: `${site} request rejected` });
      }
    }
    s.balance -= req.body.amount;
    return { balance: s.balance };
  });
  return app;
}

for (const defence of ['none', 'token', 'fetch-metadata']) {
  const app = build(defence);
  const login = await app.inject({ method: 'POST', url: '/login' });
  const cookie = login.headers['set-cookie'].split(';')[0];
  if (defence === 'none') {
    console.log('Set-Cookie:', login.headers['set-cookie'].replace(/sid=[0-9a-f]+/, 'sid=<id>'));
  }
  const { csrf } = login.json();
  // The forged request: the victim's browser attaches the cookie, but the attacker's
  // page cannot read the token or set arbitrary headers.
  const forged = await app.inject({
    method: 'POST', url: '/transfer', payload: { amount: 500 },
    headers: { cookie, origin: 'https://evil.test', 'sec-fetch-site': 'cross-site' },
  });
  const legitimate = await app.inject({
    method: 'POST', url: '/transfer', payload: { amount: 100 },
    headers: { cookie, origin: 'https://bank.test', 'sec-fetch-site': 'same-origin', 'x-csrf': csrf },
  });
  console.log(`${defence}: forged ${forged.statusCode} ${forged.body} | legitimate ${legitimate.statusCode} ${legitimate.body}`);
}

// Output:
// Set-Cookie: sid=<id>; HttpOnly; SameSite=Lax; Path=/
// none: forged 200 {"balance":500} | legitimate 200 {"balance":400}
// token: forged 403 {"error":"CSRF token missing or wrong"} | legitimate 200 {"balance":900}
// fetch-metadata: forged 403 {"error":"cross-site request rejected"} | legitimate 200 {"balance":900}

Without a defence the forged request goes through and the balance drops by 500; with either defence it is rejected with 403 while the legitimate request, the one that carries the token or starts from the same origin, succeeds. The cookie is issued with HttpOnly (the page's script cannot read it) and SameSite=Lax, which tells the browser not to attach it to cross-site requests except for navigations with a safe method. Two caveats. First, data on the server does not prove the browser's behaviour: this script proves what the server does if the cookie arrives anyway, and that is the real case when SameSite is missing, is None, or the attack starts from a subdomain of the same site. Second, do not rely on the default value: MDN explains that, where the browser applies Lax by default, it uses a more permissive version that includes POST requests arriving within two minutes of the cookie being created. Write the attribute explicitly.

The OWASP guide recommends tokens for stateful software, the double-submit cookie for stateless software and Fetch Metadata for those who only have modern browsers, to be placed alongside the fallback options the guide describes; it also reminds you never to use GET requests to change state. If you use a framework, first check whether it already offers the protection: it is almost always preferable to one of your own.

The Angular side

Angular implements double submit in its HttpClient: an interceptor reads a token from a cookie (by default XSRF-TOKEN) and writes it in the X-XSRF-TOKEN header on all requests that modify data towards relative or same-origin URLs, not on GET and HEAD. The server must issue the cookie and verify the header: without the server-side check, the interceptor protects nothing. If the names are different you configure them like this (a scheme to adapt, taken from Angular's guide, not run: it needs an Angular project):

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withXsrfConfiguration({
        cookieName: 'CUSTOM_XSRF_TOKEN',
        headerName: 'X-Custom-Xsrf-Header',
      }),
    ),
  ],
};

The withXsrfConfiguration function accepts two properties, cookieName and headerName; if you do not use it the default names apply, XSRF-TOKEN and X-XSRF-TOKEN. The risk of changing them is a mismatch with the backend: the interceptor keeps working and the server rejects every write with 403, a fault that shows up only on modifying requests. To switch everything off, the guide offers withNoXsrfProtection(): if you choose it, the defence has to live elsewhere.

CSP: the net under the tightrope walker

The Content Security Policy is a header with which you tell the browser which sources of scripts (and of other resources) are allowed. The approach that holds is the "strict" one: a nonce, a random value generated for every response, which appears in the header and in the nonce attributes of your own scripts only; the browser runs only the scripts with the right nonce. Even if an attacker manages to insert a <script> tag, they do not know the nonce of that response. The strict-dynamic keyword extends trust to scripts loaded by a script that is already trusted, useful with third-party libraries that load others.

The following script emits the policy with a nonce, object-src 'none', base-uri 'none' and frame-ancestors 'none', and deliberately inserts the user's text without encoding it: it serves to see the role of the CSP as a second line. A second route uses the Content-Security-Policy-Report-Only header, with which the browser reports violations without blocking anything: it is the way to try a policy on real traffic before enforcing it.

import Fastify from 'fastify';
import { randomBytes } from 'node:crypto';

const app = Fastify();

app.get('/', async (req, reply) => {
  const nonce = randomBytes(16).toString('base64');
  reply.header('content-security-policy', [
    "default-src 'self'",
    `script-src 'nonce-${nonce}' 'strict-dynamic'`,
    "object-src 'none'",
    "base-uri 'none'",
    "frame-ancestors 'none'",
  ].join('; '));
  reply.type('text/html');
  // Defence in depth: here the user's text is NOT encoded, to see what the CSP does.
  return `<script nonce="${nonce}">/* application code */</script><p>${req.query.q ?? ''}</p>`;
});
app.get('/trial', async (req, reply) => {
  reply.header('content-security-policy-report-only', "script-src 'self'; report-to csp-endpoint");
  return { ok: true };
});

const a = await app.inject({ url: '/', query: { q: '<script>alert(1)</script>' } });
const b = await app.inject({ url: '/', query: { q: 'hello' } });
const nonceOf = (r) => /'nonce-([^']+)'/.exec(r.headers['content-security-policy'])[1];
const totalScripts = (a.body.match(/<script/g) ?? []).length;
const withNonce = a.body.split(`<script nonce="${nonceOf(a)}">`).length - 1;
console.log('different nonce on every response:', nonceOf(a) !== nonceOf(b));
console.log('nonce in the header = nonce in the document:', a.body.includes(`nonce="${nonceOf(a)}"`));
console.log('scripts in the document:', totalScripts, '| with the right nonce:', withNonce);
console.log('unsafe-inline present:', a.headers['content-security-policy'].includes('unsafe-inline'));
console.log('directives:', a.headers['content-security-policy'].split('; ').map((d) => d.split(' ')[0]).join(', '));
console.log('report-only:', Object.keys((await app.inject({ url: '/trial' })).headers).filter((h) => h.includes('report-only')).join());

// Output:
// different nonce on every response: true
// nonce in the header = nonce in the document: true
// scripts in the document: 2 | with the right nonce: 1
// unsafe-inline present: false
// directives: default-src, script-src, object-src, base-uri, frame-ancestors
// report-only: content-security-policy-report-only

The nonce changes on every response and matches between header and document, unsafe-inline is absent, and the document contains two <script> tags but only one carries the right nonce: the one injected by the payload would be rejected by the browser. The "would" is honest: enforcing the block is up to the browser, and I did not use one here; this script proves that the policy is issued correctly, not that it is effective in a real page. The practical consequence is that a CSP is tested in a browser, starting from Report-Only. And it is no excuse to stop encoding: Angular's guide points to it, together with Trusted Types, as an extra layer that works at the DOM level, not as a substitute for sanitisation.

How they combine

AttackWhat really stops itWhat it does not stop
Reflected or stored XSSOutput encoding by context, URL validationHTML encoding alone lets javascript: through in an href
DOM-based XSSAvoiding APIs such as innerHTML with untrusted data; the framework's sanitisationA CSP with a nonce limits execution, but does not fix incorrect use
CSRFToken, Fetch Metadata, explicit SameSiteA CSP, which governs the resources loaded and does not check who wanted the modifying request
Injected script that escapes encodingCSP with a nonce and strict-dynamicAn XSS that uses an already authorised script

The table says one simple thing: defences come in layers and each has a blind spot that another covers. In article 04 you see how to build the session cookie that here we only used; in article 03 the other half of A05, server-side injection; and in article 01 the overall map.

Frequently asked questions

If I use Angular can I ignore XSS?

No. Angular encodes and sanitises by default, but the bypassSecurityTrust* functions switch sanitisation off, and every page served outside Angular, including the backend's HTML responses, remains your responsibility. Moreover its defences live in the client: the server must still encode and validate.

Does SameSite replace the anti-CSRF token?

Not on its own. It helps a lot, but it depends on the browser's behaviour and on the attack starting from another site: an action performed from a subdomain of the same site, or an old browser, gets around it. OWASP places it among the defence-in-depth measures, to be combined with a token or Fetch Metadata.

Does a CSP with unsafe-inline still protect?

Much less. With unsafe-inline in the script directive the browser also runs the scripts inserted in the document, which is exactly what an XSS attack does. The policy with a nonce exists to avoid this: the browser runs only the scripts with the nonce of that response.

What you can do now

You can tell reflected, stored and DOM-based XSS apart and you know why encoding depends on context: you have seen HTML encoding let a javascript: URL through and you have the fix. You can reproduce a CSRF against a test application and stop it with a token or with Fetch Metadata, write the cookie attributes without trusting the defaults, and issue a CSP with a nonce to be tested in Report-Only mode before enforcing it. You also know what is not proved here: execution in a real browser. Continue with SQL injection.

Sources

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects