Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 78 additions & 11 deletions src/cli/commands/shared/__tests__/header-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Expand All @@ -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']);
Expand All @@ -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']);
});
});

Expand All @@ -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 });
});
Comment thread
tejaskash marked this conversation as resolved.

it('returns error when exceeding max 20 headers', () => {
const headers = Array.from({ length: 21 }, (_, i) => `Header${i}`).join(', ');
const result = validateHeaderAllowlist(headers);
Expand All @@ -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');
});
});

Expand All @@ -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',
Expand Down Expand Up @@ -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({});
});
Expand Down
41 changes: 28 additions & 13 deletions src/cli/commands/shared/header-utils.ts
Original file line number Diff line number Diff line change
@@ -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') {
Expand All @@ -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)) {
Comment thread
tejaskash marked this conversation as resolved.
return input;
}
return `${HEADER_ALLOWLIST_PREFIX}${input}`;
Comment thread
tejaskash marked this conversation as resolved.
}

/**
* 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[] {
Expand All @@ -35,7 +43,16 @@ export function parseAndNormalizeHeaders(input: string): string[] {
.filter(Boolean)
.map(normalizeHeaderName);

return Array.from(new Set(headers));
const seen = new Set<string>();
const result: string[] = [];
for (const header of headers) {
const lower = header.toLowerCase();
if (!seen.has(lower)) {
seen.add(lower);
result.push(header);
}
}
return result;
}

/**
Expand All @@ -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}`,
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/cli/primitives/AgentPrimitive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export class AgentPrimitive extends BasePrimitive<AddAgentOptions, RemovableReso
.option('--client-secret <secret>', 'OAuth client secret [non-interactive]')
.option(
'--request-header-allowlist <headers>',
'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 <seconds>',
Expand Down
4 changes: 2 additions & 2 deletions src/cli/tui/screens/agent/AddAgentScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1181,8 +1181,8 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg
/>
<Box marginTop={1}>
<Text dimColor>
Enter header suffixes or full names. We auto-prefix with X-Amzn-Bedrock-AgentCore-Runtime-Custom- if
needed. &apos;Authorization&apos; 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.
</Text>
</Box>
</Box>
Expand Down
4 changes: 2 additions & 2 deletions src/cli/tui/screens/generate/GenerateWizardUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,8 @@ export function GenerateWizardUI({
/>
<Box marginTop={1}>
<Text dimColor>
Enter header suffixes or full names. We auto-prefix with X-Amzn-Bedrock-AgentCore-Runtime-Custom- if
needed. &apos;Authorization&apos; 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.
</Text>
</Box>
</Box>
Expand Down
38 changes: 31 additions & 7 deletions src/schema/schemas/agent-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,20 +125,44 @@ export type NetworkConfig = z.infer<typeof NetworkConfigSchema>;

/**
* 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`);

Expand Down
Loading