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