`TextElement.toString()` puts the element text back at the first `><` of the start tag, so an attribute value containing `><` swallows the text of `<style>`, `<script>`, `<title>` and `<textarea>`
Los mantenedores suelen responder en 17 días
Evaluación
- Dificultad
- 2/5
- Tiempo estimado
- 1-3 horas
- Aptitud para principiantes
- 78/100
- Tipo de issue
- Error
- Claridad
- Bien especificado
- Estado de actividad
- Activo
- Stack tecnológico
- javascript, node.js
- Área
- backend
Línea de trabajo
Lee esm/html/text-element.js, especialmente el método toString() descrito en el issue. Sigue cómo se serializa su clon sin hijos y luego usa la reproducción proporcionada para style, script, title y textarea para confirmar el fallo y comprobar que el texto de cada elemento y el valor de data-src sobreviven a la serialización y al análisis.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Environment
- linkedom 0.18.13 (latest on npm, published 2026-07-07).
esm/html/text-element.jsis byte-identical onmainat 3fcf145c. - Node 22.18.0, ESM.
- Reproduction: the full script is in the collapsed section at the end (the inline version is below).
What happens
TextElement (the base of HTMLStyleElement, HTMLScriptElement, HTMLTitleElement and HTMLTextAreaElement) serializes by cloning itself without children and splicing its text between the start and end tag, esm/html/text-element.js line 12:
return outerHTML.replace('><', () => `>${this.textContent}<`);
String.prototype.replace with a string pattern replaces only the first match. The childless clone serializes as <tag attrs></tag>, so the intended match is the >< between the two tags, but a >< inside an attribute value is found first. The text is then inserted inside the attribute value, and the element body is empty.
Minimal reproduction
import { parseHTML } from 'linkedom';
const { document } = parseHTML('<!doctype html><html><head></head><body></body></html>');
const style = document.createElement('style');
style.setAttribute('data-src', 'a><b.css');
style.textContent = '.caption{color:red}';
console.log(style.outerHTML);
// actual: <style data-src="a>.caption{color:red}<b.css"></style>
// expected: <style data-src="a><b.css">.caption{color:red}</style>
(A serializer that also escapes < and > in attribute values, as the current HTML Standard asks, would write a><b.css; the point here is only where the text goes.)
Parsing the actual output gives an empty <style> and the attribute value a>.caption{color:red}<b.css: the stylesheet is gone and the attribute is corrupted, with no error. The attached script shows the same result for style, script, title and textarea. jsdom produces the expected output.
Expected behavior
The element text is always the content of the element, whatever the attribute values contain.
Suggested change
The childless clone serializes as <tag attrs></tag>; the boundary between the two tags is always the last ><, because everything after it is the end tag:
toString() {
const outerHTML = toString.call(this.cloneNode());
const at = outerHTML.lastIndexOf('><');
return `${outerHTML.slice(0, at + 1)}${this.textContent}${outerHTML.slice(at + 1)}`;
}
This keeps the callback-style replacement from #293 (no special replacement patterns in the text). the verification script in the collapsed section at the end applies it in-process: all four elements keep their text and attribute, and attribute-free and empty elements serialize as before.
Related, not part of this report
<title> and <textarea> text goes through the same method unescaped, while the parser decodes <title> content, so a <title> with &amp; loses a level per cycle. #304 and its discussion cover the title question; I am not asking for a decision on it here.
Why it matters
The failure needs no hostile input. A file name such as a><b.css stored in a data-* attribute of an inlined <style> made our generated document ship without its stylesheet, and nothing reported it.
repro-text-element-first-match.mjs (run with npm install [email protected] && node repro-text-element-first-match.mjs)
// Minimal reproduction 2 of 2: TextElement.toString() re-inserts the element text at the
// FIRST "><" of the serialized start tag, which can be inside an attribute value.
//
// npm install [email protected] && node repro-text-element-first-match.mjs
//
// Exit code 1 = the defect reproduces; 0 = the element text stays between its tags.
//
// Cause (esm/html/text-element.js): the text is put back with
// toString.call(this.cloneNode()).replace('><', () => `>${this.textContent}<`)
// String.prototype.replace with a string pattern replaces only the first match. The clone has
// no children, so the only "><" that is guaranteed to be the end of the start tag is the one
// before the closing tag; any "><" inside an attribute value comes first.
// HTMLStyleElement, HTMLScriptElement, HTMLTitleElement and HTMLTextAreaElement all extend
// TextElement.
import { createRequire } from 'node:module';
import { parseHTML } from 'linkedom';
const { version } = createRequire(import.meta.url)('linkedom/package.json');
console.log(`linkedom ${version}`);
const SHELL = '<!doctype html><html><head></head><body></body></html>';
const TEXT = '.caption{color:red}';
const ATTRIBUTE_VALUE = 'a><b.css';
let failed = 0;
for (const tag of ['style', 'script', 'title', 'textarea']) {
const { document } = parseHTML(SHELL);
const el = document.createElement(tag);
el.setAttribute('data-src', ATTRIBUTE_VALUE);
el.textContent = TEXT;
const html = el.outerHTML;
const reparsed = parseHTML(`<!doctype html><html><body>${html}</body></html>`);
const back = reparsed.document.querySelector(tag);
const textBack = back?.textContent;
const attributeBack = back?.getAttribute('data-src');
const ok = textBack === TEXT && attributeBack === ATTRIBUTE_VALUE;
if (!ok) failed += 1;
console.log(
`${ok ? 'ok ' : 'FAIL'} <${tag} data-src=${JSON.stringify(ATTRIBUTE_VALUE)}> with text ${JSON.stringify(TEXT)}\n` +
` outerHTML: ${html}\n` +
` text after parse: ${JSON.stringify(textBack)} (expected ${JSON.stringify(TEXT)})\n` +
` attr after parse: ${JSON.stringify(attributeBack)} (expected ${JSON.stringify(ATTRIBUTE_VALUE)})`,
);
}
console.log(failed === 0 ? 'RESULT: element text stays inside the element' : `RESULT: ${failed} of 4 elements corrupted, defect reproduces`);
process.exit(failed === 0 ? 0 : 1);
verify-suggested-fixes.mjs (applies the suggested changes in-process)
// Verifies the two patches proposed in the two reports (Attr.toString and TextElement.toString).
// It patches linkedom in-process only, to show that each proposal removes the failure
// reproduced by repro-attr-html-escape.mjs and repro-text-element-first-match.mjs.
//
// npm install [email protected] && node verify-suggested-fixes.mjs
import { createRequire } from 'node:module';
import { parseHTML } from 'linkedom';
const { version } = createRequire(import.meta.url)('linkedom/package.json');
console.log(`linkedom ${version}`);
const { document } = parseHTML('<!doctype html><html><head></head><body></body></html>');
const HTMLStyleElementProto = Object.getPrototypeOf(document.createElement('style'));
const TextElementProto = Object.getPrototypeOf(HTMLStyleElementProto); // esm/html/text-element.js
const HTMLElementProto = Object.getPrototypeOf(TextElementProto);
const AttrProto = Object.getPrototypeOf(document.createAttribute('x')); // esm/interface/attr.js
// Proposal 2: put the text back at the LAST "><" of the childless clone's serialization, which
// is always the boundary between the start tag and the end tag.
const elementToString = HTMLElementProto.toString;
TextElementProto.toString = function toString() {
const outerHTML = elementToString.call(this.cloneNode());
const at = outerHTML.lastIndexOf('><');
return `${outerHTML.slice(0, at + 1)}${this.textContent}${outerHTML.slice(at + 1)}`;
};
// Proposal 1: escape `&` and U+00A0 (and keep `"`) in HTML-document attribute values.
// Empty values keep linkedom's own boolean-attribute shorthand.
const attrToString = AttrProto.toString;
AttrProto.toString = function toString() {
const { value } = this;
if (!value) return attrToString.call(this);
const escaped = value.replace(/&/g, '&').replace(/\u00a0/g, ' ').replace(/"/g, '"');
return `${this.name}="${escaped}"`;
};
let failed = 0;
const check = (label, ok) => {
if (!ok) failed += 1;
console.log(`${ok ? 'ok ' : 'FAIL'} ${label}`);
};
for (const value of ['A & B', '� ', 'a & b', 'a b']) {
const doc = parseHTML('<!doctype html><html><body><div id="t"></div></body></html>').document;
const el = doc.getElementById('t');
el.setAttribute('data-x', value);
const back = parseHTML(`<!doctype html><html><body>${el.outerHTML}</body></html>`).document
.getElementById('t')
.getAttribute('data-x');
check(`attribute value ${JSON.stringify(value)} survives serialize + parse`, back === value);
}
for (const tag of ['style', 'script', 'title', 'textarea']) {
const doc = parseHTML('<!doctype html><html><head></head><body></body></html>').document;
const el = doc.createElement(tag);
el.setAttribute('data-src', 'a><b.css');
el.textContent = '.caption{color:red}';
const back = parseHTML(`<!doctype html><html><body>${el.outerHTML}</body></html>`).document.querySelector(tag);
check(
`<${tag}> keeps its text and its attribute value`,
back.textContent === '.caption{color:red}' && back.getAttribute('data-src') === 'a><b.css',
);
}
// Elements without attributes, with `$` patterns in the text, and with empty text still serialize as before.
const plain = parseHTML('<!doctype html><html><head></head><body></body></html>').document.createElement('style');
plain.textContent = "a{content:'$&'}";
check('childless attribute-free <style> serializes as <style>text</style>', plain.outerHTML === "<style>a{content:'$&'}</style>");
const empty = parseHTML('<!doctype html><html><head></head><body></body></html>').document.createElement('script');
check('empty <script> serializes as <script></script>', empty.outerHTML === '<script></script>');
console.log(failed === 0 ? 'RESULT: both proposals fix the reproductions' : `RESULT: ${failed} check(s) failed`);
process.exit(failed === 0 ? 0 : 1);
- Lenguaje dominante
- HTML
- Estrellas
- 2.1k
- Forks
- 110
- Merge medio
- 11 d 1 h
- PR fusionados (30 d)
- 1
Preparar el entorno
Este proyecto no incluye contenedor de desarrollo, Dockerfile ni guía de contribución, así que la configuración corre por tu cuenta: empieza por su README y consulta nuestra guía para la primera contribución para los pasos generales.
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de WebReflection/linkedom
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 85/100
WebReflection/linkedom#344 ·
Los mantenedores suelen responder en 17 días
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
WebReflection/linkedom#340 ·
Los mantenedores suelen responder en 17 días
-
Element#matches() compiles the selector on every callPosiblemente ocupada @jonasporto la tomó hoy. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 35/100
WebReflection/linkedom#348 ·
Los mantenedores suelen responder en 17 días
-
Dificultad 3/5 Medio día Aptitud para principiantes 50/100
WebReflection/linkedom#345 ·
Los mantenedores suelen responder en 17 días
-
Dificultad 3/5 1-2 días Aptitud para principiantes 68/100
WebReflection/linkedom#342 ·
Los mantenedores suelen responder en 17 días
Todos los issues de WebReflection/linkedom
Issues similares
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
healthchecks/healthchecks#1349 ·
-
native-cpu: -Wreturn-type in skainet_row_threads.c:150 (worker fn has no return)Posiblemente ocupada @Doomguy-BFG9000 la tomó hoy. Abiertogood first issue size:xs skill:native
Dificultad 1/5 Menos de una hora Aptitud para principiantes 78/100
SKaiNET-developers/SKaiNET#1358 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
bug v1 v2
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
modelcontextprotocol/python-sdk#3725 ·
Los mantenedores suelen responder en 1 día
-
batchterms() drops queries without a similar() clause, misaligning results with inputsPosiblemente ocupada @MohammadHijjawi97 la tomó hoy. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 86/100
Los mantenedores suelen responder en 1 día
-
[BUG]: filesystem-edit-file converts a CRLF file's line endings to LF on every editPosiblemente ocupada @MohammadHijjawi97 la tomó hoy. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
Mintplex-Labs/anything-llm#6755 ·
Los mantenedores suelen responder en 1 día