fix(search): unblock SearXNG 403 — config + headers
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:
2026-04-17 16:56:13 +02:00
parent 24058bcb77
commit 570a524d86
3 changed files with 43 additions and 12 deletions

View File

@@ -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'

View File

@@ -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') };
}

View File

@@ -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;