From 05b419a0321819889a9f763ab536326250575dc7 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 13 May 2026 10:26:11 -0400 Subject: [PATCH 1/3] fix(schema): relax request header allowlist validation per AWS docs Relaxes header allowlist to accept any valid HTTP header name (alphanumeric, hyphens, underscores) that isn't structurally reserved (x-amz-*, x-amzn-* except Runtime-Custom-*), per the AWS AgentCore Runtime documentation. - Updates schema refine to validate character pattern + block reserved prefixes - Updates normalizeHeaderName to pass through X-* headers unchanged - Adds case-insensitive deduplication - Adds tests for X-Api-Key, X-Custom-Signature, restricted prefix rejection Refs #1151 --- .../shared/__tests__/header-utils.test.ts | 76 ++++++++++++++++--- src/cli/commands/shared/header-utils.ts | 48 +++++++++--- src/schema/schemas/agent-env.ts | 19 ++++- 3 files changed, 122 insertions(+), 21 deletions(-) diff --git a/src/cli/commands/shared/__tests__/header-utils.test.ts b/src/cli/commands/shared/__tests__/header-utils.test.ts index 640a3a9b0..e2a5029df 100644 --- a/src/cli/commands/shared/__tests__/header-utils.test.ts +++ b/src/cli/commands/shared/__tests__/header-utils.test.ts @@ -32,11 +32,17 @@ 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('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 +65,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 +80,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 +110,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); @@ -127,6 +164,12 @@ describe('validateHeaderAllowlist', () => { expect(result.success).toBe(false); expect(result.error).toContain('Invalid header name'); }); + + it('returns error for header with dots', () => { + const result = validateHeaderAllowlist('My.Header'); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid header name'); + }); }); describe('parseHeaderFlag', () => { @@ -137,6 +180,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 +233,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..6bc02181d 100644 --- a/src/cli/commands/shared/header-utils.ts +++ b/src/cli/commands/shared/header-utils.ts @@ -1,18 +1,21 @@ 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, } 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 +24,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 +42,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 +68,34 @@ 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.`, + error: `Invalid header name "${name}". Header names may only contain letters, numbers, hyphens, and underscores.`, + }; + } + + const lower = name.toLowerCase(); + if (lower.startsWith('x-amz-') && !lower.startsWith('x-amzn-')) { + return { + success: false, + error: `Header "${name}" 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 { + success: false, + error: `Header "${name}" is not allowed. Headers starting with "x-amzn-" are reserved, except for "X-Amzn-Bedrock-AgentCore-Runtime-Custom-*".`, }; } } - 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/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index 789109a38..ead4903ae 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -125,19 +125,32 @@ 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; +function isValidAllowlistHeader(val: string): boolean { + if (!HEADER_NAME_PATTERN.test(val)) return false; + const lower = val.toLowerCase(); + if (lower.startsWith('x-amz-') && !lower.startsWith('x-amzn-')) return false; + if (lower.startsWith('x-amzn-') && !lower.startsWith('x-amzn-bedrock-agentcore-runtime-custom-')) return false; + return true; +} + 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}"` + isValidAllowlistHeader, + 'Header names must contain only alphanumeric characters, hyphens, and underscores. ' + + 'Headers starting with x-amz- are reserved. ' + + 'Headers starting with x-amzn- are reserved except for X-Amzn-Bedrock-AgentCore-Runtime-Custom-*.' ) ) .max(MAX_HEADER_ALLOWLIST_SIZE, `Maximum ${MAX_HEADER_ALLOWLIST_SIZE} headers allowed`); From 6f9fde013377bdab9229c8c0ba356ae479558153 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 13 May 2026 10:36:12 -0400 Subject: [PATCH 2/3] fix(tui): update header allowlist help text to reflect relaxed validation Updates CLI flag description and TUI hints to show examples of newly-accepted header names (X-Api-Key, X-Custom-Signature) and clarify when auto-prefixing applies. Refs #1151 --- src/cli/primitives/AgentPrimitive.tsx | 2 +- src/cli/tui/screens/agent/AddAgentScreen.tsx | 4 ++-- src/cli/tui/screens/generate/GenerateWizardUI.tsx | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index d82d9e808..72ac82585 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 (e.g. Authorization, X-Api-Key, X-Custom-Signature). Bare names without X- prefix are auto-prefixed with X-Amzn-Bedrock-AgentCore-Runtime-Custom- [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. From 0855c6c62bc9a5a27c068afd418ee3f5d00a7535 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 13 May 2026 13:16:43 -0400 Subject: [PATCH 3/3] fix(schema): per-branch error messages and remove dead-code prefix check Addresses review feedback on PR #1163: - Schema now returns specific error per violated rule (character pattern, x-amz- reserved, x-amzn- reserved-except-Custom-) instead of a single three-rule string. Easier to act on for users. - Removes dead-code clause '&& !lower.startsWith('x-amzn-')' on the x-amz- check; 'x-amz-' and 'x-amzn-' are disjoint prefixes (position 5 differs: '-' vs 'n'), so the carve-out is unnecessary. - Extracts checkAllowlistHeader() in agent-env.ts as the single source of truth; header-utils.ts now consumes it instead of duplicating the rules. - Adds test pinning the documented suffix-preservation behavior of normalizeHeaderName() for the Runtime-Custom- branch. - Updates --request-header-allowlist flag help to clarify X-prefixed names pass through unchanged. Refs #1151 --- .../shared/__tests__/header-utils.test.ts | 15 ++++++-- src/cli/commands/shared/header-utils.ts | 23 ++---------- src/cli/primitives/AgentPrimitive.tsx | 2 +- src/schema/schemas/agent-env.ts | 37 ++++++++++++------- 4 files changed, 41 insertions(+), 36 deletions(-) diff --git a/src/cli/commands/shared/__tests__/header-utils.test.ts b/src/cli/commands/shared/__tests__/header-utils.test.ts index e2a5029df..6e29da9ba 100644 --- a/src/cli/commands/shared/__tests__/header-utils.test.ts +++ b/src/cli/commands/shared/__tests__/header-utils.test.ts @@ -38,6 +38,15 @@ describe('normalizeHeaderName', () => { 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'); }); @@ -156,19 +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('Invalid header name'); + expect(result.error).toContain('must contain only'); }); }); diff --git a/src/cli/commands/shared/header-utils.ts b/src/cli/commands/shared/header-utils.ts index 6bc02181d..76a9981ee 100644 --- a/src/cli/commands/shared/header-utils.ts +++ b/src/cli/commands/shared/header-utils.ts @@ -2,6 +2,7 @@ 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; @@ -70,25 +71,9 @@ export function validateHeaderAllowlist(value: string): { success: boolean; erro .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, hyphens, and underscores.`, - }; - } - - const lower = name.toLowerCase(); - if (lower.startsWith('x-amz-') && !lower.startsWith('x-amzn-')) { - return { - success: false, - error: `Header "${name}" 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 { - success: false, - error: `Header "${name}" is not allowed. Headers starting with "x-amzn-" are reserved, except for "X-Amzn-Bedrock-AgentCore-Runtime-Custom-*".`, - }; + const error = checkAllowlistHeader(name); + if (error) { + return { success: false, error }; } } diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index 72ac82585..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 header names to allow (e.g. Authorization, X-Api-Key, X-Custom-Signature). Bare names without X- prefix are 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/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index ead4903ae..3745ad2d7 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -134,24 +134,35 @@ 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; -function isValidAllowlistHeader(val: string): boolean { - if (!HEADER_NAME_PATTERN.test(val)) return false; +/** + * 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-') && !lower.startsWith('x-amzn-')) return false; - if (lower.startsWith('x-amzn-') && !lower.startsWith('x-amzn-bedrock-agentcore-runtime-custom-')) return false; - return true; + 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( - isValidAllowlistHeader, - 'Header names must contain only alphanumeric characters, hyphens, and underscores. ' + - 'Headers starting with x-amz- are reserved. ' + - 'Headers starting with x-amzn- are reserved except for X-Amzn-Bedrock-AgentCore-Runtime-Custom-*.' - ) + 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`);