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

- 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
This commit is contained in:
nyk 2026-03-12 12:22:34 +07:00 committed by GitHub
parent a18240381c
commit 55f2351bdc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 68 additions and 2 deletions

View File

@ -16,6 +16,13 @@ import { isHermesInstalled, scanHermesSessions } from '@/lib/hermes-sessions'
import { registerMcAsDashboard } from '@/lib/gateway-runtime'
export async function GET(request: NextRequest) {
// Docker/Kubernetes health probes must work without auth/cookies.
const preAction = new URL(request.url).searchParams.get('action') || 'overview'
if (preAction === 'health') {
const health = await performHealthCheck()
return NextResponse.json(health)
}
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })

View File

@ -50,4 +50,62 @@ describe('proxy host matching', () => {
const response = proxy(request)
expect(response.status).toBe(403)
})
it('allows unauthenticated health probe for /api/status?action=health', async () => {
vi.resetModules()
vi.doMock('node:os', () => ({
default: { hostname: () => 'hetzner-jarv' },
hostname: () => 'hetzner-jarv',
}))
const { proxy } = await import('./proxy')
const request = {
headers: new Headers({ host: 'localhost:3000' }),
nextUrl: {
host: 'localhost:3000',
hostname: 'localhost',
pathname: '/api/status',
searchParams: new URLSearchParams('action=health'),
clone: () => ({ pathname: '/api/status' }),
},
method: 'GET',
cookies: { get: () => undefined },
} as any
setNodeEnv('production')
process.env.MC_ALLOWED_HOSTS = 'localhost,127.0.0.1'
delete process.env.MC_ALLOW_ANY_HOST
const response = proxy(request)
expect(response.status).not.toBe(401)
})
it('still blocks unauthenticated non-health status API calls', async () => {
vi.resetModules()
vi.doMock('node:os', () => ({
default: { hostname: () => 'hetzner-jarv' },
hostname: () => 'hetzner-jarv',
}))
const { proxy } = await import('./proxy')
const request = {
headers: new Headers({ host: 'localhost:3000' }),
nextUrl: {
host: 'localhost:3000',
hostname: 'localhost',
pathname: '/api/status',
searchParams: new URLSearchParams('action=overview'),
clone: () => ({ pathname: '/api/status' }),
},
method: 'GET',
cookies: { get: () => undefined },
} as any
setNodeEnv('production')
process.env.MC_ALLOWED_HOSTS = 'localhost,127.0.0.1'
delete process.env.MC_ALLOW_ANY_HOST
const response = proxy(request)
expect(response.status).toBe(401)
})
})

View File

@ -181,8 +181,9 @@ export function proxy(request: NextRequest) {
}
}
// Allow login page, auth API, and docs without session
if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname === '/api/docs' || pathname === '/docs') {
// Allow login page, auth API, docs, and container health probe without session
const isPublicHealthProbe = pathname === '/api/status' && request.nextUrl.searchParams.get('action') === 'health'
if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname === '/api/docs' || pathname === '/docs' || isPublicHealthProbe) {
const { response, nonce } = nextResponseWithNonce(request)
return addSecurityHeaders(response, request, nonce)
}