Skip to content

Commit 76b6035

Browse files
committed
fix(desktop): scan release-notes markup instead of deleting it
Deleting tags with a chained replace is single-pass: removing an inner tag lets the surrounding text close back up into markup, and a trailing unterminated `<` survives untouched. CodeQL flagged both. Walk the input once and copy out only the text the scan passes, so nothing that was not already a tag can become one.
1 parent 6824c48 commit 76b6035

2 files changed

Lines changed: 107 additions & 6 deletions

File tree

apps/desktop/src/updater.ts

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,20 +129,91 @@ function decodeEntities(value: string): string {
129129
})
130130
}
131131

132+
const BLOCK_BREAK_TAGS: ReadonlySet<string> = new Set([
133+
'blockquote',
134+
'div',
135+
'h1',
136+
'h2',
137+
'h3',
138+
'h4',
139+
'h5',
140+
'h6',
141+
'li',
142+
'ol',
143+
'p',
144+
'pre',
145+
'table',
146+
'tr',
147+
'ul',
148+
])
149+
150+
const RAW_TEXT_TAGS: ReadonlySet<string> = new Set(['script', 'style'])
151+
152+
const TAG_NAME_CHARACTER = /[a-z0-9]/iu
153+
154+
interface HtmlTag {
155+
name: string
156+
closing: boolean
157+
end: number
158+
}
159+
160+
function readTag(value: string, start: number): HtmlTag | undefined {
161+
const end = value.indexOf('>', start)
162+
if (end < 0) return undefined
163+
const closing = value[start + 1] === '/'
164+
const nameStart = start + (closing ? 2 : 1)
165+
let cursor = nameStart
166+
while (cursor < end && TAG_NAME_CHARACTER.test(value[cursor] ?? '')) cursor += 1
167+
return { name: value.slice(nameStart, cursor).toLowerCase(), closing, end }
168+
}
169+
170+
function skipRawTextElement(value: string, name: string, from: number): number {
171+
let index = from
172+
while (index < value.length) {
173+
const next = value.indexOf('<', index)
174+
if (next < 0) return value.length
175+
const tag = readTag(value, next)
176+
if (tag === undefined) return value.length
177+
if (tag.closing && tag.name === name) return tag.end + 1
178+
index = tag.end + 1
179+
}
180+
return value.length
181+
}
182+
132183
/**
133184
* The GitHub provider reads the releases Atom feed, whose `<content>` is the
134185
* body GitHub has already rendered to HTML. The renderer shows these notes as
135186
* Markdown, so the tags would print literally — `PyModel/pythinker-code@<tt>`
136187
* and the rest. Reduce the markup to text here, at the boundary that already
137188
* owns this field.
189+
*
190+
* This walks the input once and copies out only the text it passes, rather
191+
* than deleting tags from the string. Deletion is what lets `<scr<x>ipt>`
192+
* close back up into markup; a scan that never re-reads what it emitted cannot
193+
* produce a tag that was not already there. Markup the scan cannot terminate
194+
* ends the walk, so an unclosed `<` is dropped with the rest of the tail.
138195
*/
139196
function plainReleaseNotes(value: string): string {
140-
const text = value
141-
.replaceAll(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/giu, '')
142-
.replaceAll(/<li\b[^>]*>/giu, '\n- ')
143-
.replaceAll(/<br\s*\/?>/giu, '\n')
144-
.replaceAll(/<\/(p|div|li|ul|ol|tr|h[1-6])>/giu, '\n')
145-
.replaceAll(/<[^>]*>/gu, '')
197+
let text = ''
198+
let index = 0
199+
while (index < value.length) {
200+
const next = value.indexOf('<', index)
201+
if (next < 0) {
202+
text += value.slice(index)
203+
break
204+
}
205+
text += value.slice(index, next)
206+
const tag = readTag(value, next)
207+
if (tag === undefined) break
208+
if (!tag.closing && RAW_TEXT_TAGS.has(tag.name)) {
209+
index = skipRawTextElement(value, tag.name, tag.end + 1)
210+
continue
211+
}
212+
if (tag.name === 'li' && !tag.closing) text += '\n- '
213+
else if (tag.name === 'br') text += '\n'
214+
else if (tag.closing && BLOCK_BREAK_TAGS.has(tag.name)) text += '\n'
215+
index = tag.end + 1
216+
}
146217
return decodeEntities(text)
147218
.replaceAll(/[^\S\n]+\n/gu, '\n')
148219
.replaceAll(/\n{3,}/gu, '\n\n')

apps/desktop/tests/updater.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,36 @@ describe('update prompt receipts', () => {
940940
expect(getLocalUpdateState().releaseNotes).toBe('- One fix\n- Another fix')
941941
})
942942

943+
it('cannot be made to emit markup by nesting or truncating tags', async () => {
944+
vi.resetModules()
945+
const directory = temporaryDirectory()
946+
writeFileSync(join(directory, 'app-update.yml'), '', 'utf8')
947+
const { app: localApp } = await import('electron')
948+
const localElectronUpdater = (await import('electron-updater')).default
949+
const {
950+
getUpdateState: getLocalUpdateState,
951+
initUpdater: initLocalUpdater,
952+
} = await import('../src/updater')
953+
const localAutoUpdater = localElectronUpdater.autoUpdater
954+
vi.mocked(localApp.getPath).mockReturnValue(directory)
955+
Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true })
956+
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory })
957+
958+
initLocalUpdater(() => undefined)
959+
const available = vi.mocked(localAutoUpdater.on).mock.calls
960+
.find(([event]) => event === 'update-available')?.[1] as ((info: { version: string, releaseNotes?: string }) => void) | undefined
961+
available?.({
962+
version: '1.2.3',
963+
releaseNotes: '<p>Real note.</p><scr<span>ipt>x</script><script>hidden()</script><b>Kept</b><spa',
964+
})
965+
966+
const notes = getLocalUpdateState().releaseNotes ?? ''
967+
expect(notes).not.toContain('<')
968+
expect(notes).toContain('Real note.')
969+
expect(notes).toContain('Kept')
970+
expect(notes).not.toContain('hidden()')
971+
})
972+
943973
it('reports a pending install that did not take effect as an error', async () => {
944974
vi.resetModules()
945975
const directory = temporaryDirectory()

0 commit comments

Comments
 (0)