diff --git a/app/components/admin/PostEditor.vue b/app/components/admin/PostEditor.vue index 68d9d1f3..91e4b941 100644 --- a/app/components/admin/PostEditor.vue +++ b/app/components/admin/PostEditor.vue @@ -80,11 +80,21 @@ function buildMentionChip (rkey: string): HTMLElement { return span } +function isSafeUrl (url: string): boolean { + try { + const parsed = new URL(url, 'https://example.com') + return parsed.protocol === 'https:' || parsed.protocol === 'http:' + } + catch { + return false + } +} + function buildLinkChip (label: string, url: string): HTMLElement { const a = document.createElement('a') a.dataset.linkUri = url a.contentEditable = 'false' - a.href = url + a.href = isSafeUrl(url) ? url : '#' a.target = '_blank' a.rel = 'noopener noreferrer' a.className = 'inline-block px-1.5 py-0.5 rounded bg-background text-primary underline underline-offset-2 text-sm mx-0.5' diff --git a/scripts/linkedin-auth.ts b/scripts/linkedin-auth.ts index da5fa06e..0f95ff28 100644 --- a/scripts/linkedin-auth.ts +++ b/scripts/linkedin-auth.ts @@ -44,6 +44,15 @@ const SCOPES = [ const REDIRECT_URI = 'http://localhost:8080/callback' const PORT = 8080 +function escapeHtml (str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + interface LinkedInTokenResponse { access_token: string token_type: string @@ -145,7 +154,7 @@ class LinkedInAuthGenerator { if (error) { res.writeHead(400, { 'Content-Type': 'text/html' }) - res.end(`

Authorization Error

${error}: ${error_description}

`) + res.end(`

Authorization Error

${escapeHtml(String(error))}: ${escapeHtml(String(error_description || ''))}

`) reject(new Error(`Authorization error: ${error} - ${error_description}`)) return } @@ -166,7 +175,7 @@ class LinkedInAuthGenerator { } catch (error) { res.writeHead(500, { 'Content-Type': 'text/html' }) - res.end(`

❌ Token Exchange Error

${error instanceof Error ? error.message : error}

`) + res.end(`

❌ Token Exchange Error

${escapeHtml(error instanceof Error ? error.message : String(error))}

`) reject(error) } } diff --git a/scripts/youtube-auth.ts b/scripts/youtube-auth.ts index 032bdf8f..251389a8 100644 --- a/scripts/youtube-auth.ts +++ b/scripts/youtube-auth.ts @@ -33,6 +33,15 @@ const SCOPES = [ const REDIRECT_URI = 'http://localhost:8080/callback' const PORT = 8080 +function escapeHtml (str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + interface YouTubeCredentials { client_id: string client_secret: string @@ -194,7 +203,7 @@ class YouTubeAuthGenerator { if (error) { res.writeHead(400, { 'Content-Type': 'text/html' }) - res.end(`

Authorization Error

${error}

`) + res.end(`

Authorization Error

${escapeHtml(String(error))}

`) reject(new Error(`Authorization error: ${error}`)) return } @@ -215,7 +224,7 @@ class YouTubeAuthGenerator { } catch (error) { res.writeHead(500, { 'Content-Type': 'text/html' }) - res.end(`

❌ Token Exchange Error

${error instanceof Error ? error.message : error}

`) + res.end(`

❌ Token Exchange Error

${escapeHtml(error instanceof Error ? error.message : String(error))}

`) reject(error) } }