@@ -321,7 +321,10 @@ <h1 class="title">Security Lab</h1>
321321 </ div >
322322 </ div >
323323 < div class ="card ">
324- < div class ="k "> Generated Payloads</ div >
324+ < div style ="display:flex; justify-content:space-between; align-items:center; gap:10px; margin-bottom:6px ">
325+ < div class ="k " style ="margin:0 "> Generated Payloads</ div >
326+ < button class ="copy-bulk-btn " id ="ssrfCopyAll "> Copy All</ button >
327+ </ div >
325328 < div id ="ssrfResults " class ="list " style ="max-height: 500px; "> </ div >
326329 </ div >
327330 </ section >
@@ -1237,6 +1240,43 @@ <h1 class="title">Security Lab</h1>
12371240 } ) ;
12381241
12391242 // SSRF Logic
1243+ // Robust clipboard copy that also works over plain HTTP (the Clipboard API is
1244+ // only available in secure contexts, so the dashboard needs an execCommand fallback).
1245+ function ssrfCopyText ( text ) {
1246+ if ( navigator . clipboard && window . isSecureContext ) {
1247+ return navigator . clipboard . writeText ( text ) ;
1248+ }
1249+ return new Promise ( ( resolve , reject ) => {
1250+ try {
1251+ const ta = document . createElement ( 'textarea' ) ;
1252+ ta . value = text ;
1253+ ta . setAttribute ( 'readonly' , '' ) ;
1254+ ta . style . position = 'fixed' ;
1255+ ta . style . top = '-1000px' ;
1256+ ta . style . opacity = '0' ;
1257+ document . body . appendChild ( ta ) ;
1258+ ta . select ( ) ;
1259+ const ok = document . execCommand ( 'copy' ) ;
1260+ document . body . removeChild ( ta ) ;
1261+ ok ? resolve ( ) : reject ( new Error ( 'copy command rejected' ) ) ;
1262+ } catch ( e ) { reject ( e ) ; }
1263+ } ) ;
1264+ }
1265+
1266+ function ssrfFlashBtn ( btn , msg , restore ) {
1267+ if ( ! btn ) return ;
1268+ btn . textContent = msg ;
1269+ setTimeout ( ( ) => { btn . textContent = restore ; } , 1600 ) ;
1270+ }
1271+
1272+ // Filtered render state, kept so Copy All / Copy group operate on exactly what's shown.
1273+ let ssrfFilteredUrls = [ ] ;
1274+ let ssrfGroups = [ ] ;
1275+
1276+ function ssrfCopyAllLabel ( ) {
1277+ return ssrfFilteredUrls . length ? `Copy All (${ ssrfFilteredUrls . length } )` : 'Copy All' ;
1278+ }
1279+
12401280 function renderSSRF ( ) {
12411281 const allowed = $ ( 'ssrfAllowed' ) . value || 'reservation.walibi.nl' ;
12421282 const attacker = $ ( 'ssrfAttacker' ) . value || 'evil.com' ;
@@ -1250,47 +1290,75 @@ <h1 class="title">Security Lab</h1>
12501290 }
12511291
12521292 let html = '' ;
1253- let count = 0 ;
1293+ ssrfFilteredUrls = [ ] ;
1294+ ssrfGroups = [ ] ;
12541295
12551296 window . SSRF_PAYLOADS . forEach ( group => {
12561297 let groupHtml = '' ;
1257- let groupCount = 0 ;
1258-
1298+ const groupUrls = [ ] ;
1299+
12591300 ( group . payloads || [ ] ) . forEach ( p => {
12601301 if ( filter ) {
12611302 const desc = ( p . description || '' ) . toLowerCase ( ) ;
12621303 const tags = ( p . tags || [ ] ) . join ( ' ' ) . toLowerCase ( ) ;
12631304 if ( ! desc . includes ( filter ) && ! tags . includes ( filter ) ) return ;
12641305 }
12651306
1266- let pl = p . payload || '' ;
1267- pl = pl . replace ( / < a l l o w e d > / g, allowed ) . replace ( / < a t t a c k e r > / g, attacker ) ;
1268-
1269- let fullUrl = ( p . prefix || '' ) + pl + ( p . suffix || '' ) ;
1270- let displayUrl = escapeHTML ( fullUrl ) ;
1271-
1307+ let pl = ( p . payload || '' ) . replace ( / < a l l o w e d > / g, allowed ) . replace ( / < a t t a c k e r > / g, attacker ) ;
1308+ const fullUrl = ( p . prefix || '' ) + pl + ( p . suffix || '' ) ;
1309+ const idx = ssrfFilteredUrls . length ;
1310+ ssrfFilteredUrls . push ( fullUrl ) ;
1311+ groupUrls . push ( fullUrl ) ;
1312+
1313+ // data-i indexes ssrfFilteredUrls; copy is handled by delegation below so we
1314+ // never inline-escape payloads (which broke on quotes / backslashes / newlines).
12721315 groupHtml += `<div class="item" style="display:flex; justify-content:space-between; align-items:flex-start; gap:10px;">
12731316 <div style="word-break: break-all;">
1274- <code style="color:var(--text); font-size:14px; display:block; margin-bottom:4px">${ displayUrl } </code>
1317+ <code style="color:var(--text); font-size:14px; display:block; margin-bottom:4px">${ escapeHTML ( fullUrl ) } </code>
12751318 <span class="muted" style="font-size:11px">${ escapeHTML ( p . description || 'No description' ) } </span>
12761319 </div>
1277- <button class="btn ghost" style="padding:4px 8px; font-size:11px" onclick="navigator.clipboard.writeText(' ${ escapeHTML ( fullUrl ) . replace ( / ' / g , "\\'" ) } ') ">Copy</button>
1320+ <button class="btn ghost ssrf-copy-one" data-i=" ${ idx } " style="padding:4px 8px; font-size:11px; flex-shrink:0 ">Copy</button>
12781321 </div>` ;
1279- groupCount ++ ;
1280- count ++ ;
12811322 } ) ;
12821323
1283- if ( groupCount > 0 ) {
1284- html += `<div style="padding: 8px; background: rgba(123,139,255,.05); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10;">
1285- <b style="color:var(--accent)">${ escapeHTML ( group . name ) } </b> <span class="muted">(${ groupCount } )</span>
1324+ if ( groupUrls . length ) {
1325+ const gi = ssrfGroups . length ;
1326+ ssrfGroups . push ( { name : group . name , urls : groupUrls } ) ;
1327+ html += `<div style="padding: 8px; background: rgba(123,139,255,.05); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; display:flex; justify-content:space-between; align-items:center; gap:10px;">
1328+ <span><b style="color:var(--accent)">${ escapeHTML ( group . name ) } </b> <span class="muted">(${ groupUrls . length } )</span></span>
1329+ <button class="btn ghost ssrf-copy-group" data-g="${ gi } " style="padding:3px 8px; font-size:10px; flex-shrink:0">Copy group</button>
12861330 </div>` + groupHtml ;
12871331 }
12881332 } ) ;
12891333
12901334 resultsEl . innerHTML = html || '<div class="item muted">No payloads found.</div>' ;
1291- statsEl . textContent = `${ count } payload(s) generated.` ;
1335+ statsEl . textContent = `${ ssrfFilteredUrls . length } payload(s) generated.` ;
1336+ $ ( 'ssrfCopyAll' ) . textContent = ssrfCopyAllLabel ( ) ;
12921337 }
12931338
1339+ // Single delegated handler for per-payload and per-group copy (survives re-renders).
1340+ $ ( 'ssrfResults' ) . addEventListener ( 'click' , ( e ) => {
1341+ const one = e . target . closest ( '.ssrf-copy-one' ) ;
1342+ if ( one ) {
1343+ const url = ssrfFilteredUrls [ + one . dataset . i ] ;
1344+ if ( url != null ) ssrfCopyText ( url ) . then ( ( ) => ssrfFlashBtn ( one , 'Copied' , 'Copy' ) ) ;
1345+ return ;
1346+ }
1347+ const grp = e . target . closest ( '.ssrf-copy-group' ) ;
1348+ if ( grp ) {
1349+ const urls = ( ssrfGroups [ + grp . dataset . g ] || { } ) . urls || [ ] ;
1350+ if ( urls . length ) ssrfCopyText ( urls . join ( '\n' ) ) . then ( ( ) => ssrfFlashBtn ( grp , `Copied ${ urls . length } ` , 'Copy group' ) ) ;
1351+ }
1352+ } ) ;
1353+
1354+ $ ( 'ssrfCopyAll' ) . addEventListener ( 'click' , function ( ) {
1355+ if ( ! ssrfFilteredUrls . length ) return ;
1356+ ssrfCopyText ( ssrfFilteredUrls . join ( '\n' ) ) . then ( ( ) => {
1357+ this . textContent = `Copied ${ ssrfFilteredUrls . length } !` ;
1358+ setTimeout ( ( ) => { this . textContent = ssrfCopyAllLabel ( ) ; } , 1800 ) ;
1359+ } ) ;
1360+ } ) ;
1361+
12941362 $ ( 'ssrfGenerate' ) . addEventListener ( 'click' , renderSSRF ) ;
12951363 $ ( 'ssrfFilter' ) . addEventListener ( 'input' , renderSSRF ) ;
12961364 if ( window . SSRF_PAYLOADS ) renderSSRF ( ) ;
0 commit comments