11import { describe , expect , it , afterEach } from "vitest" ;
22import type { CallbackRef } from "phoenix_live_view/assets/js/types/view_hook" ;
3- import { parseActionSpec , Toast } from "../../hooks/toast" ;
3+ import {
4+ parseActionSpec ,
5+ parseDomActionSpec ,
6+ parseServerActionSpec ,
7+ Toast ,
8+ } from "../../hooks/toast" ;
49import { getToastStore } from "../../components/toast" ;
510import { callHookDestroyed , callHookMounted , mockHookContext } from "../helpers/mock-hook" ;
611
7- describe ( "parseActionSpec " , ( ) => {
12+ describe ( "parseServerActionSpec " , ( ) => {
813 it ( "parses valid exec_js action" , ( ) => {
914 expect (
10- parseActionSpec ( {
15+ parseServerActionSpec ( {
1116 label : "Undo" ,
1217 effects : [ { kind : "exec_js" , encoded : "abc" } ] ,
1318 } )
@@ -16,7 +21,7 @@ describe("parseActionSpec", () => {
1621
1722 it ( "includes className when present" , ( ) => {
1823 expect (
19- parseActionSpec ( {
24+ parseServerActionSpec ( {
2025 label : "Go" ,
2126 class : " button--sm " ,
2227 effects : [ { kind : "exec_js" , encoded : "x" } ] ,
@@ -26,7 +31,7 @@ describe("parseActionSpec", () => {
2631
2732 it ( "includes labelHtml when present" , ( ) => {
2833 expect (
29- parseActionSpec ( {
34+ parseServerActionSpec ( {
3035 label : "<span>Open</span>" ,
3136 labelHtml : true ,
3237 effects : [ { kind : "exec_js" , encoded : "x" } ] ,
@@ -35,10 +40,37 @@ describe("parseActionSpec", () => {
3540 } ) ;
3641
3742 it ( "returns null for invalid shape" , ( ) => {
38- expect ( parseActionSpec ( null ) ) . toBeNull ( ) ;
39- expect ( parseActionSpec ( { label : "" } ) ) . toBeNull ( ) ;
40- expect ( parseActionSpec ( { label : "X" , effects : [ ] } ) ) . toBeNull ( ) ;
41- expect ( parseActionSpec ( { label : "X" , effects : [ { kind : "other" , encoded : "x" } ] } ) ) . toBeNull ( ) ;
43+ expect ( parseServerActionSpec ( null ) ) . toBeNull ( ) ;
44+ expect ( parseServerActionSpec ( { label : "" } ) ) . toBeNull ( ) ;
45+ expect ( parseServerActionSpec ( { label : "X" , effects : [ ] } ) ) . toBeNull ( ) ;
46+ expect (
47+ parseServerActionSpec ( { label : "X" , effects : [ { kind : "other" , encoded : "x" } ] } )
48+ ) . toBeNull ( ) ;
49+ } ) ;
50+ } ) ;
51+
52+ describe ( "parseDomActionSpec" , ( ) => {
53+ it ( "rejects all action payloads including labelHtml and exec_js" , ( ) => {
54+ expect ( parseDomActionSpec ( null ) ) . toBeNull ( ) ;
55+ expect (
56+ parseDomActionSpec ( {
57+ label : "<img src=x onerror=alert(1)>" ,
58+ labelHtml : true ,
59+ effects : [ { kind : "exec_js" , encoded : "evil" } ] ,
60+ } )
61+ ) . toBeNull ( ) ;
62+ expect (
63+ parseDomActionSpec ( {
64+ label : "Undo" ,
65+ effects : [ { kind : "exec_js" , encoded : "abc" } ] ,
66+ } )
67+ ) . toBeNull ( ) ;
68+ } ) ;
69+ } ) ;
70+
71+ describe ( "parseActionSpec" , ( ) => {
72+ it ( "aliases parseServerActionSpec" , ( ) => {
73+ expect ( parseActionSpec ) . toBe ( parseServerActionSpec ) ;
4274 } ) ;
4375} ) ;
4476
@@ -71,4 +103,48 @@ describe("Toast hook lifecycle", () => {
71103 expect ( getToastStore ( groupId ) ) . toBeUndefined ( ) ;
72104 expect ( el . dataset . toastGroup ) . toBeUndefined ( ) ;
73105 } ) ;
106+
107+ it ( "DOM toast:create ignores untrusted action payloads" , ( ) => {
108+ const el = document . createElement ( "div" ) ;
109+ el . id = groupId ;
110+ document . body . appendChild ( el ) ;
111+
112+ const execJs = ( ) => {
113+ throw new Error ( "execJs should not run" ) ;
114+ } ;
115+
116+ const { hook } = mockHookContext ( el , {
117+ connected : false ,
118+ overrides : {
119+ groupId : "" ,
120+ handlers : [ ] as CallbackRef [ ] ,
121+ domListeners : [ ] as Array < { el : HTMLElement ; name : string ; fn : EventListener } > ,
122+ js : ( ) => ( { exec : execJs } ) ,
123+ } ,
124+ } ) ;
125+
126+ callHookMounted ( Toast , hook ) ;
127+
128+ el . dispatchEvent (
129+ new CustomEvent ( "toast:create" , {
130+ detail : {
131+ id : "dom-toast" ,
132+ title : "Hello" ,
133+ action : {
134+ label : '<img src=x onerror="window.__toastDomXss=1">' ,
135+ labelHtml : true ,
136+ effects : [ { kind : "exec_js" , encoded : "evil" } ] ,
137+ } ,
138+ } ,
139+ } )
140+ ) ;
141+
142+ const action = el . querySelector < HTMLElement > (
143+ '[data-scope="toast"][data-part="action-trigger"]'
144+ ) ;
145+ expect ( action ) . toBeNull ( ) ;
146+
147+ document . body . removeChild ( el ) ;
148+ callHookDestroyed ( Toast , hook ) ;
149+ } ) ;
74150} ) ;
0 commit comments