');
},
};
export const passthroughWithEmptyStream = {
async test() {
const { readable, writable } = new TransformStream();
const rewriter = new HTMLRewriter().on('h3', {
async text(content) {
await scheduler.wait(10);
},
});
const response = rewriter.transform(new Response(readable));
const writer = writable.getWriter();
const results = await Promise.all([response.text(), writer.close()]);
strictEqual(results[0], '');
},
};
export const asyncElementHandler = {
async test() {
const rewriter = new HTMLRewriter().on('body', {
async element(e) {
await scheduler.wait(10);
e.setInnerContent('world');
},
});
const response = rewriter.transform(new Response('hello'));
strictEqual(await response.text(), 'world');
},
};
export const asyncCommentHandler = {
async test() {
const rewriter = new HTMLRewriter().on('body', {
async comments(comment) {
await scheduler.wait(10);
if (comment.text == 'hello') {
comment.text = 'world';
}
},
});
const response = rewriter.transform(
new Response('')
);
strictEqual(await response.text(), '');
},
};
export const objectHandlers = {
async test() {
class DocumentContentHandlers {
deadTokens = {};
doctypeCount = 0;
commentCount = 0;
textCount = 0;
expectedErrors = [];
doctype(token) {
if (!this.deadTokens.doctype) {
this.deadTokens.doctype = token;
}
++this.doctypeCount;
this.sawDoctype = JSON.stringify(token);
}
comments(token) {
if (!this.deadTokens.comment) {
this.deadTokens.comment = token;
}
++this.commentCount;
}
text(token) {
if (!this.deadTokens.text) {
this.deadTokens.text = token;
}
++this.textCount;
}
end(token) {
this.reachedEnd = true;
}
}
class ElementContentHandlers {
deadTokens = {};
elementCount = 0;
commentCount = 0;
textCount = 0;
expectedErrors = [];
element(token) {
if (!this.deadTokens.element) {
this.deadTokens.element = token;
}
if (!this.deadTokens.attributesIterator) {
this.deadTokens.attributesIterator = token.attributes;
}
++this.elementCount;
// Exercise all the different methods on Element.
if (
token.tagName === 'body' &&
token.hasAttribute('foo') &&
!token.hasAttribute('baz') &&
token.getAttribute('foo') === 'bar'
) {
token.removeAttribute('foo');
token.setAttribute('baz', 'qux');
try {
token.tagName = 'should throw';
throw new Error('should have thrown');
} catch (e) {
this.expectedErrors.push(e.message);
}
token.tagName = 'tail';
// These will show up in order in the response body.
token.before('<1>');
token.before('<2>', { html: false });
token.before('<3>\n', null);
token.before('', { html: true });
// These will show up in reverse order in the response body.
token.prepend('hello, ', { html: true });
token.prepend('<6>\n');
token.prepend('<5>', { html: false });
token.prepend('\n<4>', null);
// Iterator tests.
this.sawAttributes = JSON.stringify([...token.attributes]);
let iterator = token.attributes;
let iteratorPrototype = Object.getPrototypeOf(
Object.getPrototypeOf(iterator)
);
if (iteratorPrototype !== arrayIteratorPrototype) {
throw new Error(
'attributes iterator does not have iterator prototype'
);
}
// Run the iterator down until it's done.
for (let [_k, _v] of iterator) {
// intentionally empty
}
// .next() should now be idempotent.
let result = iterator.next();
let result2 = iterator.next();
if (
result.done !== result2.done ||
result.value !== result2.value ||
!result.done ||
result.value
) {
throw new Error(
'exhausted iterator should continually return done'
);
}
} else if (token.tagName === 'remove') {
let mode = token.getAttribute('mode');
if (mode === null) {
throw new Error("missing attribute on 'remove' element");
}
if (token.removed) {
throw new Error('element should not have been removed yet');
}
if (mode === 'all') {
token.remove();
} else {
token.removeAndKeepContent();
}
if (!token.removed) {
throw new Error('element should have been removed now');
}
} else if (token.tagName === 'after') {
let isHtml = token.getAttribute('is-html');
let html = isHtml === 'true' ? true : false;
token.after('', { html });
} else if (token.tagName === 'append') {
let isHtml = token.getAttribute('is-html');
let html = isHtml === 'true' ? true : false;
token.append('', { html });
} else if (token.tagName === 'replace') {
let isHtml = token.getAttribute('is-html');
let html = isHtml === 'true' ? true : false;
token.replace('', { html });
} else if (token.tagName === 'set-inner-content') {
let isHtml = token.getAttribute('is-html');
let html = isHtml === 'true' ? true : false;
token.setInnerContent('', { html });
} else if (token.tagName === 'set-attribute') {
if (!token.hasAttribute('foo')) {
throw new Error('element should have had attribute');
}
let attr = token.getAttribute('foo');
if (attr !== '') {
throw new Error('element attribute should have been empty');
}
token.setAttribute('foo', 'bar');
if (token.getAttribute('nonexistent')) {
throw new Error('attribute should not exist');
}
}
}
comments(token) {
if (!this.deadTokens.comment) {
this.deadTokens.comment = token;
}
++this.commentCount;
// Exercise all the different methods on Comment.
if (token.text === ' SET TEXT PROPERTY ') {
token.text = ' text property has been set ';
} else if (token.text === ' REMOVE ME ') {
if (token.removed) {
throw new Error("Shouldn't be removed yet");
}
token.remove();
if (!token.removed) {
throw new Error('Should be removed now');
}
} else if (token.text === ' REPLACE ME ') {
if (token.removed) {
throw new Error("Shouldn't be removed yet");
}
token.replace('this will get overwritten');
if (!token.removed) {
throw new Error('Should be removed now');
}
token.replace('', null);
if (!token.removed) {
throw new Error('Should still be removed');
}
token.before('', { html: true });
}
}
text(token) {
if (!this.deadTokens.text) {
this.deadTokens.text = token;
}
++this.textCount;
if (token.lastInTextNode && token.text.length > 0) {
throw new Error('last text chunk has non-zero length');
} else if (!token.lastInTextNode && token.text.length === 0) {
throw new Error('non-last text chunk has zero length');
}
if (token.text === 'world') {
token.before('again, ');
token.after('...');
if (token.removed) {
throw new Error("Shouldn't be removed yet");
}
token.replace('this will get overwritten');
if (!token.removed) {
throw new Error('Should be removed now');
}
token.replace('', { html: true });
if (!token.removed) {
throw new Error('Should still be removed');
}
} else if (token.text === 'REMOVE ME\n') {
if (token.removed) {
throw new Error("Shouldn't be removed yet");
}
token.remove();
if (!token.removed) {
throw new Error('Should be removed now');
}
}
}
}
let documentHandlers = new DocumentContentHandlers();
let elementHandlers = new ElementContentHandlers();
const rewriter = new HTMLRewriter()
.onDocument(documentHandlers)
.on('*', elementHandlers);
let _count = 0;
const enc = new TextEncoder();
const kInput = [
'',
'',
'document-level text',
'world
'));
const { writable } = new TransformStream();
await rejects(errorResponse.body.pipeTo(writable), {
message: 'intentional error for pipeTo testing',
});
const successResponse = new HTMLRewriter()
.on('div', {
element(el) {
el.setInnerContent('success after pipeTo');
},
})
.transform(new Response('
original
'));
strictEqual(
await successResponse.text(),
'
success after pipeTo
'
);
},
};
export const sameToken = {
async test() {
const obj = {};
let element;
const r = new HTMLRewriter()
.on('*', {
element(e) {
element = e;
strictEqual(e.hi, undefined);
e.hi = 'test';
strictEqual(e.hi, 'test');
e.hi = 'hi';
e.obj = obj;
e.replace('foo');
},
})
.on('img', {
element(e) {
notStrictEqual(e, element);
notStrictEqual(e.hi, 'hi');
notStrictEqual(e.obj, obj);
// The HTMLRewriter creates a fresh new Element/Doctype/Text
// object for each handler, thus `e.hi` will yield undefined even if we
// assigned it in the first handler.
// See https://jira.cfdata.org/browse/EW-2200.
e.replace(e.hi);
},
})
.transform(new Response(''))
.text();
await r;
},
};
// Regression test for VULN-122672: HTMLRewriter AttributesIterator UAF.
// When element attributes are modified during iteration (adding new attributes
// that cause the underlying Vec to reallocate), the iterator's stale pointers
// would read from freed memory. The fix invalidates all live iterators when
// setAttribute() or removeAttribute() is called.
export const attributesIteratorInvalidatedOnSetAttribute = {
async test() {
const html = `
test
`;
const rewriter = new HTMLRewriter().on('div', {
element(el) {
const iter = el.attributes[Symbol.iterator]();
// Read first attribute - valid.
const first = iter.next();
strictEqual(first.done, false);
// Mutate attributes — this must invalidate the iterator.
el.setAttribute('newattr', 'value');
// Subsequent next() must throw, not read freed memory.
throws(() => iter.next(), {
message:
'The attributes of this element have been modified during iteration. ' +
'You must create a new iterator after modifying attributes.',
});
},
});
await rewriter.transform(new Response(html)).text();
},
};
export const attributesIteratorInvalidatedOnRemoveAttribute = {
async test() {
const html = `
test
`;
const rewriter = new HTMLRewriter().on('div', {
element(el) {
const iter = el.attributes[Symbol.iterator]();
iter.next(); // consume first
// removeAttribute also must invalidate the iterator.
el.removeAttribute('b');
throws(() => iter.next(), {
message:
'The attributes of this element have been modified during iteration. ' +
'You must create a new iterator after modifying attributes.',
});
},
});
await rewriter.transform(new Response(html)).text();
},
};
// After mutation, creating a fresh iterator must work normally.
export const attributesIteratorFreshAfterMutation = {
async test() {
const html = `
test
`;
let attrs = [];
const rewriter = new HTMLRewriter().on('div', {
element(el) {
el.setAttribute('b', '2');
// A new iterator created after mutation should work fine.
for (const [name, value] of el.attributes) {
attrs.push([name, value]);
}
},
});
await rewriter.transform(new Response(html)).text();
// Should see both original and newly-set attribute.
strictEqual(attrs.length, 2);
deepStrictEqual(attrs[0], ['a', '1']);
deepStrictEqual(attrs[1], ['b', '2']);
},
};
// Iterating without mutation must still work as before.
export const attributesIteratorNormalIteration = {
async test() {
const html = `
test
`;
let attrs = [];
const rewriter = new HTMLRewriter().on('div', {
element(el) {
for (const [name, value] of el.attributes) {
attrs.push([name, value]);
}
},
});
await rewriter.transform(new Response(html)).text();
deepStrictEqual(attrs, [
['x', '1'],
['y', '2'],
['z', '3'],
]);
},
};
export const svgNamespace = {
async test() {
const response = new Response(`
`);
let namespace;
await new HTMLRewriter()
.on('a', {
element(e) {
namespace = e.namespaceURI;
},
})
.transform(response)
.text();
strictEqual(namespace, 'http://www.w3.org/2000/svg');
},
};
export const handlerPerformingManySmallWrites = {
async test() {
const promiseDepth = 128 * 100;
const numReads = 2;
const input = new Response('');
const output = new HTMLRewriter()
.onDocument({
end(e) {
for (let i = 0; i < promiseDepth; i++) {
e.append('', { html: true });
}
},
})
.transform(input);
// Begin reading but do not fully consume
const reader = output.body.getReader();
for (let i = 0; i < numReads; i++) {
await reader.read();
}
},
};
export const hugeNumberOfHandlersForAnElement = {
// Many small handlers
async test() {
const promiseDepth = 128 * 100;
const numReads = promiseDepth;
const input = new Response(''.repeat(promiseDepth));
const output = new HTMLRewriter()
.on('div', {
element(e) {
e.append('', { html: true });
},
})
.transform(input);
// Begin reading but do not fully consume
const reader = output.body.getReader();
for (let i = 0; i < numReads; i++) {
await reader.read();
}
},
};
// Test HTMLRewriter with JS-backed ReadableStream
// This test was moved from streams-respond-test.js because it triggers
// a flaky ASAN failure related to V8's cppgc memory validation.
export const htmlRewriterStream = {
async test() {
const enc = new TextEncoder();
const readable = new ReadableStream({
pull(controller) {
controller.enqueue(enc.encode('Hello World!'));
controller.close();
},
});
let response = new Response(readable, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
});
response = new HTMLRewriter().transform(response);
strictEqual(await response.text(), 'Hello World!');
},
};