Skip to content

Commit 613c995

Browse files
authored
fix(schema): relax request header allowlist to accept documented header patterns (#1163)
* 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 * 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 * 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
1 parent 9503239 commit 613c995

6 files changed

Lines changed: 142 additions & 36 deletions

File tree

src/cli/commands/shared/__tests__/header-utils.test.ts

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,26 @@ describe('normalizeHeaderName', () => {
3232
);
3333
});
3434

35-
it('auto-prefixes a bare suffix like "MyHeader"', () => {
35+
it('passes through X- prefixed headers unchanged', () => {
36+
expect(normalizeHeaderName('X-Api-Key')).toBe('X-Api-Key');
37+
expect(normalizeHeaderName('X-Custom-Signature')).toBe('X-Custom-Signature');
38+
expect(normalizeHeaderName('X-Request-Id')).toBe('X-Request-Id');
39+
});
40+
41+
it('canonicalizes Runtime-Custom- prefix casing but preserves suffix as-typed', () => {
42+
expect(normalizeHeaderName('x-amzn-bedrock-agentcore-runtime-custom-myheader')).toBe(
43+
'X-Amzn-Bedrock-AgentCore-Runtime-Custom-myheader'
44+
);
45+
expect(normalizeHeaderName('X-AMZN-BEDROCK-AGENTCORE-RUNTIME-CUSTOM-MyHeader')).toBe(
46+
'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader'
47+
);
48+
});
49+
50+
it('auto-prefixes a bare suffix like "MyHeader" (no X- prefix, backward compat)', () => {
3651
expect(normalizeHeaderName('MyHeader')).toBe('X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader');
3752
});
3853

39-
it('auto-prefixes suffix with hyphens like "My-Custom-Header"', () => {
54+
it('auto-prefixes suffix with hyphens like "My-Custom-Header" (no X- prefix)', () => {
4055
expect(normalizeHeaderName('My-Custom-Header')).toBe('X-Amzn-Bedrock-AgentCore-Runtime-Custom-My-Custom-Header');
4156
});
4257
});
@@ -59,6 +74,11 @@ describe('parseAndNormalizeHeaders', () => {
5974
]);
6075
});
6176

77+
it('passes through X- prefixed headers without auto-prefixing', () => {
78+
const result = parseAndNormalizeHeaders('X-Api-Key, X-Custom-Signature, authorization');
79+
expect(result).toEqual(['X-Api-Key', 'X-Custom-Signature', 'Authorization']);
80+
});
81+
6282
it('deduplicates after normalization', () => {
6383
const result = parseAndNormalizeHeaders('MyHeader, X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader');
6484
expect(result).toEqual(['X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader']);
@@ -69,13 +89,14 @@ describe('parseAndNormalizeHeaders', () => {
6989
expect(result).toEqual(['Authorization']);
7090
});
7191

92+
it('deduplicates case-insensitively for X- headers', () => {
93+
const result = parseAndNormalizeHeaders('X-Api-Key, x-api-key');
94+
expect(result).toEqual(['X-Api-Key']);
95+
});
96+
7297
it('trims whitespace around values', () => {
73-
const result = parseAndNormalizeHeaders(' MyHeader , authorization , Another-Header ');
74-
expect(result).toEqual([
75-
'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader',
76-
'Authorization',
77-
'X-Amzn-Bedrock-AgentCore-Runtime-Custom-Another-Header',
78-
]);
98+
const result = parseAndNormalizeHeaders(' MyHeader , authorization , X-Api-Key ');
99+
expect(result).toEqual(['X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader', 'Authorization', 'X-Api-Key']);
79100
});
80101
});
81102

@@ -98,12 +119,37 @@ describe('validateHeaderAllowlist', () => {
98119
expect(validateHeaderAllowlist('authorization')).toEqual({ success: true });
99120
});
100121

