security

Markup in a name does not run

A XSS check on the signup form: type a name that looks like HTML, submit, and expect the string to appear as text. Playwright and Cypress both assert that window.__xss stays unset and that #success contains no extra img.

SUT: js-ui · id: security.xss-escape

Cached CI results from 10/4/2026, 3:02:04 AM (ci · ff16d82)

Testing tool

Playwright · Browser UX / E2E runner · Apache-2.0

Playwright launches a real browser (Chromium here), gives you a page object, and uses locators plus expect() to assert what the user sees. Traces and auto-waiting make flaky clicks easier to debug than raw WebDriver.

webServer starts samples/js-ui. Each test opens a page, interacts through roles and CSS, and asserts URL or visible text. The SUT is HTML/CSS/JS in the browser — not an imported function.

Testing architecture

Security tests ask what an attacker can make the SUT do — not whether the happy-path JSON or heading is correct. Here that means isolation headers on js-api responses, and proving that a script-like signup name is assigned with textContent so it cannot run. Failures point at missing headers or unsafe DOM writes.

The SUT writes the name with textContent (not innerHTML). The test is a user path plus a security oracle: if the page had assigned innerHTML, the onerror handler would set window.__xss. Failures point at unsafe DOM writes, not at a missing required-field message.

SUT: js-ui · samples/js-ui/public/app.js · run npm test in examples/security/xss-escape/playwright

Code under test · samples/js-ui/public/app.js
// Increment the on-page counter (used by the navigate smoke path as a live control)
const increment = document.getElementById("increment");
const countOut = document.getElementById("count");
if (increment && countOut) {
  increment.addEventListener("click", () => {
    countOut.textContent = String(Number(countOut.textContent ?? "0") + 1);
  });
}

// Client-side required-name check — the form-validation UX scenario asserts this message
const form = document.getElementById("signup-form");
const nameInput = document.getElementById("name");
const nameError = document.getElementById("name-error");
const success = document.getElementById("success");
const thanksName = document.getElementById("thanks-name");
if (form && nameInput && nameError && success && thanksName) {
  form.addEventListener("submit", (event) => {
    event.preventDefault();
    const empty = nameInput.value.trim() === "";
    nameError.hidden = !empty;
    success.hidden = empty;
    if (!empty) {
      // textContent, never innerHTML — markup in the name must not run
      thanksName.textContent = nameInput.value.trim();
    }
  });
}
Test · examples/security/xss-escape/playwright/xss.spec.js · Apache-2.0 · run in examples/security/xss-escape/playwright: npm test
// XSS oracle — a crafted name must render as text, never run
import { test, expect } from "@playwright/test";

const payload = `<img src=x onerror="window.__xss=1">`;

test("script-like name is shown as text and does not run", async ({ page }) => {
  await page.goto("./signup.html");
  await page.getByLabel("Name").fill(payload);
  await page.getByRole("button", { name: "Submit" }).click();
  // Visible copy is the literal payload (textContent)
  await expect(page.locator("#thanks-name")).toHaveText(payload);
  // If the page had used innerHTML, onerror would set this flag
  expect(await page.evaluate(() => window.__xss)).toBeUndefined();
  await expect(page.locator("#success img")).toHaveCount(0);
});