`Attr.toString()` does not escape `&` or U+00A0 in attribute values of HTML documents, so a value loses one level of character references per serialize/parse cycle
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 2/5
- Thời gian dự kiến
- 1-3 giờ
- Mức phù hợp với người mới
- 82/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- javascript, node.js
- Lĩnh vực
- backend
Hướng nghiên cứu
Read esm/interface/attr.js and esm/shared/text-escaper.js, focusing on Attr.toString() and the HTML document branch that skips escaping. Start by running the provided repro-attr-html-escape.mjs script against linkedom 0.18.13. Done means HTML attribute values escape & and U+00A0 as described and survive serialize/parse cycles unchanged, while empty attribute behavior remains intact.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Environment
- linkedom 0.18.13 (latest on npm, published 2026-07-07). The two files involved,
esm/interface/attr.jsandesm/shared/text-escaper.js, are 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
In an HTML document (text/html), Attr.toString() writes the attribute value back with only " replaced. & and U+00A0 reach the output unescaped, so everything that parses the serialized markup decodes the value once more. The value held in the DOM and the value read back from the serialized markup differ.
esm/interface/attr.js, lines 48-49:
const escapedValue = (ignoreCase(this) ? value : escape(value)).replace(QUOTE, '"');
return `${name}="${escapedValue}"`;
ignoreCase(this) is true for every HTML document, so escape(value) is skipped for exactly the documents where the HTML serialization algorithm applies. The XML branch was fixed in #261 and #269; the HTML branch still skips escaping.
Minimal reproduction
import { parseHTML } from 'linkedom';
const { document } = parseHTML('<!doctype html><html><body><div id="t"></div></body></html>');
const el = document.getElementById('t');
el.setAttribute('data-x', 'A & B'); // the value is the 9 characters A & B
console.log(el.outerHTML);
// actual: <div id="t" data-x="A & B"></div>
// expected: <div id="t" data-x="A &amp; B"></div>
const back = parseHTML(`<!doctype html><html><body>${el.outerHTML}</body></html>`)
.document.getElementById('t').getAttribute('data-x');
console.log(back);
// actual: A & B (one level of decoding lost)
// expected: A & B
Other values, same shape (all produced by the attached script):
setAttribute('data-x', …) |
serialized by linkedom 0.18.13 | expected (WHATWG HTML 13.3.2, attribute mode) | read back after parsing the serialized markup |
|---|---|---|---|
A & B |
A & B |
A &amp; B |
A & B |
� |
� |
&#0 |
U+FFFD |
<b> |
<b> |
&lt;b&gt; |
<b> |
a & b |
a & b |
a & b |
a & b (lossless, still not conforming) |
a + U+00A0 + b |
raw U+00A0 | a b |
lossless, still not conforming |
Run in a loop, a value that starts as A &amp;amp; B in markup reads A &amp; B, then A & B, then A & B after three parse and serialize cycles. A conforming serializer keeps it stable. jsdom (parse5) produces the "expected" column.
Expected behavior
The HTML Standard, section "Serializing HTML fragments", escaping algorithm in attribute mode: replace & with &, U+00A0 with and " with " (the current standard also escapes < and > in attribute values; jsdom's parse5 7.3.0 does not yet, and this report does not depend on it). attr.value, getAttribute() and the parsed result stay unchanged; only the serialized form changes.
Suggested change
Escape & and U+00A0 in the HTML branch of Attr.toString(), keeping the existing boolean-attribute shorthand for empty values. A patch applied in-process is checked by the verification script in the collapsed section at the end: every case above round-trips, and attribute-free and empty elements serialize as before.
Related
- #304 (open) proposes a broader escaping change, which includes this one. It was asked to be split into smaller changes, and the HTML attribute fix was the part described as worth having. This issue is that part alone.
- #216 and #217 cover
<and>in XML attributes; this report is about HTML documents only.
Why it matters
Any pipeline that serializes with linkedom and parses the result again (templating, SSR followed by hydration, build steps that re-read their own output) rewrites attribute values containing character references, and the loss is silent. Our case: a document is serialized and re-parsed three times while a hand-written composition is turned into a self-contained file, and a data-* attribute holding a JSON string with & came back with a different value.
repro-attr-html-escape.mjs (run with npm install [email protected] && node repro-attr-html-escape.mjs)
// Minimal reproduction 1 of 2: Attr.toString() does not escape `&` (and U+00A0) in attribute
// values when the document is an HTML document (text/html), so a serialized attribute value
// is decoded one more time by every parse that follows.
//
// npm install [email protected] && node repro-attr-html-escape.mjs
//
// Exit code 1 = the defect reproduces (an attribute value does not survive serialize + parse);
// 0 = every attribute value round-trips.
//
// Expected serialization follows the WHATWG HTML Standard, section 13.3.2 "Serializing HTML
// fragments", escaping algorithm in attribute mode: `&` -> `&`, U+00A0 -> ` `,
// `"` -> `"`. jsdom (parse5) produces exactly these strings.
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><div id="t"></div></body></html>';
// [value held by the DOM, serialization required by the standard, does a lenient parser still
// read the value back unchanged from the unescaped form?]
const cases = [
['A & B', 'A &amp; B'],
['� ', '&#0 '],
['<b>', '&lt;b&gt;'],
['a & b', 'a & b'],
['a b', 'a b'],
['say "hi"', 'say "hi"'],
];
let dataLoss = 0;
let nonConforming = 0;
for (const [value, expected] of cases) {
const { document } = parseHTML(SHELL);
const el = document.getElementById('t');
el.setAttribute('data-x', value);
const html = el.outerHTML;
const actual = /data-x="([^"]*)"/.exec(html)[1];
// Round trip: parse the serialized element and read the attribute back.
const back = parseHTML(`<!doctype html><html><body>${html}</body></html>`).document
.getElementById('t')
.getAttribute('data-x');
const conforming = actual === expected;
const lossless = back === value;
if (!conforming) nonConforming += 1;
if (!lossless) dataLoss += 1;
console.log(
`${lossless ? (conforming ? 'ok ' : 'NONCONF ') : 'DATA LOSS'} setAttribute('data-x', ${JSON.stringify(value)})\n` +
` serialized: ${JSON.stringify(actual)} standard: ${JSON.stringify(expected)}\n` +
` re-parsed: ${JSON.stringify(back)} expected: ${JSON.stringify(value)}`,
);
}
// The same defect seen from the parser side: each parse decodes one level of character
// references, each serialization writes the decoded value back without encoding it again.
let current = '<div id="t" data-x="A &amp;amp; B"></div>';
const seen = [];
for (let i = 0; i < 3; i += 1) {
const { document } = parseHTML(`<!doctype html><html><body>${current}</body></html>`);
const el = document.getElementById('t');
seen.push(el.getAttribute('data-x'));
current = el.outerHTML;
}
console.log(`\nattribute value after each of 3 parse+serialize cycles: ${JSON.stringify(seen)}`);
console.log('a conforming serializer keeps it stable: ["A &amp; B","A &amp; B","A &amp; B"]');
if (new Set(seen).size !== 1) dataLoss += 1;
console.log(
`\nRESULT: ${dataLoss} data-loss check(s), ${nonConforming} non-conforming serialization(s) ` +
`-> ${dataLoss === 0 ? 'attribute values round-trip' : 'defect reproduces'}`,
);
process.exit(dataLoss === 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);
- Ngôn ngữ chính
- HTML
- Star
- 2.1k
- Fork
- 110
- Merge trung bình
- 11 ngày 1 giờ
- Pull request đã merge (30 ngày)
- 1
Chuẩn bị môi trường
Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của WebReflection/linkedom
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
WebReflection/linkedom#341 ·
-
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 68/100
WebReflection/linkedom#342 ·
-
v0.18.13 - Empty non-void HTML elements inside <foreignObject> are serialized as XML self-closing (<div/>)Có thể đã có người làm @WebReflection đã nhận 93 ngày trước. Đang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 68/100
WebReflection/linkedom#329 · 5 bình luận ·
-
`el.style.property = ''` and `e.style.setProperty('property', '')` should delete the propertyĐang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 52/100
WebReflection/linkedom#327 · 1 bình luận ·
-
Self closed tag circle not correctly nestedCó thể đã có người làm @jonasporto đã nhận hôm nay. Đang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 48/100
WebReflection/linkedom#326 · 5 bình luận ·
Tất cả issue của WebReflection/linkedom
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
numberly/vault-db-injector#107 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
interledger/publisher-tools#931 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
area/import kind/bug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
Maintainer thường phản hồi trong vòng 1 ngày
-
refactor: replace numbered import aliases in platform/common, platform/fabricx, integration and ciĐang mởcleanup go hacktoberfest priority: low skill: good first issue status: ready for dev
Độ khó 2/5 Nửa ngày Mức phù hợp với người mới 72/100
hyperledger-labs/fabric-smart-client#2134 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
github-stale-ci-pr-closer: KV state is double-wrapped, never persists, and hits the depth-32 limitCó thể đã có người làm @VascoSch92 đã nhận hôm nay. Đang mởpriority:medium ready-for-dev
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
OpenHands/extensions#781 ·
Maintainer thường phản hồi trong vòng 1 ngày