iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Safe Demo Lab

A defensively framed XSS demo in a single Express + Handlebars sandbox. Shows the bug, the fix, the CSP, and a verification harness. Use only on the bundled lab app.

XSS — defensive demo

EXAMPLE
// SCOPE: defensive demo on the bundled lab app at ./xss-lab. Authorised testing only.
// Do not test against any service you do not own or have written permission to test.

// ===== The bug (DO NOT do this in production) =====
// vuln_view.hbs
//   {{{comment}}}     <!-- triple-braces = raw HTML interpolation -->
// vuln_server.js
const express = require('express');
const exphbs  = require('express-handlebars');
const app = express();
app.engine('hbs', exphbs.engine({ extname: 'hbs' }));
app.set('view engine', 'hbs');
app.use(express.urlencoded({ extended: false }));

let comments = [];
app.post('/post', (req, res) => {
  comments.push(req.body.comment);   // accepted raw - the bug
  res.redirect('/');
});
app.get('/', (req, res) => res.render('vuln_view', { comments }));
// In the lab, posting <script>document.title='xss'</script> sets the page title.

// ===== The fix =====
// 1. Render with double-braces {{comment}} so Handlebars HTML-escapes.
// 2. Sanitise allowed HTML if you must accept it.
const sanitizeHtml = require('sanitize-html');
const SAFE = {
  allowedTags: ['b','i','em','strong','a','p','ul','ol','li','code','pre'],
  allowedAttributes: { a: ['href','title'] },
  allowedSchemes: ['https'],
};
app.post('/post', (req, res) => {
  comments.push(sanitizeHtml(req.body.comment, SAFE));
  res.redirect('/');
});

// ===== Defence in depth: CSP =====
// Block inline scripts entirely; nonce the ones you control.
const crypto = require('crypto');
app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.cspNonce = nonce;
  res.setHeader('Content-Security-Policy',
    "default-src 'self'; script-src 'self' 'nonce-" + nonce + "'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
  next();
});

// In your view:
//   <script nonce={{cspNonce}}>/* page script */</script>

// ===== Cookies =====
// Mark session cookies Secure, HttpOnly, SameSite=Lax (or Strict if the flow allows).
app.use(require('express-session')({
  secret: process.env.SESSION_SECRET,
  cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 86400_000 },
  resave: false, saveUninitialized: false,
}));

// ===== Verification harness =====
// __tests__/xss.test.js
const request = require('supertest');
test('comment renders as text, not HTML', async () => {
  const agent = request.agent(app);
  await agent.post('/post').type('form').send({ comment: '<script>alert(1)</script>' });
  const res = await agent.get('/');
  expect(res.text).toContain('&lt;script&gt;');           // escaped
  expect(res.text).not.toContain('<script>alert(1)</script>');
});

// ===== Patterns to internalise =====
// - Default-escape everywhere (double-braces, jsx text, htmlspecialchars)
// - Use a vetted sanitizer when you genuinely need rich text
// - Strict CSP with nonce; ban 'unsafe-inline' on script-src
// - Cookies: Secure + HttpOnly + SameSite
// - Test the escape with a snapshot assertion so regressions fail CI

// ===== Pitfalls =====
// - Triple-braces, dangerouslySetInnerHTML, document.write of any user input
// - innerHTML += userInput as a quick fix in a UI handler
// - Markdown -> HTML without sanitising the rendered output
// - 'unsafe-inline' or 'unsafe-eval' in CSP because a vendor needed it
// - Reflecting user input in JSON inside a <script> block without escaping </script>

Why it matters

This is a lab demo, not a weapon. The shape that matters: default-escape, vetted sanitiser, strict CSP, secure cookies, regression test. Practise on the bundled lab app; never on systems you do not own. That order keeps the learning sharp and the risk on your side of the firewall.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
<!-- Run this in a SANDBOXED iframe with a fake "unsafe" toggle to teach the difference. -->
<form id="f"><input id="q"><button>go</button></form>
<div id="out"></div>
<script>
    const unsafe = false;  // flip in a controlled demo
    f.onsubmit = e => {
        e.preventDefault();
        const v = q.value;
        out[unsafe ? 'innerHTML' : 'textContent'] = 'You typed: ' + v;
    };
</script>
Try it Yourself »

Discussion

Loading…