fix(browser): stop malformed localhost pages from freezing the new tab page probe (#34427)

The title and icon-link regexes rescanned the rest of the body from every
unclosed opener, so a 192 KB page of repeated '<link ' or '<title>' blocked
the main or runtime event loop for seconds. Scan tags forward only.
This commit is contained in:
Kelvin Amoaba
2026-10-27 13:50:25 +00:00
committed by GitHub
parent 14c04fe143
commit f61e7f8bfe
2 changed files with 43 additions and 3 deletions
@@ -59,6 +59,22 @@ describe('probeWorkspacePortPage', () => {
).resolves.toMatchObject({ kind: 'page', title: 'Sign in', faviconDataUrl: null })
})
it.each([
['unclosed links', `<title>ok</title>${'<link '.repeat(32_000)}`, 'ok'],
['unclosed titles', '<title>'.repeat(27_000), null]
])('parses a page of %s without stalling the event loop', async (_name, page, title) => {
const port = await serve((_request, response) => {
response.writeHead(200, { 'content-type': 'text/html' }).end(page)
})
const startedAt = performance.now()
await expect(
probeWorkspacePortPage({ host: 'localhost', port, protocol: 'http' })
).resolves.toEqual({ kind: 'page', title, faviconDataUrl: null })
// Rescanning the body from each unclosed opener took seconds on this ~190 KB page.
expect(performance.now() - startedAt).toBeLessThan(500)
})
it('leaves out APIs and error pages', async () => {
const api = await serve((_request, response) => {
response.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}')
+27 -3
View File
@@ -110,9 +110,26 @@ function fetchLoopback(url: URL, maxBytes: number): Promise<ProbeResponse | null
})
}
// Tag scans only move forward: rescanning from every unclosed opener is quadratic on
// a malformed page, and this parse blocks the main or runtime event loop.
function extractHtmlTitle(html: string): string | null {
const match = /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(html)
const title = match ? decodeHtmlEntities(match[1]).replace(/\s+/g, ' ').trim() : ''
const open = /<title\b/gi
if (!open.test(html)) {
return null
}
const tagEnd = html.indexOf('>', open.lastIndex)
if (tagEnd === -1) {
return null
}
const close = /<\/title>/gi
close.lastIndex = tagEnd + 1
const end = close.exec(html)
if (!end) {
return null
}
const title = decodeHtmlEntities(html.slice(tagEnd + 1, end.index))
.replace(/\s+/g, ' ')
.trim()
return title ? title.slice(0, 120) : null
}
@@ -133,7 +150,14 @@ function decodeHtmlEntities(text: string): string {
}
function findIconHref(html: string): string | null {
for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
const open = /<link\b/gi
while (open.test(html)) {
const end = html.indexOf('>', open.lastIndex)
if (end === -1) {
return null
}
const tag = html.slice(open.lastIndex, end)
open.lastIndex = end + 1
const attributes = new Map<string, string>()
for (const [, name, double, single, bare] of tag.matchAll(
/\b(rel|href)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi