From f6b4cb483a47182f370fbbfc4ddd2d3d8b437bbc Mon Sep 17 00:00:00 2001 From: Brian Fox <878612+onematchfox@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:48:47 +0200 Subject: [PATCH 1/2] fix(ui): preserve post-login redirect target through oauth2-proxy sign-in When oauth2-proxy intercepts an unauthenticated request it serves its sign-in page carrying the original destination as `.Redirect` (e.g. `/oauth2/sign_in?rd=%2Fagents%2Ffoo`). `sign_in.html` template ignored that and unconditionally redirected to `/login`, and `/login`'s "Sign in with SSO" link was hardcoded to `rd=/` -- so any login, expired- cookie or not, always landed back on the home page instead of the page the user was trying to reach. Signed-off-by: Brian Fox <878612+onematchfox@users.noreply.github.com> --- .../templates/oauth2-proxy-templates.yaml | 12 ++++- ui/src/app/login/page.tsx | 12 ++++- ui/src/lib/__tests__/loginRedirect.test.ts | 46 +++++++++++++++++++ ui/src/lib/loginRedirect.ts | 24 ++++++++++ 4 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 ui/src/lib/__tests__/loginRedirect.test.ts create mode 100644 ui/src/lib/loginRedirect.ts diff --git a/helm/kagent/templates/oauth2-proxy-templates.yaml b/helm/kagent/templates/oauth2-proxy-templates.yaml index 0223c9d21..072df8fd7 100644 --- a/helm/kagent/templates/oauth2-proxy-templates.yaml +++ b/helm/kagent/templates/oauth2-proxy-templates.yaml @@ -7,12 +7,20 @@ metadata: labels: {{- include "kagent.labels" . | nindent 4 }} data: + # oauth2-proxy renders this as its own Go html/template (not a Helm + # template) when it shows the sign-in page to an unauthenticated visitor -- + # e.g. a request to /agents/foo is served this page at + # /oauth2/sign_in?rd=%2Fagents%2Ffoo. Redirect is oauth2-proxy's template + # variable carrying that original destination. It is forwarded to kagent's + # branded /login page below (escaped with a Helm string-literal action so + # Helm emits it for oauth2-proxy to evaluate, instead of trying to evaluate + # it itself). sign_in.html: | - - + + Redirecting to login... diff --git a/ui/src/app/login/page.tsx b/ui/src/app/login/page.tsx index c64316334..1a559e10e 100644 --- a/ui/src/app/login/page.tsx +++ b/ui/src/app/login/page.tsx @@ -1,12 +1,20 @@ import Link from "next/link"; import KagentLogo from "@/components/kagent-logo"; +import { sanitizeRedirect } from "@/lib/loginRedirect"; import { skipToContentLinkClassName } from "@/lib/skipToContent"; import { cn } from "@/lib/utils"; // SSO redirect path - defaults to oauth2-proxy's start endpoint const SSO_REDIRECT_PATH = process.env.SSO_REDIRECT_PATH || "/oauth2/start"; -export default function LoginPage() { +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ rd?: string }>; +}) { + const { rd } = await searchParams; + const redirectTo = sanitizeRedirect(rd); + return ( <> {/* Preload background image for faster rendering */} @@ -53,7 +61,7 @@ export default function LoginPage() {
{/* Glow ring */} diff --git a/ui/src/lib/__tests__/loginRedirect.test.ts b/ui/src/lib/__tests__/loginRedirect.test.ts new file mode 100644 index 000000000..7dbda3d2c --- /dev/null +++ b/ui/src/lib/__tests__/loginRedirect.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from '@jest/globals'; +import { sanitizeRedirect } from '../loginRedirect'; + +describe('sanitizeRedirect', () => { + it('passes through a same-origin path', () => { + expect(sanitizeRedirect('/agents/foo')).toBe('/agents/foo'); + }); + + it('passes through a same-origin path with a query string', () => { + expect(sanitizeRedirect('/agents/foo?tab=history')).toBe('/agents/foo?tab=history'); + }); + + it('defaults to "/" when undefined', () => { + expect(sanitizeRedirect(undefined)).toBe('/'); + }); + + it('defaults to "/" for an empty string', () => { + expect(sanitizeRedirect('')).toBe('/'); + }); + + it('rejects an absolute URL', () => { + expect(sanitizeRedirect('https://evil.example.com/phish')).toBe('/'); + }); + + it('treats a bare host+path with no leading slash as a relative path segment', () => { + // Matches URL/browser semantics: with no scheme and no leading "/", + // this resolves relative to the current path rather than a new host. + expect(sanitizeRedirect('evil.example.com/phish')).toBe('/evil.example.com/phish'); + }); + + it('rejects a protocol-relative URL', () => { + expect(sanitizeRedirect('//evil.example.com/phish')).toBe('/'); + }); + + it('rejects a backslash-prefixed path some browsers treat as protocol-relative', () => { + expect(sanitizeRedirect('/\\evil.example.com/phish')).toBe('/'); + }); + + it('rejects a tab-smuggled protocol-relative URL (stripped by the URL parser before host resolution)', () => { + expect(sanitizeRedirect('/\t/evil.example.com/phish')).toBe('/'); + }); + + it('rejects a different scheme entirely', () => { + expect(sanitizeRedirect('javascript:alert(1)')).toBe('/'); + }); +}); diff --git a/ui/src/lib/loginRedirect.ts b/ui/src/lib/loginRedirect.ts new file mode 100644 index 000000000..044485595 --- /dev/null +++ b/ui/src/lib/loginRedirect.ts @@ -0,0 +1,24 @@ +// Any fixed placeholder works here -- it's never dereferenced, just used as +// the base for URL parsing so we can tell whether `rd` stayed same-origin. +const SENTINEL_ORIGIN = "http://kagent-login-redirect.invalid"; + +/** + * Validate a post-login redirect target. + * + * Only a same-origin relative path is safe to hand back to oauth2-proxy's + * `rd` parameter: an absolute URL, a protocol-relative `//host/...`, or a + * disguised variant of either (e.g. a backslash or a stripped tab/newline + * that the URL Standard normalizes into one of the above) would let a + * crafted `/login?rd=...` link send an authenticated session off to an + * attacker's site after sign-in. + */ +export function sanitizeRedirect(rd: string | undefined): string { + if (!rd) return "/"; + try { + const url = new URL(rd, SENTINEL_ORIGIN); + if (url.origin !== SENTINEL_ORIGIN) return "/"; + return url.pathname + url.search + url.hash; + } catch { + return "/"; + } +} From 34c34e9d905a04271854d4cfb1ba621aebb1fe36 Mon Sep 17 00:00:00 2001 From: Brian Fox <878612+onematchfox@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:12:45 +0200 Subject: [PATCH 2/2] fix(helm): force oauth2-proxy rollout when sign_in.html template changes Adds env var to `oauth2-proxy`'s `Deployment` to ensure that it rolls out when content is updated. The vendored chart's `Deployment` doesn't support a checksum/config-style pod annotation for extra mounted ConfigMaps. Signed-off-by: Brian Fox <878612+onematchfox@users.noreply.github.com> --- helm/kagent/templates/_helpers.tpl | 25 +++++++++++++++++++ .../templates/oauth2-proxy-templates.yaml | 20 +++------------ helm/kagent/values.yaml | 5 ++++ 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/helm/kagent/templates/_helpers.tpl b/helm/kagent/templates/_helpers.tpl index f6a6d9f73..63ec26a8e 100644 --- a/helm/kagent/templates/_helpers.tpl +++ b/helm/kagent/templates/_helpers.tpl @@ -299,3 +299,28 @@ imagePullSecrets: {{- toYaml $global | nindent 2 }} {{- end -}} {{- end -}} + +{{/* +Body of oauth2-proxy's custom sign_in.html template (see +templates/oauth2-proxy-templates.yaml). Kept as its own named template, rather +than inline in that ConfigMap, so oauth2-proxy.extraEnv in values.yaml can hash +the content. + +oauth2-proxy renders this as its own Go html/template (not a Helm template) when +it shows the sign-in page to an unauthenticated visitor -- e.g. a request to +/agents/foo is served this page at /oauth2/sign_in?rd=%2Fagents%2Ffoo. +`Redirect` is oauth2-proxy's template variable carrying that original +destination (escaped with a Helm string-literal action so Helm emits it for +oauth2-proxy to evaluate, instead of trying to evaluate it itself). It is +forwarded to kagent's branded /login page. +*/}} +{{- define "kagent.oauth2ProxySignInHTML" -}} + + + + + + +Redirecting to login... + +{{- end -}} diff --git a/helm/kagent/templates/oauth2-proxy-templates.yaml b/helm/kagent/templates/oauth2-proxy-templates.yaml index 072df8fd7..70397c527 100644 --- a/helm/kagent/templates/oauth2-proxy-templates.yaml +++ b/helm/kagent/templates/oauth2-proxy-templates.yaml @@ -7,21 +7,9 @@ metadata: labels: {{- include "kagent.labels" . | nindent 4 }} data: - # oauth2-proxy renders this as its own Go html/template (not a Helm - # template) when it shows the sign-in page to an unauthenticated visitor -- - # e.g. a request to /agents/foo is served this page at - # /oauth2/sign_in?rd=%2Fagents%2Ffoo. Redirect is oauth2-proxy's template - # variable carrying that original destination. It is forwarded to kagent's - # branded /login page below (escaped with a Helm string-literal action so - # Helm emits it for oauth2-proxy to evaluate, instead of trying to evaluate - # it itself). + # The body lives in the kagent.oauth2ProxySignInHTML named template + # (_helpers.tpl) so oauth2-proxy.extraEnv in values.yaml can hash the content to + # force a rollout when it changes. sign_in.html: | - - - - - - - Redirecting to login... - + {{- include "kagent.oauth2ProxySignInHTML" . | nindent 4 }} {{- end }} diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 1e2264585..5b4b2f595 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -771,6 +771,11 @@ oauth2-proxy: mountPath: /templates readOnly: true + extraEnv: + # Forces a rollout whenever the sign_in.html ConfigMap's content changes. + - name: KAGENT_OAUTH2_PROXY_SIGNIN_TEMPLATE_CHECKSUM + value: '{{ include "kagent.oauth2ProxySignInHTML" . | sha256sum }}' + # Mount custom CA certificate for TLS verification (if needed) # Add to extraVolumes: # - name: custom-ca-cert