diff --git a/payment-widget/README.md b/payment-widget/README.md index 68eb7ed..edb3a6f 100644 --- a/payment-widget/README.md +++ b/payment-widget/README.md @@ -30,7 +30,9 @@ A lightweight, embeddable JavaScript widget for accepting **RTC (RustChain Token
+ data-memo="Payment for services" + data-allow-iframe="false" + data-allow-callback-any-origin="false">
``` @@ -128,6 +130,8 @@ console.log(balance.amount_rtc); // e.g., 150.5 | `data-memo` | No | Payment memo/description | | `data-label` | No | Custom button text | | `data-callback` | No | Webhook URL for payment notification | +| `data-allow-iframe` | No | Set to `true` to allow running inside an iframe (default: blocked) | +| `data-allow-callback-any-origin` | No | Set to `true` to allow cross-origin callback URLs (default: same-origin only) | ### Success Callback Payload @@ -144,6 +148,11 @@ console.log(balance.amount_rtc); // e.g., 150.5 ## 🔐 Security +### Embed Hardening +- The widget renders user-controlled fields via `textContent`/text nodes, not `innerHTML`. +- By default the widget blocks running inside iframes unless `data-allow-iframe="true"` is set. +- Callback URL (`data-callback`) is same-origin only by default; set `data-allow-callback-any-origin="true"` to override. + ### Client-Side Signing All cryptographic operations happen in the browser: diff --git a/payment-widget/poc/xss-label.html b/payment-widget/poc/xss-label.html new file mode 100644 index 0000000..2c254ba --- /dev/null +++ b/payment-widget/poc/xss-label.html @@ -0,0 +1,28 @@ + + + + + + PoC: XSS via data-label (Payment Widget) + + +

PoC: XSS via data-label

+

+ Vulnerable behavior (before patch): the widget rendered data-label via + innerHTML, so HTML/JS would execute. +

+

+ Expected behavior (after patch): label is rendered via a text node and should not execute. +

+ +
+ + + + + diff --git a/payment-widget/poc/xss-memo.html b/payment-widget/poc/xss-memo.html new file mode 100644 index 0000000..c5f45f4 --- /dev/null +++ b/payment-widget/poc/xss-memo.html @@ -0,0 +1,27 @@ + + + + + + PoC: XSS via data-memo (Payment Widget) + + +

PoC: XSS via data-memo

+

+ Vulnerable behavior (before patch): memo was injected into modal HTML via innerHTML. +

+

+ Expected behavior (after patch): memo is rendered via textContent and should not execute. +

+ +
+ + + + + diff --git a/payment-widget/rustchain-pay.js b/payment-widget/rustchain-pay.js index 6954744..d988eb4 100644 --- a/payment-widget/rustchain-pay.js +++ b/payment-widget/rustchain-pay.js @@ -463,6 +463,32 @@ `; + function isValidRtcAddress(addr) { + return /^RTC[0-9a-fA-F]{40}$/.test(String(addr || '').trim()); + } + + function normalizeAmount(x) { + const n = typeof x === 'number' ? x : Number(String(x || '').trim()); + if (!Number.isFinite(n) || n <= 0) return null; + // Keep transport/display stable; chain should enforce canonical semantics. + return Math.round(n * 1e6) / 1e6; + } + + function safeCallbackUrl(url, allowAnyOrigin) { + if (!url) return null; + let u; + try { + u = new URL(String(url), window.location.href); + } catch (e) { + return null; + } + if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; + if (u.username || u.password) return null; + if (allowAnyOrigin) return u.toString(); + if (u.origin !== window.location.origin) return null; + return u.toString(); + } + class RustChainPay { constructor(config = {}) { this.nodeUrl = config.nodeUrl || DEFAULT_NODE; @@ -493,22 +519,45 @@ const config = { to: el.dataset.to || options.to, - amount: parseFloat(el.dataset.amount || options.amount || 0), + amount: normalizeAmount(el.dataset.amount || options.amount || 0), memo: el.dataset.memo || options.memo || '', label: el.dataset.label || options.label || `Pay ${el.dataset.amount || options.amount || ''} RTC`, - callback: el.dataset.callback || options.callback + callback: el.dataset.callback || options.callback, + allowIframe: String(el.dataset.allowIframe || options.allowIframe || 'false').toLowerCase() === 'true', + allowCallbackAnyOrigin: String(el.dataset.allowCallbackAnyOrigin || options.allowCallbackAnyOrigin || 'false').toLowerCase() === 'true', }; const btn = document.createElement('button'); btn.className = 'rtc-pay-btn'; - btn.innerHTML = `${LOGO_SVG} ${config.label}`; - btn.onclick = () => this.openPaymentModal(config); + btn.innerHTML = LOGO_SVG; + btn.appendChild(document.createTextNode(' ' + String(config.label || 'Pay RTC'))); + btn.addEventListener('click', () => this.openPaymentModal(config)); el.appendChild(btn); return btn; } openPaymentModal(config) { + // Anti-clickjacking / consent hardening: default deny in iframes unless explicitly allowed. + if (window.top !== window.self && !config.allowIframe) { + this.onError(new Error('Widget blocked in iframe (set data-allow-iframe=\"true\" to override).')); + return; + } + + // Validate config before rendering anything. + const to = String(config.to || '').trim(); + if (!isValidRtcAddress(to)) { + this.onError(new Error('Invalid recipient address format.')); + return; + } + const amount = normalizeAmount(config.amount); + if (amount === null) { + this.onError(new Error('Invalid amount.')); + return; + } + const memoRaw = String(config.memo || ''); + const memo = memoRaw.length > 200 ? memoRaw.slice(0, 200) + '...' : memoRaw; + // Create modal const overlay = document.createElement('div'); overlay.className = 'rtc-modal-overlay'; @@ -521,10 +570,10 @@
-

${config.amount} RTC

+

Payment Amount

- ${config.memo ? `

Memo: ${config.memo}

` : ''} -

To: ${config.to}

+ +

@@ -565,6 +614,15 @@ document.body.appendChild(overlay); + // Fill summary fields using textContent to prevent DOM injection. + overlay.querySelector('#rtc-summary-amount').textContent = `${amount} RTC`; + overlay.querySelector('#rtc-summary-to').textContent = `To: ${to}`; + if (memo) { + const memoEl = overlay.querySelector('#rtc-summary-memo'); + memoEl.textContent = `Memo: ${memo}`; + memoEl.style.display = 'block'; + } + // Event handlers const modal = overlay.querySelector('.rtc-modal'); const closeBtn = overlay.querySelector('.rtc-modal-close'); @@ -583,22 +641,22 @@ this.onCancel(); }; - closeBtn.onclick = close; - overlay.onclick = (e) => { + closeBtn.addEventListener('click', close); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); - }; + }); tabs.forEach(tab => { - tab.onclick = () => { + tab.addEventListener('click', () => { activeTab = tab.dataset.tab; tabs.forEach(t => t.classList.toggle('active', t === tab)); tabContents.forEach(c => { c.style.display = c.dataset.content === activeTab ? 'block' : 'none'; }); - }; + }); }); - fileInput.onchange = (e) => { + fileInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); @@ -614,9 +672,9 @@ }; reader.readAsText(file); } - }; + }); - submitBtn.onclick = async () => { + submitBtn.addEventListener('click', async () => { errorDiv.style.display = 'none'; submitBtn.disabled = true; submitBtn.innerHTML = ' Processing...'; @@ -641,12 +699,13 @@ wallet = await decryptKeystore(keystoreData, password); } - const result = await this._sendPayment(wallet, config); + const safeConfig = { ...config, to, amount, memo }; + const result = await this._sendPayment(wallet, safeConfig); this._showSuccess(overlay, result); this.onSuccess(result); - if (config.callback) { - this._notifyCallback(config.callback, result); + if (safeConfig.callback) { + this._notifyCallback(safeConfig.callback, result, safeConfig); } } catch (err) { @@ -654,7 +713,7 @@ submitBtn.disabled = false; submitBtn.innerHTML = 'Sign & Send Payment'; } - }; + }); } async _sendPayment(wallet, config) { @@ -721,17 +780,22 @@
${CHECK_SVG}

Payment Successful!

-

TX: ${result.tx_hash}

+

- + `; + body.querySelector('#rtc-success-tx').textContent = `TX: ${String(result.tx_hash || '')}`; + body.querySelector('#rtc-success-done').addEventListener('click', () => { + const ov = body.closest('.rtc-modal-overlay'); + if (ov) ov.remove(); + }); } - async _notifyCallback(callbackUrl, result) { + async _notifyCallback(callbackUrl, result, config) { try { - await fetch(callbackUrl, { + const safeUrl = safeCallbackUrl(callbackUrl, !!(config && config.allowCallbackAnyOrigin)); + if (!safeUrl) throw new Error('Invalid callback URL (must be same-origin unless explicitly allowed).'); + await fetch(safeUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(result)