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 @@
+
+
+
+
+ Expected behavior (after patch): label is rendered via a text node and should not execute.
+
+
+
+ Vulnerable behavior (before patch): memo was injected into modal HTML via innerHTML.
+
+
-
${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)