diff --git a/src/cli/commands/shared/__tests__/header-utils.test.ts b/src/cli/commands/shared/__tests__/header-utils.test.ts index 640a3a9b0..5b07d7061 100644 --- a/src/cli/commands/shared/__tests__/header-utils.test.ts +++ b/src/cli/commands/shared/__tests__/header-utils.test.ts @@ -39,6 +39,21 @@ describe('normalizeHeaderName', () => { it('auto-prefixes suffix with hyphens like "My-Custom-Header"', () => { expect(normalizeHeaderName('My-Custom-Header')).toBe('X-Amzn-Bedrock-AgentCore-Runtime-Custom-My-Custom-Header'); }); + + it('preserves a header already under the broader namespace prefix without re-prefixing', () => { + expect(normalizeHeaderName('X-Amzn-Bedrock-AgentCore-Runtime-User-Id')).toBe( + 'X-Amzn-Bedrock-AgentCore-Runtime-User-Id' + ); + expect(normalizeHeaderName('X-Amzn-Bedrock-AgentCore-Runtime-Session-Id')).toBe( + 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id' + ); + }); + + it('canonicalizes the namespace prefix casing for non-Custom headers', () => { + expect(normalizeHeaderName('x-amzn-bedrock-agentcore-Runtime-User-Id')).toBe( + 'X-Amzn-Bedrock-AgentCore-Runtime-User-Id' + ); + }); }); describe('parseAndNormalizeHeaders', () => { @@ -127,6 +142,16 @@ describe('validateHeaderAllowlist', () => { expect(result.success).toBe(false); expect(result.error).toContain('Invalid header name'); }); + + it('accepts headers under the broader X-Amzn-Bedrock-AgentCore- namespace', () => { + expect(validateHeaderAllowlist('X-Amzn-Bedrock-AgentCore-Runtime-User-Id')).toEqual({ success: true }); + expect(validateHeaderAllowlist('X-Amzn-Bedrock-AgentCore-Runtime-Session-Id')).toEqual({ success: true }); + expect( + validateHeaderAllowlist( + 'Authorization, X-Amzn-Bedrock-AgentCore-Runtime-User-Id, X-Amzn-Bedrock-AgentCore-Runtime-Custom-Foo' + ) + ).toEqual({ success: true }); + }); }); describe('parseHeaderFlag', () => { diff --git a/src/cli/commands/shared/header-utils.ts b/src/cli/commands/shared/header-utils.ts index 6791d1647..e0cf4d4f6 100644 --- a/src/cli/commands/shared/header-utils.ts +++ b/src/cli/commands/shared/header-utils.ts @@ -1,9 +1,12 @@ import { + HEADER_ALLOWLIST_NAMESPACE_PREFIX as HEADER_ALLOWLIST_NAMESPACE_PREFIX_FROM_SCHEMA, HEADER_ALLOWLIST_PREFIX as HEADER_ALLOWLIST_PREFIX_FROM_SCHEMA, MAX_HEADER_ALLOWLIST_SIZE as MAX_HEADER_ALLOWLIST_SIZE_FROM_SCHEMA, + isAllowedRequestHeader, } from '../../../schema/schemas/agent-env'; export const HEADER_ALLOWLIST_PREFIX = HEADER_ALLOWLIST_PREFIX_FROM_SCHEMA; +export const HEADER_ALLOWLIST_NAMESPACE_PREFIX = HEADER_ALLOWLIST_NAMESPACE_PREFIX_FROM_SCHEMA; export const MAX_HEADER_ALLOWLIST_SIZE = MAX_HEADER_ALLOWLIST_SIZE_FROM_SCHEMA; const HEADER_NAME_PATTERN = /^[A-Za-z0-9-]+$/; @@ -11,8 +14,12 @@ const HEADER_NAME_PATTERN = /^[A-Za-z0-9-]+$/; /** * Normalize a header name according to AgentCore Runtime rules: * - "Authorization" (case-insensitive) -> "Authorization" - * - Headers already starting with the prefix (case-insensitive) -> canonical prefix + original suffix - * - Other headers -> prepend the prefix + * - Headers already starting with the canonical custom prefix + * (case-insensitive) -> canonical prefix + original suffix + * - Headers already under the broader 'X-Amzn-Bedrock-AgentCore-' namespace + * (case-insensitive) -> canonical namespace prefix + original suffix + * (i.e. these are accepted as-is and NOT re-prefixed with the Custom- prefix) + * - Other (bare) header names -> prepend the Custom- prefix */ export function normalizeHeaderName(input: string): string { if (input.toLowerCase() === 'authorization') { @@ -21,6 +28,9 @@ export function normalizeHeaderName(input: string): string { if (input.toLowerCase().startsWith(HEADER_ALLOWLIST_PREFIX.toLowerCase())) { return `${HEADER_ALLOWLIST_PREFIX}${input.slice(HEADER_ALLOWLIST_PREFIX.length)}`; } + if (input.toLowerCase().startsWith(HEADER_ALLOWLIST_NAMESPACE_PREFIX.toLowerCase())) { + return `${HEADER_ALLOWLIST_NAMESPACE_PREFIX}${input.slice(HEADER_ALLOWLIST_NAMESPACE_PREFIX.length)}`; + } return `${HEADER_ALLOWLIST_PREFIX}${input}`; } @@ -69,6 +79,15 @@ export function validateHeaderAllowlist(value: string): { success: boolean; erro }; } + for (const header of headers) { + if (!isAllowedRequestHeader(header)) { + return { + success: false, + error: `Invalid header "${header}". Must be "Authorization" or start with "${HEADER_ALLOWLIST_NAMESPACE_PREFIX}". See https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-header-allowlist.html`, + }; + } + } + return { success: true }; } diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index b9873990b..406a1f62b 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -254,7 +254,7 @@ export class AgentPrimitive extends BasePrimitive', 'OAuth client secret [non-interactive]') .option( '--request-header-allowlist ', - 'Comma-separated list of custom header names to allow (auto-prefixed with X-Amzn-Bedrock-AgentCore-Runtime-Custom-) [non-interactive]' + 'Comma-separated list of allowed request header names. Bare names are auto-prefixed with X-Amzn-Bedrock-AgentCore-Runtime-Custom-; "Authorization" and any header under the X-Amzn-Bedrock-AgentCore- namespace are also accepted. See https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-header-allowlist.html [non-interactive]' ) .option( '--idle-timeout ', diff --git a/src/cli/tui/screens/agent/AddAgentScreen.tsx b/src/cli/tui/screens/agent/AddAgentScreen.tsx index c8961c065..c8a81e518 100644 --- a/src/cli/tui/screens/agent/AddAgentScreen.tsx +++ b/src/cli/tui/screens/agent/AddAgentScreen.tsx @@ -1181,8 +1181,9 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg /> - Enter header suffixes or full names. We auto-prefix with X-Amzn-Bedrock-AgentCore-Runtime-Custom- if - needed. 'Authorization' is also accepted. + Enter header suffixes or full names. Bare names are auto-prefixed with + X-Amzn-Bedrock-AgentCore-Runtime-Custom-. 'Authorization' and any header under the + X-Amzn-Bedrock-AgentCore- namespace are also accepted. diff --git a/src/schema/schemas/__tests__/agent-env.test.ts b/src/schema/schemas/__tests__/agent-env.test.ts index b5b0e55a2..17f46418d 100644 --- a/src/schema/schemas/__tests__/agent-env.test.ts +++ b/src/schema/schemas/__tests__/agent-env.test.ts @@ -6,11 +6,17 @@ import { EnvVarNameSchema, EnvVarSchema, GatewayNameSchema, + HEADER_ALLOWLIST_NAMESPACE_PREFIX, + HEADER_ALLOWLIST_PREFIX, InstrumentationSchema, LifecycleConfigurationSchema, + MAX_HEADER_ALLOWLIST_SIZE, NetworkConfigSchema, + REQUEST_HEADER_ALLOWLIST_PATTERN, + RequestHeaderAllowlistSchema, RuntimeEndpointNameSchema, RuntimeEndpointSchema, + isAllowedRequestHeader, } from '../agent-env.js'; import { describe, expect, it } from 'vitest'; @@ -624,3 +630,58 @@ describe('AgentEnvSpecSchema - endpoints', () => { } }); }); + +describe('REQUEST_HEADER_ALLOWLIST_PATTERN / isAllowedRequestHeader', () => { + it('matches Authorization (case-insensitive)', () => { + expect(REQUEST_HEADER_ALLOWLIST_PATTERN.test('Authorization')).toBe(true); + expect(isAllowedRequestHeader('authorization')).toBe(true); + }); + + it('matches headers under the AgentCore namespace prefix', () => { + expect(isAllowedRequestHeader('X-Amzn-Bedrock-AgentCore-Runtime-Custom-Foo')).toBe(true); + expect(isAllowedRequestHeader('X-Amzn-Bedrock-AgentCore-Runtime-User-Id')).toBe(true); + expect(isAllowedRequestHeader('X-Amzn-Bedrock-AgentCore-Runtime-Session-Id')).toBe(true); + }); + + it('rejects non-matching names and the bare namespace prefix', () => { + expect(isAllowedRequestHeader('Foo')).toBe(false); + expect(isAllowedRequestHeader('X-Custom-Foo')).toBe(false); + expect(isAllowedRequestHeader(HEADER_ALLOWLIST_NAMESPACE_PREFIX)).toBe(false); + expect(isAllowedRequestHeader('')).toBe(false); + }); +}); + +describe('RequestHeaderAllowlistSchema', () => { + it('accepts Authorization and Custom- prefixed headers', () => { + const result = RequestHeaderAllowlistSchema.safeParse(['Authorization', `${HEADER_ALLOWLIST_PREFIX}Foo`]); + expect(result.success).toBe(true); + }); + + it('accepts the broader namespace prefix', () => { + const result = RequestHeaderAllowlistSchema.safeParse([ + 'X-Amzn-Bedrock-AgentCore-Runtime-User-Id', + 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id', + ]); + expect(result.success).toBe(true); + }); + + it('rejects bare names without the namespace prefix', () => { + const result = RequestHeaderAllowlistSchema.safeParse(['Foo-Bar']); + expect(result.success).toBe(false); + }); + + it(`rejects more than ${MAX_HEADER_ALLOWLIST_SIZE} headers`, () => { + const headers = Array.from({ length: MAX_HEADER_ALLOWLIST_SIZE + 1 }, (_, i) => `${HEADER_ALLOWLIST_PREFIX}H${i}`); + const result = RequestHeaderAllowlistSchema.safeParse(headers); + expect(result.success).toBe(false); + }); + + it('error message references the AWS doc URL', () => { + const result = RequestHeaderAllowlistSchema.safeParse(['Foo']); + expect(result.success).toBe(false); + if (!result.success) { + const allMessages = result.error.issues.map(i => i.message).join(' | '); + expect(allMessages).toContain('runtime-header-allowlist.html'); + } + }); +}); diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index 789109a38..ef6d06e5c 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -125,19 +125,51 @@ export type NetworkConfig = z.infer; /** * Allowed request headers for the runtime. - * Each header must be 'Authorization' or start with 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-'. + * + * Per AWS Bedrock AgentCore runtime header allowlist documentation + * (https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-header-allowlist.html), + * each header must be either: + * - 'Authorization' (case-sensitive canonical form), or + * - any header beginning with the AgentCore runtime namespace prefix + * 'X-Amzn-Bedrock-AgentCore-' (case-insensitive), e.g. + * * 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-' + * * 'X-Amzn-Bedrock-AgentCore-Runtime-User-Id' + * * 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id' + * * other documented headers under the same namespace + * * Maximum 20 headers. + * + * NOTE: This file is duplicated in agentcore-l3-cdk-constructs/src/schema/schemas/agent-env.ts. + * Keep both copies in sync. */ export const HEADER_ALLOWLIST_PREFIX = 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-'; +export const HEADER_ALLOWLIST_NAMESPACE_PREFIX = 'X-Amzn-Bedrock-AgentCore-'; export const MAX_HEADER_ALLOWLIST_SIZE = 20; +/** + * Pattern matching any header name that is an acceptable entry in the + * request header allowlist. Matches case-insensitively for the namespace + * prefix to accommodate user-supplied casing variations; the runtime + * itself is HTTP-header-case-insensitive. + */ +export const REQUEST_HEADER_ALLOWLIST_PATTERN = /^(Authorization|X-Amzn-Bedrock-AgentCore-[A-Za-z0-9-]+)$/i; + +/** + * Returns true when the given header name is allowed in the runtime + * request header allowlist (Authorization or any header under the + * AgentCore runtime namespace prefix). + */ +export function isAllowedRequestHeader(name: string): boolean { + return REQUEST_HEADER_ALLOWLIST_PATTERN.test(name); +} + export const RequestHeaderAllowlistSchema = z .array( z .string() .refine( - val => val === 'Authorization' || val.startsWith(HEADER_ALLOWLIST_PREFIX), - `Must be "Authorization" or start with "${HEADER_ALLOWLIST_PREFIX}"` + isAllowedRequestHeader, + `Must be "Authorization" or start with "${HEADER_ALLOWLIST_NAMESPACE_PREFIX}". See https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-header-allowlist.html` ) ) .max(MAX_HEADER_ALLOWLIST_SIZE, `Maximum ${MAX_HEADER_ALLOWLIST_SIZE} headers allowed`);