Skip to content

Commit 5592902

Browse files
authored
Merge pull request #407 from Hovibby/fix/receipt-xss-and-interval-scheduling
fix: patch stored XSS in receipt HTML and stabilise auto-withdraw int…
2 parents ce0b760 + 04fc587 commit 5592902

2 files changed

Lines changed: 86 additions & 30 deletions

File tree

‎hooks/use-auto-withdraw.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,12 @@ export function useAutoWithdraw(stream: StreamData | null) {
210210
calculateWithdrawAmount,
211211
addWithdrawalHistory,
212212
network,
213+
// NOTE: `autoWithdrawPending` is intentionally excluded from this array.
214+
// The interval callback guards against concurrent withdrawals via
215+
// `autoWithdrawPendingRef` (a ref), so the state counterpart — which
216+
// exists only to trigger UI re-renders — must not be listed here.
217+
// Including it would tear down and recreate the interval on every
218+
// withdrawal, resetting the cadence instead of keeping a stable schedule.
213219
]);
214220

215221
return {

‎lib/receipt-utils.ts‎

Lines changed: 80 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -154,19 +154,69 @@ export function generateReceiptCSV(receipt: ReceiptData): string {
154154
return lines.join('\n')
155155
}
156156

157+
/**
158+
* Escape a string for safe interpolation into HTML text content or attribute values.
159+
* Replaces the five characters that carry meaning in HTML/XML.
160+
*/
161+
function escapeHtml(value: string): string {
162+
return value
163+
.replace(/&/g, '&')
164+
.replace(/</g, '&lt;')
165+
.replace(/>/g, '&gt;')
166+
.replace(/"/g, '&quot;')
167+
.replace(/'/g, '&#x27;')
168+
}
169+
157170
/**
158171
* Generate HTML content for receipt (print-friendly).
159172
*/
160173
export function generateReceiptHTML(
161174
receipt: ReceiptData,
162175
logoBase64?: string,
163176
): string {
177+
// Escape all dynamic values before they touch the HTML string.
178+
const id = escapeHtml(receipt.streamId)
179+
const status = escapeHtml(receipt.status)
180+
// status is always one of 'Active' | 'Completed' | 'Cancelled' (ASCII), but
181+
// we still escape it and use the escaped form for the CSS class name so a
182+
// hypothetical future status value cannot break out of the attribute.
183+
const statusCls = escapeHtml(receipt.status.toLowerCase())
184+
const sender = escapeHtml(receipt.sender)
185+
const recipient = escapeHtml(receipt.recipient)
186+
const symbol = escapeHtml(receipt.tokenSymbol)
187+
const address = escapeHtml(receipt.tokenAddress)
188+
const generated = escapeHtml(receipt.generatedDate)
189+
const startDate = escapeHtml(receipt.startDate)
190+
const endDate = escapeHtml(receipt.endDate)
191+
const cliffDate = escapeHtml(receipt.cliffDate)
192+
const duration = escapeHtml(receipt.duration)
193+
const total = escapeHtml(receipt.totalAmount)
194+
const withdrawn = escapeHtml(receipt.withdrawnAmount)
195+
const remaining = escapeHtml(receipt.remainingAmount)
196+
const cliffAmt = receipt.cliffAmount ? escapeHtml(receipt.cliffAmount) : null
197+
const perSec = receipt.amountPerSecond ? escapeHtml(receipt.amountPerSecond) : null
198+
const creationTx = receipt.creationTx ? escapeHtml(receipt.creationTx) : null
199+
const cancellationTx = receipt.cancellationTx ? escapeHtml(receipt.cancellationTx) : null
200+
const withdrawalTxs = (receipt.withdrawalTxs ?? []).map(escapeHtml)
201+
202+
// The CSV data is passed through JSON.stringify which produces a valid JS
203+
// string literal. The only remaining risk is a </script> sequence inside
204+
// the JSON payload; replace it with a safe Unicode escape so the inline
205+
// script tag cannot be terminated early.
206+
const csvJson = JSON.stringify(generateReceiptCSV(receipt))
207+
.replace(/<\/script>/gi, '<\\/script>')
208+
209+
// The download filename only needs to appear inside a JS string literal
210+
// (already wrapped in quotes by the template below), so we escape backslash
211+
// and the quote character that wraps it.
212+
const safeId = receipt.streamId.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
213+
164214
const html = `<!DOCTYPE html>
165215
<html lang="en">
166216
<head>
167217
<meta charset="UTF-8">
168218
<meta name="viewport" content="width=device-width, initial-scale=1.0">
169-
<title>FlowStar Stream Receipt - ${receipt.streamId}</title>
219+
<title>FlowStar Stream Receipt - ${id}</title>
170220
<style>
171221
* {
172222
margin: 0;
@@ -357,43 +407,43 @@ export function generateReceiptHTML(
357407
</div>
358408
<div class="generated-date">
359409
<div>Generated</div>
360-
<div>${receipt.generatedDate}</div>
410+
<div>${generated}</div>
361411
</div>
362412
</div>
363413
364414
<div class="section">
365415
<div class="section-title">Stream Information</div>
366416
<div class="row">
367417
<span class="row-label">Stream ID</span>
368-
<span class="row-value mono">${receipt.streamId}</span>
418+
<span class="row-value mono">${id}</span>
369419
</div>
370420
<div class="row">
371421
<span class="row-label">Status</span>
372-
<span class="row-value status-${receipt.status.toLowerCase()}">${receipt.status}</span>
422+
<span class="row-value status-${statusCls}">${status}</span>
373423
</div>
374424
</div>
375425
376426
<div class="section">
377427
<div class="section-title">Parties</div>
378428
<div class="row">
379429
<span class="row-label">Sender</span>
380-
<span class="row-value mono">${receipt.sender}</span>
430+
<span class="row-value mono">${sender}</span>
381431
</div>
382432
<div class="row">
383433
<span class="row-label">Recipient</span>
384-
<span class="row-value mono">${receipt.recipient}</span>
434+
<span class="row-value mono">${recipient}</span>
385435
</div>
386436
</div>
387437
388438
<div class="section">
389439
<div class="section-title">Token</div>
390440
<div class="row">
391441
<span class="row-label">Symbol</span>
392-
<span class="row-value">${receipt.tokenSymbol}</span>
442+
<span class="row-value">${symbol}</span>
393443
</div>
394444
<div class="row">
395445
<span class="row-label">Address</span>
396-
<span class="row-value mono">${receipt.tokenAddress}</span>
446+
<span class="row-value mono">${address}</span>
397447
</div>
398448
</div>
399449
@@ -403,23 +453,23 @@ export function generateReceiptHTML(
403453
<div class="section-title">Schedule</div>
404454
<div class="row">
405455
<span class="row-label">Start Date</span>
406-
<span class="row-value">${receipt.startDate}</span>
456+
<span class="row-value">${startDate}</span>
407457
</div>
408458
<div class="row">
409459
<span class="row-label">End Date</span>
410-
<span class="row-value">${receipt.endDate}</span>
460+
<span class="row-value">${endDate}</span>
411461
</div>
412462
<div class="row">
413463
<span class="row-label">Cliff Date</span>
414-
<span class="row-value">${receipt.cliffDate}</span>
464+
<span class="row-value">${cliffDate}</span>
415465
</div>
416-
${receipt.cliffAmount ? `<div class="row">
466+
${cliffAmt ? `<div class="row">
417467
<span class="row-label">Cliff Amount</span>
418-
<span class="row-value">${receipt.cliffAmount} ${receipt.tokenSymbol}</span>
468+
<span class="row-value">${cliffAmt} ${symbol}</span>
419469
</div>` : ''}
420470
<div class="row">
421471
<span class="row-label">Duration</span>
422-
<span class="row-value">${receipt.duration}</span>
472+
<span class="row-value">${duration}</span>
423473
</div>
424474
</div>
425475
@@ -429,39 +479,39 @@ export function generateReceiptHTML(
429479
<div class="section-title">Amounts</div>
430480
<div class="row amount-row">
431481
<span class="row-label">Total Deposited</span>
432-
<span class="row-value">${receipt.totalAmount} ${receipt.tokenSymbol}</span>
482+
<span class="row-value">${total} ${symbol}</span>
433483
</div>
434484
<div class="row">
435485
<span class="row-label">Withdrawn</span>
436-
<span class="row-value">${receipt.withdrawnAmount} ${receipt.tokenSymbol}</span>
486+
<span class="row-value">${withdrawn} ${symbol}</span>
437487
</div>
438488
<div class="row">
439489
<span class="row-label">Remaining</span>
440-
<span class="row-value">${receipt.remainingAmount} ${receipt.tokenSymbol}</span>
490+
<span class="row-value">${remaining} ${symbol}</span>
441491
</div>
442492
</div>
443493
444-
${receipt.amountPerSecond ? `<div class="section">
494+
${perSec ? `<div class="section">
445495
<div class="section-title">Unlock Rate</div>
446496
<div class="row">
447497
<span class="row-label">Per Second</span>
448-
<span class="row-value">${receipt.amountPerSecond} ${receipt.tokenSymbol}</span>
498+
<span class="row-value">${perSec} ${symbol}</span>
449499
</div>
450500
</div>` : ''}
451501
452502
<div class="divider"></div>
453503
454504
<div class="section">
455505
<div class="section-title">Transaction Hashes</div>
456-
${receipt.creationTx ? `<div class="row"><span class="row-label">Creation</span></div><div class="tx-list">${receipt.creationTx}</div>` : ''}
457-
${receipt.withdrawalTxs && receipt.withdrawalTxs.length > 0 ? receipt.withdrawalTxs.map((tx, idx) => `<div class="row"><span class="row-label">Withdrawal ${idx + 1}</span></div><div class="tx-list">${tx}</div>`).join('') : ''}
458-
${receipt.cancellationTx ? `<div class="row"><span class="row-label">Cancellation</span></div><div class="tx-list">${receipt.cancellationTx}</div>` : ''}
506+
${creationTx ? `<div class="row"><span class="row-label">Creation</span></div><div class="tx-list">${creationTx}</div>` : ''}
507+
${withdrawalTxs.length > 0 ? withdrawalTxs.map((tx, idx) => `<div class="row"><span class="row-label">Withdrawal ${idx + 1}</span></div><div class="tx-list">${tx}</div>`).join('') : ''}
508+
${cancellationTx ? `<div class="row"><span class="row-label">Cancellation</span></div><div class="tx-list">${cancellationTx}</div>` : ''}
459509
</div>
460510
461511
<div class="actions no-print">
462-
<button onclick="window.print()">🖨️ Print Receipt</button>
463-
<button onclick="downloadAsCSV()">📥 Download CSV</button>
464-
<button onclick="window.close()">✕ Close</button>
512+
<button onclick="window.print()">&#x1F5A8;&#xFE0F; Print Receipt</button>
513+
<button onclick="downloadAsCSV()">&#x1F4E5; Download CSV</button>
514+
<button onclick="window.close()">&#x2715; Close</button>
465515
</div>
466516
467517
<div class="footer">
@@ -471,12 +521,12 @@ export function generateReceiptHTML(
471521
472522
<script>
473523
function downloadAsCSV() {
474-
const csv = ${JSON.stringify(generateReceiptCSV(receipt))};
475-
const blob = new Blob([csv], { type: 'text/csv' });
476-
const url = URL.createObjectURL(blob);
477-
const a = document.createElement('a');
524+
var csv = ${csvJson};
525+
var blob = new Blob([csv], { type: 'text/csv' });
526+
var url = URL.createObjectURL(blob);
527+
var a = document.createElement('a');
478528
a.href = url;
479-
a.download = 'flowstar-receipt-${receipt.streamId}.csv';
529+
a.download = 'flowstar-receipt-${safeId}.csv';
480530
a.click();
481531
URL.revokeObjectURL(url);
482532
}

0 commit comments

Comments
 (0)