fix(search): unblock SearXNG 403 — config + headers
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 53s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 53s
SearXNG returned 403 for every query, logging
'X-Forwarded-For nor X-Real-IP header is set!'. Two fixes, both needed:
1. searxng/settings.yml was being overwritten by SearXNG's default
config in fresh volumes. Explicitly set limiter: false,
public_instance: false, and move secret_key to env lookup via
${SEARXNG_SECRET:-…}. Force a well-known JSON format list.
2. Even with the limiter off, SearXNG's bot detection still nags on
missing forwarder headers. The Node client now sends
X-Forwarded-For: 127.0.0.1, X-Real-IP: 127.0.0.1 and Accept: json
deterministically. Done via a new extraHeaders option on the http
wrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,31 @@
|
||||
use_default_settings: true
|
||||
|
||||
server:
|
||||
secret_key: 'dev-secret-change-in-prod'
|
||||
# In production override via env (see docker-compose.prod.yml).
|
||||
secret_key: ${SEARXNG_SECRET:-dev-secret-change-in-prod}
|
||||
# Disables rate limiter + bot detection. This is a private internal service
|
||||
# called only by kochwas — no public exposure, no abuse risk.
|
||||
limiter: false
|
||||
public_instance: false
|
||||
image_proxy: false
|
||||
default_http_headers:
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Download-Options: noopen
|
||||
X-Robots-Tag: noindex, nofollow
|
||||
|
||||
search:
|
||||
formats:
|
||||
- html
|
||||
- json
|
||||
safe_search: 0
|
||||
autocomplete: ''
|
||||
default_lang: 'de'
|
||||
|
||||
ui:
|
||||
default_locale: de
|
||||
|
||||
# Quieten engines that fail on cold start and aren't useful here
|
||||
enabled_plugins:
|
||||
- 'Hash plugin'
|
||||
- 'Tracker URL remover'
|
||||
- 'Open Access DOI rewrite'
|
||||
|
||||
@@ -2,9 +2,10 @@ export type FetchOptions = {
|
||||
maxBytes?: number;
|
||||
timeoutMs?: number;
|
||||
userAgent?: string;
|
||||
extraHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
const DEFAULTS: Required<FetchOptions> = {
|
||||
const DEFAULTS: Required<Omit<FetchOptions, 'extraHeaders'>> = {
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
timeoutMs: 10_000,
|
||||
userAgent: 'Kochwas/0.1'
|
||||
@@ -57,14 +58,19 @@ async function readBody(
|
||||
|
||||
async function doFetch(url: string, opts: FetchOptions): Promise<Response> {
|
||||
assertSafeUrl(url);
|
||||
const merged = { ...DEFAULTS, ...opts };
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULTS.timeoutMs;
|
||||
const userAgent = opts.userAgent ?? DEFAULTS.userAgent;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), merged.timeoutMs);
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const headers: Record<string, string> = {
|
||||
'user-agent': userAgent,
|
||||
...(opts.extraHeaders ?? {})
|
||||
};
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
headers: { 'user-agent': merged.userAgent }
|
||||
headers
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
return res;
|
||||
@@ -74,9 +80,9 @@ async function doFetch(url: string, opts: FetchOptions): Promise<Response> {
|
||||
}
|
||||
|
||||
export async function fetchText(url: string, opts: FetchOptions = {}): Promise<string> {
|
||||
const merged = { ...DEFAULTS, ...opts };
|
||||
const res = await doFetch(url, merged);
|
||||
const { data } = await readBody(res, merged.maxBytes);
|
||||
const maxBytes = opts.maxBytes ?? DEFAULTS.maxBytes;
|
||||
const res = await doFetch(url, opts);
|
||||
const { data } = await readBody(res, maxBytes);
|
||||
return new TextDecoder('utf-8').decode(data);
|
||||
}
|
||||
|
||||
@@ -84,8 +90,8 @@ export async function fetchBuffer(
|
||||
url: string,
|
||||
opts: FetchOptions = {}
|
||||
): Promise<{ data: Uint8Array; contentType: string | null }> {
|
||||
const merged = { ...DEFAULTS, ...opts };
|
||||
const res = await doFetch(url, merged);
|
||||
const { data } = await readBody(res, merged.maxBytes);
|
||||
const maxBytes = opts.maxBytes ?? DEFAULTS.maxBytes;
|
||||
const res = await doFetch(url, opts);
|
||||
const { data } = await readBody(res, maxBytes);
|
||||
return { data, contentType: res.headers.get('content-type') };
|
||||
}
|
||||
|
||||
@@ -96,7 +96,16 @@ export async function searchWeb(
|
||||
endpoint.searchParams.set('format', 'json');
|
||||
endpoint.searchParams.set('language', 'de');
|
||||
|
||||
const body = await fetchText(endpoint.toString(), { timeoutMs: 15_000 });
|
||||
const body = await fetchText(endpoint.toString(), {
|
||||
timeoutMs: 15_000,
|
||||
// SearXNG's bot detection complains without these; we are the only caller
|
||||
// and we're not a bot, so satisfy the check deterministically.
|
||||
extraHeaders: {
|
||||
'X-Forwarded-For': '127.0.0.1',
|
||||
'X-Real-IP': '127.0.0.1',
|
||||
Accept: 'application/json'
|
||||
}
|
||||
});
|
||||
let parsed: SearxngResponse;
|
||||
try {
|
||||
parsed = JSON.parse(body) as SearxngResponse;
|
||||
|
||||
Reference in New Issue
Block a user