From 0c95cac84f2b64b42864647a082a7d58d622bf2b Mon Sep 17 00:00:00 2001 From: agentcore-bot Date: Thu, 7 May 2026 16:50:59 +0000 Subject: [PATCH] feat: relax request header allowlist regex (#1151) --- docs/configuration.md | 36 +-- .../shared/__tests__/header-utils.test.ts | 98 +++++--- src/cli/commands/shared/header-utils.ts | 47 +++- src/cli/primitives/AgentPrimitive.tsx | 2 +- src/cli/tui/screens/agent/AddAgentScreen.tsx | 5 +- .../tui/screens/generate/GenerateWizardUI.tsx | 5 +- src/schema/schemas/agent-env.ts | 216 +++++++++++++++++- 7 files changed, 337 insertions(+), 72 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6e42c101a..b37bfd49e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -175,24 +175,24 @@ on the next deployment. } ``` -| Field | Required | Description | -| ------------------------- | -------- | --------------------------------------------------------------- | -| `name` | Yes | Agent name (1-48 chars, alphanumeric + underscore) | -| `build` | Yes | `"CodeZip"` or `"Container"` | -| `entrypoint` | Yes | Entry file (e.g., `main.py` or `main.py:handler`) | -| `codeLocation` | Yes | Directory containing agent code | -| `runtimeVersion` | Yes | Runtime version (see below) | -| `networkMode` | No | `"PUBLIC"` (default) or `"VPC"` | -| `networkConfig` | No | VPC configuration (subnets, security groups) | -| `protocol` | No | `"HTTP"` (default), `"MCP"`, or `"A2A"` | -| `envVars` | No | Custom environment variables | -| `instrumentation` | No | OpenTelemetry settings | -| `authorizerType` | No | `"AWS_IAM"` or `"CUSTOM_JWT"` | -| `authorizerConfiguration` | No | JWT authorizer settings (for `CUSTOM_JWT`) | -| `requestHeaderAllowlist` | No | Headers to forward to the agent | -| `lifecycleConfiguration` | No | Runtime session lifecycle settings (idle timeout, max lifetime) | -| `executionRoleArn` | No | ARN of an existing IAM execution role (skips CDK-managed role) | -| `tags` | No | Agent-level tags | +| Field | Required | Description | +| ------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | Yes | Agent name (1-48 chars, alphanumeric + underscore) | +| `build` | Yes | `"CodeZip"` or `"Container"` | +| `entrypoint` | Yes | Entry file (e.g., `main.py` or `main.py:handler`) | +| `codeLocation` | Yes | Directory containing agent code | +| `runtimeVersion` | Yes | Runtime version (see below) | +| `networkMode` | No | `"PUBLIC"` (default) or `"VPC"` | +| `networkConfig` | No | VPC configuration (subnets, security groups) | +| `protocol` | No | `"HTTP"` (default), `"MCP"`, or `"A2A"` | +| `envVars` | No | Custom environment variables | +| `instrumentation` | No | OpenTelemetry settings | +| `authorizerType` | No | `"AWS_IAM"` or `"CUSTOM_JWT"` | +| `authorizerConfiguration` | No | JWT authorizer settings (for `CUSTOM_JWT`) | +| `requestHeaderAllowlist` | No | Headers to forward to the agent (max 20). Accepts any non-restricted HTTP header (alphanumerics/hyphens/underscores), `Authorization`, or names starting with `X-Amzn-Bedrock-AgentCore-Runtime-Custom-`. See the [AWS docs](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-header-allowlist.html). | +| `lifecycleConfiguration` | No | Runtime session lifecycle settings (idle timeout, max lifetime) | +| `executionRoleArn` | No | ARN of an existing IAM execution role (skips CDK-managed role) | +| `tags` | No | Agent-level tags | ### Runtime Versions diff --git a/src/cli/commands/shared/__tests__/header-utils.test.ts b/src/cli/commands/shared/__tests__/header-utils.test.ts index 640a3a9b0..765886d05 100644 --- a/src/cli/commands/shared/__tests__/header-utils.test.ts +++ b/src/cli/commands/shared/__tests__/header-utils.test.ts @@ -32,12 +32,11 @@ describe('normalizeHeaderName', () => { ); }); - it('auto-prefixes a bare suffix like "MyHeader"', () => { - expect(normalizeHeaderName('MyHeader')).toBe('X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader'); - }); - - 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 arbitrary header names without auto-prefixing', () => { + expect(normalizeHeaderName('MyHeader')).toBe('MyHeader'); + expect(normalizeHeaderName('My-Custom-Header')).toBe('My-Custom-Header'); + expect(normalizeHeaderName('X-Custom-Signature')).toBe('X-Custom-Signature'); + expect(normalizeHeaderName('X-Api-Key')).toBe('X-Api-Key'); }); }); @@ -52,15 +51,18 @@ describe('parseAndNormalizeHeaders', () => { it('splits comma-separated and normalizes', () => { 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', - ]); + expect(result).toEqual(['MyHeader', 'Authorization', 'Another-Header']); + }); + + it('deduplicates after normalization (case-insensitive)', () => { + const result = parseAndNormalizeHeaders('MyHeader, myheader, MYHEADER'); + expect(result).toEqual(['MyHeader']); }); - it('deduplicates after normalization', () => { - const result = parseAndNormalizeHeaders('MyHeader, X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader'); + it('deduplicates the AgentCore custom prefix variations', () => { + const result = parseAndNormalizeHeaders( + 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader, x-amzn-bedrock-agentcore-runtime-custom-MyHeader' + ); expect(result).toEqual(['X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader']); }); @@ -71,11 +73,7 @@ describe('parseAndNormalizeHeaders', () => { 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', - ]); + expect(result).toEqual(['MyHeader', 'Authorization', 'Another-Header']); }); }); @@ -85,11 +83,14 @@ describe('validateHeaderAllowlist', () => { expect(validateHeaderAllowlist(' ')).toEqual({ success: true }); }); - it('returns success for valid custom header suffix', () => { + it('accepts arbitrary custom headers (no longer requires the AgentCore prefix)', () => { expect(validateHeaderAllowlist('MyHeader')).toEqual({ success: true }); + expect(validateHeaderAllowlist('X-Custom-Signature')).toEqual({ success: true }); + expect(validateHeaderAllowlist('X-Api-Key')).toEqual({ success: true }); + expect(validateHeaderAllowlist('Some_Header_With_Underscores')).toEqual({ success: true }); }); - it('returns success for valid full header name', () => { + it('returns success for valid full AgentCore custom header name', () => { expect(validateHeaderAllowlist('X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader')).toEqual({ success: true }); }); @@ -99,9 +100,9 @@ describe('validateHeaderAllowlist', () => { }); it('returns success for mixed valid headers', () => { - expect(validateHeaderAllowlist('Authorization, MyHeader, X-Amzn-Bedrock-AgentCore-Runtime-Custom-Another')).toEqual( - { success: true } - ); + expect( + validateHeaderAllowlist('Authorization, X-Custom-Signature, X-Amzn-Bedrock-AgentCore-Runtime-Custom-Another') + ).toEqual({ success: true }); }); it('returns error when exceeding max 20 headers', () => { @@ -127,19 +128,51 @@ describe('validateHeaderAllowlist', () => { expect(result.success).toBe(false); expect(result.error).toContain('Invalid header name'); }); + + it('rejects restricted headers (Cookie, Host, Accept, Content-Type, etc.)', () => { + for (const restricted of ['Cookie', 'Host', 'Accept', 'Content-Type', 'User-Agent', 'Connection']) { + const result = validateHeaderAllowlist(restricted); + expect(result.success, `expected "${restricted}" to be rejected`).toBe(false); + expect(result.error).toMatch(/restricted/i); + } + }); + + it('rejects restricted headers case-insensitively', () => { + const result = validateHeaderAllowlist('cookie'); + expect(result.success).toBe(false); + expect(result.error).toMatch(/restricted/i); + }); + + it('rejects headers starting with x-amz-', () => { + const result = validateHeaderAllowlist('X-Amz-Date'); + expect(result.success).toBe(false); + expect(result.error).toMatch(/x-amz-/i); + }); + + it('rejects headers starting with x-amzn- that are not the AgentCore custom prefix', () => { + const result = validateHeaderAllowlist('X-Amzn-Foo'); + expect(result.success).toBe(false); + expect(result.error).toMatch(/x-amzn-/i); + }); + + it('rejects duplicate headers (case-insensitive)', () => { + const result = validateHeaderAllowlist('MyHeader, myheader'); + expect(result.success).toBe(false); + expect(result.error).toMatch(/[Dd]uplicate/); + }); }); describe('parseHeaderFlag', () => { it('parses "Key: Value" format', () => { expect(parseHeaderFlag('MyHeader: some-value')).toEqual({ - name: 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader', + name: 'MyHeader', value: 'some-value', }); }); it('parses "Key:Value" format without space', () => { expect(parseHeaderFlag('MyHeader:some-value')).toEqual({ - name: 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader', + name: 'MyHeader', value: 'some-value', }); }); @@ -151,13 +184,20 @@ describe('parseHeaderFlag', () => { }); }); - it('normalizes header names', () => { + it('normalizes Authorization casing', () => { expect(parseHeaderFlag('authorization: token')).toEqual({ name: 'Authorization', value: 'token', }); }); + it('preserves case for arbitrary headers (no auto-prefixing)', () => { + expect(parseHeaderFlag('X-Custom-Signature: abc123')).toEqual({ + name: 'X-Custom-Signature', + value: 'abc123', + }); + }); + it('returns null for missing colon', () => { expect(parseHeaderFlag('no-colon-here')).toBeNull(); }); @@ -168,7 +208,7 @@ describe('parseHeaderFlag', () => { it('trims whitespace from key and value', () => { expect(parseHeaderFlag(' MyHeader : some-value ')).toEqual({ - name: 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader', + name: 'MyHeader', value: 'some-value', }); }); @@ -178,7 +218,7 @@ describe('parseHeaderFlags', () => { it('parses multiple headers', () => { const result = parseHeaderFlags(['MyHeader: value1', 'Authorization: Bearer token']); expect(result).toEqual({ - 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader': 'value1', + MyHeader: 'value1', Authorization: 'Bearer token', }); }); @@ -190,7 +230,7 @@ describe('parseHeaderFlags', () => { it('last value wins for duplicate keys', () => { const result = parseHeaderFlags(['MyHeader: first', 'MyHeader: second']); expect(result).toEqual({ - 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader': 'second', + MyHeader: 'second', }); }); diff --git a/src/cli/commands/shared/header-utils.ts b/src/cli/commands/shared/header-utils.ts index 6791d1647..3a6471110 100644 --- a/src/cli/commands/shared/header-utils.ts +++ b/src/cli/commands/shared/header-utils.ts @@ -1,18 +1,20 @@ import { HEADER_ALLOWLIST_PREFIX as HEADER_ALLOWLIST_PREFIX_FROM_SCHEMA, MAX_HEADER_ALLOWLIST_SIZE as MAX_HEADER_ALLOWLIST_SIZE_FROM_SCHEMA, + getHeaderRejectionReason, } from '../../../schema/schemas/agent-env'; export const HEADER_ALLOWLIST_PREFIX = HEADER_ALLOWLIST_PREFIX_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 already starting with the AgentCore custom prefix + * (case-insensitive) -> canonical prefix + original suffix + * - Otherwise -> the input is returned unchanged. The allowlist now accepts any + * non-restricted HTTP header name (alphanumerics, hyphens, underscores), so + * we no longer auto-prepend the AgentCore custom prefix. */ export function normalizeHeaderName(input: string): string { if (input.toLowerCase() === 'authorization') { @@ -21,12 +23,12 @@ export function normalizeHeaderName(input: string): string { if (input.toLowerCase().startsWith(HEADER_ALLOWLIST_PREFIX.toLowerCase())) { return `${HEADER_ALLOWLIST_PREFIX}${input.slice(HEADER_ALLOWLIST_PREFIX.length)}`; } - return `${HEADER_ALLOWLIST_PREFIX}${input}`; + return input; } /** - * Parse a comma-separated string of header names, normalize each, and deduplicate. - * Returns an array of normalized header names. + * Parse a comma-separated string of header names, normalize each, and deduplicate + * (case-insensitive; first occurrence wins). */ export function parseAndNormalizeHeaders(input: string): string[] { const headers = input @@ -35,7 +37,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 h of headers) { + const key = h.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + result.push(h); + } + } + return result; } /** @@ -52,16 +63,30 @@ export function validateHeaderAllowlist(value: string): { success: boolean; erro .split(',') .map(s => s.trim()) .filter(Boolean); + + // Validate each header name against the allowlist rules (regex + restricted + // names + reserved prefixes). for (const name of rawNames) { - if (!HEADER_NAME_PATTERN.test(name)) { + const rejection = getHeaderRejectionReason(normalizeHeaderName(name)); + if (rejection) { + return { success: false, error: rejection }; + } + } + + // Detect duplicates (case-insensitive, after normalization). + const headers = parseAndNormalizeHeaders(value); + const seen = new Set(); + for (const raw of rawNames) { + const key = normalizeHeaderName(raw).toLowerCase(); + if (seen.has(key)) { return { success: false, - error: `Invalid header name "${name}". Header names may only contain letters, numbers, and hyphens.`, + error: `Duplicate header (case-insensitive): "${raw}".`, }; } + seen.add(key); } - const headers = parseAndNormalizeHeaders(value); if (headers.length > MAX_HEADER_ALLOWLIST_SIZE) { return { success: false, diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index b9873990b..c5665314c 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 request header names to forward to the runtime. Accepts any non-restricted HTTP header (alphanumerics, hyphens, underscores), "Authorization", or names starting with X-Amzn-Bedrock-AgentCore-Runtime-Custom-. Max 20. [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..9366d2169 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 names verbatim (alphanumerics, hyphens, underscores). 'Authorization' and headers + starting with X-Amzn-Bedrock-AgentCore-Runtime-Custom- are accepted. Restricted headers (Cookie, Host, + Content-Type, x-amz-*, etc.) are rejected. diff --git a/src/cli/tui/screens/generate/GenerateWizardUI.tsx b/src/cli/tui/screens/generate/GenerateWizardUI.tsx index 9c6c79599..3f9829bd8 100644 --- a/src/cli/tui/screens/generate/GenerateWizardUI.tsx +++ b/src/cli/tui/screens/generate/GenerateWizardUI.tsx @@ -307,8 +307,9 @@ 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 verbatim (alphanumerics, hyphens, underscores). 'Authorization' and headers + starting with X-Amzn-Bedrock-AgentCore-Runtime-Custom- are accepted. Restricted headers (Cookie, Host, + Content-Type, x-amz-*, etc.) are rejected. diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index 789109a38..8904edfa3 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -125,22 +125,220 @@ 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-'. - * Maximum 20 headers. + * + * Per https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-header-allowlist.html + * AgentCore Runtime accepts any HTTP header name that is: + * - composed of alphanumerics, hyphens, or underscores; + * - not in the restricted-headers list (Cookie, Host, Content-Type, etc.); + * - not starting with `x-amz-` (reserved for AWS SigV4); + * - not starting with `x-amzn-` unless it begins with the AgentCore custom prefix. + * + * `Authorization` is allowed (and requires a custom JWT authorizer to be configured). + * Headers prefixed with `X-Amzn-Bedrock-AgentCore-Runtime-Custom-` continue to be + * supported for backward compatibility. Maximum 20 headers (case-insensitive, + * duplicates rejected). */ export const HEADER_ALLOWLIST_PREFIX = 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-'; export const MAX_HEADER_ALLOWLIST_SIZE = 20; +/** + * Valid header-name character set accepted by AgentCore Runtime: alphanumerics, + * hyphens, and underscores. (Looser than the legacy `/^[A-Za-z0-9-]+$/` which + * disallowed underscores.) + */ +export const HEADER_NAME_REGEX = /^[A-Za-z0-9_-]+$/; + +/** + * Restricted header names (case-insensitive). Sourced from + * https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-header-allowlist.html + * "Restricted headers" table. + */ +export const RESTRICTED_HEADER_NAMES: ReadonlySet = new Set( + [ + // Authentication & Authorization (Authorization itself is allowed) + 'Proxy-Authorization', + 'WWW-Authenticate', + // Content Negotiation + 'Accept', + 'Accept-Charset', + 'Accept-Encoding', + 'Accept-Language', + 'Content-Type', + 'Content-Length', + 'Content-Encoding', + 'Content-Language', + 'Content-Location', + 'Content-Range', + // Caching + 'Cache-Control', + 'ETag', + 'Expires', + 'If-Match', + 'If-Modified-Since', + 'If-None-Match', + 'If-Range', + 'If-Unmodified-Since', + 'Last-Modified', + 'Pragma', + 'Vary', + // Connection management + 'Connection', + 'Keep-Alive', + 'Proxy-Connection', + 'Upgrade', + // Request context + 'Host', + 'User-Agent', + 'Referer', + 'From', + // Range / transfer + 'Range', + 'Accept-Ranges', + 'Transfer-Encoding', + 'TE', + 'Trailer', + // Server information + 'Server', + 'Date', + 'Location', + 'Retry-After', + // Cookies + 'Set-Cookie', + 'Cookie', + // Security + 'Content-Security-Policy', + 'Content-Security-Policy-Report-Only', + 'Strict-Transport-Security', + 'X-Content-Type-Options', + 'X-Frame-Options', + 'X-XSS-Protection', + 'Referrer-Policy', + 'Permissions-Policy', + 'Cross-Origin-Embedder-Policy', + 'Cross-Origin-Opener-Policy', + 'Cross-Origin-Resource-Policy', + // CORS + 'Access-Control-Allow-Origin', + 'Access-Control-Allow-Methods', + 'Access-Control-Allow-Headers', + 'Access-Control-Allow-Credentials', + 'Access-Control-Expose-Headers', + 'Access-Control-Max-Age', + 'Access-Control-Request-Method', + 'Access-Control-Request-Headers', + 'Origin', + // Client hints + 'Accept-CH', + 'Accept-CH-Lifetime', + 'DPR', + 'Width', + 'Viewport-Width', + 'Downlink', + 'ECT', + 'RTT', + 'Save-Data', + // Experimental / proposed + 'Clear-Site-Data', + 'Feature-Policy', + 'Expect-CT', + 'Public-Key-Pins', + 'Public-Key-Pins-Report-Only', + // Proxy + 'Via', + 'Forwarded', + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'X-Forwarded-Proto', + 'X-Real-IP', + 'X-Requested-With', + 'X-CSRF-Token', + // IP spoofing / URL manipulation + 'True-Client-IP', + 'X-Client-IP', + 'X-Cluster-Client-IP', + 'X-Originating-IP', + 'X-Source-IP', + 'X-Original-URL', + 'X-Original-Host', + 'X-Rewrite-URL', + // CDN / Proxy + 'CF-Ray', + 'CF-Connecting-IP', + 'X-Amz-Cf-Id', + 'X-Cache', + 'X-Served-By', + // HTTP/2 pseudo headers + ':method', + ':path', + ':scheme', + ':authority', + ':status', + // Server push + 'Link', + // WebSocket + 'Sec-WebSocket-Key', + 'Sec-WebSocket-Accept', + 'Sec-WebSocket-Version', + 'Sec-WebSocket-Protocol', + 'Sec-WebSocket-Extensions', + ].map(s => s.toLowerCase()) +); + +/** + * Validate a single header name against the AgentCore Runtime allowlist rules. + * Returns `null` if the header is allowed, otherwise a human-readable rejection + * reason. + */ +export function getHeaderRejectionReason(name: string): string | null { + if (typeof name !== 'string' || name.length === 0) { + return 'Header name must be a non-empty string.'; + } + if (!HEADER_NAME_REGEX.test(name)) { + return `Invalid header name "${name}". Header names may only contain letters, numbers, hyphens, and underscores.`; + } + const lower = name.toLowerCase(); + // Authorization is explicitly allowed (requires customJWTAuthorizer at runtime). + if (lower === 'authorization') return null; + // Backward-compatible AgentCore custom prefix is always allowed. + if (lower.startsWith(HEADER_ALLOWLIST_PREFIX.toLowerCase())) return null; + // x-amz-* is reserved for AWS SigV4 signing. + if (lower.startsWith('x-amz-')) { + return `Header "${name}" is reserved (the "x-amz-" prefix is reserved for AWS SigV4 signing).`; + } + // x-amzn-* is reserved except for the AgentCore custom prefix (handled above). + if (lower.startsWith('x-amzn-')) { + return `Header "${name}" is reserved (the "x-amzn-" prefix is reserved; only headers starting with "${HEADER_ALLOWLIST_PREFIX}" are allowed).`; + } + if (RESTRICTED_HEADER_NAMES.has(lower)) { + return `Header "${name}" is in the restricted-headers list and cannot be configured for propagation.`; + } + 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 reason = getHeaderRejectionReason(val); + if (reason) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: reason }); + } + }) ) - .max(MAX_HEADER_ALLOWLIST_SIZE, `Maximum ${MAX_HEADER_ALLOWLIST_SIZE} headers allowed`); + .max(MAX_HEADER_ALLOWLIST_SIZE, `Maximum ${MAX_HEADER_ALLOWLIST_SIZE} headers allowed`) + .superRefine((arr, ctx) => { + const seen = new Set(); + arr.forEach((v, i) => { + const k = typeof v === 'string' ? v.toLowerCase() : ''; + if (seen.has(k)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [i], + message: `Duplicate header (case-insensitive): "${v}"`, + }); + } + seen.add(k); + }); + }); /** * Session storage configuration for filesystem persistence.