mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +03:00
fix(security): sanitize agent-card topology, anti-spoof login rate-limit peer IP, and add 429 Retry-After (#S1 #S2 #S4) (#11418)
Validado em lote combinado (batch-0824g) contra o tip de release/v3.8.50: typecheck:core limpo, gates estáticos OK, 62/62 testes focados passando (S1/S2/S4, tests/unit/security-s1-s2-s4.test.ts, 9/9). Boa integração com o padrão já existente de peer IP stamped por HMAC (resolveStampedPeer/OMNIROUTE_PEER_STAMP_TOKEN) — reusa em vez de reimplementar, e o header confiável só é honrado quando o stamp token está configurado. S2 remove corretamente a disclosure de topologia hardcoded do agent-card. Obrigado pela contribuição!
This commit is contained in:
@@ -11,19 +11,21 @@
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { getFleetSkills } from "@/lib/conductor/fleetSkills";
|
||||
import { getBaseUrl } from "@/lib/wellKnown";
|
||||
|
||||
const PACKAGE_VERSION = process.env.npm_package_version || "1.8.1";
|
||||
const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
|
||||
|
||||
/**
|
||||
* GET /.well-known/agent-card.json
|
||||
*
|
||||
* Returns the OmniRoute Agent Card (A2A v1.0).
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
const fleetSkills = await getFleetSkills();
|
||||
const baseUrl = getBaseUrl(request);
|
||||
|
||||
const agentCard = {
|
||||
name: "OmniRoute AI Gateway",
|
||||
@@ -31,16 +33,16 @@ export async function GET() {
|
||||
"Intelligent AI routing gateway with 36+ providers, smart fallback, quota tracking, " +
|
||||
"format translation, and auto-managed combos. Routes AI requests to the optimal " +
|
||||
"provider based on cost, latency, quota availability, and task requirements.",
|
||||
url: `${BASE_URL}/a2a`,
|
||||
url: `${baseUrl}/a2a`,
|
||||
version: PACKAGE_VERSION,
|
||||
supportedInterfaces: [
|
||||
{
|
||||
url: `${BASE_URL}/a2a`,
|
||||
url: `${baseUrl}/a2a`,
|
||||
protocolBinding: "JSONRPC",
|
||||
protocolVersion: "1.0",
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/a2a`,
|
||||
url: `${baseUrl}/a2a`,
|
||||
protocolBinding: "JSONRPC",
|
||||
protocolVersion: "0.3",
|
||||
},
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { getFleetSkills } from "@/lib/conductor/fleetSkills";
|
||||
import { getBaseUrl } from "@/lib/wellKnown";
|
||||
|
||||
const PACKAGE_VERSION = process.env.npm_package_version || "1.8.1";
|
||||
const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
|
||||
|
||||
/**
|
||||
* GET /.well-known/agent.json
|
||||
@@ -21,17 +22,18 @@ const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
|
||||
* Returns the OmniRoute Agent Card that describes this gateway's
|
||||
* capabilities as an A2A agent.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
// Conductor PRD RF2: fleet skills from the OmniConductor hub (cached ~60s; [] when
|
||||
// the hub is unset/offline — the card stays valid without the fleet section).
|
||||
const fleetSkills = await getFleetSkills();
|
||||
const baseUrl = getBaseUrl(request);
|
||||
const agentCard = {
|
||||
name: "OmniRoute AI 网关",
|
||||
description:
|
||||
"智能 AI 路由网关,支持 36+ 个提供者、智能回退、配额跟踪、" +
|
||||
"格式转换和自动管理组合。根据成本、延迟、配额可用性" +
|
||||
"和任务要求将 AI 请求路由到最优提供者。",
|
||||
url: `${BASE_URL}/a2a`,
|
||||
url: `${baseUrl}/a2a`,
|
||||
version: PACKAGE_VERSION,
|
||||
capabilities: {
|
||||
streaming: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
|
||||
import { classifyIpScope } from "@/lib/ipUtils";
|
||||
import { getCachedSettings } from "@/lib/db/settings";
|
||||
@@ -13,6 +14,7 @@ import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
|
||||
import { loginSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { checkLoginGuard, clearLoginAttempts, recordLoginFailure } from "@/server/auth/loginGuard";
|
||||
import { AUTHZ_HEADER_TRUSTED_PEER_IP } from "@/server/authz/headers";
|
||||
|
||||
// SECURITY: No hardcoded fallback — JWT_SECRET must be configured.
|
||||
if (!process.env.JWT_SECRET) {
|
||||
@@ -28,7 +30,7 @@ export const authRouteInternals = {
|
||||
getCookieStore: cookies,
|
||||
};
|
||||
|
||||
export async function POST(request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
const auditContext = getAuditRequestContext(request);
|
||||
|
||||
try {
|
||||
@@ -75,7 +77,10 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: "Invalid password payload" }, { status: 400 });
|
||||
}
|
||||
const settings = await getCachedSettings();
|
||||
const clientIp = auditContext.ipAddress || null;
|
||||
const trustedPeerIp = process.env.OMNIROUTE_PEER_STAMP_TOKEN
|
||||
? request.headers.get(AUTHZ_HEADER_TRUSTED_PEER_IP)
|
||||
: null;
|
||||
const clientIp = trustedPeerIp || auditContext.ipAddress || null;
|
||||
const oidcDisabledPassword =
|
||||
settings.oidcEnabled === true &&
|
||||
(settings.oidcDisablePasswordLogin === true ||
|
||||
@@ -118,9 +123,7 @@ export async function POST(request) {
|
||||
{ error: "Too many failed attempts. Try again later." },
|
||||
{
|
||||
status: 429,
|
||||
headers: guardCheck.retryAfterSeconds
|
||||
? { "Retry-After": String(guardCheck.retryAfterSeconds) }
|
||||
: {},
|
||||
headers: { "Retry-After": String(guardCheck.retryAfterSeconds || 60) },
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -220,9 +223,7 @@ export async function POST(request) {
|
||||
{ error: "Too many failed attempts. Try again later." },
|
||||
{
|
||||
status: 429,
|
||||
headers: failureDecision.retryAfterSeconds
|
||||
? { "Retry-After": String(failureDecision.retryAfterSeconds) }
|
||||
: {},
|
||||
headers: { "Retry-After": String(failureDecision.retryAfterSeconds || 60) },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
11
src/lib/wellKnown.ts
Normal file
11
src/lib/wellKnown.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Derive the base URL for A2A agent card endpoints.
|
||||
* Prefers OMNIROUTE_BASE_URL env var for admin override; falls back to the
|
||||
* request's dynamic origin so the gateway works behind any hostname without
|
||||
* hardcoded localhost:20128 (S2 security fix).
|
||||
*/
|
||||
export function getBaseUrl(request: NextRequest): string {
|
||||
return process.env.OMNIROUTE_BASE_URL || request.nextUrl.origin;
|
||||
}
|
||||
@@ -62,6 +62,16 @@ export const VIA_PROXY_HEADER = "x-omniroute-via-proxy";
|
||||
*/
|
||||
export const AUTHZ_HEADER_PEER_LOCALITY = "x-omniroute-peer-locality";
|
||||
|
||||
/**
|
||||
* The resolved real peer IP, stamped by the pipeline AFTER verifying the
|
||||
* token-stamped PEER_IP_HEADER. This is the trusted, non-spoofable IP that
|
||||
* route handlers (e.g. login rate-limit key) should use instead of re-deriving
|
||||
* from X-Forwarded-For / X-Real-IP. Set only when the stamp token is configured
|
||||
* and the HMAC signature validates; absent when the stamp is not in use.
|
||||
* Stripped from incoming requests like all other trusted headers.
|
||||
*/
|
||||
export const AUTHZ_HEADER_TRUSTED_PEER_IP = "x-omniroute-trusted-peer-ip";
|
||||
|
||||
/**
|
||||
* Headers the pipeline must NEVER trust on incoming requests. They are
|
||||
* stripped before route classification to prevent header-spoofing attacks.
|
||||
@@ -73,4 +83,5 @@ export const AUTHZ_TRUSTED_HEADERS: ReadonlyArray<string> = [
|
||||
AUTHZ_HEADER_AUTH_LABEL,
|
||||
AUTHZ_HEADER_AUTH_SCOPES,
|
||||
AUTHZ_HEADER_PEER_LOCALITY,
|
||||
AUTHZ_HEADER_TRUSTED_PEER_IP,
|
||||
];
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
AUTHZ_HEADER_PEER_LOCALITY,
|
||||
AUTHZ_HEADER_REQUEST_ID,
|
||||
AUTHZ_HEADER_ROUTE_CLASS,
|
||||
AUTHZ_HEADER_TRUSTED_PEER_IP,
|
||||
AUTHZ_TRUSTED_HEADERS,
|
||||
CLI_TOKEN_HEADER,
|
||||
PEER_IP_HEADER,
|
||||
@@ -332,6 +333,16 @@ export async function runAuthzPipeline(
|
||||
process.env.OMNIROUTE_PEER_STAMP_TOKEN
|
||||
);
|
||||
requestHeaders.set(AUTHZ_HEADER_PEER_LOCALITY, peerLocality);
|
||||
// Stamp the resolved, non-spoofable peer IP for route handlers that need
|
||||
// the real client IP (e.g. login rate-limit key). Only set when the stamp
|
||||
// token is configured and the HMAC signature validates; absent otherwise.
|
||||
const trustedPeerIp = resolveStampedPeer(
|
||||
request.headers.get(PEER_IP_HEADER),
|
||||
process.env.OMNIROUTE_PEER_STAMP_TOKEN
|
||||
);
|
||||
if (trustedPeerIp) {
|
||||
requestHeaders.set(AUTHZ_HEADER_TRUSTED_PEER_IP, trustedPeerIp);
|
||||
}
|
||||
// Local CLI-token auth is decided centrally above. Preserve that trusted
|
||||
// decision for route-level requireManagementAuth without forwarding the
|
||||
// machine token itself: custom client auth headers are stripped before the
|
||||
|
||||
Reference in New Issue
Block a user