Skip to content
Open
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
4 changes: 4 additions & 0 deletions helm/kagent/templates/ui-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ spec:
- name: SSO_REDIRECT_PATH
value: {{ .Values.ui.auth.ssoRedirectPath | default "/oauth2/start" | quote }}
{{- end }}
{{- with .Values.ui.additionalForwardedHeaders }}
- name: KAGENT_ADDITIONAL_FORWARDED_HEADERS
value: {{ join "," . | quote }}
{{- end }}
{{- with .Values.ui.env }}
{{- toYaml . | nindent 12 }}
{{- end }}
Expand Down
8 changes: 8 additions & 0 deletions helm/kagent/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,14 @@ ui:
# Default: /oauth2/start (oauth2-proxy's authentication start endpoint)
ssoRedirectPath: "/oauth2/start"
env: {} # Additional configuration key-value pairs for the ui ConfigMap
# -- Additional request headers (beyond Authorization) the UI proxy will forward
# to the backend. Names are case-insensitive. Hop-by-hop headers (Connection,
# Transfer-Encoding, etc.) are silently dropped.
additionalForwardedHeaders: []
# Example:
# additionalForwardedHeaders:
# - X-Forwarded-User
# - X-Forwarded-Email
# -- Pod-level security context for the UI pod. Overrides the global podSecurityContext.
# @default -- (uses global podSecurityContext)
podSecurityContext: {}
Expand Down
8 changes: 4 additions & 4 deletions ui/src/app/a2a-sandboxes/[namespace]/[agentName]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getBackendUrl } from '@/lib/utils';
import { getAuthHeadersFromRequest } from '@/lib/auth';
import { getAuthHeadersFromRequest, CORS_ALLOW_HEADERS } from '@/lib/auth';

export async function POST(
request: NextRequest,
Expand All @@ -19,12 +19,12 @@ export async function POST(
const backendResponse = await fetch(targetUrl, {
method: 'POST',
headers: {
...authHeaders,
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'User-Agent': 'kagent-ui',
...authHeaders,
},
body: JSON.stringify(a2aRequest),
});
Expand All @@ -49,7 +49,7 @@ export async function POST(
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept',
'Access-Control-Allow-Headers': CORS_ALLOW_HEADERS,
});

const KEEP_ALIVE_INTERVAL_MS = 30000;
Expand Down Expand Up @@ -137,7 +137,7 @@ export async function OPTIONS() {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept',
'Access-Control-Allow-Headers': CORS_ALLOW_HEADERS,
'Access-Control-Max-Age': '86400',
},
});
Expand Down
8 changes: 4 additions & 4 deletions ui/src/app/a2a/[namespace]/[agentName]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getBackendUrl } from '@/lib/utils';
import { getAuthHeadersFromRequest } from '@/lib/auth';
import { getAuthHeadersFromRequest, CORS_ALLOW_HEADERS } from '@/lib/auth';

export async function POST(
request: NextRequest,
Expand All @@ -20,12 +20,12 @@ export async function POST(
const backendResponse = await fetch(targetUrl, {
method: 'POST',
headers: {
...authHeaders,
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'User-Agent': 'kagent-ui',
...authHeaders,
},
body: JSON.stringify(a2aRequest),
});
Expand All @@ -51,7 +51,7 @@ export async function POST(
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept',
'Access-Control-Allow-Headers': CORS_ALLOW_HEADERS,
});

const KEEP_ALIVE_INTERVAL_MS = 30000; // 30 seconds
Expand Down Expand Up @@ -142,7 +142,7 @@ export async function OPTIONS() {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept',
'Access-Control-Allow-Headers': CORS_ALLOW_HEADERS,
'Access-Control-Max-Age': '86400',
},
});
Expand Down
2 changes: 1 addition & 1 deletion ui/src/app/actions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ export async function fetchApi<T>(path: string, options: ApiOptions = {}): Promi
...options,
cache: "no-store",
headers: {
...authHeaders,
"Content-Type": "application/json",
Accept: "application/json",
...authHeaders,
...options.headers,
},
signal: AbortSignal.timeout(30000), // 30 second timeout
Expand Down
183 changes: 183 additions & 0 deletions ui/src/lib/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { describe, expect, it, jest, beforeEach, afterAll } from '@jest/globals';
import {
CORS_ALLOW_HEADERS,
extractAllowedHeaders,
getAuthHeadersFromRequest,
parseAllowedForwardHeaders,
} from '../auth';
import type { NextRequest } from 'next/server';

function fakeRequest(headers: Record<string, string>): NextRequest {
const lower: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
lower[k.toLowerCase()] = v;
}
return {
headers: {
get: (name: string) => lower[name.toLowerCase()] ?? null,
},
} as unknown as NextRequest;
}

