@@ -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, '<' )
165+ . replace ( / > / g, '>' )
166+ . replace ( / " / g, '"' )
167+ . replace ( / ' / g, ''' )
168+ }
169+
157170/**
158171 * Generate HTML content for receipt (print-friendly).
159172 */
160173export 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 ( / < \/ s c r i p t > / 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()">🖨️ Print Receipt</button>
513+ <button onclick="downloadAsCSV()">📥 Download CSV</button>
514+ <button onclick="window.close()">✕ 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