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:
Bob.Hou
2026-08-24 16:24:08 -04:00
committed by GitHub
parent 9464792cfc
commit 59dccdd9e1
8 changed files with 408 additions and 16 deletions

View File

@@ -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",
},

View File

@@ -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,

View File

@@ -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
View 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;
}

View File

@@ -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,
];

View File

@@ -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