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
|
use_default_settings: true
|
||||||
|
|
||||||
server:
|
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
|
limiter: false
|
||||||
|
public_instance: false
|
||||||
image_proxy: false
|
image_proxy: false
|
||||||
default_http_headers:
|
default_http_headers:
|
||||||
X-Content-Type-Options: nosniff
|
X-Content-Type-Options: nosniff
|
||||||
X-Download-Options: noopen
|
X-Download-Options: noopen
|
||||||
X-Robots-Tag: noindex, nofollow
|
X-Robots-Tag: noindex, nofollow
|
||||||
|
|
||||||
search:
|
search:
|
||||||
formats:
|
formats:
|
||||||
- html
|
- html
|
||||||
- json
|
- json
|
||||||
|
safe_search: 0
|
||||||
|
autocomplete: ''
|
||||||
|
default_lang: 'de'
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
default_locale: de
|
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;
|
maxBytes?: number;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
|
extraHeaders?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULTS: Required<FetchOptions> = {
|
const DEFAULTS: Required<Omit<FetchOptions, 'extraHeaders'>> = {
|
||||||
maxBytes: 10 * 1024 * 1024,
|
maxBytes: 10 * 1024 * 1024,
|
||||||
timeoutMs: 10_000,
|
timeoutMs: 10_000,
|
||||||
userAgent: 'Kochwas/0.1'
|
userAgent: 'Kochwas/0.1'
|
||||||
@@ -57,14 +58,19 @@ async function readBody(
|
|||||||
|
|
||||||
async function doFetch(url: string, opts: FetchOptions): Promise<Response> {
|
async function doFetch(url: string, opts: FetchOptions): Promise<Response> {
|
||||||
assertSafeUrl(url);
|
assertSafeUrl(url);
|
||||||
const merged = { ...DEFAULTS, ...opts };
|
const timeoutMs = opts.timeoutMs ?? DEFAULTS.timeoutMs;
|
||||||
|
const userAgent = opts.userAgent ?? DEFAULTS.userAgent;
|
||||||
const controller = new AbortController();
|
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 {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
headers: { 'user-agent': merged.userAgent }
|
headers
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||||||
return res;
|
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> {
|
export async function fetchText(url: string, opts: FetchOptions = {}): Promise<string> {
|
||||||
const merged = { ...DEFAULTS, ...opts };
|
const maxBytes = opts.maxBytes ?? DEFAULTS.maxBytes;
|
||||||
const res = await doFetch(url, merged);
|
const res = await doFetch(url, opts);
|
||||||
const { data } = await readBody(res, merged.maxBytes);
|
const { data } = await readBody(res, maxBytes);
|
||||||
return new TextDecoder('utf-8').decode(data);
|
return new TextDecoder('utf-8').decode(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +90,8 @@ export async function fetchBuffer(
|
|||||||
url: string,
|
url: string,
|
||||||
opts: FetchOptions = {}
|
opts: FetchOptions = {}
|
||||||
): Promise<{ data: Uint8Array; contentType: string | null }> {
|
): Promise<{ data: Uint8Array; contentType: string | null }> {
|
||||||
const merged = { ...DEFAULTS, ...opts };
|
const maxBytes = opts.maxBytes ?? DEFAULTS.maxBytes;
|
||||||
const res = await doFetch(url, merged);
|
const res = await doFetch(url, opts);
|
||||||
const { data } = await readBody(res, merged.maxBytes);
|
const { data } = await readBody(res, maxBytes);
|
||||||
return { data, contentType: res.headers.get('content-type') };
|
return { data, contentType: res.headers.get('content-type') };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,16 @@ export async function searchWeb(
|
|||||||
endpoint.searchParams.set('format', 'json');
|
endpoint.searchParams.set('format', 'json');
|
||||||
endpoint.searchParams.set('language', 'de');
|
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;
|
let parsed: SearxngResponse;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(body) as SearxngResponse;
|
parsed = JSON.parse(body) as SearxngResponse;
|
||||||
|
|||||||
Reference in New Issue
Block a user