diff --git a/skills/company-research/scripts/compile_report.mjs b/skills/company-research/scripts/compile_report.mjs index 2759c69..b18ee24 100644 --- a/skills/company-research/scripts/compile_report.mjs +++ b/skills/company-research/scripts/compile_report.mjs @@ -93,6 +93,20 @@ function escapeHtml(str) { return (str || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } +// Reject any URL whose scheme isn't http(s)/mailto so the company `website` +// field can't smuggle a `javascript:` payload into the rendered href. +function safeUrl(u) { + if (!u || typeof u !== 'string') return null; + // Strip C0 controls + DEL before any scheme check. The WHATWG URL parser + // removes these before parsing, so `java\tscript:` reaches the browser as + // `javascript:` and runs — but the scheme regex below wouldn't catch it + // because `[a-z0-9+.-]` rejects the tab and falls through to return trimmed. + const trimmed = u.trim().replace(/[\x00-\x1F\x7F]/g, ''); + if (/^(\/\/|https?:\/\/|mailto:)/i.test(trimmed)) return trimmed; + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return null; + return trimmed; +} + function scoreClass(score) { const s = parseInt(score) || 0; if (s >= 8) return 'high'; @@ -205,8 +219,9 @@ const tableRows = deduped.map(c => { const nameHtml = hasDetail ? `${escapeHtml(c.company_name)}` : escapeHtml(c.company_name); - const websiteHtml = c.website - ? `
${escapeHtml(c.website.replace(/^https?:\/\/(www\.)?/, ''))}` + const safeWebsite = safeUrl(c.website); + const websiteHtml = safeWebsite + ? `
${escapeHtml(safeWebsite.replace(/^https?:\/\/(www\.)?/, ''))}` : ''; return ` ${escapeHtml(c.icp_fit_score || '—')} @@ -289,7 +304,7 @@ for (const c of deduped) {

${escapeHtml(c.company_name)}

ICP Score: ${escapeHtml(c.icp_fit_score || '—')} - ${c.website ? `${escapeHtml(c.website)}` : ''} + ${(() => { const s = safeUrl(c.website); return s ? `${escapeHtml(s)}` : ''; })()}
diff --git a/skills/event-prospecting/scripts/compile_report.mjs b/skills/event-prospecting/scripts/compile_report.mjs index e0f55c6..cae998c 100644 --- a/skills/event-prospecting/scripts/compile_report.mjs +++ b/skills/event-prospecting/scripts/compile_report.mjs @@ -157,6 +157,21 @@ function escapeJsInAttr(str) { return escapeAttr(js); } +// Reject any URL whose scheme isn't http(s)/mailto, so attacker-controlled +// fields (LinkedIn, X, GitHub, blog, podcast, image, company website) can't +// smuggle a `javascript:` payload into a rendered href. +function safeUrl(u) { + if (!u || typeof u !== 'string') return null; + // Strip C0 controls + DEL before any scheme check. The WHATWG URL parser + // removes these before parsing, so `java\tscript:` reaches the browser as + // `javascript:` and runs — but the scheme regex below wouldn't catch it + // because `[a-z0-9+.-]` rejects the tab and falls through to return trimmed. + const trimmed = u.trim().replace(/[\x00-\x1F\x7F]/g, ''); + if (/^(\/\/|https?:\/\/|mailto:)/i.test(trimmed)) return trimmed; + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return null; + return trimmed; +} + function scoreClass(score) { const s = parseInt(score) || 0; if (s >= 8) return 'high'; @@ -386,8 +401,9 @@ function renderPersonCard(person, company) { podcast: person.podcast || null, }; const linkPills = ['linkedin', 'x', 'github', 'blog', 'podcast'] - .filter(k => links[k]) - .map(k => `${k.toUpperCase()}`) + .map(k => ({ k, safe: safeUrl(links[k]) })) + .filter(({ safe }) => safe) + .map(({ k, safe }) => `${k.toUpperCase()}`) .join(' '); const score = c.icp_fit_score || person.icp_fit_score || '?'; @@ -396,8 +412,9 @@ function renderPersonCard(person, company) { const hook = person.hook || extractSection(person.body, 'Hook') || '—'; const roleReason = person.role_reason || extractSection(person.body, 'Why the person') || '—'; const dmOpener = person.dm_opener || extractSection(person.body, 'DM Opener') || ''; - const photo = person.image - ? `${escapeHtml(person.name || '')}` + const safeImage = safeUrl(person.image); + const photo = safeImage + ? `${escapeHtml(person.name || '')}` : `
${escapeHtml(initials(person.name))}
`; return `
@@ -566,8 +583,9 @@ function renderGroupedByCompany(personList) { const nameHtml = hasDetail ? `${escapeHtml(company.company_name)}` : escapeHtml(company.company_name); - const websiteHtml = company.website - ? ` · ${escapeHtml(company.website.replace(/^https?:\/\/(www\.)?/, ''))}` + const safeCompanyWebsite = safeUrl(company.website); + const websiteHtml = safeCompanyWebsite + ? ` · ${escapeHtml(safeCompanyWebsite.replace(/^https?:\/\/(www\.)?/, ''))}` : ''; const metaBits = [ `${members.length} speaker${members.length === 1 ? '' : 's'}`, @@ -656,15 +674,20 @@ function renderCompaniesTable() { const nameHtml = hasDetail ? `${escapeHtml(c.company_name)}` : escapeHtml(c.company_name); - const websiteHtml = c.website - ? `
${escapeHtml(c.website.replace(/^https?:\/\/(www\.)?/, ''))}` + const safeWebsite = safeUrl(c.website); + const websiteHtml = safeWebsite + ? `
${escapeHtml(safeWebsite.replace(/^https?:\/\/(www\.)?/, ''))}` : ''; const key = c.slug || (c.company_name || '').toLowerCase(); const attendees = byCompany.get(key) || []; const attendeeBlock = attendees.length ? `
${attendees.length} attendee${attendees.length === 1 ? '' : 's'} -
    ${attendees.map(a => `
  • ${escapeHtml(a.name || a.slug)}${a.title ? ' — ' + escapeHtml(a.title) : ''}${(a.links && a.links.linkedin) ? ` · LinkedIn` : ''}
  • `).join('')}
+
    ${attendees.map(a => { + const safeLinkedin = safeUrl(a.links && a.links.linkedin); + const linkedinHtml = safeLinkedin ? ` · LinkedIn` : ''; + return `
  • ${escapeHtml(a.name || a.slug)}${a.title ? ' — ' + escapeHtml(a.title) : ''}${linkedinHtml}
  • `; + }).join('')}
` : ''; return ` ${escapeHtml(c.icp_fit_score || '—')} @@ -802,7 +825,7 @@ for (const c of deduped) {

${escapeHtml(c.company_name)}

ICP Score: ${escapeHtml(c.icp_fit_score || '—')} - ${c.website ? `${escapeHtml(c.website)}` : ''} + ${(() => { const s = safeUrl(c.website); return s ? `${escapeHtml(s)}` : ''; })()}