diff --git a/src/sync/auth.ts b/src/sync/auth.ts
index ff60a71..10d020f 100644
--- a/src/sync/auth.ts
+++ b/src/sync/auth.ts
@@ -149,7 +149,14 @@ export interface CallbackResult {
* is served once from a throwaway localhost server, so it must not depend
* on network fonts, external CSS, or dashboard assets.
*/
-export function renderCallbackPage(ok: boolean, title: string, message: string): string {
+export function renderCallbackPage(ok: boolean, rawTitle: string, rawMessage: string): string {
+ // Current call sites pass literals, but escape anyway so a future caller
+ // interpolating IdP-influenced text (e.g. the callback `error` param) cannot
+ // turn this localhost page into an XSS sink.
+ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
+ const title = escapeHtml(rawTitle)
+ const message = escapeHtml(rawMessage)
const accent = ok ? '#1f8a5b' : '#c8541f' // --primary / --chart-5 (terracotta)
const mark = ok ? '✓' : '✕' // ✓ / ✕
return `
diff --git a/tests/sync.test.ts b/tests/sync.test.ts
index 5c51257..ad73a54 100644
--- a/tests/sync.test.ts
+++ b/tests/sync.test.ts
@@ -10,6 +10,7 @@ import {
buildAuthUrl,
resolveScopes,
startCallbackServer,
+ renderCallbackPage,
CALLBACK_PORTS,
} from '../src/sync/auth.js'
@@ -258,3 +259,20 @@ describe('syncConfig', () => {
expect(raw).not.toContain('password')
})
})
+
+// ── Callback Page Escaping ────────────────────────────────────────────
+
+describe('renderCallbackPage', () => {
+ it('escapes HTML metacharacters in title and message', () => {
+ const html = renderCallbackPage(false, '', 'a & b "c" \'d\'')
+ expect(html).not.toContain('')
+ expect(html).toContain('<script>alert(1)</script>')
+ expect(html).toContain('a & b "c" 'd'')
+ })
+
+ it('leaves literal copy intact', () => {
+ const html = renderCallbackPage(true, 'Login successful', 'You can close this tab.')
+ expect(html).toContain('Login successful')
+ expect(html).toContain('You can close this tab.')
+ })
+})