mirror of
https://github.com/HackTricks-wiki/hacktricks.git
synced 2025-10-10 18:36:50 +00:00
Translated ['src/pentesting-web/h2c-smuggling.md'] to ko
This commit is contained in:
parent
5f447c33d9
commit
48b27f3b60
@ -6,25 +6,25 @@
|
||||
|
||||
#### HTTP2 Over Cleartext (H2C) <a href="#http2-over-cleartext-h2c" id="http2-over-cleartext-h2c"></a>
|
||||
|
||||
H2C, 또는 **http2 over cleartext**는 표준 HTTP **연결을 지속적인 연결로 업그레이드**하여 일시적인 HTTP 연결의 규범에서 벗어납니다. 이 업그레이드된 연결은 평문 HTTP의 단일 요청 특성과는 달리 지속적인 통신을 위해 http2 이진 프로토콜을 사용합니다.
|
||||
H2C, 또는 **http2 over cleartext**, 표준 HTTP **연결을 지속적인 연결로 업그레이드**하여 일시적인 HTTP 연결의 규범에서 벗어납니다. 이 업그레이드된 연결은 평문 HTTP의 단일 요청 특성 대신 지속적인 통신을 위해 http2 이진 프로토콜을 사용합니다.
|
||||
|
||||
밀수 문제의 핵심은 **리버스 프록시**의 사용에서 발생합니다. 일반적으로 리버스 프록시는 HTTP 요청을 처리하고 백엔드로 전달한 후 백엔드의 응답을 반환합니다. 그러나 HTTP 요청에 `Connection: Upgrade` 헤더가 있을 경우(웹소켓 연결에서 일반적으로 볼 수 있음), 리버스 **프록시는 클라이언트와 서버 간의 지속적인 연결을 유지**하여 특정 프로토콜에서 요구하는 지속적인 교환을 용이하게 합니다. H2C 연결의 경우, RFC 준수를 위해 세 가지 특정 헤더가 필요합니다:
|
||||
밀수 문제의 핵심은 **리버스 프록시**의 사용에서 발생합니다. 일반적으로 리버스 프록시는 HTTP 요청을 처리하고 백엔드로 전달하며, 그 후 백엔드의 응답을 반환합니다. 그러나 HTTP 요청에 `Connection: Upgrade` 헤더가 포함되어 있을 때(웹소켓 연결에서 일반적으로 볼 수 있음), 리버스 **프록시는 클라이언트와 서버 간의 지속적인 연결을 유지**하여 특정 프로토콜에서 요구하는 지속적인 교환을 용이하게 합니다. H2C 연결의 경우, RFC 준수를 위해 세 가지 특정 헤더가 필요합니다:
|
||||
```
|
||||
Upgrade: h2c
|
||||
HTTP2-Settings: AAMAAABkAARAAAAAAAIAAAAA
|
||||
Connection: Upgrade, HTTP2-Settings
|
||||
```
|
||||
취약점은 연결을 업그레이드한 후 리버스 프록시가 개별 요청을 관리하는 것을 중단하고, 연결 설정 후 라우팅 작업이 완료되었다고 가정할 때 발생합니다. H2C Smuggling을 이용하면 요청 처리 중 적용된 리버스 프록시 규칙(예: 경로 기반 라우팅, 인증 및 WAF 처리)을 우회할 수 있으며, H2C 연결이 성공적으로 시작되었다고 가정합니다.
|
||||
취약점은 연결을 업그레이드한 후, 리버스 프록시가 개별 요청을 관리하지 않게 되어 연결 설정 후 라우팅 작업이 완료되었다고 가정할 때 발생합니다. H2C Smuggling을 이용하면 요청 처리 중 적용된 리버스 프록시 규칙(예: 경로 기반 라우팅, 인증 및 WAF 처리)을 우회할 수 있으며, H2C 연결이 성공적으로 시작되었다고 가정합니다.
|
||||
|
||||
#### 취약한 프록시 <a href="#exploitation" id="exploitation"></a>
|
||||
|
||||
취약점은 리버스 프록시가 `Upgrade` 및 때때로 `Connection` 헤더를 처리하는 방식에 따라 달라집니다. 다음 프록시는 프록시 패스 중 이러한 헤더를 본질적으로 전달하여 H2C 스머글링을 본질적으로 가능하게 합니다:
|
||||
취약점은 리버스 프록시가 `Upgrade` 및 때때로 `Connection` 헤더를 처리하는 방식에 따라 달라집니다. 다음 프록시는 프록시 패스 중 이러한 헤더를 본질적으로 전달하여 H2C 스머글링을 가능하게 합니다:
|
||||
|
||||
- HAProxy
|
||||
- Traefik
|
||||
- Nuster
|
||||
|
||||
반대로, 이러한 서비스는 프록시 패스 중 두 헤더를 본질적으로 전달하지 않습니다. 그러나 불안전하게 구성될 수 있어 `Upgrade` 및 `Connection` 헤더의 필터링되지 않은 전달을 허용할 수 있습니다:
|
||||
반대로, 이러한 서비스는 프록시 패스 중 두 헤더를 본질적으로 전달하지 않습니다. 그러나 불안전하게 구성될 수 있어 `Upgrade` 및 `Connection` 헤더의 필터링 없는 전달을 허용할 수 있습니다:
|
||||
|
||||
- AWS ALB/CLB
|
||||
- NGINX
|
||||
@ -37,7 +37,7 @@ Connection: Upgrade, HTTP2-Settings
|
||||
|
||||
#### 악용 <a href="#exploitation" id="exploitation"></a>
|
||||
|
||||
모든 서버가 H2C 연결 업그레이드에 필요한 헤더를 본질적으로 전달하지 않는다는 점에 유의해야 합니다. 따라서 AWS ALB/CLB, NGINX 및 Apache Traffic Server와 같은 서버는 자연스럽게 H2C 연결을 차단합니다. 그럼에도 불구하고, 일부 백엔드가 표준을 준수하지 않을 수 있으므로 `Connection: Upgrade` 변형을 테스트해 볼 가치가 있습니다. 이 변형은 `Connection` 헤더에서 `HTTP2-Settings` 값을 제외합니다.
|
||||
모든 서버가 H2C 연결 업그레이드에 필요한 헤더를 본질적으로 전달하지 않는다는 점에 유의해야 합니다. 따라서 AWS ALB/CLB, NGINX 및 Apache Traffic Server와 같은 서버는 자연스럽게 H2C 연결을 차단합니다. 그럼에도 불구하고, `Connection: Upgrade` 변형을 사용하여 테스트해 볼 가치가 있으며, 이는 `Connection` 헤더에서 `HTTP2-Settings` 값을 제외합니다. 일부 백엔드는 표준을 준수하지 않을 수 있습니다.
|
||||
|
||||
> [!CAUTION]
|
||||
> `proxy_pass` URL에 지정된 특정 **경로**(예: `http://backend:9999/socket.io`)와 관계없이, 설정된 연결은 기본적으로 `http://backend:9999`로 설정됩니다. 이는 이 기술을 활용하여 해당 내부 엔드포인트 내의 모든 경로와 상호작용할 수 있게 합니다. 따라서 `proxy_pass` URL에 경로를 지정하는 것은 접근을 제한하지 않습니다.
|
||||
@ -48,15 +48,15 @@ Connection: Upgrade, HTTP2-Settings
|
||||
|
||||
## 웹소켓 스머글링
|
||||
|
||||
웹소켓 스머글링은 프록시를 통해 접근 가능한 엔드포인트에 HTTP2 터널을 생성하는 것과 달리, 잠재적인 프록시 제한을 우회하고 엔드포인트와 직접 통신하기 위해 웹소켓 터널을 설정합니다.
|
||||
웹소켓 스머글링은 프록시를 통해 접근할 수 있는 엔드포인트에 HTTP2 터널을 생성하는 것과 달리, 잠재적인 프록시 제한을 우회하고 엔드포인트와 직접 통신하기 위해 웹소켓 터널을 설정합니다.
|
||||
|
||||
### 시나리오 1
|
||||
|
||||
이 시나리오에서는 공용 웹소켓 API와 접근할 수 없는 내부 REST API를 제공하는 백엔드가 악의적인 클라이언트의 공격 대상이 됩니다. 공격은 여러 단계로 진행됩니다:
|
||||
이 시나리오에서는 공개 웹소켓 API와 접근할 수 없는 내부 REST API를 제공하는 백엔드가 악의적인 클라이언트의 공격 대상이 됩니다. 공격은 여러 단계로 진행됩니다:
|
||||
|
||||
1. 클라이언트는 잘못된 `Sec-WebSocket-Version` 프로토콜 버전을 헤더에 포함하여 리버스 프록시로 업그레이드 요청을 보냅니다. 프록시는 `Sec-WebSocket-Version` 헤더를 검증하지 못하고 업그레이드 요청이 유효하다고 믿고 이를 백엔드로 전달합니다.
|
||||
2. 백엔드는 `Sec-WebSocket-Version` 헤더의 잘못된 프로토콜 버전을 나타내는 상태 코드 `426`으로 응답합니다. 리버스 프록시는 백엔드의 응답 상태를 간과하고 웹소켓 통신 준비가 완료되었다고 가정하며 응답을 클라이언트에게 전달합니다.
|
||||
3. 결과적으로 리버스 프록시는 클라이언트와 백엔드 간에 웹소켓 연결이 설정되었다고 잘못 믿게 되지만, 실제로 백엔드는 업그레이드 요청을 거부했습니다. 그럼에도 불구하고 프록시는 클라이언트와 백엔드 간에 열린 TCP 또는 TLS 연결을 유지하여 클라이언트가 이 연결을 통해 비공식 REST API에 무제한으로 접근할 수 있게 합니다.
|
||||
1. 클라이언트는 잘못된 `Sec-WebSocket-Version` 프로토콜 버전을 헤더에 포함하여 리버스 프록시로 업그레이드 요청을 보냅니다. 프록시는 `Sec-WebSocket-Version` 헤더를 검증하지 못하고 업그레이드 요청이 유효하다고 믿고 백엔드로 전달합니다.
|
||||
2. 백엔드는 `Sec-WebSocket-Version` 헤더의 잘못된 프로토콜 버전을 나타내는 상태 코드 `426`으로 응답합니다. 리버스 프록시는 백엔드의 응답 상태를 간과하고 웹소켓 통신 준비가 완료되었다고 가정하며 클라이언트에게 응답을 전달합니다.
|
||||
3. 결과적으로 리버스 프록시는 클라이언트와 백엔드 간에 웹소켓 연결이 설정되었다고 잘못 믿게 되며, 실제로는 백엔드가 업그레이드 요청을 거부했습니다. 그럼에도 불구하고 프록시는 클라이언트와 백엔드 간에 열린 TCP 또는 TLS 연결을 유지하여 클라이언트가 이 연결을 통해 비공식 REST API에 무제한으로 접근할 수 있게 합니다.
|
||||
|
||||
영향을 받는 리버스 프록시에는 문제를 해결하지 않기로 결정한 Varnish와 업그레이드 메커니즘이 변경된 Envoy 프록시 버전 1.8.0 이하가 포함됩니다. 다른 프록시도 취약할 수 있습니다.
|
||||
|
||||
@ -64,10 +64,10 @@ Connection: Upgrade, HTTP2-Settings
|
||||
|
||||
### 시나리오 2
|
||||
|
||||
이 시나리오는 공용 웹소켓 API와 건강 검사를 위한 공용 REST API, 그리고 접근할 수 없는 내부 REST API를 가진 백엔드를 포함합니다. 공격은 더 복잡하며 다음 단계로 진행됩니다:
|
||||
이 시나리오는 공개 웹소켓 API와 건강 검사를 위한 공개 REST API, 그리고 접근할 수 없는 내부 REST API를 가진 백엔드를 포함합니다. 공격은 더 복잡하며 다음 단계로 진행됩니다:
|
||||
|
||||
1. 클라이언트는 건강 검사 API를 트리거하기 위해 POST 요청을 보내고, 추가 HTTP 헤더 `Upgrade: websocket`을 포함합니다. NGINX는 리버스 프록시로서 이를 `Upgrade` 헤더만 기반으로 표준 업그레이드 요청으로 해석하고 요청의 다른 측면을 무시하며 백엔드로 전달합니다.
|
||||
2. 백엔드는 건강 검사 API를 실행하고, 공격자가 제어하는 외부 리소스에 접근하여 상태 코드 `101`이 포함된 HTTP 응답을 반환합니다. 이 응답은 백엔드에 의해 수신되고 NGINX로 전달되며, 프록시는 상태 코드만 검증하여 웹소켓 연결이 설정되었다고 착각하게 됩니다.
|
||||
1. 클라이언트는 건강 검사 API를 트리거하기 위해 POST 요청을 보내고 추가 HTTP 헤더 `Upgrade: websocket`을 포함합니다. NGINX는 리버스 프록시로서 이를 `Upgrade` 헤더만 기반으로 표준 업그레이드 요청으로 해석하고 요청의 다른 측면을 무시하며 백엔드로 전달합니다.
|
||||
2. 백엔드는 건강 검사 API를 실행하고 공격자가 제어하는 외부 리소스에 접근하여 상태 코드 `101`이 포함된 HTTP 응답을 반환합니다. 이 응답은 백엔드에 의해 수신되고 NGINX로 전달되며, 프록시는 상태 코드만 검증하여 웹소켓 연결이 설정되었다고 잘못 생각하게 됩니다.
|
||||
|
||||

