diff --git a/packages/coding-agent/src/web/search/providers/searxng.ts b/packages/coding-agent/src/web/search/providers/searxng.ts index f9baf2611..c2a40106e 100644 --- a/packages/coding-agent/src/web/search/providers/searxng.ts +++ b/packages/coding-agent/src/web/search/providers/searxng.ts @@ -119,6 +119,11 @@ function buildBasicAuthValue(username: string, password: string): string { return Buffer.from(`${username}:${password}`, "utf-8").toString("base64"); } +/** RFC 7617 forbids C0 and C1 control characters in Basic auth credentials. */ +function hasControlCharacters(value: string): boolean { + return /[\u0000-\u001F\u007F-\u009F]/u.test(value); +} + /** Find SearXNG authentication from settings or environment. Basic auth takes precedence over bearer tokens. */ function findAuth(): SearXNGAuth | null { const basicUsername = findBasicUsername(); @@ -132,6 +137,9 @@ function findAuth(): SearXNGAuth | null { if (basicUsername.includes(":")) { throw new Error("SearXNG Basic auth username cannot contain ':' because RFC 7617 uses it as the separator."); } + if (hasControlCharacters(basicUsername) || hasControlCharacters(basicPassword)) { + throw new Error("SearXNG Basic auth credentials must not contain RFC 7617 control characters."); + } return { type: "basic", value: buildBasicAuthValue(basicUsername, basicPassword) }; } diff --git a/packages/coding-agent/test/tools/web-search-searxng.test.ts b/packages/coding-agent/test/tools/web-search-searxng.test.ts index 8f8fcaff7..3ac128118 100644 --- a/packages/coding-agent/test/tools/web-search-searxng.test.ts +++ b/packages/coding-agent/test/tools/web-search-searxng.test.ts @@ -1,10 +1,15 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; import { hookFetch } from "@oh-my-pi/pi-utils"; +import { _resetSettingsForTest, Settings } from "../../src/config/settings"; import { searchSearXNG } from "../../src/web/search/providers/searxng"; describe("SearXNG web search provider", () => { afterEach(() => { vi.restoreAllMocks(); + _resetSettingsForTest(); delete process.env.SEARXNG_ENDPOINT; delete process.env.SEARXNG_TOKEN; delete process.env.SEARXNG_BASIC_USERNAME; @@ -46,6 +51,40 @@ describe("SearXNG web search provider", () => { }); }); + it("reads Basic auth credentials from nested config.yml settings", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "searxng-settings-")); + try { + await Bun.write( + path.join(agentDir, "config.yml"), + [ + "searxng:", + " endpoint: https://searx.example.org", + " basicUsername: alice", + " basicPassword: s3cret", + "", + ].join("\n"), + ); + await Settings.init({ agentDir }); + + const captured: { headers?: Headers } = {}; + using _hook = hookFetch((_input, init) => { + captured.headers = new Headers(init?.headers); + return new Response(JSON.stringify({ results: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await searchSearXNG({ query: "settings basic auth" }); + + expect(captured.headers?.get("Authorization")).toBe( + `Basic ${Buffer.from("alice:s3cret", "utf-8").toString("base64")}`, + ); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + it("prefers Basic auth over bearer token when both are configured", async () => { process.env.SEARXNG_ENDPOINT = "https://searx.example.org"; process.env.SEARXNG_TOKEN = "bearer-token"; @@ -136,6 +175,26 @@ describe("SearXNG web search provider", () => { ); }); + it("rejects Basic auth usernames containing control characters", async () => { + process.env.SEARXNG_ENDPOINT = "https://searx.example.org"; + process.env.SEARXNG_BASIC_USERNAME = "alice\u0007"; + process.env.SEARXNG_BASIC_PASSWORD = "s3cret"; + + await expect(searchSearXNG({ query: "invalid username control character" })).rejects.toThrow( + "SearXNG Basic auth credentials must not contain RFC 7617 control characters", + ); + }); + + it("rejects Basic auth passwords containing control characters", async () => { + process.env.SEARXNG_ENDPOINT = "https://searx.example.org"; + process.env.SEARXNG_BASIC_USERNAME = "alice"; + process.env.SEARXNG_BASIC_PASSWORD = "s3cret\u0001"; + + await expect(searchSearXNG({ query: "invalid password control character" })).rejects.toThrow( + "SearXNG Basic auth credentials must not contain RFC 7617 control characters", + ); + }); + it("keeps bearer token authentication when Basic auth is not configured", async () => { process.env.SEARXNG_ENDPOINT = "https://searx.example.org"; process.env.SEARXNG_TOKEN = "bearer-token";