Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion payment-widget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ A lightweight, embeddable JavaScript widget for accepting **RTC (RustChain Token
<div id="rtc-pay"
data-to="RTCyour_wallet_address_here"
data-amount="10"
data-memo="Payment for services">
data-memo="Payment for services"
data-allow-iframe="false"
data-allow-callback-any-origin="false">
</div>
```

Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions payment-widget/poc/xss-label.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PoC: XSS via data-label (Payment Widget)</title>
</head>
<body>
<h1>PoC: XSS via <code>data-label</code></h1>
<p>
Vulnerable behavior (before patch): the widget rendered <code>data-label</code> via
<code>innerHTML</code>, so HTML/JS would execute.
</p>
<p>
Expected behavior (after patch): label is rendered via a text node and should not execute.
</p>

<div
id="rtc-pay"
data-to="RTC0123456789abcdef0123456789abcdef01234567"
data-amount="1"
data-label="Pay 1 RTC &lt;img src=x onerror=alert('xss-label')&gt;"
></div>

<script src="../rustchain-pay.js"></script>
</body>
</html>

27 changes: 27 additions & 0 deletions payment-widget/poc/xss-memo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PoC: XSS via data-memo (Payment Widget)</title>
</head>
<body>
<h1>PoC: XSS via <code>data-memo</code></h1>
<p>
Vulnerable behavior (before patch): memo was injected into modal HTML via <code>innerHTML</code>.
</p>
<p>
Expected behavior (after patch): memo is rendered via <code>textContent</code> and should not execute.
</p>

<div
id="rtc-pay"
data-to="RTC0123456789abcdef0123456789abcdef01234567"
data-amount="1"
data-memo="Order &lt;img src=x onerror=alert('xss-memo')&gt;"
></div>

<script src="../rustchain-pay.js"></script>
</body>
</html>

114 changes: 89 additions & 25 deletions payment-widget/rustchain-pay.js
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,32 @@
<polyline points="20 6 9 17 4 12"/>
</svg>`;

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;
Expand Down Expand Up @@ -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';
Expand All @@ -521,10 +570,10 @@
</div>
<div class="rtc-modal-body">
<div class="rtc-payment-summary">
<p class="rtc-payment-amount">${config.amount} RTC</p>
<p class="rtc-payment-amount" id="rtc-summary-amount"></p>
<p class="rtc-payment-label">Payment Amount</p>
${config.memo ? `<p class="rtc-payment-to">Memo: ${config.memo}</p>` : ''}
<p class="rtc-payment-to">To: ${config.to}</p>
<p class="rtc-payment-to" id="rtc-summary-memo" style="display:none"></p>
<p class="rtc-payment-to" id="rtc-summary-to"></p>
</div>

<div class="rtc-error" style="display: none"></div>
Expand Down Expand Up @@ -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');
Expand All @@ -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();
Expand All @@ -614,9 +672,9 @@
};
reader.readAsText(file);
}
};
});

submitBtn.onclick = async () => {
submitBtn.addEventListener('click', async () => {
errorDiv.style.display = 'none';
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="rtc-spinner"></span> Processing...';
Expand All @@ -641,20 +699,21 @@
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) {
this._showError(errorDiv, err.message);
submitBtn.disabled = false;
submitBtn.innerHTML = 'Sign & Send Payment';
}
};
});
}

async _sendPayment(wallet, config) {
Expand Down Expand Up @@ -721,17 +780,22 @@
<div class="rtc-success">
<div class="rtc-success-icon">${CHECK_SVG}</div>
<h3 class="rtc-success-title">Payment Successful!</h3>
<p class="rtc-success-tx">TX: ${result.tx_hash}</p>
<p class="rtc-success-tx" id="rtc-success-tx"></p>
</div>
<button class="rtc-btn-primary" onclick="this.closest('.rtc-modal-overlay').remove()">
Done
</button>
<button class="rtc-btn-primary" id="rtc-success-done">Done</button>
`;
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)
Expand Down