nyk commited on
Commit
d94b281
·
unverified ·
1 Parent(s): 6060dbd

fix: allow unauthenticated status health probe for container checks (#295)

Browse files

- allow /api/status?action=health through proxy without session/API key
- short-circuit health action in status route before role gating
- add proxy regression tests for health probe allow + non-health deny

Regression checks:
- pnpm vitest run src/proxy.test.ts src/lib/__tests__/auth.test.ts
- pnpm playwright test tests/auth-guards.spec.ts
- smoke: /api/status?action=health=200, login=200, /api/auth/me=200

Files changed (3) hide show
  1. src/app/api/status/route.ts +7 -0
  2. src/proxy.test.ts +58 -0
  3. src/proxy.ts +3 -2
src/app/api/status/route.ts CHANGED
@@ -16,6 +16,13 @@ import { isHermesInstalled, scanHermesSessions } from '@/lib/hermes-sessions'
16
  import { registerMcAsDashboard } from '@/lib/gateway-runtime'
17
 
18
  export async function GET(request: NextRequest) {
 
 
 
 
 
 
 
19
  const auth = requireRole(request, 'viewer')
20
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
21
 
 
16
  import { registerMcAsDashboard } from '@/lib/gateway-runtime'
17
 
18
  export async function GET(request: NextRequest) {
19
+ // Docker/Kubernetes health probes must work without auth/cookies.
20
+ const preAction = new URL(request.url).searchParams.get('action') || 'overview'
21
+ if (preAction === 'health') {
22
+ const health = await performHealthCheck()
23
+ return NextResponse.json(health)
24
+ }
25
+
26
  const auth = requireRole(request, 'viewer')
27
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
28
 
src/proxy.test.ts CHANGED
@@ -50,4 +50,62 @@ describe('proxy host matching', () => {
50
  const response = proxy(request)
51
  expect(response.status).toBe(403)
52
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  })
 
50
  const response = proxy(request)
51
  expect(response.status).toBe(403)
52
  })
53
+
54
+ it('allows unauthenticated health probe for /api/status?action=health', async () => {
55
+ vi.resetModules()
56
+ vi.doMock('node:os', () => ({
57
+ default: { hostname: () => 'hetzner-jarv' },
58
+ hostname: () => 'hetzner-jarv',
59
+ }))
60
+
61
+ const { proxy } = await import('./proxy')
62
+ const request = {
63
+ headers: new Headers({ host: 'localhost:3000' }),
64
+ nextUrl: {
65
+ host: 'localhost:3000',
66
+ hostname: 'localhost',
67
+ pathname: '/api/status',
68
+ searchParams: new URLSearchParams('action=health'),
69
+ clone: () => ({ pathname: '/api/status' }),
70
+ },
71
+ method: 'GET',
72
+ cookies: { get: () => undefined },
73
+ } as any
74
+
75
+ setNodeEnv('production')
76
+ process.env.MC_ALLOWED_HOSTS = 'localhost,127.0.0.1'
77
+ delete process.env.MC_ALLOW_ANY_HOST
78
+
79
+ const response = proxy(request)
80
+ expect(response.status).not.toBe(401)
81
+ })
82
+
83
+ it('still blocks unauthenticated non-health status API calls', async () => {
84
+ vi.resetModules()
85
+ vi.doMock('node:os', () => ({
86
+ default: { hostname: () => 'hetzner-jarv' },
87
+ hostname: () => 'hetzner-jarv',
88
+ }))
89
+
90
+ const { proxy } = await import('./proxy')
91
+ const request = {
92
+ headers: new Headers({ host: 'localhost:3000' }),
93
+ nextUrl: {
94
+ host: 'localhost:3000',
95
+ hostname: 'localhost',
96
+ pathname: '/api/status',
97
+ searchParams: new URLSearchParams('action=overview'),
98
+ clone: () => ({ pathname: '/api/status' }),
99
+ },
100
+ method: 'GET',
101
+ cookies: { get: () => undefined },
102
+ } as any
103
+
104
+ setNodeEnv('production')
105
+ process.env.MC_ALLOWED_HOSTS = 'localhost,127.0.0.1'
106
+ delete process.env.MC_ALLOW_ANY_HOST
107
+
108
+ const response = proxy(request)
109
+ expect(response.status).toBe(401)
110
+ })
111
  })
src/proxy.ts CHANGED
@@ -181,8 +181,9 @@ export function proxy(request: NextRequest) {
181
  }
182
  }
183
 
184
- // Allow login page, auth API, and docs without session
185
- if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname === '/api/docs' || pathname === '/docs') {
 
186
  const { response, nonce } = nextResponseWithNonce(request)
187
  return addSecurityHeaders(response, request, nonce)
188
  }
 
181
  }
182
  }
183
 
184
+ // Allow login page, auth API, docs, and container health probe without session
185
+ const isPublicHealthProbe = pathname === '/api/status' && request.nextUrl.searchParams.get('action') === 'health'
186
+ if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname === '/api/docs' || pathname === '/docs' || isPublicHealthProbe) {
187
  const { response, nonce } = nextResponseWithNonce(request)
188
  return addSecurityHeaders(response, request, nonce)
189
  }