Spaces:
Runtime error
Runtime error
nyk commited on
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
- src/app/api/status/route.ts +7 -0
- src/proxy.test.ts +58 -0
- 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
|
| 185 |
-
|
|
|
|
| 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 |
}
|