Root cause
SSRF & network boundaries introduced
feat(kiro): headless API-key auth + direct Claude/Kiro route
How AI contributed: Direct introductionPublished Jul 10, 2026 in
decolua/9routerAll 3 findings in this repository
9Router is an AI router & token saver. Prior to 0.5.6, the Kiro API-key validation endpoint POST /api/oauth/kiro/api-key builds an upstream URL using a user-controlled region value, allowing an authenticated attacker to supply a crafted region such as kiro-canary.local:8443# and cause 9Router to send the Kiro validation request to an attacker-controlled host while forwarding the submitted Kiro API key as an Authorization header. This issue is fixed in version 0.5.6.
CNA CVE-2026-56678 (GitHub_M) source.advisory is GitHub-reviewed GHSA-6mwv-4mrm-5p3m, omitted from the row public_ids. Advisory names POST /api/oauth/kiro/api-key interpolating user-controlled region into https://codewhisperer.${region}.amazonaws.com and forwarding the submitted API key as Authorization. Parent 3de6ce15 has no api-key route; list-profiles host is hardcoded codewhisperer.us-east-1.amazonaws.com. Member 706e6513 (Claude Opus 4.8 and Cursor trailers) adds the route, validateApiKey, and listAvailableProfiles with unvalidated region. Fix 126aa244 (GHSA-6mwv in subject) adds assertValidAwsRegion before interpolation and stops reflecting upstream bodies. Published GitHub releases v0.5.2 (tag commit 5da508af, GHSA-named) and v0.5.4 contain origin without the fix; v0.5.6 contains 126aa244.
Root cause
feat(kiro): headless API-key auth + direct Claude/Kiro route
Fix
fix(kiro): validate region to prevent SSRF (GHSA-6mwv-4mrm-5p3m)
AI-assisted fix: Cursor · decolua
Case facts
@@ -254,6 +254,67 @@ export class KiroService { } } + /**+ * List available CodeWhisperer profiles for a token (or API key) and return+ * the best-matching profileArn. AWS SSO OIDC logins return no profileArn, so+ * it must be fetched separately — the same call works for API-key auth.+ * Accepts both `arn` and `profileArn` response field names (the API-key+ * JSON-1.0 surface returns `arn`).+ */+ async listAvailableProfiles(accessToken, region = "us-east-1") {+ const endpoint = `https://codewhisperer.${region}.amazonaws.com`;++ const response = await fetch(endpoint, {+ method: "POST",+ headers: {+ "Content-Type": "application/x-amz-json-1.0",+ "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles",+ "Authorization": `Bearer ${accessToken}`,+ "Accept": "application/json",+ },+ body: JSON.stringify({ maxResults: 10 }),+ });++ if (!response.ok) {+ const error = await response.text();+ throw new Error(`Failed to list profiles: ${error}`);+ }++ const data = await response.json();+ const profiles = Array.isArray(data?.profiles) ? data.profiles : [];+ const arnOf = (p) => p?.arn || p?.profileArn || null;+ const match = profiles.find((p) => arnOf(p)?.split(":")[3] === region) || profiles[0];+ return arnOf(match);+ }++ /**+ * Validate an API-key credential by listing profiles with it. API keys are+ * long-lived bearer tokens (no refresh), so the only way to validate one is+ * to make an authenticated CodeWhisperer call. Returns a credential object+ * ready to persist as a "kiro" connection with authMethod="api_key".+ */+ async validateApiKey(apiKey, region = "us-east-1") {+ if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {+ throw new Error("API key is required");+ }+ const trimmed = apiKey.trim();++ let profileArn = null;+ try {+ profileArn = await this.listAvailableProfiles(trimmed, region);+ } catch (error) {+ throw new Error(`API key validation failed: ${error.message}`);+ }++ return {+ accessToken: trimmed,+ refreshToken: null,+ profileArn,+ region,+ authMethod: "api_key",+ };+ }+ /** * List available models from CodeWhisperer API */@@ -37,6 +37,36 @@ import { import Card from "@/shared/components/Card"; import { ConfirmModal, EditConnectionModal } from "@/shared/components"; import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";+import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";++// Maps the stored providerSpecificData.authMethod to a human label for Kiro.+// Values come from the Kiro connect flows: builder-id/idc (device code),+// google/github (social), imported (refresh-token paste), api_key (headless).+const KIRO_METHOD_LABELS = {+ "builder-id": "AWS Builder ID",+ idc: "IAM Identity Center",+ google: "Google",+ github: "GitHub",+ imported: "Imported Token",+ api_key: "API Key",+};++function kiroMethodLabel(conn) {+ const m = conn.providerSpecificData?.authMethod;+ if (m && KIRO_METHOD_LABELS[m]) return KIRO_METHOD_LABELS[m];+ return conn.authType === "api_key" ? "API Key" : "OAuth";+}++// Region is stored for builder-id/idc/api_key flows; social and imported flows+// omit it, so fall back to the region segment of the profileArn+// (arn:aws:codewhisperer:<region>:...).+function kiroRegion(conn) {+ const r = conn.providerSpecificData?.region;+ if (r) return r;+ const arn = conn.providerSpecificData?.profileArn;+ const seg = typeof arn === "string" ? arn.split(":")[3] : "";+ return seg || "";+} function getCodexResetCreditCount(quota) { const value = quota?.raw?.resetCredits?.availableCount;@@ -45,6 +75,7 @@ function getCodexResetCreditCount(quota) { } export default function ProviderLimits() {+ const { copied, copy } = useCopyToClipboard(); const [connections, setConnections] = useState([]); const [quotaData, setQuotaData] = useState({}); const [loading, setLoading] = useState({});@@ -892,6 +923,46 @@ export default function ProviderLimits() { Reset eligible: {resetCreditCount} </p> )}+ {conn.provider === "kiro" && (+ <div className="mt-1 flex flex-wrap items-center gap-1">+ <span className="rounded-full bg-brand-500/10 px-2 py-0.5 text-[10px] font-semibold text-brand-600 dark:text-brand-300">+ {kiroMethodLabel(conn)}+ </span>+ {kiroRegion(conn) && (+ <span className="rounded-full bg-blue-500/10 px-2 py-0.5 text-[10px] font-semibold text-blue-600 dark:text-blue-400">+ {kiroRegion(conn)}+ </span>+ )}+ <span+ className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${+ isInactive+ ? "bg-surface-2 text-text-muted"+ : conn.testStatus === "active" || conn.testStatus === "success"+ ? "bg-green-500/10 text-green-600 dark:text-green-400"+ : conn.testStatus === "error" || conn.testStatus === "expired" || conn.testStatus === "unavailable"+ ? "bg-red-500/10 text-red-600 dark:text-red-400"+ : "bg-surface-2 text-text-muted"+ }`}+ >+ {isInactive ? "disabled" : conn.testStatus || "unknown"}+ </span>+ {conn.providerSpecificData?.profileArn && (+ <button+ type="button"+ onClick={() => copy(conn.providerSpecificData.profileArn, conn.id)}+ title={conn.providerSpecificData.profileArn}+ className="inline-flex max-w-full items-center gap-1 rounded-full border border-border-subtle px-2 py-0.5 text-[10px] text-text-muted transition-colors hover:text-primary"+ >+ <span className="material-symbols-outlined text-[12px]">+ {copied === conn.id ? "check" : "content_copy"}+ </span>+ <code className="truncate font-mono">+ {conn.providerSpecificData.profileArn}+ </code>+ </button>+ )}+ </div>+ )} </div>@@ -0,0 +1,63 @@+import { NextResponse } from "next/server";+import { KiroService } from "@/lib/oauth/services/kiro";+import { createProviderConnection } from "@/models";++/**+ * POST /api/oauth/kiro/api-key+ * Import a Kiro API key (headless auth). The key is a long-lived bearer+ * credential — there is no refresh token. It is validated by listing+ * CodeWhisperer profiles, then stored with authMethod="api_key".+ */+export async function POST(request) {+ try {+ const { apiKey, region } = await request.json();++ if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {+ return NextResponse.json(+ { error: "API key is required" },+ { status: 400 }+ );+ }++ const kiroService = new KiroService();++ // Validate the key and resolve its profileArn via ListAvailableProfiles+ const credential = await kiroService.validateApiKey(+ apiKey,+ region || "us-east-1"+ );++ // Extract email from JWT if the key happens to be a JWT (optional display)+ const email = kiroService.extractEmailFromJWT(credential.accessToken);++ // API keys never expire on a fixed schedule; persist a long horizon so the+ // proactive refresh path (which requires a refreshToken anyway) is skipped.+ const connection = await createProviderConnection({+ provider: "kiro",+ authType: "api_key",+ accessToken: credential.accessToken,+ refreshToken: null,+ expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),+ email: email || null,+ providerSpecificData: {+ profileArn: credential.profileArn,+ region: credential.region,+ authMethod: "api_key",+ provider: "API Key",+ },+ testStatus: "active",+ });++ return NextResponse.json({+ success: true,+ connection: {+ id: connection.id,+ provider: connection.provider,+ email: connection.email,+ },+ });+ } catch (error) {+ console.log("Kiro API key import error:", error);+ return NextResponse.json({ error: error.message }, { status: 500 });+ }+}@@ -67,6 +67,17 @@ export const GITHUB_CONFIG = { ...PROVIDER_OAUTH["github"] }; // Kiro OAuth Configuration (multi-method: AWS Builder ID / IDC / Social / Import Token) export const KIRO_CONFIG = { ...PROVIDER_OAUTH["kiro"] }; +// AWS region allowlist pattern — prevents SSRF via region injection into upstream URLs (GHSA-6mwv-4mrm-5p3m)+export const AWS_REGION_PATTERN = /^[a-z]{2}-[a-z]+-\d{1,2}$/;++// Reject any region that is not a valid AWS region before interpolating it into a URL+export function assertValidAwsRegion(region) {+ if (typeof region !== "string" || !AWS_REGION_PATTERN.test(region)) {+ throw new Error("Invalid region");+ }+ return region;+}+ // Cursor OAuth Configuration (Import Token from Cursor IDE) // tokenStoragePaths: user-reference only, not stored in registry export const CURSOR_CONFIG = {@@ -18,6 +18,7 @@ import { ANTIGRAVITY_CONFIG, GITHUB_CONFIG, KIRO_CONFIG,+ assertValidAwsRegion, CURSOR_CONFIG, KIMI_CODING_CONFIG, KILOCODE_CONFIG,@@ -792,6 +793,7 @@ const PROVIDERS = { requestDeviceCode: async (config, codeChallenge, options = {}) => { const trimmedRegion = typeof options.region === "string" ? options.region.trim() : ""; const region = trimmedRegion || "us-east-1";+ assertValidAwsRegion(region); const trimmedStartUrl = typeof options.startUrl === "string" ? options.startUrl.trim() : ""; const startUrl = trimmedStartUrl || config.startUrl; const authMethod = options.authMethod === "idc" ? "idc" : "builder-id";@@ -860,6 +862,7 @@ const PROVIDERS = { }, pollToken: async (config, deviceCode, codeVerifier, extraData) => { const region = extraData?._region || "us-east-1";+ assertValidAwsRegion(region); const tokenUrl = `https://oidc.${region}.amazonaws.com/token`; const response = await fetch(tokenUrl, { method: "POST",@@ -1,4 +1,4 @@-import { KIRO_CONFIG } from "../constants/oauth.js";+import { KIRO_CONFIG, assertValidAwsRegion } from "../constants/oauth.js"; /** * Kiro OAuth Service@@ -17,6 +17,7 @@ export class KiroService { * Returns clientId and clientSecret for device code flow */ async registerClient(region = "us-east-1") {+ assertValidAwsRegion(region); const endpoint = `https://oidc.${region}.amazonaws.com/client/register`; const response = await fetch(endpoint, {@@ -50,6 +51,7 @@ export class KiroService { * Start device authorization for AWS Builder ID or IDC */ async startDeviceAuthorization(clientId, clientSecret, startUrl, region = "us-east-1") {+ assertValidAwsRegion(region); const endpoint = `https://oidc.${region}.amazonaws.com/device_authorization`; const response = await fetch(endpoint, {@@ -84,6 +86,7 @@ export class KiroService { * Poll for token using device code (AWS Builder ID/IDC) */ async pollDeviceToken(clientId, clientSecret, deviceCode, region = "us-east-1") {+ assertValidAwsRegion(region); const endpoint = `https://oidc.${region}.amazonaws.com/token`; const response = await fetch(endpoint, {@@ -176,7 +179,9 @@ export class KiroService { // AWS SSO OIDC refresh (Builder ID or IDC) if (clientId && clientSecret) {- const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`;+ const safeRegion = region || "us-east-1";+ assertValidAwsRegion(safeRegion);+ const endpoint = `https://oidc.${safeRegion}.amazonaws.com/token`; const response = await fetch(endpoint, { method: "POST",@@ -262,6 +267,7 @@ export class KiroService { * JSON-1.0 surface returns `arn`). */ async listAvailableProfiles(accessToken, region = "us-east-1") {+ assertValidAwsRegion(region); const endpoint = `https://codewhisperer.${region}.amazonaws.com`; const response = await fetch(endpoint, {AI-assisted change 29f999629a76e9458d4d1c81c00bfe1328c0d65c49303ab89a736935b179f224 · Fix c4e90f6e5a5f3e07905c8dd08998de8a744c46fe2b22081d4f84156f4f51c65e