|
||||
|
||||
@ -81,9 +81,9 @@ Connection: Upgrade, HTTP2-Settings
|
||||
|
||||
#### 실습
|
||||
|
||||
[https://github.com/0ang3el/websocket-smuggle.git](https://github.com/0ang3el/websocket-smuggle.git)에서 두 시나리오를 테스트할 수 있는 실습을 확인하십시오.
|
||||
두 시나리오를 테스트하기 위한 실습을 확인하십시오 [https://github.com/0ang3el/websocket-smuggle.git](https://github.com/0ang3el/websocket-smuggle.git)
|
||||
|
||||
### 참고 문헌
|
||||
### 참고자료
|
||||
|
||||
- [https://blog.assetnote.io/2021/03/18/h2c-smuggling/](https://blog.assetnote.io/2021/03/18/h2c-smuggling/)
|
||||
- [https://bishopfox.com/blog/h2c-smuggling-request](https://bishopfox.com/blog/h2c-smuggling-request)
|
||||
|
10
theme/elasticlunr.min.js
vendored
Normal file
10
theme/elasticlunr.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -1,554 +1,40 @@
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
Polyfill so requestIdleCallback works everywhere (IE 11/Safari)
|
||||
─────────────────────────────────────────────────────────────── */
|
||||
if (typeof window.requestIdleCallback !== "function") {
|
||||
window.requestIdleCallback = function (cb) {
|
||||
const start = Date.now();
|
||||
return setTimeout(function () {
|
||||
cb({
|
||||
didTimeout: false,
|
||||
timeRemaining: function () {
|
||||
return Math.max(0, 50 - (Date.now() - start));
|
||||
}
|
||||
});
|
||||
}, 1);
|
||||
};
|
||||
window.cancelIdleCallback = window.clearTimeout;
|
||||
}
|
||||
|
||||
/* ht_searcher.js --------------------------------------------------------- */
|
||||
(() => {
|
||||
const WRAPPER = document.getElementById('search-wrapper');
|
||||
const TOGGLE = document.getElementById('search-toggle');
|
||||
const INPUT = document.getElementById('searchbar');
|
||||
const LIST = document.getElementById('searchresults');
|
||||
const HOTKEY = 83; // “s”
|
||||
let worker, debounce;
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
search.js
|
||||
─────────────────────────────────────────────────────────────── */
|
||||
|
||||
"use strict";
|
||||
window.search = window.search || {};
|
||||
(function search(search) {
|
||||
// Search functionality
|
||||
//
|
||||
// You can use !hasFocus() to prevent keyhandling in your key
|
||||
// event handlers while the user is typing their search.
|
||||
|
||||
if (!Mark || !elasticlunr) {
|
||||
return;
|
||||
function startWorker() {
|
||||
if (worker) return;
|
||||
worker = new Worker('/search-worker.js', { type:'module' });
|
||||
worker.onmessage = ({data}) => {
|
||||
LIST.innerHTML = data.slice(0,30).map(h =>
|
||||
`<li><a href="${h.doc.url}">${h.doc.title}</a></li>`
|
||||
).join('');
|
||||
};
|
||||
}
|
||||
|
||||
//IE 11 Compatibility from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith
|
||||
if (!String.prototype.startsWith) {
|
||||
String.prototype.startsWith = function(search, pos) {
|
||||
return this.substr(!pos || pos < 0 ? 0 : +pos, search.length) === search;
|
||||
};
|
||||
|
||||
async function openUI() {
|
||||
WRAPPER.classList.remove('hidden');
|
||||
INPUT.focus();
|
||||
startWorker(); // fetches CDN/GitHub in parallel
|
||||
}
|
||||
|
||||
var search_wrap = document.getElementById('search-wrapper'),
|
||||
search_modal = document.getElementById('search-modal'),
|
||||
searchbar = document.getElementById('searchbar'),
|
||||
searchbar_outer = document.getElementById('searchbar-outer'),
|
||||
searchresults = document.getElementById('searchresults'),
|
||||
searchresults_outer = document.getElementById('searchresults-outer'),
|
||||
searchresults_header = document.getElementById('searchresults-header'),
|
||||
searchicon = document.getElementById('search-toggle'),
|
||||
content = document.getElementById('content'),
|
||||
|
||||
searchindex = null,
|
||||
doc_urls = [],
|
||||
results_options = {
|
||||
teaser_word_count: 30,
|
||||
limit_results: 30,
|
||||
},
|
||||
search_options = {
|
||||
bool: "AND",
|
||||
expand: true,
|
||||
fields: {
|
||||
title: {boost: 1},
|
||||
body: {boost: 1},
|
||||
breadcrumbs: {boost: 0}
|
||||
}
|
||||
},
|
||||
mark_exclude = [],
|
||||
marker = new Mark(content),
|
||||
current_searchterm = "",
|
||||
URL_SEARCH_PARAM = 'search',
|
||||
URL_MARK_PARAM = 'highlight',
|
||||
teaser_count = 0,
|
||||
|
||||
SEARCH_HOTKEY_KEYCODE = 83,
|
||||
ESCAPE_KEYCODE = 27,
|
||||
DOWN_KEYCODE = 40,
|
||||
UP_KEYCODE = 38,
|
||||
SELECT_KEYCODE = 13;
|
||||
|
||||
function hasFocus() {
|
||||
return searchbar === document.activeElement;
|
||||
}
|
||||
|
||||
function removeChildren(elem) {
|
||||
while (elem.firstChild) {
|
||||
elem.removeChild(elem.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to parse a url into its building blocks.
|
||||
function parseURL(url) {
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
return {
|
||||
source: url,
|
||||
protocol: a.protocol.replace(':',''),
|
||||
host: a.hostname,
|
||||
port: a.port,
|
||||
params: (function(){
|
||||
var ret = {};
|
||||
var seg = a.search.replace(/^\?/,'').split('&');
|
||||
var len = seg.length, i = 0, s;
|
||||
for (;i<len;i++) {
|
||||
if (!seg[i]) { continue; }
|
||||
s = seg[i].split('=');
|
||||
ret[s[0]] = s[1];
|
||||
}
|
||||
return ret;
|
||||
})(),
|
||||
file: (a.pathname.match(/\/([^/?#]+)$/i) || [,''])[1],
|
||||
hash: a.hash.replace('#',''),
|
||||
path: a.pathname.replace(/^([^/])/,'/$1')
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to recreate a url string from its building blocks.
|
||||
function renderURL(urlobject) {
|
||||
var url = urlobject.protocol + "://" + urlobject.host;
|
||||
if (urlobject.port != "") {
|
||||
url += ":" + urlobject.port;
|
||||
}
|
||||
url += urlobject.path;
|
||||
var joiner = "?";
|
||||
for(var prop in urlobject.params) {
|
||||
if(urlobject.params.hasOwnProperty(prop)) {
|
||||
url += joiner + prop + "=" + urlobject.params[prop];
|
||||
joiner = "&";
|
||||
}
|
||||
}
|
||||
if (urlobject.hash != "") {
|
||||
url += "#" + urlobject.hash;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// Helper to escape html special chars for displaying the teasers
|
||||
var escapeHTML = (function() {
|
||||
var MAP = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
var repl = function(c) { return MAP[c]; };
|
||||
return function(s) {
|
||||
return s.replace(/[&<>'"]/g, repl);
|
||||
};
|
||||
})();
|
||||
|
||||
function formatSearchMetric(count, searchterm) {
|
||||
if (count == 1) {
|
||||
return count + " search result for '" + searchterm + "':";
|
||||
} else if (count == 0) {
|
||||
return "No search results for '" + searchterm + "'.";
|
||||
} else {
|
||||
return count + " search results for '" + searchterm + "':";
|
||||
}
|
||||
}
|
||||
|
||||
function formatSearchResult(result, searchterms) {
|
||||
var teaser = makeTeaser(escapeHTML(result.doc.body), searchterms);
|
||||
teaser_count++;
|
||||
|
||||
// The ?URL_MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor
|
||||
var url = doc_urls[result.ref].split("#");
|
||||
if (url.length == 1) { // no anchor found
|
||||
url.push("");
|
||||
}
|
||||
|
||||
// encodeURIComponent escapes all chars that could allow an XSS except
|
||||
// for '. Due to that we also manually replace ' with its url-encoded
|
||||
// representation (%27).
|
||||
var searchterms = encodeURIComponent(searchterms.join(" ")).replace(/\'/g, "%27");
|
||||
|
||||
return '<a href="' + path_to_root + url[0] + '?' + URL_MARK_PARAM + '=' + searchterms + '#' + url[1]
|
||||
+ '" aria-details="teaser_' + teaser_count + '">' + result.doc.breadcrumbs
|
||||
+ '<span class="teaser" id="teaser_' + teaser_count + '" aria-label="Search Result Teaser">'
|
||||
+ teaser + '</span>' + '</a>';
|
||||
}
|
||||
|
||||
function makeTeaser(body, searchterms) {
|
||||
// The strategy is as follows:
|
||||
// First, assign a value to each word in the document:
|
||||
// Words that correspond to search terms (stemmer aware): 40
|
||||
// Normal words: 2
|
||||
// First word in a sentence: 8
|
||||
// Then use a sliding window with a constant number of words and count the
|
||||
// sum of the values of the words within the window. Then use the window that got the
|
||||
// maximum sum. If there are multiple maximas, then get the last one.
|
||||
// Enclose the terms in <em>.
|
||||
var stemmed_searchterms = searchterms.map(function(w) {
|
||||
return elasticlunr.stemmer(w.toLowerCase());
|
||||
});
|
||||
var searchterm_weight = 40;
|
||||
var weighted = []; // contains elements of ["word", weight, index_in_document]
|
||||
// split in sentences, then words
|
||||
var sentences = body.toLowerCase().split('. ');
|
||||
var index = 0;
|
||||
var value = 0;
|
||||
var searchterm_found = false;
|
||||
for (var sentenceindex in sentences) {
|
||||
var words = sentences[sentenceindex].split(' ');
|
||||
value = 8;
|
||||
for (var wordindex in words) {
|
||||
var word = words[wordindex];
|
||||
if (word.length > 0) {
|
||||
for (var searchtermindex in stemmed_searchterms) {
|
||||
if (elasticlunr.stemmer(word).startsWith(stemmed_searchterms[searchtermindex])) {
|
||||
value = searchterm_weight;
|
||||
searchterm_found = true;
|
||||
}
|
||||
};
|
||||
weighted.push([word, value, index]);
|
||||
value = 2;
|
||||
}
|
||||
index += word.length;
|
||||
index += 1; // ' ' or '.' if last word in sentence
|
||||
};
|
||||
index += 1; // because we split at a two-char boundary '. '
|
||||
};
|
||||
|
||||
if (weighted.length == 0) {
|
||||
return body;
|
||||
}
|
||||
|
||||
var window_weight = [];
|
||||
var window_size = Math.min(weighted.length, results_options.teaser_word_count);
|
||||
|
||||
var cur_sum = 0;
|
||||
for (var wordindex = 0; wordindex < window_size; wordindex++) {
|
||||
cur_sum += weighted[wordindex][1];
|
||||
};
|
||||
window_weight.push(cur_sum);
|
||||
for (var wordindex = 0; wordindex < weighted.length - window_size; wordindex++) {
|
||||
cur_sum -= weighted[wordindex][1];
|
||||
cur_sum += weighted[wordindex + window_size][1];
|
||||
window_weight.push(cur_sum);
|
||||
};
|
||||
|
||||
if (searchterm_found) {
|
||||
var max_sum = 0;
|
||||
var max_sum_window_index = 0;
|
||||
// backwards
|
||||
for (var i = window_weight.length - 1; i >= 0; i--) {
|
||||
if (window_weight[i] > max_sum) {
|
||||
max_sum = window_weight[i];
|
||||
max_sum_window_index = i;
|
||||
}
|
||||
};
|
||||
} else {
|
||||
max_sum_window_index = 0;
|
||||
}
|
||||
|
||||
// add <em/> around searchterms
|
||||
var teaser_split = [];
|
||||
var index = weighted[max_sum_window_index][2];
|
||||
for (var i = max_sum_window_index; i < max_sum_window_index+window_size; i++) {
|
||||
var word = weighted[i];
|
||||
if (index < word[2]) {
|
||||
// missing text from index to start of `word`
|
||||
teaser_split.push(body.substring(index, word[2]));
|
||||
index = word[2];
|
||||
}
|
||||
if (word[1] == searchterm_weight) {
|
||||
teaser_split.push("<em>")
|
||||
}
|
||||
index = word[2] + word[0].length;
|
||||
teaser_split.push(body.substring(word[2], index));
|
||||
if (word[1] == searchterm_weight) {
|
||||
teaser_split.push("</em>")
|
||||
}
|
||||
};
|
||||
|
||||
return teaser_split.join('');
|
||||
}
|
||||
|
||||
function init(config) {
|
||||
results_options = config.results_options;
|
||||
search_options = config.search_options;
|
||||
searchbar_outer = config.searchbar_outer;
|
||||
doc_urls = config.doc_urls;
|
||||
searchindex = elasticlunr.Index.load(config.index);
|
||||
|
||||
// Set up events
|
||||
searchicon.addEventListener('click', function(e) { searchIconClickHandler(); }, false);
|
||||
search_wrap.addEventListener('click', function(e) { searchIconClickHandler(); }, false);
|
||||
search_modal.addEventListener('click', function(e) { e.stopPropagation(); }, false);
|
||||
searchbar.addEventListener('keyup', function(e) { searchbarKeyUpHandler(); }, false);
|
||||
document.addEventListener('keydown', function(e) { globalKeyHandler(e); }, false);
|
||||
// If the user uses the browser buttons, do the same as if a reload happened
|
||||
window.onpopstate = function(e) { doSearchOrMarkFromUrl(); };
|
||||
// Suppress "submit" events so the page doesn't reload when the user presses Enter
|
||||
document.addEventListener('submit', function(e) { e.preventDefault(); }, false);
|
||||
|
||||
// If reloaded, do the search or mark again, depending on the current url parameters
|
||||
doSearchOrMarkFromUrl();
|
||||
}
|
||||
|
||||
function unfocusSearchbar() {
|
||||
// hacky, but just focusing a div only works once
|
||||
var tmp = document.createElement('input');
|
||||
tmp.setAttribute('style', 'position: absolute; opacity: 0;');
|
||||
searchicon.appendChild(tmp);
|
||||
tmp.focus();
|
||||
tmp.remove();
|
||||
}
|
||||
|
||||
// On reload or browser history backwards/forwards events, parse the url and do search or mark
|
||||
function doSearchOrMarkFromUrl() {
|
||||
// Check current URL for search request
|
||||
var url = parseURL(window.location.href);
|
||||
if (url.params.hasOwnProperty(URL_SEARCH_PARAM)
|
||||
&& url.params[URL_SEARCH_PARAM] != "") {
|
||||
showSearch(true);
|
||||
searchbar.value = decodeURIComponent(
|
||||
(url.params[URL_SEARCH_PARAM]+'').replace(/\+/g, '%20'));
|
||||
searchbarKeyUpHandler(); // -> doSearch()
|
||||
} else {
|
||||
showSearch(false);
|
||||
}
|
||||
|
||||
if (url.params.hasOwnProperty(URL_MARK_PARAM)) {
|
||||
var words = decodeURIComponent(url.params[URL_MARK_PARAM]).split(' ');
|
||||
marker.mark(words, {
|
||||
exclude: mark_exclude
|
||||
});
|
||||
|
||||
var markers = document.querySelectorAll("mark");
|
||||
function hide() {
|
||||
for (var i = 0; i < markers.length; i++) {
|
||||
markers[i].classList.add("fade-out");
|
||||
window.setTimeout(function(e) { marker.unmark(); }, 300);
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < markers.length; i++) {
|
||||
markers[i].addEventListener('click', hide);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Eventhandler for keyevents on `document`
|
||||
function globalKeyHandler(e) {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.target.type === 'textarea' || e.target.type === 'text' || !hasFocus() && /^(?:input|select|textarea)$/i.test(e.target.nodeName)) { return; }
|
||||
|
||||
if (e.keyCode === ESCAPE_KEYCODE) {
|
||||
e.preventDefault();
|
||||
searchbar.classList.remove("active");
|
||||
setSearchUrlParameters("",
|
||||
(searchbar.value.trim() !== "") ? "push" : "replace");
|
||||
if (hasFocus()) {
|
||||
unfocusSearchbar();
|
||||
}
|
||||
showSearch(false);
|
||||
marker.unmark();
|
||||
} else if (!hasFocus() && e.keyCode === SEARCH_HOTKEY_KEYCODE) {
|
||||
e.preventDefault();
|
||||
showSearch(true);
|
||||
window.scrollTo(0, 0);
|
||||
searchbar.select();
|
||||
} else if (hasFocus() && e.keyCode === DOWN_KEYCODE) {
|
||||
e.preventDefault();
|
||||
unfocusSearchbar();
|
||||
searchresults.firstElementChild.classList.add("focus");
|
||||
} else if (!hasFocus() && (e.keyCode === DOWN_KEYCODE
|
||||
|| e.keyCode === UP_KEYCODE
|
||||
|| e.keyCode === SELECT_KEYCODE)) {
|
||||
// not `:focus` because browser does annoying scrolling
|
||||
var focused = searchresults.querySelector("li.focus");
|
||||
if (!focused) return;
|
||||
e.preventDefault();
|
||||
if (e.keyCode === DOWN_KEYCODE) {
|
||||
var next = focused.nextElementSibling;
|
||||
if (next) {
|
||||
focused.classList.remove("focus");
|
||||
next.classList.add("focus");
|
||||
}
|
||||
} else if (e.keyCode === UP_KEYCODE) {
|
||||
focused.classList.remove("focus");
|
||||
var prev = focused.previousElementSibling;
|
||||
if (prev) {
|
||||
prev.classList.add("focus");
|
||||
} else {
|
||||
searchbar.select();
|
||||
}
|
||||
} else { // SELECT_KEYCODE
|
||||
window.location.assign(focused.querySelector('a'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showSearch(yes) {
|
||||
if (yes) {
|
||||
search_wrap.classList.remove('hidden');
|
||||
searchicon.setAttribute('aria-expanded', 'true');
|
||||
} else {
|
||||
search_wrap.classList.add('hidden');
|
||||
searchicon.setAttribute('aria-expanded', 'false');
|
||||
var results = searchresults.children;
|
||||
for (var i = 0; i < results.length; i++) {
|
||||
results[i].classList.remove("focus");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showResults(yes) {
|
||||
if (yes) {
|
||||
searchresults_outer.classList.remove('hidden');
|
||||
} else {
|
||||
searchresults_outer.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Eventhandler for search icon
|
||||
function searchIconClickHandler() {
|
||||
if (search_wrap.classList.contains('hidden')) {
|
||||
showSearch(true);
|
||||
window.scrollTo(0, 0);
|
||||
searchbar.select();
|
||||
} else {
|
||||
showSearch(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Eventhandler for keyevents while the searchbar is focused
|
||||
function searchbarKeyUpHandler() {
|
||||
var searchterm = searchbar.value.trim();
|
||||
if (searchterm != "") {
|
||||
searchbar.classList.add("active");
|
||||
doSearch(searchterm);
|
||||
} else {
|
||||
searchbar.classList.remove("active");
|
||||
showResults(false);
|
||||
removeChildren(searchresults);
|
||||
}
|
||||
|
||||
setSearchUrlParameters(searchterm, "push_if_new_search_else_replace");
|
||||
|
||||
// Remove marks
|
||||
marker.unmark();
|
||||
}
|
||||
|
||||
// Update current url with ?URL_SEARCH_PARAM= parameter, remove ?URL_MARK_PARAM and #heading-anchor .
|
||||
// `action` can be one of "push", "replace", "push_if_new_search_else_replace"
|
||||
// and replaces or pushes a new browser history item.
|
||||
// "push_if_new_search_else_replace" pushes if there is no `?URL_SEARCH_PARAM=abc` yet.
|
||||
function setSearchUrlParameters(searchterm, action) {
|
||||
var url = parseURL(window.location.href);
|
||||
var first_search = ! url.params.hasOwnProperty(URL_SEARCH_PARAM);
|
||||
if (searchterm != "" || action == "push_if_new_search_else_replace") {
|
||||
url.params[URL_SEARCH_PARAM] = searchterm;
|
||||
delete url.params[URL_MARK_PARAM];
|
||||
url.hash = "";
|
||||
} else {
|
||||
delete url.params[URL_MARK_PARAM];
|
||||
delete url.params[URL_SEARCH_PARAM];
|
||||
}
|
||||
// A new search will also add a new history item, so the user can go back
|
||||
// to the page prior to searching. A updated search term will only replace
|
||||
// the url.
|
||||
if (action == "push" || (action == "push_if_new_search_else_replace" && first_search) ) {
|
||||
history.pushState({}, document.title, renderURL(url));
|
||||
} else if (action == "replace" || (action == "push_if_new_search_else_replace" && !first_search) ) {
|
||||
history.replaceState({}, document.title, renderURL(url));
|
||||
}
|
||||
}
|
||||
|
||||
function doSearch(searchterm) {
|
||||
|
||||
// Don't search the same twice
|
||||
if (current_searchterm == searchterm) { return; }
|
||||
else { current_searchterm = searchterm; }
|
||||
|
||||
if (searchindex == null) { return; }
|
||||
|
||||
// Do the actual search
|
||||
var results = searchindex.search(searchterm, search_options);
|
||||
var resultcount = Math.min(results.length, results_options.limit_results);
|
||||
|
||||
// Display search metrics
|
||||
searchresults_header.innerText = formatSearchMetric(resultcount, searchterm);
|
||||
|
||||
// Clear and insert results
|
||||
var searchterms = searchterm.split(' ');
|
||||
removeChildren(searchresults);
|
||||
for(var i = 0; i < resultcount ; i++){
|
||||
var resultElem = document.createElement('li');
|
||||
resultElem.innerHTML = formatSearchResult(results[i], searchterms);
|
||||
searchresults.appendChild(resultElem);
|
||||
}
|
||||
|
||||
// Display results
|
||||
showResults(true);
|
||||
}
|
||||
|
||||
(async function loadSearchIndex(lang = window.lang || "en") {
|
||||
const branch = lang === "en" ? "master" : lang;
|
||||
const rawUrl =
|
||||
`https://raw.githubusercontent.com/HackTricks-wiki/hacktricks/refs/heads/${branch}/searchindex.js`;
|
||||
const localJs = "/searchindex.js";
|
||||
const TIMEOUT_MS = 10_000;
|
||||
|
||||
const injectScript = (src) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const s = document.createElement("script");
|
||||
s.src = src;
|
||||
s.onload = () => resolve(src);
|
||||
s.onerror = (e) => reject(e);
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
|
||||
try {
|
||||
/* 1 — download raw JS from GitHub */
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
const res = await fetch(rawUrl, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
/* 2 — wrap in a Blob so the browser sees application/javascript */
|
||||
const code = await res.text();
|
||||
const blobUrl = URL.createObjectURL(
|
||||
new Blob([code], { type: "application/javascript" })
|
||||
);
|
||||
|
||||
/* 3 — execute it */
|
||||
await injectScript(blobUrl);
|
||||
|
||||
/* ───────────── PATCH ─────────────
|
||||
heavy parsing now deferred to idle time
|
||||
*/
|
||||
requestIdleCallback(() => init(window.search));
|
||||
return; // ✔ UI remains responsive
|
||||
} catch (eRemote) {
|
||||
console.warn("Remote JS failed →", eRemote);
|
||||
}
|
||||
|
||||
/* ───────── fallback: local copy ───────── */
|
||||
try {
|
||||
await injectScript(localJs);
|
||||
|
||||
/* ───────────── PATCH ───────────── */
|
||||
requestIdleCallback(() => init(window.search));
|
||||
return;
|
||||
} catch (eLocal) {
|
||||
console.error("Local JS failed →", eLocal);
|
||||
}
|
||||
})();
|
||||
|
||||
// Exported functions
|
||||
search.hasFocus = hasFocus;
|
||||
})(window.search);
|
||||
|
||||
TOGGLE.addEventListener('click', openUI);
|
||||
document.addEventListener('keydown', e => {
|
||||
if (!e.metaKey && !e.ctrlKey && !e.altKey && e.keyCode === HOTKEY) {
|
||||
e.preventDefault(); openUI();
|
||||
}
|
||||
});
|
||||
|
||||
INPUT.addEventListener('input', e => {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(() => {
|
||||
worker?.postMessage(e.target.value.trim());
|
||||
}, 120); // small debounce keeps typing smooth
|
||||
});
|
||||
})();
|
||||
|
40
theme/search-worker.js
Normal file
40
theme/search-worker.js
Normal file
@ -0,0 +1,40 @@
|
||||
/* search-worker.js ------------------------------------------------------- */
|
||||
/* Make code written for window work in a worker: */
|
||||
self.window = self;
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 1. elasticlunr.min.js : CDN first → local fallback
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
try {
|
||||
importScripts('https://cdn.jsdelivr.net/npm/elasticlunr@0.9.5/elasticlunr.min.js');
|
||||
} catch (e) {
|
||||
importScripts('/elasticlunr.min.js'); // ship this with your site
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 2. searchindex.js : GitHub Raw first → local fallback
|
||||
// We fetch → wrap in a Blob({type:'application/javascript'}) to bypass
|
||||
// GitHub’s text/plain + nosniff MIME blocking.
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
try {
|
||||
const res = await fetch(
|
||||
'https://raw.githubusercontent.com/HackTricks-wiki/hacktricks/refs/heads/master/searchindex.js',
|
||||
{mode: 'cors'}
|
||||
);
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const blobUrl = URL.createObjectURL(
|
||||
new Blob([await res.text()], { type:'application/javascript' })
|
||||
);
|
||||
importScripts(blobUrl); // correct MIME, runs once
|
||||
} catch (e) {
|
||||
importScripts('/searchindex.js'); // offline fallback
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 3. Build the index once and answer queries
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
const idx = elasticlunr.Index.load(self.search.index);
|
||||
|
||||
self.onmessage = ({data: q}) => {
|
||||
postMessage(idx.search(q, { bool:'AND', expand:true }));
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user