describe('parseAllowedForwardHeaders', () => {
it('returns just the default forward set when env is undefined', () => {
const { allowed, blocked } = parseAllowedForwardHeaders(undefined);
expect(Array.from(allowed)).toEqual(['authorization']);
expect(blocked).toEqual([]);
});

it('returns just the default forward set when env is empty', () => {
const { allowed, blocked } = parseAllowedForwardHeaders('');
expect(Array.from(allowed)).toEqual(['authorization']);
expect(blocked).toEqual([]);
});

it('adds extra headers from env, lowercased', () => {
const { allowed } = parseAllowedForwardHeaders('X-Slack-User, X-Slack-Team');
expect(allowed.has('authorization')).toBe(true);
expect(allowed.has('x-slack-user')).toBe(true);
expect(allowed.has('x-slack-team')).toBe(true);
});

it('trims whitespace and ignores empty entries', () => {
const { allowed } = parseAllowedForwardHeaders(' X-A ,, ,X-B,');
expect(allowed.has('x-a')).toBe(true);
expect(allowed.has('x-b')).toBe(true);
expect(allowed.size).toBe(3); // authorization + x-a + x-b
});

it('drops hop-by-hop / routing headers and reports them as blocked', () => {
const { allowed, blocked } = parseAllowedForwardHeaders(
'Host, Connection, Transfer-Encoding, Content-Length, X-Slack-User'
);
expect(allowed.has('host')).toBe(false);
expect(allowed.has('connection')).toBe(false);
expect(allowed.has('transfer-encoding')).toBe(false);
expect(allowed.has('content-length')).toBe(false);
expect(allowed.has('x-slack-user')).toBe(true);
expect(blocked.sort()).toEqual(
['connection', 'content-length', 'host', 'transfer-encoding'].sort()
);
});

it('treats Authorization listed in env as a no-op (already in default set)', () => {
const { allowed, blocked } = parseAllowedForwardHeaders('Authorization');
expect(Array.from(allowed)).toEqual(['authorization']);
expect(blocked).toEqual([]);
});
});

describe('extractAllowedHeaders', () => {
it('returns only headers present in the allowlist', () => {
const got = extractAllowedHeaders(
new Set(['authorization', 'x-slack-user']),
(name) =>
({
authorization: 'Bearer abc',
'x-slack-user': 'U123',
'x-other': 'should-not-appear',
} as Record<string, string>)[name] ?? null
);
expect(got).toEqual({
authorization: 'Bearer abc',
'x-slack-user': 'U123',
});
});

it('skips headers that are absent from the request', () => {
const got = extractAllowedHeaders(
new Set(['authorization', 'x-slack-user']),
(name) => (name === 'authorization' ? 'Bearer abc' : null)
);
expect(got).toEqual({ authorization: 'Bearer abc' });
});

it('skips empty header values', () => {
const got = extractAllowedHeaders(
new Set(['authorization', 'x-slack-user']),
(name) => (name === 'authorization' ? 'Bearer abc' : ''),
);
expect(got).toEqual({ authorization: 'Bearer abc' });
});

it('returns an empty record when the allowlist is empty', () => {
const got = extractAllowedHeaders(new Set(), () => 'whatever');
expect(got).toEqual({});
});
});

describe('getAuthHeadersFromRequest (env-driven)', () => {
const originalEnv = process.env;

beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.KAGENT_ADDITIONAL_FORWARDED_HEADERS;
});

afterAll(() => {
process.env = originalEnv;
});

it('forwards only Authorization by default', () => {
const got = getAuthHeadersFromRequest(
fakeRequest({ Authorization: 'Bearer x', 'X-Slack-User': 'U123' })
);
expect(got).toEqual({ authorization: 'Bearer x' });
});

it('forwards extra headers when env lists them', () => {
process.env.KAGENT_ADDITIONAL_FORWARDED_HEADERS = 'X-Slack-User, X-Slack-Team';
const got = getAuthHeadersFromRequest(
fakeRequest({
Authorization: 'Bearer x',
'X-Slack-User': 'U123',
'X-Slack-Team': 'T456',
'X-Not-Listed': 'nope',
})
);
expect(got).toEqual({
authorization: 'Bearer x',
'x-slack-user': 'U123',
'x-slack-team': 'T456',
});
});

it('reads env on every call (not frozen at module load)', () => {
const req = fakeRequest({ Authorization: 'Bearer x', 'X-Late': 'v' });

expect(getAuthHeadersFromRequest(req)).toEqual({ authorization: 'Bearer x' });

process.env.KAGENT_ADDITIONAL_FORWARDED_HEADERS = 'X-Late';
expect(getAuthHeadersFromRequest(req)).toEqual({
authorization: 'Bearer x',
'x-late': 'v',
});

delete process.env.KAGENT_ADDITIONAL_FORWARDED_HEADERS;
expect(getAuthHeadersFromRequest(req)).toEqual({ authorization: 'Bearer x' });
});

it('does not forward hop-by-hop headers even if listed', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
process.env.KAGENT_ADDITIONAL_FORWARDED_HEADERS = 'Host, Connection, X-Slack-User';
const got = getAuthHeadersFromRequest(
fakeRequest({
Authorization: 'Bearer x',
Host: 'evil.example.com',
Connection: 'close',
'X-Slack-User': 'U123',
})
);
expect(got).toEqual({
authorization: 'Bearer x',
'x-slack-user': 'U123',
});
warnSpy.mockRestore();
});
});

describe('CORS_ALLOW_HEADERS', () => {
it('does not advertise additional forwarded headers cross-origin', () => {
expect(CORS_ALLOW_HEADERS).toBe('Content-Type, Authorization, Accept');
});
});
Loading
Loading