From 39b17fe596b0029f23a77000292abdebe59ed7e1 Mon Sep 17 00:00:00 2001 From: Aaron Mars Date: Mon, 4 May 2026 08:02:31 +0000 Subject: [PATCH 1/2] fix(security): validate URL scheme before rendering href MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two `compile_report.mjs` scripts (`event-prospecting` + `company-research`) render attacker-controlled URL fields — LinkedIn / X / GitHub / blog / podcast / image / company website — into `` via `escapeHtml` only. `escapeHtml` handles `& < > "` but does not validate scheme, so a `javascript:fetch(...)` payload survives unchanged and produces a clickable XSS link in the report. 7 sinks share the root cause — link pills + per-card photo (`event-prospecting`), companies-table website rows, attendee LinkedIn links, and per-company detail website rows in both reports. Threat: an attacker hosts a Next.js conference page with a malicious `linkedInProfile` / `website` field. The investigator runs the skill, the subagent stores the URL verbatim into `people/.md` frontmatter, `compile_report.mjs` renders it as a clickable href, one click on the LINKEDIN pill exfiltrates the full report DOM (every speaker + every company in the run, not just the attacker's own data) to attacker.com. Fix: a small `safeUrl(u)` helper in each file. Allows `http://`, `https://`, `mailto:`, and protocol-relative `//`; rejects anything that parses as a URL with any other scheme (`javascript:`, `data:`, `vbscript:`, `file:`, etc.) by anchoring against the `[a-z][a-z0-9+.-]*:` pattern. Plain strings without a scheme pass through (relative paths still work). Filed by Aeon (https://github.com/aaronjmars/aeon-aaron). Severity: medium (CWE-79). Detected by manual review. --- .../scripts/compile_report.mjs | 17 ++++++-- .../scripts/compile_report.mjs | 39 ++++++++++++++----- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/skills/company-research/scripts/compile_report.mjs b/skills/company-research/scripts/compile_report.mjs index 2759c69..fbc24d6 100644 --- a/skills/company-research/scripts/compile_report.mjs +++ b/skills/company-research/scripts/compile_report.mjs @@ -93,6 +93,16 @@ 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; + const trimmed = u.trim(); + 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 +215,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 +300,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..28a4768 100644 --- a/skills/event-prospecting/scripts/compile_report.mjs +++ b/skills/event-prospecting/scripts/compile_report.mjs @@ -157,6 +157,17 @@ 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; + const trimmed = u.trim(); + 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 +397,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 +408,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 +579,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 +670,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 +821,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)}` : ''; })()}
From 39ac3ed571a2bb8c4a85c71d186fd94b2a1ad688 Mon Sep 17 00:00:00 2001 From: aaronjmars Date: Sun, 10 May 2026 19:25:01 -0400 Subject: [PATCH 2/2] fix(security): strip C0 controls + DEL in safeUrl before scheme check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot follow-up: java\tscript:alert(1) bypassed the scheme regex because [a-z0-9+.-] rejects the tab — falls through to return trimmed. Browsers strip C0 controls per WHATWG URL spec before parsing the scheme, so the javascript: payload still executes. Strip them up front in both safeUrl implementations (event-prospecting + company-research). --- skills/company-research/scripts/compile_report.mjs | 6 +++++- skills/event-prospecting/scripts/compile_report.mjs | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/skills/company-research/scripts/compile_report.mjs b/skills/company-research/scripts/compile_report.mjs index fbc24d6..b18ee24 100644 --- a/skills/company-research/scripts/compile_report.mjs +++ b/skills/company-research/scripts/compile_report.mjs @@ -97,7 +97,11 @@ function escapeHtml(str) { // field can't smuggle a `javascript:` payload into the rendered href. function safeUrl(u) { if (!u || typeof u !== 'string') return null; - const trimmed = u.trim(); + // 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; diff --git a/skills/event-prospecting/scripts/compile_report.mjs b/skills/event-prospecting/scripts/compile_report.mjs index 28a4768..cae998c 100644 --- a/skills/event-prospecting/scripts/compile_report.mjs +++ b/skills/event-prospecting/scripts/compile_report.mjs @@ -162,7 +162,11 @@ function escapeJsInAttr(str) { // smuggle a `javascript:` payload into a rendered href. function safeUrl(u) { if (!u || typeof u !== 'string') return null; - const trimmed = u.trim(); + // 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;