From d4f85ced71a67e5337929bf8cbf3d5a00d80e5a0 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sun, 26 Jul 2026 07:05:44 -0700 Subject: [PATCH] chore(sync): HTML-escape callback page inputs --- src/sync/auth.ts | 9 ++++++++- tests/sync.test.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) 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.') + }) +})