Skip to content

Commit e4e84f7

Browse files
committed
fix(desktop): keep decoded release notes inert
Entity decoding runs after the tag scan, so `&lt;script&gt;` — how GitHub renders a tag an author typed literally — became `<script>` once the scan could no longer see it. The update dialog renders this value with a Markdown component that does render raw HTML, so escape the angle brackets on the way out: the text still reads as written and can no longer open an element.
1 parent 76b6035 commit e4e84f7

2 files changed

Lines changed: 39 additions & 1 deletion

File tree

apps/desktop/src/updater.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,18 @@ function skipRawTextElement(value: string, name: string, from: number): number {
180180
return value.length
181181
}
182182

183+
/**
184+
* Decoding runs after the scan, so `&lt;script&gt;` — which is how GitHub
185+
* renders a literal tag an author typed — turns back into `<script>` once the
186+
* scan can no longer see it. The update dialog renders this value with a
187+
* Markdown component that does render raw HTML, so the decoded text has to
188+
* leave here inert. Escaping the angle brackets keeps it readable: a Markdown
189+
* renderer prints `&lt;` as `<` text rather than opening an element.
190+
*/
191+
function escapeMarkupStarts(value: string): string {
192+
return value.replaceAll('<', '&lt;').replaceAll('>', '&gt;')
193+
}
194+
183195
/**
184196
* The GitHub provider reads the releases Atom feed, whose `<content>` is the
185197
* body GitHub has already rendered to HTML. The renderer shows these notes as
@@ -214,7 +226,7 @@ function plainReleaseNotes(value: string): string {
214226
else if (tag.closing && BLOCK_BREAK_TAGS.has(tag.name)) text += '\n'
215227
index = tag.end + 1
216228
}
217-
return decodeEntities(text)
229+
return escapeMarkupStarts(decodeEntities(text))
218230
.replaceAll(/[^\S\n]+\n/gu, '\n')
219231
.replaceAll(/\n{3,}/gu, '\n\n')
220232
.trim()

apps/desktop/tests/updater.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,32 @@ describe('update prompt receipts', () => {
970970
expect(notes).not.toContain('hidden()')
971971
})
972972

973+
it('does not let encoded markup decode back into tags', async () => {
974+
vi.resetModules()
975+
const directory = temporaryDirectory()
976+
writeFileSync(join(directory, 'app-update.yml'), '', 'utf8')
977+
const { app: localApp } = await import('electron')
978+
const localElectronUpdater = (await import('electron-updater')).default
979+
const {
980+
getUpdateState: getLocalUpdateState,
981+
initUpdater: initLocalUpdater,
982+
} = await import('../src/updater')
983+
const localAutoUpdater = localElectronUpdater.autoUpdater
984+
vi.mocked(localApp.getPath).mockReturnValue(directory)
985+
Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true })
986+
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory })
987+
988+
initLocalUpdater(() => undefined)
989+
const available = vi.mocked(localAutoUpdater.on).mock.calls
990+
.find(([event]) => event === 'update-available')?.[1] as ((info: { version: string, releaseNotes?: string }) => void) | undefined
991+
available?.({
992+
version: '1.2.3',
993+
releaseNotes: '<p>Note</p>&lt;script&gt;payload&lt;/script&gt;',
994+
})
995+
996+
expect(getLocalUpdateState().releaseNotes).toBe('Note\n&lt;script&gt;payload&lt;/script&gt;')
997+
})
998+
973999
it('reports a pending install that did not take effect as an error', async () => {
9741000
vi.resetModules()
9751001
const directory = temporaryDirectory()

0 commit comments

Comments
 (0)