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

1
.gitignore vendored
View File

@@ -14,6 +14,7 @@ _tasks/
.agents/**
.claude/**
.gemini/**
.code-forge/**
.config/**
.data/**
.logs/**

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

View File

@@ -0,0 +1,353 @@
/**
* Security compliance tickets S1, S2, S4 — unit tests.
*
* S1 — Login rate-limit key uses anti-spoofed peer IP (x-omniroute-trusted-peer-ip)
* S2 — A2A agent-card topology sanitisation (no hardcoded localhost:20128)
* S4 — 429 Retry-After header always present on lockout responses
*/
import { describe, it, beforeEach, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { NextRequest } from "next/server";
// ── S2: agent-card route tests (no heavy mocking needed) ──────────────
describe("S2 — agent-card topology sanitisation", () => {
const BASE_URL_SAVED = process.env.OMNIROUTE_BASE_URL;
beforeEach(() => {
delete process.env.OMNIROUTE_BASE_URL;
});
after(() => {
if (BASE_URL_SAVED !== undefined) {
process.env.OMNIROUTE_BASE_URL = BASE_URL_SAVED;
} else {
delete process.env.OMNIROUTE_BASE_URL;
}
});
it("agent-card.json derives URL from request.nextUrl.origin when OMNIROUTE_BASE_URL is unset", async () => {
const mod = await import("../../src/app/.well-known/agent-card.json/route.ts");
const request = new Request("https://gateway.example.com/.well-known/agent-card.json") as unknown as NextRequest;
Object.defineProperty(request, "nextUrl", {
value: new URL("https://gateway.example.com/.well-known/agent-card.json"),
configurable: true,
});
const res = await mod.GET(request);
assert.equal(res.status, 200);
const card = (await res.json()) as { url?: string; supportedInterfaces?: { url?: string }[] };
assert.ok(card.url, "card must have a url");
assert.ok(card.url.startsWith("https://gateway.example.com"), `expected gateway.example.com, got ${card.url}`);
if (card.supportedInterfaces && card.supportedInterfaces.length > 0) {
assert.ok(
card.supportedInterfaces[0].url?.startsWith("https://gateway.example.com"),
`interface URL should use dynamic origin, got ${card.supportedInterfaces[0].url}`
);
}
});
it("agent-card.json uses OMNIROUTE_BASE_URL when set", async () => {
process.env.OMNIROUTE_BASE_URL = "https://custom.example.com";
const mod = await import("../../src/app/.well-known/agent-card.json/route.ts");
const request = new Request("http://localhost:20128/.well-known/agent-card.json") as unknown as NextRequest;
Object.defineProperty(request, "nextUrl", {
value: new URL("http://localhost:20128/.well-known/agent-card.json"),
configurable: true,
});
const res = await mod.GET(request);
assert.equal(res.status, 200);
const card = (await res.json()) as { url?: string };
assert.ok(card.url?.startsWith("https://custom.example.com"), `expected custom.example.com, got ${card.url}`);
});
it("agent.json derives URL from request.nextUrl.origin when OMNIROUTE_BASE_URL is unset", async () => {
const mod = await import("../../src/app/.well-known/agent.json/route.ts");
const request = new Request("https://gateway.example.com/.well-known/agent.json") as unknown as NextRequest;
Object.defineProperty(request, "nextUrl", {
value: new URL("https://gateway.example.com/.well-known/agent.json"),
configurable: true,
});
const res = await mod.GET(request);
assert.equal(res.status, 200);
const card = (await res.json()) as { url?: string };
assert.ok(card.url?.startsWith("https://gateway.example.com"), `expected gateway.example.com, got ${card.url}`);
});
});
// ── Login guard module (loaded once for S4 tests) ─────────────────────
const loginGuardMod = await import("../../src/server/auth/loginGuard");
// ── S4: login guard Retry-After tests ─────────────────────────────────
describe("S4 — 429 Retry-After header", () => {
const {
checkLoginGuard,
recordLoginFailure,
resetLoginGuardForTests,
LOGIN_GUARD_TUNABLES,
} = loginGuardMod;
beforeEach(() => {
resetLoginGuardForTests();
});
it("checkLoginGuard returns retryAfterSeconds when locked", () => {
const ip = "10.0.0.99";
for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) {
recordLoginFailure(ip, { enabled: true });
}
const decision = checkLoginGuard(ip, { enabled: true });
assert.equal(decision.allowed, false);
assert.ok(typeof decision.retryAfterSeconds === "number" && decision.retryAfterSeconds > 0,
`retryAfterSeconds should be > 0, got ${decision.retryAfterSeconds}`);
});
it("recordLoginFailure returns retryAfterSeconds on threshold hit", () => {
const ip = "10.0.0.100";
for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) {
const dec = recordLoginFailure(ip, { enabled: true });
if (i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD - 1) {
assert.equal(dec.allowed, true, `attempt #${i + 1} should still be allowed`);
} else {
assert.equal(dec.allowed, false, `attempt #${i + 1} (threshold) should be locked`);
assert.ok(typeof dec.retryAfterSeconds === "number" && dec.retryAfterSeconds > 0,
`retryAfterSeconds should be > 0 on threshold hit, got ${dec.retryAfterSeconds}`);
}
}
});
it("both guard functions provide retryAfterSeconds for the response header", () => {
const ip = "10.0.0.101";
for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) {
recordLoginFailure(ip, { enabled: true });
}
const guardDec = checkLoginGuard(ip, { enabled: true });
assert.equal(guardDec.allowed, false);
const headerValue = String(guardDec.retryAfterSeconds || 60);
assert.ok(/^\d+$/.test(headerValue), `Retry-After should be an integer string, got ${headerValue}`);
assert.ok(Number.parseInt(headerValue, 10) > 0, "Retry-After should be positive");
resetLoginGuardForTests();
const ip2 = "10.0.0.102";
let failureDec: ReturnType<typeof recordLoginFailure> | undefined;
for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) {
failureDec = recordLoginFailure(ip2, { enabled: true });
}
assert.equal(failureDec!.allowed, false);
const headerValue2 = String(failureDec!.retryAfterSeconds || 60);
assert.ok(/^\d+$/.test(headerValue2), `Retry-After should be an integer string, got ${headerValue2}`);
assert.ok(Number.parseInt(headerValue2, 10) > 0, "Retry-After should be positive");
});
});
// ── S1: login route uses trusted peer IP for rate-limit key ───────────
// Integration test: sets up the real DB, management password, and settings,
// then calls the login route POST function to verify the clientIp derivation.
// The route uses: clientIp = request.headers.get("x-omniroute-trusted-peer-ip") || auditContext.ipAddress || null
describe("S1 — login rate-limit key uses anti-spoofed peer IP", () => {
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-security-s1-s2-s4-"));
const JWT_SAVED = process.env.JWT_SECRET;
const INITIAL_PASSWORD_SAVED = process.env.INITIAL_PASSWORD;
let loginRoute: typeof import("../../src/app/api/auth/login/route.ts");
let loginGuardModRef: typeof import("../../src/server/auth/loginGuard");
let settingsDb: typeof import("../../src/lib/db/settings.ts");
beforeEach(async () => {
// Reset env
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.JWT_SECRET = "test-jwt-secret-for-s1-s2-s4-tests";
// Use a bcrypt hash of "test-password" as the initial password so the
// login route already has a valid hash in the DB settings.
process.env.INITIAL_PASSWORD = "test-password";
delete process.env.OMNIROUTE_PEER_STAMP_TOKEN;
delete process.env.OMNIROUTE_BASE_URL;
// Create data dir
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
// Reset DB and set up settings
const core = await import("../../src/lib/db/core.ts");
core.resetDbInstance();
settingsDb = await import("../../src/lib/db/settings.ts");
await settingsDb.updateSettings({ bruteForceProtection: true });
// Import login guard and reset state
loginGuardModRef = await import("../../src/server/auth/loginGuard");
loginGuardModRef.resetLoginGuardForTests();
// Now import the login route
loginRoute = await import("../../src/app/api/auth/login/route.ts");
});
after(() => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (JWT_SAVED !== undefined) {
process.env.JWT_SECRET = JWT_SAVED;
} else {
delete process.env.JWT_SECRET;
}
// Restore INITIAL_PASSWORD
if (INITIAL_PASSWORD_SAVED !== undefined) {
process.env.INITIAL_PASSWORD = INITIAL_PASSWORD_SAVED;
} else {
delete process.env.INITIAL_PASSWORD;
}
});
it("uses x-omniroute-trusted-peer-ip for rate-limit key when header is present", async () => {
// The login route derives clientIp from the trusted peer IP header.
// We make multiple requests with the same trusted peer IP but different
// forged XFF headers to verify they share the same rate-limit bucket.
//
// The route only trusts the header when OMNIROUTE_PEER_STAMP_TOKEN is set.
// Without the token, spoofed headers are rejected (tested separately below).
process.env.OMNIROUTE_PEER_STAMP_TOKEN = "test-stamp-token";
const TRUSTED_IP = "203.0.113.42";
const FORGED_XFF = "192.168.1.1, 10.0.0.1";
// Make enough requests to trigger the rate limit
for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) {
const request = new Request("http://localhost:20128/api/auth/login", {
method: "POST",
headers: {
"content-type": "application/json",
"x-omniroute-trusted-peer-ip": TRUSTED_IP,
"x-forwarded-for": i === 0 ? FORGED_XFF : `10.0.0.${i}, 172.16.0.1`,
},
body: JSON.stringify({ password: "wrong-password" }),
}) as unknown as NextRequest;
Object.defineProperty(request, "nextUrl", {
value: new URL("http://localhost:20128/api/auth/login"),
configurable: true,
});
// The email is not checked in the login route — only password matters
// Let's also try the correct password to make sure login works
const res = await loginRoute.POST(request);
if (res.status === 429) {
// Locked out — rate-limit key is tied to the trusted peer IP, not XFF
const retryAfter = res.headers.get("Retry-After");
assert.ok(retryAfter !== null, "429 response must include Retry-After header");
assert.ok(/^\d+$/.test(retryAfter!), `Retry-After should be a positive integer, got ${retryAfter}`);
return;
}
}
assert.fail("Expected at least one 429 response after threshold failed attempts with the same trusted peer IP");
});
it("ignores spoofed x-omniroute-trusted-peer-ip when OMNIROUTE_PEER_STAMP_TOKEN is not set", async () => {
loginGuardModRef.resetLoginGuardForTests();
// OMNIROUTE_PEER_STAMP_TOKEN is already deleted in beforeEach.
// The route should NOT trust the spoofed header and fall back to
// auditContext.ipAddress (derived from X-Forwarded-For).
//
// TDD: each iteration uses a DIFFERENT spoofed IP. With the bug
// (unconditional trust), each request goes to a different rate-limit
// bucket — no bucket reaches the threshold → test FAILS (RED).
// With the fix (gate on OMNIROUTE_PEER_STAMP_TOKEN), all requests
// share the REAL_IP bucket → threshold hit → test PASSES (GREEN).
const REAL_IP = "10.0.0.200";
for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) {
const SPOOFED_IP = `203.0.113.${i}`;
const request = new Request("http://localhost:20128/api/auth/login", {
method: "POST",
headers: {
"content-type": "application/json",
"x-omniroute-trusted-peer-ip": SPOOFED_IP,
"x-forwarded-for": REAL_IP,
},
body: JSON.stringify({ password: "wrong-password" }),
}) as unknown as NextRequest;
Object.defineProperty(request, "nextUrl", {
value: new URL("http://localhost:20128/api/auth/login"),
configurable: true,
});
const res = await loginRoute.POST(request);
if (res.status === 429) {
// Locked out — rate-limit key is tied to REAL_IP (XFF), not the spoofed header
const retryAfter = res.headers.get("Retry-After");
assert.ok(retryAfter !== null, "429 response must include Retry-After header");
return;
}
}
assert.fail("Expected 429 after threshold failures — spoofed header should not bypass rate-limit");
});
it("falls back to auditContext.ipAddress when trusted peer IP header is absent", async () => {
loginGuardModRef.resetLoginGuardForTests();
// Without the trusted peer IP header, the rate-limit key falls back to
// auditContext.ipAddress which reads from X-Forwarded-For / X-Real-IP.
// We set XFF to a specific IP and verify that requests with that IP get
// rate-limited, while requests with a different IP do not.
const REQUEST_IP = "10.0.0.99";
for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) {
const request = new Request("http://localhost:20128/api/auth/login", {
method: "POST",
headers: {
"content-type": "application/json",
"x-forwarded-for": REQUEST_IP,
},
body: JSON.stringify({ password: "wrong-password" }),
}) as unknown as NextRequest;
Object.defineProperty(request, "nextUrl", {
value: new URL("http://localhost:20128/api/auth/login"),
configurable: true,
});
const res = await loginRoute.POST(request);
if (res.status === 429) {
// Locked out — rate-limit key is tied to the XFF-derived IP
const retryAfter = res.headers.get("Retry-After");
assert.ok(retryAfter !== null, "429 response must include Retry-After header");
assert.ok(Number.parseInt(retryAfter!, 10) > 0, `Retry-After should be > 0, got ${retryAfter}`);
return;
}
}
assert.fail("Expected 429 after threshold failures from the same IP");
});
it("S4 — 429 response includes Retry-After header in login route", async () => {
loginGuardModRef.resetLoginGuardForTests();
for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) {
const request = new Request("http://localhost:20128/api/auth/login", {
method: "POST",
headers: {
"content-type": "application/json",
"x-omniroute-trusted-peer-ip": "203.0.113.99",
},
body: JSON.stringify({ password: "wrong-password" }),
}) as unknown as NextRequest;
Object.defineProperty(request, "nextUrl", {
value: new URL("http://localhost:20128/api/auth/login"),
configurable: true,
});
const res = await loginRoute.POST(request);
if (res.status === 429) {
const retryAfter = res.headers.get("Retry-After");
assert.ok(retryAfter !== null, "429 response must include Retry-After header");
assert.ok(Number.parseInt(retryAfter!, 10) > 0, `Retry-After should be > 0, got ${retryAfter}`);
return;
}
}
assert.fail("Expected at least one 429 response after threshold failed attempts");
});
});