Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions docs/audits/ap-iss-0197-dispositions.md

Large diffs are not rendered by default.

14 changes: 13 additions & 1 deletion open-sse/services/cache/embeddingClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ export async function generateEmbeddingWithTimeout(
embeddingGeneratorCooldownUntil.set(generator, cooldowns);
if (generatorPromise) {
quarantineToken = Symbol(cooldownKey);
const inFlight = embeddingGeneratorTimedOutInFlight.get(generator) ?? new Map<string, symbol>();
const inFlight =
embeddingGeneratorTimedOutInFlight.get(generator) ?? new Map<string, symbol>();
inFlight.set(cooldownKey, quarantineToken);
embeddingGeneratorTimedOutInFlight.set(generator, inFlight);
setTimeout(clearTimedOutInFlight, timeoutMs);
Expand Down Expand Up @@ -192,6 +193,7 @@ export function createDefaultEmbeddingGenerator(config: {
embeddingModel?: string;
embeddingBaseUrl?: string;
embeddingApiKey?: string;
embeddingDimension?: number;
}): EmbeddingGenerator {
return async (
text: string,
Expand Down Expand Up @@ -219,6 +221,13 @@ export function createDefaultEmbeddingGenerator(config: {
}
}
const apiKey = config.embeddingApiKey;
if (
config.embeddingDimension !== undefined &&
(!Number.isInteger(config.embeddingDimension) || config.embeddingDimension <= 0)
) {
console.warn("[CACHE] Invalid embedding dimension; expected a positive integer");
return null;
}

try {
const guard = getProviderValidationGuard();
Expand Down Expand Up @@ -247,6 +256,9 @@ export function createDefaultEmbeddingGenerator(config: {
body: JSON.stringify({
model,
input: text,
...(config.embeddingDimension !== undefined
? { dimensions: config.embeddingDimension }
: {}),
}),
signal: options?.signal,
});
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/memory/rerank-providers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export async function GET(request: NextRequest) {

// Local provider nodes, listed with the rerank-typed models they expose
// (synced + custom rows; see nodeModalityListings.ts).
const extra = await listProviderNodeModalityListings("rerank");
const extra = await listProviderNodeModalityListings("rerank").catch(() => []);

return NextResponse.json({ providers: mergeRerankProviderListings(curated, extra) });
} catch (err: unknown) {
Expand Down
9 changes: 5 additions & 4 deletions src/app/api/monitoring/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { APP_CONFIG } from "@/shared/constants/config";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { parseAndValidatePublicUrl } from "@/shared/network/outboundUrlGuard";

/**
* GET /api/monitoring/health — System health overview
Expand Down Expand Up @@ -83,9 +84,7 @@ function resolveDeepHealthTarget(): { url: string; token?: string } | null {
const rawUrl = (process.env.DEEP_HEALTH_CHECK_URL ?? "").trim();
if (!rawUrl) return null;
try {
const parsed = new URL(rawUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
if (parsed.username || parsed.password) return null;
const parsed = parseAndValidatePublicUrl(rawUrl);
const token = (process.env.DEEP_HEALTH_CHECK_TOKEN ?? "").trim();
return { url: parsed.toString(), ...(token ? { token } : {}) };
} catch {
Expand All @@ -94,7 +93,9 @@ function resolveDeepHealthTarget(): { url: string; token?: string } | null {
}

function withDeepHealth(payload: unknown, wantDeep: boolean): unknown {
if (!wantDeep || !deepHealthVerdictCache) return payload;
if (!wantDeep || !deepHealthVerdictCache || Date.now() > deepHealthVerdictCache.expiresAt) {
return payload;
}
return { ...(payload as Record<string, unknown>), deepHealth: deepHealthVerdictCache.verdict };
}

Expand Down
4 changes: 4 additions & 0 deletions src/app/api/services/openwa/auto-restart-adopted/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { z } from "zod";
import { updateServiceField } from "@/lib/db/versionManager";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

const BodySchema = z.object({ enabled: z.boolean() });

export async function POST(request: Request): Promise<Response> {
const authError = await requireManagementAuth(request, { alwaysRequireAuth: true });
if (authError) return authError;

let body: unknown;
try {
body = await request.json();
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/services/openwa/auto-start/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { z } from "zod";
import { updateServiceField } from "@/lib/db/versionManager";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

const BodySchema = z.object({ enabled: z.boolean() });

export async function POST(request: Request): Promise<Response> {
const authError = await requireManagementAuth(request, { alwaysRequireAuth: true });
if (authError) return authError;

let body: unknown;
try {
body = await request.json();
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/services/openwa/install/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { install } from "@/lib/services/installers/openwa";
import { handleServiceInstall } from "@/app/api/services/_shared/installRoute";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

export async function POST(request: Request): Promise<Response> {
const authError = await requireManagementAuth(request, { alwaysRequireAuth: true });
if (authError) return authError;

return handleServiceInstall(request, install);
}
6 changes: 5 additions & 1 deletion src/app/api/services/openwa/restart/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { getServiceRow } from "@/lib/db/versionManager";
import { getOrInitSupervisor } from "../_lib";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

const TOOL = "openwa";

export async function POST(): Promise<Response> {
export async function POST(request: Request): Promise<Response> {
const authError = await requireManagementAuth(request, { alwaysRequireAuth: true });
if (authError) return authError;

try {
const row = await getServiceRow(TOOL);
if (!row || row.status === "not_installed") {
Expand Down
6 changes: 5 additions & 1 deletion src/app/api/services/openwa/start/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { getServiceRow } from "@/lib/db/versionManager";
import { getOrInitSupervisor } from "../_lib";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

const TOOL = "openwa";

export async function POST(): Promise<Response> {
export async function POST(request: Request): Promise<Response> {
const authError = await requireManagementAuth(request, { alwaysRequireAuth: true });
if (authError) return authError;

try {
const row = await getServiceRow(TOOL);
if (!row || row.status === "not_installed") {
Expand Down
6 changes: 5 additions & 1 deletion src/app/api/services/openwa/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ import {
} from "@/lib/services/installers/openwa";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

const TOOL = "openwa";

export async function GET(): Promise<Response> {
export async function GET(request: Request): Promise<Response> {
const authError = await requireManagementAuth(request, { alwaysRequireAuth: true });
if (authError) return authError;

try {
const sup = getSupervisor(TOOL);
const row = await getServiceRow(TOOL);
Expand Down
6 changes: 5 additions & 1 deletion src/app/api/services/openwa/stop/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { getSupervisor } from "@/lib/services/registry";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

const TOOL = "openwa";

export async function POST(): Promise<Response> {
export async function POST(request: Request): Promise<Response> {
const authError = await requireManagementAuth(request, { alwaysRequireAuth: true });
if (authError) return authError;

try {
const sup = getSupervisor(TOOL);
if (!sup) {
Expand Down
6 changes: 5 additions & 1 deletion src/app/api/services/openwa/update/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ import {
} from "@/lib/services/installers/openwa";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

export async function POST(request: Request): Promise<Response> {
const authError = await requireManagementAuth(request, { alwaysRequireAuth: true });
if (authError) return authError;

export async function POST(): Promise<Response> {
try {
const [installed, latest] = await Promise.all([getInstalledVersion(), getLatestVersion()]);

Expand Down
3 changes: 1 addition & 2 deletions src/app/api/settings/cache-config/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const cacheConfigUpdateSchema = z.object({
semanticCacheThreshold: z.number().min(0).max(1).optional(),
semanticCacheEmbeddingProvider: z.string().trim().optional(),
semanticCacheEmbeddingModel: z.string().trim().optional(),
semanticCacheEmbeddingDimension: z.number().positive().nullable().optional(),
semanticCacheEmbeddingDimension: z.number().int().positive().nullable().optional(),
semanticCacheEmbeddingBaseUrl: z.string().trim().nullable().optional(),
semanticCacheEmbeddingApiKey: z.string().trim().nullable().optional(),
semanticCacheRedisUrl: z.string().trim().nullable().optional(),
Expand All @@ -47,7 +47,6 @@ const CACHE_CONFIG_KEYS = [
"semanticCacheEmbeddingModel",
"semanticCacheEmbeddingDimension",
"semanticCacheEmbeddingBaseUrl",
"semanticCacheEmbeddingApiKey",
"semanticCacheRedisUrl",
Comment on lines 48 to 50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
Removing the key from CACHE_CONFIG_KEYS makes the dashboard load semApiKey as empty, but saveSemanticCache() always sends semanticCacheEmbeddingApiKey: semApiKey.trim() || null; any subsequent semantic-cache save therefore clears the persisted provider credential, breaking embeddings. Keep the secret out of GET while preserving it on partial updates, or distinguish “unchanged” from explicit clear in the client/API.

Suggestion:

Suggested change
"semanticCacheEmbeddingDimension",
"semanticCacheEmbeddingBaseUrl",
"semanticCacheEmbeddingApiKey",
"semanticCacheRedisUrl",
"semanticCacheEmbeddingDimension",
"semanticCacheEmbeddingBaseUrl",
"semanticCacheRedisUrl",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed at 1794e1654b92a3ff79a0abe1916d7ae096e311a8: GET continues to redact semanticCacheEmbeddingApiKey, while a PUT carrying redacted null preserves the stored credential. Focused cache-config-route-8219 regression verifies GET never leaks the secret and subsequent redacted save does not clear it (6/6 suite PASS).

"semanticCacheRedisPrefix",
"semanticCacheRequireZeroTemp",
Expand Down
31 changes: 27 additions & 4 deletions src/app/api/settings/cache-config/test-embedding/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error";
import { z } from "zod";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { resolveProviderConnectionDetails } from "@/lib/cache/semanticCacheDbBridge";
import { parseAndValidatePublicUrl } from "@/shared/network/outboundUrlGuard";

const testEmbeddingSchema = z.object({
provider: z.string().trim().min(1),
model: z.string().trim().min(1),
baseUrl: z.string().trim().optional(),
apiKey: z.string().trim().optional(),
dimensions: z.number().positive().optional(),
dimensions: z.number().int().positive().optional(),
});

export async function POST(request: Request) {
Expand All @@ -34,19 +35,33 @@ export async function POST(request: Request) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}

const { provider, model, baseUrl, apiKey } = validation.data;
const { provider, model, baseUrl, apiKey, dimensions } = validation.data;

// Resolve connection details from DB if not explicitly passed
// A caller-supplied endpoint is untrusted input: keep it public-only and never
// pair it with a credential silently loaded from persistent provider state.
if (baseUrl) {
try {
parseAndValidatePublicUrl(baseUrl);
} catch {
return NextResponse.json({
ok: false,
error: "Embedding base URL must be a safe public URL",
});
}
}

// Saved connection credentials may only travel to the saved connection endpoint.
const conn = resolveProviderConnectionDetails(provider);
const effectiveBaseUrl = baseUrl || conn.baseUrl;
const effectiveApiKey = apiKey || conn.apiKey;
const effectiveApiKey = apiKey || (baseUrl ? undefined : conn.apiKey);

try {
const generator = createDefaultEmbeddingGenerator({
embeddingProvider: provider,
embeddingModel: model,
embeddingBaseUrl: effectiveBaseUrl,
embeddingApiKey: effectiveApiKey,
embeddingDimension: dimensions,
});

const start = Date.now();
Expand All @@ -63,6 +78,14 @@ export async function POST(request: Request) {
);
}

if (dimensions !== undefined && result.embedding.length !== dimensions) {
return NextResponse.json({
ok: false,
error: `Embedding dimension mismatch: expected ${dimensions}, got ${result.embedding.length}`,
dimensions: result.embedding.length,
});
}

return NextResponse.json({
ok: true,
latencyMs,
Expand Down
44 changes: 33 additions & 11 deletions src/app/api/settings/require-login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import {
hasManagementPasswordConfigured,
hashManagementPassword,
} from "@/lib/auth/managementPassword";
import { consumeBootstrapToken, peekBootstrapToken } from "@/lib/auth/bootstrapToken";
import {
claimBootstrapToken,
commitBootstrapTokenClaim,
consumeBootstrapToken,
peekBootstrapToken,
releaseBootstrapTokenClaim,
} from "@/lib/auth/bootstrapToken";
import { hasConfiguredOidc, isAuthenticated } from "@/shared/utils/apiAuth";
import { AUTHZ_HEADER_PEER_LOCALITY, BOOTSTRAP_TOKEN_HEADER } from "@/server/authz/headers";
import { getNodeRuntimeSupport } from "@/shared/utils/nodeRuntimeSupport.ts";
Expand Down Expand Up @@ -90,17 +96,18 @@ export async function GET() {
*/
export async function POST(request: Request) {
const settings = await getSettings();
let requiresBootstrapTokenClaim = false;
let bootstrapTokenClaim: ReturnType<typeof claimBootstrapToken> = null;
if (isBootstrapSecurityWindow(settings)) {
const peerLocality = request.headers.get(AUTHZ_HEADER_PEER_LOCALITY);
// In production this header is stripped from client input and re-stamped by
// the authz pipeline from the authenticated TCP-peer stamp. Reject any
// explicitly non-loopback verdict before parsing or persisting the body.
if (
peerLocality &&
peerLocality !== "loopback" &&
!peekBootstrapToken(request.headers.get(BOOTSTRAP_TOKEN_HEADER))
) {
return NextResponse.json({ error: "Local bootstrap required" }, { status: 403 });
if (peerLocality && peerLocality !== "loopback") {
if (!peekBootstrapToken(request.headers.get(BOOTSTRAP_TOKEN_HEADER))) {
return NextResponse.json({ error: "Local bootstrap required" }, { status: 403 });
}
requiresBootstrapTokenClaim = true;
}
} else if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Expand Down Expand Up @@ -140,13 +147,28 @@ export async function POST(request: Request) {
updates.password = hashedPassword;
}

const bootstrapCandidate = request.headers.get(BOOTSTRAP_TOKEN_HEADER);
if (requiresBootstrapTokenClaim) {
bootstrapTokenClaim = claimBootstrapToken(bootstrapCandidate);
if (!bootstrapTokenClaim) {
return NextResponse.json({ error: "Local bootstrap required" }, { status: 403 });
}
}

await updateSettings(updates);
// #14296: one-shot — a Docker/NAT-forwarded operator that authenticated
// this write via the bootstrap token cannot replay it for a second write.
// A no-op when the header is absent or stale (never matches).
consumeBootstrapToken(request.headers.get(BOOTSTRAP_TOKEN_HEADER));
if (bootstrapTokenClaim) {
if (!commitBootstrapTokenClaim(bootstrapTokenClaim)) {
throw new Error("Bootstrap token claim was lost before commit");
}
bootstrapTokenClaim = null;
} else {
// Loopback bootstrap does not need the token for authorization, but preserve
// the existing one-shot behavior when a valid token happens to be supplied.
consumeBootstrapToken(bootstrapCandidate);
}
return NextResponse.json({ success: true });
} catch (error) {
if (bootstrapTokenClaim) releaseBootstrapTokenClaim(bootstrapTokenClaim);
console.error("[API] Error updating require-login settings:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to update settings" },
Expand Down
Loading
Loading