Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

**Bugfixes:**

- Fixed an issue where the Cypress script was injected into a commented-out `<head>`, `<body>`, or `<html>` tag inside an HTML comment, corrupting the served page. Commented-out tags are no longer considered injection points. Fixes [#33000](https://github.com/cypress-io/cypress/issues/33000).
- Fixed an issue where, on Windows, enhancing a test failure stack could throw a secondary `TypeError: Cannot read properties of undefined (reading 'replaceAll')` and mask the original error. Fixed in [#34252](https://github.com/cypress-io/cypress/pull/34252).
- Fixed an issue where [`experimentalMemoryManagement`](https://on.cypress.io/experiments) could fail to prevent the browser from running out of memory and crashing when Cypress was running inside a memory-limited container. Memory is now managed correctly in these environments. Fixes [#34104](https://github.com/cypress-io/cypress/issues/34104). Addressed in [#34123](https://github.com/cypress-io/cypress/pull/34123).

Expand Down
88 changes: 82 additions & 6 deletions packages/proxy/lib/http/util/rewriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,75 @@ function getHtmlToInject (opts: InjectionOpts & SecurityOpts) {
}
}

// replaces HTML comments with same-length whitespace so the injection point
// regexes can't match tags inside comments, while every index stays aligned
// with the original html. a small scanner tracks tag and quoted-attribute
// state so `<!--` inside an attribute value is not mistaken for a comment,
// comments may close with `-->`, `--!>` or abruptly (`<!-->`, `<!--->`), and
// an unterminated comment blanks the rest of the document
const maskHtmlComments = (html: string) => {
const masked: string[] = []
let i = 0
let inTag = false
let quote = ''

while (i < html.length) {
const ch = html[i]

if (inTag) {
if (quote) {
if (ch === quote) {
quote = ''
}
} else if (ch === '"' || ch === '\'') {
quote = ch
} else if (ch === '>') {
inTag = false
}

masked.push(ch)
i++

continue
}

if (html.startsWith('<!--', i)) {
let end

if (html.startsWith('>', i + 4)) {
end = i + 5
} else if (html.startsWith('->', i + 4)) {
end = i + 6
} else {
const normalClose = html.indexOf('-->', i + 4)
const bangClose = html.indexOf('--!>', i + 4)

if (normalClose !== -1 && (bangClose === -1 || normalClose < bangClose)) {
end = normalClose + 3
} else if (bangClose !== -1) {
end = bangClose + 4
} else {
end = html.length
}
}

masked.push(' '.repeat(end - i))
i = end

continue
}

if (ch === '<' && /[a-zA-Z!/?]/.test(html[i + 1] || '')) {
inTag = true
}

masked.push(ch)
i++
}

return masked.join('')
}

const insertBefore = (originalString, match, stringToInsert) => {
const index = match.index || 0

Expand All @@ -93,7 +162,11 @@ export async function html (html: string, opts: SecurityOpts & InjectionOpts) {
return html
}

const bootstrapMatch = html.match(bootstrapScriptRe)
// search a comment-masked copy so a commented-out tag can't become the
// injection point, then splice into the original html by index
const searchableHtml = maskHtmlComments(html)

const bootstrapMatch = searchableHtml.match(bootstrapScriptRe)

if (bootstrapMatch) {
const contentToInject = htmlToInject.replace(/^<script[^>]*>|<\/script>$/g, '')
Expand All @@ -104,31 +177,34 @@ export async function html (html: string, opts: SecurityOpts & InjectionOpts) {
openTag = openTag.replace(/>$/, ` nonce="${opts.cspNonce}">`)
}

return html.replace(bootstrapScriptRe, `${openTag}${contentToInject}${bootstrapMatch[3]}`)
const start = bootstrapMatch.index || 0
const end = start + bootstrapMatch[0].length

return `${html.slice(0, start)}${openTag}${contentToInject}${bootstrapMatch[3]}${html.slice(end)}`
}

// TODO: move this into regex-rewriting and have ast-rewriting handle this in its own way

const headMatch = html.match(headRe)
const headMatch = searchableHtml.match(headRe)

if (headMatch) {
return insertAfter(html, headMatch, htmlToInject)
}

const bodyMatch = html.match(bodyRe)
const bodyMatch = searchableHtml.match(bodyRe)

if (bodyMatch) {
return insertBefore(html, bodyMatch, `<head> ${htmlToInject} </head>`)
}

const htmlMatch = html.match(htmlRe)
const htmlMatch = searchableHtml.match(htmlRe)

if (htmlMatch) {
return insertAfter(html, htmlMatch, `<head> ${htmlToInject} </head>`)
}

// if only <!DOCTYPE> content, inject <head> after doctype
if (doctypeRe.test(html)) {
if (doctypeRe.test(searchableHtml)) {
return `${html}<head> ${htmlToInject} </head>`
}

Expand Down
116 changes: 116 additions & 0 deletions packages/proxy/test/unit/http/util/rewriter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,122 @@ describe('http/util/rewriter', () => {
expect(result).toContain('document.domain')
})

it('does not inject into a commented-out <head>', async () => {
// https://github.com/cypress-io/cypress/issues/33000
const html = '<html>\n<!-- <head>\n<title>Test</title>\n</head> -->\n</html>'
const opts = {
domainName: 'localhost',
wantsInjection: 'full',
shouldInjectDocumentDomain: true,
} as any

const result = await rewriter.html(html, opts)

// The comment must survive untouched, with the injection placed in a
// real <head> after the <html> tag
expect(result).toContain('<!-- <head>\n<title>Test</title>\n</head> -->')
expect(result).toContain('<html> <head> <script')
})

it('does not treat a commented-out <body> as the injection point', async () => {
const html = '<html><!-- <body></body> --><body></body></html>'
const opts = {
domainName: 'localhost',
wantsInjection: 'full',
shouldInjectDocumentDomain: true,
} as any

const result = await rewriter.html(html, opts)

expect(result).toContain('<!-- <body></body> -->')
expect(result).toContain('</head> <body>')
expect(result.indexOf('<script')).toBeGreaterThan(result.indexOf('-->'))
})

it('ignores an unterminated comment when picking the injection point', async () => {
const html = '<html><!-- <head></head><body>'
const opts = {
domainName: 'localhost',
wantsInjection: 'full',
shouldInjectDocumentDomain: true,
} as any

const result = await rewriter.html(html, opts)

expect(result).toContain('<html> <head> <script')
})

it('does not inject into a commented-out bootstrap script', async () => {
const html = '<html><!-- <script data-cy-bootstrap></script> --><head><title>t</title></head><body></body></html>'
const opts = {
domainName: 'localhost',
wantsInjection: 'full',
shouldInjectDocumentDomain: true,
} as any

const result = await rewriter.html(html, opts)

// The commented marker must survive untouched and the injection must
// land in the real head
expect(result).toContain('<!-- <script data-cy-bootstrap></script> -->')
expect(result).toContain('<head> <script')
})

it('does not treat <!-- inside a quoted attribute as a comment', async () => {
const html = '<html><head id="real" data-marker="<!--"><title>t</title></head><body></body></html>'
const opts = {
domainName: 'localhost',
wantsInjection: 'full',
shouldInjectDocumentDomain: true,
} as any

const result = await rewriter.html(html, opts)

// The real head, including its attributes, must be the injection point
expect(result).toContain('<head id="real" data-marker="<!--"> <script')
expect(result).not.toContain('<html> <head> <script')
})

it('handles abruptly closed comments before the injection point', async () => {
const html = '<html><!--><head id="real"><title>t</title></head><body></body></html>'
const opts = {
domainName: 'localhost',
wantsInjection: 'full',
shouldInjectDocumentDomain: true,
} as any

const result = await rewriter.html(html, opts)

expect(result).toContain('<head id="real"> <script')
})

it('handles comments closed with --!> before the injection point', async () => {
const html = '<html><!-- <head></head> --!><head id="real"><title>t</title></head><body></body></html>'
const opts = {
domainName: 'localhost',
wantsInjection: 'full',
shouldInjectDocumentDomain: true,
} as any

const result = await rewriter.html(html, opts)

expect(result).toContain('<head id="real"> <script')
})

it('does not treat a commented-out <html> as the injection point', async () => {
const html = '<!-- <html> -->\n<html id="real"><body></body></html>'
const opts = {
domainName: 'localhost',
wantsInjection: 'full',
shouldInjectDocumentDomain: true,
} as any

const result = await rewriter.html(html, opts)

expect(result).toContain('<!-- <html> -->')
expect(result.indexOf('<script')).toBeGreaterThan(result.indexOf('<html id="real">'))
})

it('preserves existing attributes on developer-provided script tag', async () => {
const html = '<html><head><script data-cy-bootstrap id="cy-bootstrap" nonce="existing"></script></head><body></body></html>'
const opts = {
Expand Down