@@ -494,6 +494,104 @@ describe("export html security hardening", () => {
494494 ) ;
495495 } ) ;
496496
497+ it ( "escapes entry.id in element id and data-entry-id attributes" , async ( ) => {
498+ const xssId = `"><script>alert(1)</script><div data-x="` ;
499+ const session : SessionData = {
500+ header : { id : "session-xss-id" , timestamp : now ( ) } ,
501+ entries : [
502+ {
503+ id : xssId ,
504+ parentId : null ,
505+ timestamp : now ( ) ,
506+ type : "message" ,
507+ message : { role : "user" , content : "hello" } ,
508+ } ,
509+ {
510+ id : "safe-child" ,
511+ parentId : xssId ,
512+ timestamp : now ( ) ,
513+ type : "message" ,
514+ message : {
515+ role : "assistant" ,
516+ content : [ { type : "text" , text : "world" } ] ,
517+ } ,
518+ } ,
519+ ] ,
520+ leafId : "safe-child" ,
521+ systemPrompt : "" ,
522+ tools : [ ] ,
523+ } ;
524+
525+ const { document } = await renderTemplate ( session ) ;
526+ const messages = requireElement ( document . getElementById ( "messages" ) , "messages root missing" ) ;
527+
528+ // Core XSS prevention: no <script> tags should be injected into the DOM
529+ expect ( messages . querySelectorAll ( "script" ) . length ) . toBe ( 0 ) ;
530+
531+ // No attribute breakout: onmouseover, data-x must not appear as real attributes
532+ expect ( messages . querySelector ( "[onmouseover]" ) ) . toBeNull ( ) ;
533+
534+ // The copy-link button must exist with the payload safely contained
535+ const copyBtn = requireElement (
536+ messages . querySelector ( ".copy-link-btn" ) ,
537+ "copy-link button missing" ,
538+ ) ;
539+ // data-entry-id must be present as a proper attribute (not broken out)
540+ expect ( copyBtn . hasAttribute ( "data-entry-id" ) ) . toBe ( true ) ;
541+ // No stray attributes from the payload
542+ expect ( copyBtn . hasAttribute ( "data-x" ) ) . toBe ( false ) ;
543+
544+ // The user message element must not have attribute breakout either
545+ const userMsg = requireElement (
546+ messages . querySelector ( ".user-message" ) ,
547+ "user message element missing" ,
548+ ) ;
549+ expect ( userMsg . getAttribute ( "data-x" ) ) . toBeNull ( ) ;
550+ // The element id must start with entry- (the payload is contained within)
551+ const elementId = userMsg . getAttribute ( "id" ) ?? "" ;
552+ expect ( elementId . startsWith ( "entry-" ) ) . toBe ( true ) ;
553+ } ) ;
554+
555+ it ( "copy-link round-trip: dataset.entryId matches raw entry.id after browser decoding" , async ( ) => {
556+ // IDs with characters that need HTML escaping but should round-trip correctly
557+ const specialId = `msg-with"quotes&'s` ;
558+ const session : SessionData = {
559+ header : { id : "session-roundtrip" , timestamp : now ( ) } ,
560+ entries : [
561+ {
562+ id : specialId ,
563+ parentId : null ,
564+ timestamp : now ( ) ,
565+ type : "message" ,
566+ message : { role : "user" , content : "test" } ,
567+ } ,
568+ ] ,
569+ leafId : specialId ,
570+ systemPrompt : "" ,
571+ tools : [ ] ,
572+ } ;
573+
574+ const { document } = await renderTemplate ( session ) ;
575+ const messages = requireElement ( document . getElementById ( "messages" ) , "messages root missing" ) ;
576+
577+ // The copy-link button should exist
578+ const copyBtn = requireElement (
579+ messages . querySelector ( ".copy-link-btn" ) ,
580+ "copy-link button missing" ,
581+ ) ;
582+
583+ // Browser decodes HTML entities in dataset reads, so dataset.entryId
584+ // must return the RAW entry.id (not the HTML-escaped version).
585+ // This is essential for buildShareUrl() to produce the correct URL.
586+ const datasetValue = ( copyBtn as HTMLElement ) . dataset . entryId ;
587+ expect ( datasetValue ) . toBe ( specialId ) ;
588+
589+ // The DOM element id must also round-trip: getElementById should find it
590+ const userMsg = document . getElementById ( `entry-${ specialId } ` ) ;
591+ expect ( userMsg ) . not . toBeNull ( ) ;
592+ expect ( userMsg ?. classList . contains ( "user-message" ) ) . toBe ( true ) ;
593+ } ) ;
594+
497595 it ( "escapes markdown data-image attributes" , async ( ) => {
498596 const dataImage = "data:image/png;base64,AAAA" ;
499597 const session : SessionData = {
0 commit comments