Root cause
Injection & unsafe execution introduced
The new renderer escaped angle brackets in report text but left quotation marks as written, and it placed link URLs inside an href attribute.
How AI contributed: Direct introductionPublished Aug 13, 2026 in
ccyl13/pentestify
Pentestify gained a markdown renderer for the report fields that users fill in, and the rendering path that previously escaped those values was replaced by one that does not escape quotes. The renderer protects ampersands, angle brackets and code spans, then converts text links by writing the URL into an href attribute; a quote inside that URL ends the attribute early and anything after it is treated as markup. Report content is stored and both the preview and the exported HTML are built from the converted markup with innerHTML, so a crafted link runs script for every user who opens the report. The fix encodes double and single quotes in the same step that handles the other reserved characters.
The new renderer escapes ampersands and angle brackets in the text that remains, but it leaves quotation marks untouched. When it converts a markdown link it writes the URL straight into an href attribute, so a URL containing a double quote closes the attribute and the rest of the URL is parsed as extra markup on the same tag. Report text is stored as written and the converted markup is inserted into the page with innerHTML, so the injected handler runs in the browser of anyone who opens the report preview or the exported HTML.
Root cause
The new renderer escaped angle brackets in report text but left quotation marks as written, and it placed link URLs inside an href attribute.
Fix
The escape step now also encodes double and single quotes, so a URL in a link can no longer close the href attribute and inject markup.
Case facts
@@ -500,0 +578,16 @@ const escapeHTML = (str) => {+ s = s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');++ // 4. Negrita+cursiva, negrita, cursiva+ s = s.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');+ s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');+ s = s.replace(/\*([^\s*][^*\n]*?[^\s*])\*/g, '<em>$1</em>');+ s = s.replace(/\*([^\s*\n])\*/g, '<em>$1</em>');++ // 5. Links [texto](url)+ s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,+ '<a href="$2" target="_blank" style="color:inherit;text-decoration:underline;opacity:0.85;">$1</a>');++ // 6. Restaurar código inline+ s = s.replace(/\x01IC(\d+)\x01/g, (_, i) =>+ `<code style="font-family:ui-monospace,SFMono-Regular,monospace;background:rgba(128,128,128,0.15);padding:0.1em 0.4em;border-radius:3px;font-size:0.875em;word-break:break-word;">${inlineCodes[+i]}</code>`);+@@ -588,8 +588,12 @@ function markdownToHtml(str) { return `\x01IC${inlineCodes.length - 1}\x01`; }); - // 3. Escapar HTML en el texto restante- s = s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');+ // 3. Escapar HTML en el texto restante. Se escapan también las comillas+ // (dobles y simples): la URL de los links [texto](url) del paso 5 se+ // interpola dentro de un atributo href="…", así que un `"` sin escapar+ // permitiría cerrar el atributo e inyectar event handlers (XSS almacenado).+ s = s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')+ .replace(/"/g,'"').replace(/'/g,'''); // 4. Negrita+cursiva, negrita, cursiva s = s.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');AI-assisted change d34635dc21aaefae29cef58cfd5903c8d08fff4a9e0f203d4618b2391f1ad0cf · Fix f0497d42da280a42e48fdaf24673dc93a8fbb206e15182c6e89cdce522263739