From 8a527fec91390f36eafdbade01f5c2361b6ed881 Mon Sep 17 00:00:00 2001 From: zmf Date: Thu, 13 Aug 2026 11:50:00 +0700 Subject: [PATCH] fix(security): SSRF guard on search baseUrl, default-password remote login, and request-details redaction - resolveBaseUrl() rejects client-supplied non-public baseUrls via assertPublicUrl (SSRF guard on /v1/search) - fresh-install remote login with default password returns 403 without issuing a JWT - /api/usage/request-details redacts request/providerRequest/providerResponse/response payloads - declare chalk and prop-types in package.json (used but previously undeclared) --- open-sse/handlers/search/callers.js | 21 ++++++++ package.json | 2 + src/app/api/auth/login/route.js | 27 ++++++++-- src/app/api/usage/request-details/route.js | 19 ++++++- tests/unit/request-details-redaction.test.js | 54 ++++++++++++++++++++ tests/unit/search-ssrf-guard.test.js | 49 ++++++++++++++++++ 6 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 tests/unit/request-details-redaction.test.js create mode 100644 tests/unit/search-ssrf-guard.test.js diff --git a/open-sse/handlers/search/callers.js b/open-sse/handlers/search/callers.js index e32d93ed..3c02828e 100644 --- a/open-sse/handlers/search/callers.js +++ b/open-sse/handlers/search/callers.js @@ -29,6 +29,8 @@ * @property {Record} [providerSpecificData] */ +import { assertPublicUrl } from "../../../src/shared/utils/ssrfGuard.js"; + // ── Helpers ───────────────────────────────────────────────────────────── /** @@ -63,12 +65,31 @@ export function getProviderSetting(params, key) { /** * Resolve base URL with optional override from providerOptions.baseUrl. + * + * The override is client-controlled and therefore SSRF-hardened: only public + * http(s) URLs are accepted (internal/private/loopback/metadata addresses are + * rejected via assertPublicUrl). The provider's own configured baseUrl is + * trusted as-is (admin-controlled). + * * @param {SearchProviderConfig} config * @param {SearchRequestParams} params * @returns {string} */ export function resolveBaseUrl(config, params) { const override = getProviderSetting(params, "baseUrl"); + if (override) { + // SSRF guard: client-supplied base URLs must be public http(s) only. + let parsed; + try { + parsed = new URL(override); + } catch { + throw new Error(`Invalid baseUrl: ${override}`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`Invalid baseUrl protocol: ${parsed.protocol}`); + } + assertPublicUrl(override); + } return (override || config.baseUrl).replace(/\/+$/, ""); } diff --git a/package.json b/package.json index 4374a515..2b25bdbf 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@next/third-parties": "^16.2.9", "@xyflow/react": "^12.10.1", "bcryptjs": "^3.0.3", + "chalk": "^5.6.2", "confbox": "^0.2.4", "express": "^5.2.1", "http-proxy-middleware": "^3.0.5", @@ -37,6 +38,7 @@ "node-machine-id": "^1.1.12", "open": "^11.0.0", "ora": "^9.1.0", + "prop-types": "^15.8.1", "react": "19.2.4", "react-dom": "19.2.4", "react-is": "^16.13.1", diff --git a/src/app/api/auth/login/route.js b/src/app/api/auth/login/route.js index cbd3c6ab..b7a069f5 100644 --- a/src/app/api/auth/login/route.js +++ b/src/app/api/auth/login/route.js @@ -54,15 +54,36 @@ export async function POST(request) { if (isValid) { recordSuccess(ip); - const cookieStore = await cookies(); - await setDashboardAuthCookie(cookieStore, request); // Default password still in use on a remote client → force a password // change before the dashboard is exposed remotely (keeps local UX intact). const mustChangePassword = !storedHash && !process.env.INITIAL_PASSWORD && !isLocalRequest(request); - return NextResponse.json({ success: true, mustChangePassword }, { headers: NO_STORE_HEADERS }); + if (mustChangePassword) { + // Do NOT issue a session token: a fresh install's default password is + // public knowledge ("123456"), so handing out a valid JWT would let any + // remote attacker authenticate and (e.g.) PATCH /api/settings to disable + // authentication entirely (CVE-2026-56679 class). Require the password + // to be changed first. + // + // NOTE: this intentionally leaves no remote self-service password-change + // path — the change-password flow (PATCH /api/settings) requires a JWT, + // which we deliberately withhold. A remote fresh-install user must either + // change the password from the local machine or set INITIAL_PASSWORD + // before first launch. This is a deliberate security trade-off, not an + // oversight: issuing any credential before the default password is + // rotated re-opens the exact attack chain this branch closes. + return NextResponse.json( + { success: false, error: "Default password must be changed before remote access. Change it from the local machine (or set INITIAL_PASSWORD).", mustChangePassword }, + { status: 403, headers: NO_STORE_HEADERS } + ); + } + + const cookieStore = await cookies(); + await setDashboardAuthCookie(cookieStore, request); + + return NextResponse.json({ success: true, mustChangePassword: false }, { headers: NO_STORE_HEADERS }); } const { remainingBeforeLock } = recordFail(ip); diff --git a/src/app/api/usage/request-details/route.js b/src/app/api/usage/request-details/route.js index 9b154497..20989889 100644 --- a/src/app/api/usage/request-details/route.js +++ b/src/app/api/usage/request-details/route.js @@ -47,8 +47,23 @@ export async function GET(request) { if (endDate) filter.endDate = endDate; const result = await getRequestDetails(filter); - - return NextResponse.json(result); + + // Redact conversation payloads: the stored details include full request + // bodies (user prompts, tool calls) and provider responses. Returning them + // wholesale lets any dashboard-authenticated user (or, if requireLogin is + // disabled, anyone) read every user's conversation history. Keep the + // metadata (model, tokens, latency, status) but drop message content. + const redactedDetails = (result.details || []).map((d) => { + const redacted = { ...d }; + for (const key of ["request", "providerRequest", "providerResponse", "response"]) { + if (redacted[key] !== undefined) { + redacted[key] = { redacted: true }; + } + } + return redacted; + }); + + return NextResponse.json({ ...result, details: redactedDetails }); } catch (error) { console.error("[API] Failed to get request details:", error); return NextResponse.json( diff --git a/tests/unit/request-details-redaction.test.js b/tests/unit/request-details-redaction.test.js new file mode 100644 index 00000000..23850be6 --- /dev/null +++ b/tests/unit/request-details-redaction.test.js @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; + +// Mirror the redaction logic from src/app/api/usage/request-details/route.js +// so we can test it in isolation. +function redactDetails(details) { + return (details || []).map((d) => { + const redacted = { ...d }; + for (const key of ["request", "providerRequest", "providerResponse", "response"]) { + if (redacted[key] !== undefined) { + redacted[key] = { redacted: true }; + } + } + return redacted; + }); +} + +describe("request-details redaction", () => { + it("removes conversation payloads but keeps metadata", () => { + const details = [{ + id: "abc", + provider: "opencode", + model: "deepseek-v4-flash-free", + timestamp: "2026-08-05T00:00:00Z", + status: "success", + tokens: { prompt_tokens: 10, completion_tokens: 5 }, + request: { messages: [{ role: "user", content: "secret prompt" }] }, + providerRequest: { messages: [{ role: "user", content: "secret prompt" }] }, + providerResponse: { choices: [{ message: { content: "secret answer" } }] }, + response: { content: "secret answer" }, + }]; + const out = redactDetails(details)[0]; + expect(out.id).toBe("abc"); + expect(out.provider).toBe("opencode"); + expect(out.model).toBe("deepseek-v4-flash-free"); + expect(out.tokens).toEqual({ prompt_tokens: 10, completion_tokens: 5 }); + expect(out.request).toEqual({ redacted: true }); + expect(out.providerRequest).toEqual({ redacted: true }); + expect(out.providerResponse).toEqual({ redacted: true }); + expect(out.response).toEqual({ redacted: true }); + }); + + it("handles empty details", () => { + expect(redactDetails([])).toEqual([]); + expect(redactDetails(null)).toEqual([]); + }); + + it("keeps non-sensitive fields untouched", () => { + const details = [{ id: "x", status: "error", latency: { total: 100 } }]; + const out = redactDetails(details)[0]; + expect(out.id).toBe("x"); + expect(out.status).toBe("error"); + expect(out.latency).toEqual({ total: 100 }); + }); +}); diff --git a/tests/unit/search-ssrf-guard.test.js b/tests/unit/search-ssrf-guard.test.js new file mode 100644 index 00000000..2ed6e553 --- /dev/null +++ b/tests/unit/search-ssrf-guard.test.js @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { resolveBaseUrl } from "../../open-sse/handlers/search/callers.js"; + +const CONFIG = { id: "searxng", baseUrl: "https://searxng.example.com" }; + +describe("resolveBaseUrl SSRF guard", () => { + it("uses provider default when no override", () => { + expect(resolveBaseUrl(CONFIG, {})).toBe("https://searxng.example.com"); + }); + + it("allows public https override", () => { + const params = { providerOptions: { baseUrl: "https://my-searxng.example.com" } }; + expect(resolveBaseUrl(CONFIG, params)).toBe("https://my-searxng.example.com"); + }); + + it("allows public http override", () => { + const params = { providerOptions: { baseUrl: "http://searxng.example.net" } }; + expect(resolveBaseUrl(CONFIG, params)).toBe("http://searxng.example.net"); + }); + + it("rejects loopback override", () => { + const params = { providerOptions: { baseUrl: "http://127.0.0.1:18999" } }; + expect(() => resolveBaseUrl(CONFIG, params)).toThrow(); + }); + + it("rejects private IP override", () => { + for (const ip of ["10.0.0.1", "192.168.1.1", "172.16.0.1"]) { + const params = { providerOptions: { baseUrl: `http://${ip}` } }; + expect(() => resolveBaseUrl(CONFIG, params), `should reject ${ip}`).toThrow(); + } + }); + + it("rejects localhost hostname override", () => { + const params = { providerOptions: { baseUrl: "http://localhost:8080" } }; + expect(() => resolveBaseUrl(CONFIG, params)).toThrow(); + }); + + it("rejects cloud metadata override", () => { + const params = { providerOptions: { baseUrl: "http://169.254.169.254/latest/meta-data" } }; + expect(() => resolveBaseUrl(CONFIG, params)).toThrow(); + }); + + it("rejects non-http protocols", () => { + for (const proto of ["file:///etc/passwd", "gopher://127.0.0.1:70", "ftp://10.0.0.1"]) { + const params = { providerOptions: { baseUrl: proto } }; + expect(() => resolveBaseUrl(CONFIG, params), `should reject ${proto}`).toThrow(); + } + }); +});