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('<script>'); // 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…