122+
it('returns success for X- prefixed headers from AWS docs', () => {
123+
expect(validateHeaderAllowlist('X-Api-Key')).toEqual({ success: true });
124+
expect(validateHeaderAllowlist('X-Custom-Signature')).toEqual({ success: true });
125+
});
126+
101127
it('returns success for mixed valid headers', () => {
102-
expect(validateHeaderAllowlist('Authorization, MyHeader, X-Amzn-Bedrock-AgentCore-Runtime-Custom-Another')).toEqual(
128+
expect(validateHeaderAllowlist('Authorization, X-Api-Key, X-Amzn-Bedrock-AgentCore-Runtime-Custom-UserId')).toEqual(
103129
{ success: true }
104130
);
105131
});
106132

133+
it('returns success for headers with underscores', () => {
134+
expect(validateHeaderAllowlist('X-My_Custom_Header')).toEqual({ success: true });
135+
});
136+
137+
it('returns error for x-amz- prefixed headers', () => {
138+
const result = validateHeaderAllowlist('x-amz-security-token');
139+
expect(result.success).toBe(false);
140+
expect(result.error).toContain('reserved for AWS request signing');
141+
});
142+
143+
it('returns error for x-amzn- prefixed headers (not Runtime-Custom-)', () => {
144+
const result = validateHeaderAllowlist('x-amzn-trace-id');
145+
expect(result.success).toBe(false);
146+
expect(result.error).toContain('x-amzn-');
147+
});
148+
149+
it('returns success for X-Amzn-Bedrock-AgentCore-Runtime-Custom- headers', () => {
150+
expect(validateHeaderAllowlist('X-Amzn-Bedrock-AgentCore-Runtime-Custom-UserId')).toEqual({ success: true });
151+
});
152+
107153
it('returns error when exceeding max 20 headers', () => {
108154
const headers = Array.from({ length: 21 }, (_, i) => `Header${i}`).join(', ');
109155
const result = validateHeaderAllowlist(headers);
@@ -119,13 +165,19 @@ describe('validateHeaderAllowlist', () => {
119165
it('returns error for header names containing whitespace', () => {
120166
const result = validateHeaderAllowlist('My Header');
121167
expect(result.success).toBe(false);
122-
expect(result.error).toContain('Invalid header name');
168+
expect(result.error).toContain('must contain only');
123169
});
124170

125171
it('returns error for header names with special characters', () => {
126172
const result = validateHeaderAllowlist('My@Header');
127173
expect(result.success).toBe(false);
128-
expect(result.error).toContain('Invalid header name');
174+
expect(result.error).toContain('must contain only');
175+
});
176+
177+
it('returns error for header with dots', () => {
178+
const result = validateHeaderAllowlist('My.Header');
179+
expect(result.success).toBe(false);
180+
expect(result.error).toContain('must contain only');
129181
});
130182
});
131183

@@ -137,6 +189,13 @@ describe('parseHeaderFlag', () => {
137189
});
138190
});
139191

192+
it('parses X- prefixed header without auto-prefixing', () => {
193+
expect(parseHeaderFlag('X-Api-Key: my-key')).toEqual({
194+
name: 'X-Api-Key',
195+
value: 'my-key',
196+
});
197+
});
198+
140199
it('parses "Key:Value" format without space', () => {
141200
expect(parseHeaderFlag('MyHeader:some-value')).toEqual({
142201
name: 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader',
@@ -183,6 +242,14 @@ describe('parseHeaderFlags', () => {
183242
});
184243
});
185244

245+
it('parses X- prefixed headers without prefixing', () => {
246+
const result = parseHeaderFlags(['X-Api-Key: key123', 'X-Custom-Signature: sha256=abc']);
247+
expect(result).toEqual({
248+
'X-Api-Key': 'key123',
249+
'X-Custom-Signature': 'sha256=abc',
250+
});
251+
});
252+
186253
it('returns empty object for empty array', () => {
187254
expect(parseHeaderFlags([])).toEqual({});
188255
});

src/cli/commands/shared/header-utils.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
import {
22
HEADER_ALLOWLIST_PREFIX as HEADER_ALLOWLIST_PREFIX_FROM_SCHEMA,
3+
HEADER_NAME_PATTERN as HEADER_NAME_PATTERN_FROM_SCHEMA,
34
MAX_HEADER_ALLOWLIST_SIZE as MAX_HEADER_ALLOWLIST_SIZE_FROM_SCHEMA,
5+
checkAllowlistHeader,
46
} from '../../../schema/schemas/agent-env';
57

68
export const HEADER_ALLOWLIST_PREFIX = HEADER_ALLOWLIST_PREFIX_FROM_SCHEMA;
9+
export const HEADER_NAME_PATTERN = HEADER_NAME_PATTERN_FROM_SCHEMA;
710
export const MAX_HEADER_ALLOWLIST_SIZE = MAX_HEADER_ALLOWLIST_SIZE_FROM_SCHEMA;
811

9-
const HEADER_NAME_PATTERN = /^[A-Za-z0-9-]+$/;
10-
1112
/**
1213
* Normalize a header name according to AgentCore Runtime rules:
1314
* - "Authorization" (case-insensitive) -> "Authorization"
14-
* - Headers already starting with the prefix (case-insensitive) -> canonical prefix + original suffix
15-
* - Other headers -> prepend the prefix
15+
* - Headers starting with X-Amzn-Bedrock-AgentCore-Runtime-Custom- (case-insensitive) ->
16+
* canonical prefix casing + original suffix
17+
* - Any other X- prefixed header (e.g. X-Api-Key, X-Custom-Signature) -> pass through unchanged
18+
* - Bare suffixes without X- prefix (e.g. MyHeader) -> auto-prefix with Runtime-Custom- for
19+
* backward compatibility
1620
*/
1721
export function normalizeHeaderName(input: string): string {
1822
if (input.toLowerCase() === 'authorization') {
@@ -21,11 +25,15 @@ export function normalizeHeaderName(input: string): string {
2125
if (input.toLowerCase().startsWith(HEADER_ALLOWLIST_PREFIX.toLowerCase())) {
2226
return `${HEADER_ALLOWLIST_PREFIX}${input.slice(HEADER_ALLOWLIST_PREFIX.length)}`;
2327
}
28+
if (/^x-/i.test(input)) {
29+
return input;
30+
}
2431
return `${HEADER_ALLOWLIST_PREFIX}${input}`;
2532
}
2633

2734
/**
2835
* Parse a comma-separated string of header names, normalize each, and deduplicate.
36+
* Deduplication is case-insensitive per AWS docs.
2937
* Returns an array of normalized header names.
3038
*/
3139
export function parseAndNormalizeHeaders(input: string): string[] {
@@ -35,7 +43,16 @@ export function parseAndNormalizeHeaders(input: string): string[] {
3543
.filter(Boolean)
3644
.map(normalizeHeaderName);
3745

38-
return Array.from(new Set(headers));
46+
const seen = new Set<string>();
47+
const result: string[] = [];
48+
for (const header of headers) {
49+
const lower = header.toLowerCase();
50+
if (!seen.has(lower)) {
51+
seen.add(lower);
52+
result.push(header);
53+
}
54+
}
55+
return result;
3956
}
4057

4158
/**
@@ -52,20 +69,18 @@ export function validateHeaderAllowlist(value: string): { success: boolean; erro
5269
.split(',')
5370
.map(s => s.trim())
5471
.filter(Boolean);
72+
5573
for (const name of rawNames) {
56-
if (!HEADER_NAME_PATTERN.test(name)) {
57-
return {
58-
success: false,
59-
error: `Invalid header name "${name}". Header names may only contain letters, numbers, and hyphens.`,
60-
};
74+
const error = checkAllowlistHeader(name);
75+
if (error) {
76+
return { success: false, error };
6177
}
6278
}
6379

64-
const headers = parseAndNormalizeHeaders(value);
65-
if (headers.length > MAX_HEADER_ALLOWLIST_SIZE) {
80+
if (rawNames.length > MAX_HEADER_ALLOWLIST_SIZE) {
6681
return {
6782
success: false,
68-
error: `Header allowlist cannot exceed ${MAX_HEADER_ALLOWLIST_SIZE} headers. Provided: ${headers.length}`,
83+
error: `Header allowlist cannot exceed ${MAX_HEADER_ALLOWLIST_SIZE} headers. Provided: ${rawNames.length}`,
6984
};
7085
}
7186

src/cli/primitives/AgentPrimitive.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ export class AgentPrimitive extends BasePrimitive<AddAgentOptions, RemovableReso
266266
.option('--client-secret <secret>', 'OAuth client secret [non-interactive]')
267267
.option(
268268
'--request-header-allowlist <headers>',
269-
'Comma-separated list of custom header names to allow (auto-prefixed with X-Amzn-Bedrock-AgentCore-Runtime-Custom-) [non-interactive]'
269+
'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]'
270270
)
271271
.option(
272272
'--idle-timeout <seconds>',

src/cli/tui/screens/agent/AddAgentScreen.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1181,8 +1181,8 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg
11811181
/>
11821182
<Box marginTop={1}>
11831183
<Text dimColor>
1184-
Enter header suffixes or full names. We auto-prefix with X-Amzn-Bedrock-AgentCore-Runtime-Custom- if
1185-
needed. &apos;Authorization&apos; is also accepted.
1184+
Enter header names (e.g. Authorization, X-Api-Key, X-Custom-Signature). Bare names without X- prefix are
1185+
auto-prefixed with X-Amzn-Bedrock-AgentCore-Runtime-Custom- for backward compatibility.
11861186
</Text>
11871187
</Box>
11881188
</Box>

src/cli/tui/screens/generate/GenerateWizardUI.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,8 @@ export function GenerateWizardUI({
307307
/>
308308
<Box marginTop={1}>
309309
<Text dimColor>
310-
Enter header suffixes or full names. We auto-prefix with X-Amzn-Bedrock-AgentCore-Runtime-Custom- if
311-
needed. &apos;Authorization&apos; is also accepted.
310+
Enter header names (e.g. Authorization, X-Api-Key, X-Custom-Signature). Bare names without X- prefix are
311+
auto-prefixed with X-Amzn-Bedrock-AgentCore-Runtime-Custom- for backward compatibility.
312312
</Text>
313313
</Box>
314314
</Box>

src/schema/schemas/agent-env.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,20 +125,44 @@ export type NetworkConfig = z.infer<typeof NetworkConfigSchema>;
125125

126126
/**
127127
* Allowed request headers for the runtime.
128-
* Each header must be 'Authorization' or start with 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-'.
128+
* Per https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-header-allowlist.html
129+
* any valid HTTP header name (alphanumeric, hyphens, underscores) may be allow-listed,
130+
* provided it is not structurally reserved (x-amz-*, x-amzn-* except Runtime-Custom-*).
129131
* Maximum 20 headers.
130132
*/
131133
export const HEADER_ALLOWLIST_PREFIX = 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-';
134+
export const HEADER_NAME_PATTERN = /^[A-Za-z0-9\-_]+$/;
132135
export const MAX_HEADER_ALLOWLIST_SIZE = 20;
133136

137+
/**
138+
* Validate a single allowlist header name. Returns null if valid, or a specific
139+
* error message describing which rule the input violated.
140+
*
141+
* Note: 'x-amz-' and 'x-amzn-' are disjoint prefixes (position 5 differs: '-' vs 'n'),
142+
* so the two checks below are independent.
143+
*/
144+
export function checkAllowlistHeader(val: string): string | null {
145+
if (!HEADER_NAME_PATTERN.test(val)) {
146+
return `Header name "${val}" must contain only alphanumeric characters, hyphens, and underscores.`;
147+
}
148+
const lower = val.toLowerCase();
149+
if (lower.startsWith('x-amz-')) {
150+
return `Header "${val}" is not allowed. Headers starting with "x-amz-" are reserved for AWS request signing.`;
151+
}
152+
if (lower.startsWith('x-amzn-') && !lower.startsWith('x-amzn-bedrock-agentcore-runtime-custom-')) {
153+
return `Header "${val}" is not allowed. Headers starting with "x-amzn-" are reserved, except for "X-Amzn-Bedrock-AgentCore-Runtime-Custom-*".`;
154+
}
155+
return null;
156+
}
157+
134158
export const RequestHeaderAllowlistSchema = z
135159
.array(
136-
z
137-
.string()
138-
.refine(
139-
val => val === 'Authorization' || val.startsWith(HEADER_ALLOWLIST_PREFIX),
140-
`Must be "Authorization" or start with "${HEADER_ALLOWLIST_PREFIX}"`
141-
)
160+
z.string().superRefine((val, ctx) => {
161+
const error = checkAllowlistHeader(val);
162+
if (error) {
163+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: error });
164+
}
165+
})
142166
)
143167
.max(MAX_HEADER_ALLOWLIST_SIZE, `Maximum ${MAX_HEADER_ALLOWLIST_SIZE} headers allowed`);
144168

0 commit comments

Comments
 (0)