diff --git a/src/cli/commands/shared/__tests__/header-utils.test.ts b/src/cli/commands/shared/__tests__/header-utils.test.ts index 640a3a9b0..6e29da9ba 100644 --- a/src/cli/commands/shared/__tests__/header-utils.test.ts +++ b/src/cli/commands/shared/__tests__/header-utils.test.ts @@ -32,11 +32,26 @@ describe('normalizeHeaderName', () => { ); }); - it('auto-prefixes a bare suffix like "MyHeader"', () => { + it('passes through X- prefixed headers unchanged', () => { + expect(normalizeHeaderName('X-Api-Key')).toBe('X-Api-Key'); + expect(normalizeHeaderName('X-Custom-Signature')).toBe('X-Custom-Signature'); + expect(normalizeHeaderName('X-Request-Id')).toBe('X-Request-Id'); + }); + + it('canonicalizes Runtime-Custom- prefix casing but preserves suffix as-typed', () => { + expect(normalizeHeaderName('x-amzn-bedrock-agentcore-runtime-custom-myheader')).toBe( + 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-myheader' + ); + expect(normalizeHeaderName('X-AMZN-BEDROCK-AGENTCORE-RUNTIME-CUSTOM-MyHeader')).toBe( + 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader' + ); + }); + + it('auto-prefixes a bare suffix like "MyHeader" (no X- prefix, backward compat)', () => { expect(normalizeHeaderName('MyHeader')).toBe('X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader'); }); - it('auto-prefixes suffix with hyphens like "My-Custom-Header"', () => { + it('auto-prefixes suffix with hyphens like "My-Custom-Header" (no X- prefix)', () => { expect(normalizeHeaderName('My-Custom-Header')).toBe('X-Amzn-Bedrock-AgentCore-Runtime-Custom-My-Custom-Header'); }); }); @@ -59,6 +74,11 @@ describe('parseAndNormalizeHeaders', () => { ]); }); + it('passes through X- prefixed headers without auto-prefixing', () => { + const result = parseAndNormalizeHeaders('X-Api-Key, X-Custom-Signature, authorization'); + expect(result).toEqual(['X-Api-Key', 'X-Custom-Signature', 'Authorization']); + }); + it('deduplicates after normalization', () => { const result = parseAndNormalizeHeaders('MyHeader, X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader'); expect(result).toEqual(['X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader']); @@ -69,13 +89,14 @@ describe('parseAndNormalizeHeaders', () => { expect(result).toEqual(['Authorization']); }); + it('deduplicates case-insensitively for X- headers', () => { + const result = parseAndNormalizeHeaders('X-Api-Key, x-api-key'); + expect(result).toEqual(['X-Api-Key']); + }); + it('trims whitespace around values', () => { - const result = parseAndNormalizeHeaders(' MyHeader , authorization , Another-Header '); - expect(result).toEqual([ - 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader', - 'Authorization', - 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-Another-Header', - ]); + const result = parseAndNormalizeHeaders(' MyHeader , authorization , X-Api-Key '); + expect(result).toEqual(['X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader', 'Authorization', 'X-Api-Key']); }); }); @@ -98,12 +119,37 @@ describe('validateHeaderAllowlist', () => { expect(validateHeaderAllowlist('authorization')).toEqual({ success: true }); }); + it('returns success for X- prefixed headers from AWS docs', () => { + expect(validateHeaderAllowlist('X-Api-Key')).toEqual({ success: true }); + expect(validateHeaderAllowlist('X-Custom-Signature')).toEqual({ success: true }); + }); + it('returns success for mixed valid headers', () => { - expect(validateHeaderAllowlist('Authorization, MyHeader, X-Amzn-Bedrock-AgentCore-Runtime-Custom-Another')).toEqual( + expect(validateHeaderAllowlist('Authorization, X-Api-Key, X-Amzn-Bedrock-AgentCore-Runtime-Custom-UserId')).toEqual( { success: true } ); }); + it('returns success for headers with underscores', () => { + expect(validateHeaderAllowlist('X-My_Custom_Header')).toEqual({ success: true }); + }); + + it('returns error for x-amz- prefixed headers', () => { + const result = validateHeaderAllowlist('x-amz-security-token'); + expect(result.success).toBe(false); + expect(result.error).toContain('reserved for AWS request signing'); + }); + + it('returns error for x-amzn- prefixed headers (not Runtime-Custom-)', () => { + const result = validateHeaderAllowlist('x-amzn-trace-id'); + expect(result.success).toBe(false); + expect(result.error).toContain('x-amzn-'); + }); + + it('returns success for X-Amzn-Bedrock-AgentCore-Runtime-Custom- headers', () => { + expect(validateHeaderAllowlist('X-Amzn-Bedrock-AgentCore-Runtime-Custom-UserId')).toEqual({ success: true }); + }); + it('returns error when exceeding max 20 headers', () => { const headers = Array.from({ length: 21 }, (_, i) => `Header${i}`).join(', '); const result = validateHeaderAllowlist(headers); @@ -119,13 +165,19 @@ describe('validateHeaderAllowlist', () => { it('returns error for header names containing whitespace', () => { const result = validateHeaderAllowlist('My Header'); expect(result.success).toBe(false); - expect(result.error).toContain('Invalid header name'); + expect(result.error).toContain('must contain only'); }); it('returns error for header names with special characters', () => { const result = validateHeaderAllowlist('My@Header'); expect(result.success).toBe(false); - expect(result.error).toContain('Invalid header name'); + expect(result.error).toContain('must contain only'); + }); + + it('returns error for header with dots', () => { + const result = validateHeaderAllowlist('My.Header'); + expect(result.success).toBe(false); + expect(result.error).toContain('must contain only'); }); }); @@ -137,6 +189,13 @@ describe('parseHeaderFlag', () => { }); }); + it('parses X- prefixed header without auto-prefixing', () => { + expect(parseHeaderFlag('X-Api-Key: my-key')).toEqual({ + name: 'X-Api-Key', + value: 'my-key', + }); + }); + it('parses "Key:Value" format without space', () => { expect(parseHeaderFlag('MyHeader:some-value')).toEqual({ name: 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader', @@ -183,6 +242,14 @@ describe('parseHeaderFlags', () => { }); }); + it('parses X- prefixed headers without prefixing', () => { + const result = parseHeaderFlags(['X-Api-Key: key123', 'X-Custom-Signature: sha256=abc']); + expect(result).toEqual({ + 'X-Api-Key': 'key123', + 'X-Custom-Signature': 'sha256=abc', + }); + }); + it('returns empty object for empty array', () => { expect(parseHeaderFlags([])).toEqual({}); }); diff --git a/src/cli/commands/shared/header-utils.ts b/src/cli/commands/shared/header-utils.ts index 6791d1647..76a9981ee 100644 --- a/src/cli/commands/shared/header-utils.ts +++ b/src/cli/commands/shared/header-utils.ts @@ -1,18 +1,22 @@ import { HEADER_ALLOWLIST_PREFIX as HEADER_ALLOWLIST_PREFIX_FROM_SCHEMA, + HEADER_NAME_PATTERN as HEADER_NAME_PATTERN_FROM_SCHEMA, MAX_HEADER_ALLOWLIST_SIZE as MAX_HEADER_ALLOWLIST_SIZE_FROM_SCHEMA, + checkAllowlistHeader, } from '../../../schema/schemas/agent-env'; export const HEADER_ALLOWLIST_PREFIX = HEADER_ALLOWLIST_PREFIX_FROM_SCHEMA; +export const HEADER_NAME_PATTERN = HEADER_NAME_PATTERN_FROM_SCHEMA; export const MAX_HEADER_ALLOWLIST_SIZE = MAX_HEADER_ALLOWLIST_SIZE_FROM_SCHEMA; -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 starting with X-Amzn-Bedrock-AgentCore-Runtime-Custom- (case-insensitive) -> + * canonical prefix casing + original suffix + * - Any other X- prefixed header (e.g. X-Api-Key, X-Custom-Signature) -> pass through unchanged + * - Bare suffixes without X- prefix (e.g. MyHeader) -> auto-prefix with Runtime-Custom- for + * backward compatibility */ export function normalizeHeaderName(input: string): string { if (input.toLowerCase() === 'authorization') { @@ -21,11 +25,15 @@ 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 (/^x-/i.test(input)) { + return input; + } return `${HEADER_ALLOWLIST_PREFIX}${input}`; } /** * Parse a comma-separated string of header names, normalize each, and deduplicate. + * Deduplication is case-insensitive per AWS docs. * Returns an array of normalized header names. */ export function parseAndNormalizeHeaders(input: string): string[] { @@ -35,7 +43,16 @@ export function parseAndNormalizeHeaders(input: string): string[] { .filter(Boolean) .map(normalizeHeaderName); - return Array.from(new Set(headers)); + const seen = new Set(); + const result: string[] = []; + for (const header of headers) { + const lower = header.toLowerCase(); + if (!seen.has(lower)) { + seen.add(lower); + result.push(header); + } + } + return result; } /** @@ -52,20 +69,18 @@ export function validateHeaderAllowlist(value: string): { success: boolean; erro .split(',') .map(s => s.trim()) .filter(Boolean); + for (const name of rawNames) { - if (!HEADER_NAME_PATTERN.test(name)) { - return { - success: false, - error: `Invalid header name "${name}". Header names may only contain letters, numbers, and hyphens.`, - }; + const error = checkAllowlistHeader(name); + if (error) { + return { success: false, error }; } } - const headers = parseAndNormalizeHeaders(value); - if (headers.length > MAX_HEADER_ALLOWLIST_SIZE) { + if (rawNames.length > MAX_HEADER_ALLOWLIST_SIZE) { return { success: false, - error: `Header allowlist cannot exceed ${MAX_HEADER_ALLOWLIST_SIZE} headers. Provided: ${headers.length}`, + error: `Header allowlist cannot exceed ${MAX_HEADER_ALLOWLIST_SIZE} headers. Provided: ${rawNames.length}`, }; } diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index d82d9e808..d8ffc96ff 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -266,7 +266,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 header names to allow. X-prefixed names (e.g. Authorization, X-Api-Key, X-Custom-Signature) pass through unchanged; bare names without X- prefix are auto-prefixed with X-Amzn-Bedrock-AgentCore-Runtime-Custom- for backward compatibility. [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..5072e8ccb 100644 --- a/src/cli/tui/screens/agent/AddAgentScreen.tsx +++ b/src/cli/tui/screens/agent/AddAgentScreen.tsx @@ -1181,8 +1181,8 @@ 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 names (e.g. Authorization, X-Api-Key, X-Custom-Signature). Bare names without X- prefix are + auto-prefixed with X-Amzn-Bedrock-AgentCore-Runtime-Custom- for backward compatibility. diff --git a/src/cli/tui/screens/generate/GenerateWizardUI.tsx b/src/cli/tui/screens/generate/GenerateWizardUI.tsx index 9c6c79599..2677858de 100644 --- a/src/cli/tui/screens/generate/GenerateWizardUI.tsx +++ b/src/cli/tui/screens/generate/GenerateWizardUI.tsx @@ -307,8 +307,8 @@ export function GenerateWizardUI({ /> - Enter header suffixes or full names. We auto-prefix with X-Amzn-Bedrock-AgentCore-Runtime-Custom- if - needed. 'Authorization' is also accepted. + Enter header names (e.g. Authorization, X-Api-Key, X-Custom-Signature). Bare names without X- prefix are + auto-prefixed with X-Amzn-Bedrock-AgentCore-Runtime-Custom- for backward compatibility. diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index 789109a38..3745ad2d7 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -125,20 +125,44 @@ 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 https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-header-allowlist.html + * any valid HTTP header name (alphanumeric, hyphens, underscores) may be allow-listed, + * provided it is not structurally reserved (x-amz-*, x-amzn-* except Runtime-Custom-*). * Maximum 20 headers. */ export const HEADER_ALLOWLIST_PREFIX = 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-'; +export const HEADER_NAME_PATTERN = /^[A-Za-z0-9\-_]+$/; export const MAX_HEADER_ALLOWLIST_SIZE = 20; +/** + * Validate a single allowlist header name. Returns null if valid, or a specific + * error message describing which rule the input violated. + * + * Note: 'x-amz-' and 'x-amzn-' are disjoint prefixes (position 5 differs: '-' vs 'n'), + * so the two checks below are independent. + */ +export function checkAllowlistHeader(val: string): string | null { + if (!HEADER_NAME_PATTERN.test(val)) { + return `Header name "${val}" must contain only alphanumeric characters, hyphens, and underscores.`; + } + const lower = val.toLowerCase(); + if (lower.startsWith('x-amz-')) { + return `Header "${val}" is not allowed. Headers starting with "x-amz-" are reserved for AWS request signing.`; + } + if (lower.startsWith('x-amzn-') && !lower.startsWith('x-amzn-bedrock-agentcore-runtime-custom-')) { + return `Header "${val}" is not allowed. Headers starting with "x-amzn-" are reserved, except for "X-Amzn-Bedrock-AgentCore-Runtime-Custom-*".`; + } + return null; +} + 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}"` - ) + z.string().superRefine((val, ctx) => { + const error = checkAllowlistHeader(val); + if (error) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: error }); + } + }) ) .max(MAX_HEADER_ALLOWLIST_SIZE, `Maximum ${MAX_HEADER_ALLOWLIST_SIZE} headers allowed`);