Translated ['src/pentesting-web/open-redirect.md'] to sw

This commit is contained in:
Translator 2025-10-04 00:07:26 +00:00
parent fa4815b96e
commit bd073ca196
2 changed files with 225 additions and 184 deletions

View File

@ -7,17 +7,17 @@
### Redirect to localhost or arbitrary domains
- Ikiwa app “allows only internal/whitelisted hosts”, jaribu alternative host notations ili kufikia loopback au internal ranges kupitia redirect target:
- Varianti za IPv4 loopback: 127.0.0.1, 127.1, 2130706433 (decimal), 0x7f000001 (hex), 017700000001 (octal)
- Varianti za IPv6 loopback: [::1], [0:0:0:0:0:0:0:1], [::ffff:127.0.0.1]
- Trailing dot na casing: localhost., LOCALHOST, 127.0.0.1.
- Wildcard DNS zinazorejelea loopback: lvh.me, sslip.io (e.g., 127.0.0.1.sslip.io), traefik.me, localtest.me. Hizi ni muhimu wakati tu “subdomains of X” zinazoruhusiwa lakini host resolution bado inaonyesha 127.0.0.1.
- Network-path references mara nyingi hupita naive validators ambazo zinapanga scheme au kuangalia tu prefixes:
- //attacker.tld → humaeleweka kama scheme-relative na hupeleka mtumiaji nje ya tovuti kwa scheme ya sasa.
- Mbinu za userinfo zinavunja checks za contains/startswith dhidi ya trusted hosts:
- https://trusted.tld@attacker.tld/ → browser hupeleka mtumiaji kwenye attacker.tld lakini ukaguzi rahisi wa string unaona trusted.tld.
- Mkanganyiko wa parsing wa backslash kati ya frameworks/browsers:
- https://trusted.tld\@attacker.tld → baadhi ya backends huchukulia “\” kama char ya path na hupitisha validation; browsers hu-normalize hadi “/” na hutafsiri trusted.tld kama userinfo, kupeleka watumiaji kwenye attacker.tld. Hii pia inaonekana katika mismatches ya URL-parser za Node/PHP.
- Ikiwa app “allows only internal/whitelisted hosts”, jaribu notations mbadala za host ili kugonga loopback au ranges za ndani kupitia redirect target:
- IPv4 loopback variants: 127.0.0.1, 127.1, 2130706433 (decimal), 0x7f000001 (hex), 017700000001 (octal)
- IPv6 loopback variants: [::1], [0:0:0:0:0:0:0:1], [::ffff:127.0.0.1]
- Koma ya mwisho na ukubwa wa herufi: localhost., LOCALHOST, 127.0.0.1.
- Wildcard DNS inayotatua kwa loopback: lvh.me, sslip.io (e.g., 127.0.0.1.sslip.io), traefik.me, localtest.me. Hizi zinatumika pale tu “subdomains of X” zinaporuhusiwa lakini utatuzi wa host bado unaelekeza 127.0.0.1.
- Marejeleo ya network-path mara nyingi hupitisha validators dhaifu ambao huweka scheme mwanzoni au kuangalia tu prefixes:
- //attacker.tld → inatafsiriwa kama scheme-relative na inapeleka nje ya tovuti kwa scheme ya sasa.
- Mbinu za userinfo zinavunja ukaguzi wa contains/startswith dhidi ya trusted hosts:
- https://trusted.tld@attacker.tld/ → browser inatembea kwenda attacker.tld lakini ukaguzi rahisi wa string “unaona” trusted.tld.
- Kuchanganyikiwa kwa parsing kwa backslash kati ya frameworks/browsers:
- https://trusted.tld\@attacker.tld → baadhi ya backends hutibu “\” kama path char na kupitisha validation; browsers hurekebisha kuwa “/” na kufasiri trusted.tld kama userinfo, wakitumia watumiaji kwenda attacker.tld. Hii pia inaonekana katika mismatches za Node/PHP URL-parser.
{{#ref}}
ssrf-server-side-request-forgery/url-format-bypass.md
@ -224,15 +224,15 @@ curl -s -I "https://target.tld/redirect?url=//evil.example" | grep -i "^Location
<summary>Click to expand</summary>
```bash
# 1) Kusanya URLs za kihistoria, zihifadhi zile zilizo na common redirect params
# 1) Kusanya URLs za kihistoria, uhifadhi zile zenye redirect params za kawaida
cat domains.txt \
| gau --o urls.txt # or: waybackurls / katana / hakrawler
| gau --o urls.txt # au: waybackurls / katana / hakrawler
# 2) Grep common parameters na sawazisha orodha
# 2) Grep common parameters na normalize orodha
rg -NI "(url=|next=|redir=|redirect|dest=|rurl=|return=|continue=)" urls.txt \
| sed 's/\r$//' | sort -u > candidates.txt
# 3) Tumia OpenRedireX to fuzz with payload corpus
# 3) Tumia OpenRedireX kufanya fuzzing kwa payload corpus
cat candidates.txt | openredirex -p payloads.txt -k FUZZ -c 50 > results.txt
# 4) Thibitisha kwa mkono hits zinazovutia
@ -241,17 +241,21 @@ awk '/30[1237]|Location:/I' results.txt
```
</details>
- Usisahau client-side sinks katika SPAs: angalia window.location/assign/replace na framework helpers zinazosomea query/hash na kufanya redirect.
- Usisahau client-side sinks katika SPAs: angalia window.location/assign/replace na framework helpers ambazo huchukua query/hash na redirect.
- Frameworks mara nyingi huleta footguns wakati redirect destinations zinapotokana na input isiyo ya kuaminika (query params, Referer, cookies). Angalia maelezo ya Next.js kuhusu redirects na epuka dynamic destinations zinazotokana na user input.
- Frameworks mara nyingi huleta footguns wakati redirect destinations zinapotokana na input zisizoaminika (query params, Referer, cookies). Tazama Next.js notes kuhusu redirects na epuka dynamic destinations zinazotokana na user input.
- OAuth/OIDC flows: abusing open redirectors frequently escalates to account takeover by leaking authorization codes/tokens. See dedicated guide:
{{#ref}}
../network-services-pentesting/pentesting-web/nextjs.md
{{#endref}}
- OAuth/OIDC flows: abusing open redirectors mara nyingi hupelekea account takeover kwa leaking authorization codes/tokens. Tazama mwongozo maalum:
{{#ref}}
./oauth-to-account-takeover.md
{{#endref}}
- Majibu ya server yanayotumia redirects bila Location (meta refresh/JavaScript) bado yanaweza kutumiwa kwa phishing na wakati mwingine yanaweza kuunganishwa (chained). Grep for:
- Server responses ambazo zina implement redirects bila Location (meta refresh/JavaScript) bado zinaweza kutumika kwa phishing na zinaweza kuunganishwa wakati mwingine. Grep kwa:
```html
<meta http-equiv="refresh" content="0;url=//evil.example">
<script>location = new URLSearchParams(location.search).get('next')</script>
@ -259,7 +263,7 @@ awk '/30[1237]|Location:/I' results.txt
## Zana
- [https://github.com/0xNanda/Oralyzer](https://github.com/0xNanda/Oralyzer)
- OpenRedireX fuzzer ya kugundua open redirects. Mfano:
- OpenRedireX fuzzer kwa kugundua open redirects. Mfano:
```bash
# Install
git clone https://github.com/devanshbatham/OpenRedireX && cd OpenRedireX && ./setup.sh
@ -269,11 +273,11 @@ cat list_of_urls.txt | ./openredirex.py -p payloads.txt -k FUZZ -c 50
```
## Marejeo
- Kwenye https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Open%20Redirect unaweza kupata orodha za fuzzing.
- Katika https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Open%20Redirect unaweza kupata orodha za fuzzing.
- [https://pentester.land/cheatsheets/2018/11/02/open-redirect-cheatsheet.html](https://pentester.land/cheatsheets/2018/11/02/open-redirect-cheatsheet.html)
- [https://github.com/cujanovic/Open-Redirect-Payloads](https://github.com/cujanovic/Open-Redirect-Payloads)
- [https://infosecwriteups.com/open-redirects-bypassing-csrf-validations-simplified-4215dc4f180a](https://infosecwriteups.com/open-redirects-bypassing-csrf-validations-simplified-4215dc4f180a)
- PortSwigger Web Security Academy DOM-based open redirection: https://portswigger.net/web-security/dom-based/open-redirection
- OpenRedireX A fuzzer for detecting open redirect vulnerabilities: https://github.com/devanshbatham/OpenRedireX
- OpenRedireX Fuzzer kwa kugundua udhaifu za open redirect: https://github.com/devanshbatham/OpenRedireX
{{#include ../banners/hacktricks-training.md}}

View File

@ -6,34 +6,63 @@
*/
(() => {
"use strict";
"use strict";
/* ───────────── 0. helpers (main thread) ───────────── */
const clear = el => { while (el.firstChild) el.removeChild(el.firstChild); };
/* ───────────── 1. WebWorker code ─────────────────── */
const workerCode = `
self.window = self;
self.search = self.search || {};
const abs = p => location.origin + p;
/* 1 — elasticlunr */
try { importScripts('https://cdn.jsdelivr.net/npm/elasticlunr@0.9.5/elasticlunr.min.js'); }
catch { importScripts(abs('/elasticlunr.min.js')); }
/* 2 — decompress gzip data */
async function decompressGzip(arrayBuffer){
if(typeof DecompressionStream !== 'undefined'){
/* Modern browsers: use native DecompressionStream */
const stream = new Response(arrayBuffer).body.pipeThrough(new DecompressionStream('gzip'));
const decompressed = await new Response(stream).arrayBuffer();
return new TextDecoder().decode(decompressed);
} else {
/* Fallback: use pako library */
if(typeof pako === 'undefined'){
try { importScripts('https://cdn.jsdelivr.net/npm/pako@2.1.0/dist/pako.min.js'); }
catch(e){ throw new Error('pako library required for decompression: '+e); }
}
const uint8Array = new Uint8Array(arrayBuffer);
const decompressed = pako.ungzip(uint8Array, {to: 'string'});
return decompressed;
}
}
/* ───────────── 0. helpers (main thread) ───────────── */
const clear = el => { while (el.firstChild) el.removeChild(el.firstChild); };
/* ───────────── 1. WebWorker code ─────────────────── */
const workerCode = `
self.window = self;
self.search = self.search || {};
const abs = p => location.origin + p;
/* 1 — elasticlunr */
try { importScripts('https://cdn.jsdelivr.net/npm/elasticlunr@0.9.5/elasticlunr.min.js'); }
catch { importScripts(abs('/elasticlunr.min.js')); }
/* 2 — load a single index (remote → local) */
/* 3 — load a single index (remote → local) */
async function loadIndex(remote, local, isCloud=false){
let rawLoaded = false;
if(remote){
/* Try ONLY compressed version from GitHub (remote already includes .js.gz) */
try {
const r = await fetch(remote,{mode:'cors'});
if (!r.ok) throw new Error('HTTP '+r.status);
importScripts(URL.createObjectURL(new Blob([await r.text()],{type:'application/javascript'})));
rawLoaded = true;
} catch(e){ console.warn('remote',remote,'failed →',e); }
if (r.ok) {
const compressed = await r.arrayBuffer();
const text = await decompressGzip(compressed);
importScripts(URL.createObjectURL(new Blob([text],{type:'application/javascript'})));
rawLoaded = true;
console.log('Loaded compressed from GitHub:',remote);
}
} catch(e){ console.warn('compressed GitHub',remote,'failed →',e); }
}
/* If remote (GitHub) failed, fall back to local uncompressed file */
if(!rawLoaded && local){
try { importScripts(abs(local)); rawLoaded = true; }
try {
importScripts(abs(local));
rawLoaded = true;
console.log('Loaded local fallback:',local);
}
catch(e){ console.error('local',local,'failed →',e); }
}
if(!rawLoaded) return null; /* give up on this index */
@ -61,151 +90,159 @@
return local ? loadIndex(null, local, isCloud) : null;
}
let built = [];
const MAX = 30, opts = {bool:'AND', expand:true};
self.onmessage = async ({data}) => {
if(data.type === 'init'){
const lang = data.lang || 'en';
const searchindexBase = 'https://raw.githubusercontent.com/HackTricks-wiki/hacktricks-searchindex/master';
(async () => {
const htmlLang = (document.documentElement.lang || 'en').toLowerCase();
const lang = htmlLang.split('-')[0];
const mainReleaseBase = 'https://github.com/HackTricks-wiki/hacktricks/releases/download';
const cloudReleaseBase = 'https://github.com/HackTricks-wiki/hacktricks-cloud/releases/download';
/* Remote sources are .js.gz (compressed), local fallback is .js (uncompressed) */
const mainFilenames = Array.from(new Set(['searchindex-' + lang + '.js.gz', 'searchindex-en.js.gz']));
const cloudFilenames = Array.from(new Set(['searchindex-cloud-' + lang + '.js.gz', 'searchindex-cloud-en.js.gz']));
const mainTags = Array.from(new Set([\`searchindex-\${lang}\`, 'searchindex-en', 'searchindex-master']));
const cloudTags = Array.from(new Set([\`searchindex-\${lang}\`, 'searchindex-en', 'searchindex-master']));
const MAIN_REMOTE_SOURCES = mainFilenames.map(function(filename) { return searchindexBase + '/' + filename; });
const CLOUD_REMOTE_SOURCES = cloudFilenames.map(function(filename) { return searchindexBase + '/' + filename; });
const MAIN_REMOTE_SOURCES = mainTags.map(tag => \`\${mainReleaseBase}/\${tag}/searchindex.js\`);
const CLOUD_REMOTE_SOURCES = cloudTags.map(tag => \`\${cloudReleaseBase}/\${tag}/searchindex.js\`);
const indices = [];
const main = await loadWithFallback(MAIN_REMOTE_SOURCES , '/searchindex.js', false); if(main) indices.push(main);
const cloud= await loadWithFallback(CLOUD_REMOTE_SOURCES, '/searchindex-cloud.js', true ); if(cloud) indices.push(cloud);
if(!indices.length){ postMessage({ready:false, error:'no-index'}); return; }
/* build index objects */
const built = indices.map(d => ({
idx : elasticlunr.Index.load(d.json),
urls: d.urls,
cloud: d.cloud,
base: d.cloud ? 'https://cloud.hacktricks.wiki/' : ''
}));
postMessage({ready:true});
const MAX = 30, opts = {bool:'AND', expand:true};
self.onmessage = ({data:q}) => {
if(!q){ postMessage([]); return; }
const all = [];
for(const s of built){
const res = s.idx.search(q,opts);
if(!res.length) continue;
const max = res[0].score || 1;
res.forEach(r => {
const doc = s.idx.documentStore.getDoc(r.ref);
all.push({
norm : r.score / max,
title: doc.title,
body : doc.body,
breadcrumbs: doc.breadcrumbs,
url : s.base + s.urls[r.ref],
cloud: s.cloud
const indices = [];
const main = await loadWithFallback(MAIN_REMOTE_SOURCES , '/searchindex-book.js', false); if(main) indices.push(main);
const cloud= await loadWithFallback(CLOUD_REMOTE_SOURCES, '/searchindex.js', true ); if(cloud) indices.push(cloud);
if(!indices.length){ postMessage({ready:false, error:'no-index'}); return; }
/* build index objects */
built = indices.map(d => ({
idx : elasticlunr.Index.load(d.json),
urls: d.urls,
cloud: d.cloud,
base: d.cloud ? 'https://cloud.hacktricks.wiki/' : ''
}));
postMessage({ready:true});
return;
}
const q = data.query || data;
if(!q){ postMessage([]); return; }
const all = [];
for(const s of built){
const res = s.idx.search(q,opts);
if(!res.length) continue;
const max = res[0].score || 1;
res.forEach(r => {
const doc = s.idx.documentStore.getDoc(r.ref);
all.push({
norm : r.score / max,
title: doc.title,
body : doc.body,
breadcrumbs: doc.breadcrumbs,
url : s.base + s.urls[r.ref],
cloud: s.cloud
});
});
});
}
all.sort((a,b)=>b.norm-a.norm);
postMessage(all.slice(0,MAX));
};
})();
`;
}
all.sort((a,b)=>b.norm-a.norm);
postMessage(all.slice(0,MAX));
};
`;
/* ───────────── 2. spawn worker ───────────── */
const worker = new Worker(URL.createObjectURL(new Blob([workerCode],{type:'application/javascript'})));
/* ───────────── 2.1. initialize worker with language ───────────── */
const htmlLang = (document.documentElement.lang || 'en').toLowerCase();
const lang = htmlLang.split('-')[0];
worker.postMessage({type: 'init', lang: lang});
/* ───────────── 3. DOM refs ─────────────── */
const wrap = document.getElementById('search-wrapper');
const bar = document.getElementById('searchbar');
const list = document.getElementById('searchresults');
const listOut = document.getElementById('searchresults-outer');
const header = document.getElementById('searchresults-header');
const icon = document.getElementById('search-toggle');
const READY_ICON = icon.innerHTML;
icon.textContent = '⏳';
icon.setAttribute('aria-label','Loading search …');
icon.setAttribute('title','Search is loading, please wait...');
/* ───────────── 2. spawn worker ───────────── */
const worker = new Worker(URL.createObjectURL(new Blob([workerCode],{type:'application/javascript'})));
/* ───────────── 3. DOM refs ─────────────── */
const wrap = document.getElementById('search-wrapper');
const bar = document.getElementById('searchbar');
const list = document.getElementById('searchresults');
const listOut = document.getElementById('searchresults-outer');
const header = document.getElementById('searchresults-header');
const icon = document.getElementById('search-toggle');
const READY_ICON = icon.innerHTML;
icon.textContent = '⏳';
icon.setAttribute('aria-label','Loading search …');
icon.setAttribute('title','Search is loading, please wait...');
const HOT=83, ESC=27, DOWN=40, UP=38, ENTER=13;
let debounce, teaserCount=0;
/* ───────────── helpers (teaser, metric) ───────────── */
const escapeHTML = (()=>{const M={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&#34;','\'':'&#39;'};return s=>s.replace(/[&<>'"]/g,c=>M[c]);})();
const URL_MARK='highlight';
function metric(c,t){return c?`${c} search result${c>1?'s':''} for '${t}':`:`No search results for '${t}'.`;}
function makeTeaser(body,terms){
const stem=w=>elasticlunr.stemmer(w.toLowerCase());
const T=terms.map(stem),W_S=40,W_F=8,W_N=2,WIN=30;
const W=[],sents=body.toLowerCase().split('. ');
let i=0,v=W_F,found=false;
sents.forEach(s=>{v=W_F; s.split(' ').forEach(w=>{ if(w){ if(T.some(t=>stem(w).startsWith(t))){v=W_S;found=true;} W.push([w,v,i]); v=W_N;} i+=w.length+1; }); i++;});
if(!W.length) return body;
const win=Math.min(W.length,WIN);
const sums=[W.slice(0,win).reduce((a,[,wt])=>a+wt,0)];
for(let k=1;k<=W.length-win;k++) sums[k]=sums[k-1]-W[k-1][1]+W[k+win-1][1];
const best=found?sums.lastIndexOf(Math.max(...sums)):0;
const out=[]; i=W[best][2];
for(let k=best;k<best+win;k++){const [w,wt,pos]=W[k]; if(i<pos){out.push(body.substring(i,pos)); i=pos;} if(wt===W_S) out.push('<em>'); out.push(body.substr(pos,w.length)); if(wt===W_S) out.push('</em>'); i=pos+w.length;}
return out.join('');
}
function format(d,terms){
const teaser=makeTeaser(escapeHTML(d.body),terms);
teaserCount++;
const enc=encodeURIComponent(terms.join(' ')).replace(/'/g,'%27');
const parts=d.url.split('#'); if(parts.length===1) parts.push('');
const abs=d.url.startsWith('http');
const href=`${abs?'':path_to_root}${parts[0]}?${URL_MARK}=${enc}#${parts[1]}`;
const style=d.cloud?" style=\"color:#1e88e5\"":"";
const isCloud=d.cloud?" [Cloud]":" [Book]";
return `<a href="${href}" aria-details="teaser_${teaserCount}"${style}>`+
`${d.breadcrumbs}${isCloud}<span class="teaser" id="teaser_${teaserCount}" aria-label="Search Result Teaser">${teaser}</span></a>`;
}
/* ───────────── UI control ───────────── */
function showUI(s){wrap.classList.toggle('hidden',!s); icon.setAttribute('aria-expanded',s); if(s){window.scrollTo(0,0); bar.focus(); bar.select();} else {listOut.classList.add('hidden'); [...list.children].forEach(li=>li.classList.remove('focus'));}}
function blur(){const t=document.createElement('input'); t.style.cssText='position:absolute;opacity:0;'; icon.appendChild(t); t.focus(); t.remove();}
icon.addEventListener('click',()=>showUI(wrap.classList.contains('hidden')));
document.addEventListener('keydown',e=>{
if(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey) return;
const f=/^(?:input|select|textarea)$/i.test(e.target.nodeName);
if(e.keyCode===HOT && !f){e.preventDefault(); showUI(true);} else if(e.keyCode===ESC){e.preventDefault(); showUI(false); blur();}
else if(e.keyCode===DOWN && document.activeElement===bar){e.preventDefault(); const first=list.firstElementChild; if(first){blur(); first.classList.add('focus');}}
else if([DOWN,UP,ENTER].includes(e.keyCode) && document.activeElement!==bar){const cur=list.querySelector('li.focus'); if(!cur) return; e.preventDefault(); if(e.keyCode===DOWN){const nxt=cur.nextElementSibling; if(nxt){cur.classList.remove('focus'); nxt.classList.add('focus');}} else if(e.keyCode===UP){const prv=cur.previousElementSibling; cur.classList.remove('focus'); if(prv){prv.classList.add('focus');} else {bar.focus();}} else {const a=cur.querySelector('a'); if(a) window.location.assign(a.href);}}
});
bar.addEventListener('input',e=>{ clearTimeout(debounce); debounce=setTimeout(()=>worker.postMessage(e.target.value.trim()),120); });
/* ───────────── worker messages ───────────── */
worker.onmessage = ({data}) => {
if(data && data.ready!==undefined){
if(data.ready){
icon.innerHTML=READY_ICON;
icon.setAttribute('aria-label','Open search (S)');
icon.removeAttribute('title');
}
else {
icon.textContent='❌';
icon.setAttribute('aria-label','Search unavailable');
icon.setAttribute('title','Search is unavailable');
}
return;
const HOT=83, ESC=27, DOWN=40, UP=38, ENTER=13;
let debounce, teaserCount=0;
/* ───────────── helpers (teaser, metric) ───────────── */
const escapeHTML = (()=>{const M={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&#34;','\'':'&#39;'};return s=>s.replace(/[&<>'"]/g,c=>M[c]);})();
const URL_MARK='highlight';
function metric(c,t){return c?`${c} search result${c>1?'s':''} for '${t}':`:`No search results for '${t}'.`;}
function makeTeaser(body,terms){
const stem=w=>elasticlunr.stemmer(w.toLowerCase());
const T=terms.map(stem),W_S=40,W_F=8,W_N=2,WIN=30;
const W=[],sents=body.toLowerCase().split('. ');
let i=0,v=W_F,found=false;
sents.forEach(s=>{v=W_F; s.split(' ').forEach(w=>{ if(w){ if(T.some(t=>stem(w).startsWith(t))){v=W_S;found=true;} W.push([w,v,i]); v=W_N;} i+=w.length+1; }); i++;});
if(!W.length) return body;
const win=Math.min(W.length,WIN);
const sums=[W.slice(0,win).reduce((a,[,wt])=>a+wt,0)];
for(let k=1;k<=W.length-win;k++) sums[k]=sums[k-1]-W[k-1][1]+W[k+win-1][1];
const best=found?sums.lastIndexOf(Math.max(...sums)):0;
const out=[]; i=W[best][2];
for(let k=best;k<best+win;k++){const [w,wt,pos]=W[k]; if(i<pos){out.push(body.substring(i,pos)); i=pos;} if(wt===W_S) out.push('<em>'); out.push(body.substr(pos,w.length)); if(wt===W_S) out.push('</em>'); i=pos+w.length;}
return out.join('');
}
const docs=data, q=bar.value.trim(), terms=q.split(/\s+/).filter(Boolean);
header.textContent=metric(docs.length,q);
clear(list);
docs.forEach(d=>{const li=document.createElement('li'); li.innerHTML=format(d,terms); list.appendChild(li);});
listOut.classList.toggle('hidden',!docs.length);
};
})();
function format(d,terms){
const teaser=makeTeaser(escapeHTML(d.body),terms);
teaserCount++;
const enc=encodeURIComponent(terms.join(' ')).replace(/'/g,'%27');
const parts=d.url.split('#'); if(parts.length===1) parts.push('');
const abs=d.url.startsWith('http');
const href=`${abs?'':path_to_root}${parts[0]}?${URL_MARK}=${enc}#${parts[1]}`;
const style=d.cloud?" style=\"color:#1e88e5\"":"";
const isCloud=d.cloud?" [Cloud]":" [Book]";
return `<a href="${href}" aria-details="teaser_${teaserCount}"${style}>`+
`${d.breadcrumbs}${isCloud}<span class="teaser" id="teaser_${teaserCount}" aria-label="Search Result Teaser">${teaser}</span></a>`;
}
/* ───────────── UI control ───────────── */
function showUI(s){wrap.classList.toggle('hidden',!s); icon.setAttribute('aria-expanded',s); if(s){window.scrollTo(0,0); bar.focus(); bar.select();} else {listOut.classList.add('hidden'); [...list.children].forEach(li=>li.classList.remove('focus'));}}
function blur(){const t=document.createElement('input'); t.style.cssText='position:absolute;opacity:0;'; icon.appendChild(t); t.focus(); t.remove();}
icon.addEventListener('click',()=>showUI(wrap.classList.contains('hidden')));
document.addEventListener('keydown',e=>{
if(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey) return;
const f=/^(?:input|select|textarea)$/i.test(e.target.nodeName);
if(e.keyCode===HOT && !f){e.preventDefault(); showUI(true);} else if(e.keyCode===ESC){e.preventDefault(); showUI(false); blur();}
else if(e.keyCode===DOWN && document.activeElement===bar){e.preventDefault(); const first=list.firstElementChild; if(first){blur(); first.classList.add('focus');}}
else if([DOWN,UP,ENTER].includes(e.keyCode) && document.activeElement!==bar){const cur=list.querySelector('li.focus'); if(!cur) return; e.preventDefault(); if(e.keyCode===DOWN){const nxt=cur.nextElementSibling; if(nxt){cur.classList.remove('focus'); nxt.classList.add('focus');}} else if(e.keyCode===UP){const prv=cur.previousElementSibling; cur.classList.remove('focus'); if(prv){prv.classList.add('focus');} else {bar.focus();}} else {const a=cur.querySelector('a'); if(a) window.location.assign(a.href);}}
});
bar.addEventListener('input',e=>{ clearTimeout(debounce); debounce=setTimeout(()=>worker.postMessage({query: e.target.value.trim()}),120); });
/* ───────────── worker messages ───────────── */
worker.onmessage = ({data}) => {
if(data && data.ready!==undefined){
if(data.ready){
icon.innerHTML=READY_ICON;
icon.setAttribute('aria-label','Open search (S)');
icon.removeAttribute('title');
}
else {
icon.textContent='❌';
icon.setAttribute('aria-label','Search unavailable');
icon.setAttribute('title','Search is unavailable');
}
return;
}
const docs=data, q=bar.value.trim(), terms=q.split(/\s+/).filter(Boolean);
header.textContent=metric(docs.length,q);
clear(list);
docs.forEach(d=>{const li=document.createElement('li'); li.innerHTML=format(d,terms); list.appendChild(li);});
listOut.classList.toggle('hidden',!docs.length);
};
})();