Hint 1: Choose the sink for the job
Text belongs in textContent (or createTextNode). innerHTML runs the HTML parser on the value, which is what turns an answer into an img element. escapeText is for serialized HTML that something else parses later.
Hint 1 unlocks after your first failed run.
Hint 2: Parse, then compare whole values
A prefix or substring check accepts docs.example.test.attacker.invalid, and a javascript: blocklist misses java\tscript:, which the URL parser reads as javascript:. Parse first and compare url.protocol and url.hostname exactly.
Hint 2 unlocks after 2 failed runs.
Reveal the reference patch and explanation
export function escapeText(value) {
const entities = {'&':'&', '<':'<', '>':'>', '"':'"', "'":'''};
return String(value).replace(/[&<>"']/g, character => entities[character]);
}
export function renderAnswer(answer, sourceUrl) {
const card = document.createElement('article');
const text = document.createElement('p');
text.textContent = answer;
card.append(text);
const href = allowedSource(sourceUrl);
if (href) {
const link = document.createElement('a');
link.href = href;
link.textContent = 'Source';
card.append(link);
}
return card;
}
// HTTPS, exactly docs.example.test, no credentials, no explicit port: the guide's link rule.
function allowedSource(value) {
const url = URL.parse(String(value));
if (!url || url.protocol !== 'https:' || url.hostname !== 'docs.example.test'
|| url.username || url.password || url.port) return null;
return url.href;
}escapeText turns <img src=x onerror="steal()"> into entity-escaped source, so parsing it in a text position creates visible text, and turns an existing <admin> into &lt;admin&gt; so the entity spelling stays literal. renderAnswer puts the answer in a text node, so the same hostile answer shows as text and the DOM holds no img element; a closing </p> in an answer stays text too. For the source, URL.parse applies the browser’s own rules: it strips leading and trailing spaces and control characters and removes every tab and newline, so java\tscript: and " javascript:" both parse as javascript: and fail the https: check; docs.example.test.attacker.invalid fails the exact host; :8443 fails the port; credentials fail. A quote in an allowed URL is percent-encoded by the parser and, set through the href property, stays inside one attribute. The documentation link renders with its parsed href.
Compare it with your own fix, then rerun the tests. The editor is not changed when you open this explanation.
The reference patch unlocks after you run code you have edited.
Inspect the actual test harness
The browser and the lab download run the same cases, including cases that catch common wrong fixes, such as comparing with one fixture value instead of the real rule. The download includes the complete test adapter and cryptographic fixtures.
Download cases.mjsHow the browser sandbox runs your code
Your code runs in QuickJS 2025-09-13 (quickjs-emscripten 0.32.0) inside a Web Worker, isolated from the page, with a 32 MiB memory limit and a 2.5-second time limit. The test cases and the pass or fail verdict run outside that sandbox, so changing built-ins such as Array.prototype.push cannot change the result. The lab download runs the same cases under Node.js, where your code runs directly.
The same as in current Node.js:
- ES2025 syntax and built-ins, including Set methods such as intersection, iterator helpers, Promise.try, RegExp.escape and the RegExp v flag
Added to the sandbox so that it behaves like Node.js:
- structuredClone, Array.fromAsync, queueMicrotask, atob and btoa
- URL and URLSearchParams, parsed by your browser’s own URL implementation
- TextEncoder and TextDecoder for UTF-8
- Intl.Collator and a locale-aware String.prototype.localeCompare
- console.log and related methods, whose output appears with your results
Not available in the browser sandbox:
- Timers (setTimeout, setInterval), fetch and other network or storage APIs; the exercises are deterministic and offline
- Node.js modules and globals: import, require, process and Buffer
- Web Crypto (crypto.getRandomValues, crypto.subtle); each workshop supplies the cryptographic operations it needs
- Intl other than Collator: toLocaleString and toLocaleDateString ignore the locale, so numbers have no digit grouping and dates use one fixed format in the device’s time zone
- using declarations, Symbol.dispose and DisposableStack (explicit resource management)
- Uint8Array.fromBase64 and toBase64, JSON.rawJSON, Atomics and Error.captureStackTrace
The cases replay your DOM writes in a real DOM and inspect the structure; they do not open the page or click the link. Prefer the platform’s text interfaces and your framework’s normal escaping; rich HTML needs a maintained sanitizer (see the HTML sanitizer lab). This encoder is not for JavaScript, CSS or URL contexts, and it is not a SQL parameter binder, shell argument validator or authorization control. A Content Security Policy is a second layer, not a substitute for choosing the right sink.
Your next task: Which other destinations can generated output reach after rendering, such as exports, links, tools or queries?
Related guide: Keep generated output out of executable contexts