Files
floci-ui/packages/api/src/auth.test.ts
T
Zakaria c5a2fc9105 fix(api): block open redirects, add health check and body limits, run as non-root
- safeReturnTo resolves the target against a placeholder origin and rejects
  backslashes and control characters, so `/\evil.example` and similar
  payloads can no longer redirect off-site after sign-in.
- Add a real unauthenticated /api/health and answer unknown /api/* paths
  with a JSON 404 instead of the SPA shell.
- Limit request bodies: 16 KB for /api/auth/* (read before a session
  exists), 128 MB for resource create/update and uploads, 10 MB elsewhere.
- Run the production image as an unprivileged user and add a Docker
  HEALTHCHECK against /api/health.
2026-10-23 10:43:41 -04:00

251 lines
14 KiB
TypeScript

import {afterEach, beforeAll, describe, expect, test} from 'bun:test'
import {Hono} from 'hono'
import {exportJWK, generateKeyPair, SignJWT, type JWK, type JWTPayload} from 'jose'
import {authConfig, authMiddleware, authRoutes, authStateSizes, sweepExpired, type AuthConfig} from './auth'
const SECRET = '01234567890123456789012345678901'
const ISSUER = 'https://idp.test/application/o/the-cloud/'
const CLIENT_ID = 'the-cloud'
const PUBLIC_BASE_URL = 'https://thecloud.test'
const NPM_PROXY = '192.168.1.10'
const realFetch = globalThis.fetch
let passwordHash: string
beforeAll(async () => {
passwordHash = await Bun.password.hash('correct-horse-battery')
})
afterEach(() => {
globalThis.fetch = realFetch
})
function app(config: AuthConfig) {
const hono = new Hono()
hono.use('*', authMiddleware(config))
hono.use('*', authRoutes(config))
hono.get('/api/protected', (c) => c.json({ok: true}))
return hono
}
function request(hono: Hono, url: string, peer: string, init: RequestInit = {}) {
return hono.fetch(new Request(`http://localhost${url}`, init), {requestIP: () => ({address: peer, family: peer.includes(':') ? 'IPv6' : 'IPv4', port: 50000})})
}
function localConfig(env: Record<string, string> = {}) {
return authConfig({FLOCI_UI_SESSION_SECRET: SECRET, FLOCI_UI_ADMIN_USER: 'admin', FLOCI_UI_ADMIN_PASSWORD_HASH: passwordHash, FLOCI_UI_SECURE_COOKIES: 'false', ...env})
}
function oidcEnv(env: Record<string, string> = {}): Record<string, string> {
return {FLOCI_UI_SESSION_SECRET: SECRET, FLOCI_UI_AUTH_MODE: 'oidc', FLOCI_UI_OIDC_ISSUER: ISSUER, FLOCI_UI_OIDC_CLIENT_ID: CLIENT_ID, FLOCI_UI_OIDC_CLIENT_SECRET: 'client-secret', FLOCI_UI_PUBLIC_BASE_URL: PUBLIC_BASE_URL, FLOCI_UI_SECURE_COOKIES: 'false', ...env}
}
function login(hono: Hono, peer: string, password: string, headers: Record<string, string> = {}) {
return request(hono, '/api/auth/login', peer, {
method: 'POST',
headers: {cookie: 'floci_ui_csrf=csrf-token', 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json', ...headers},
body: new URLSearchParams({username: 'admin', password, csrf: 'csrf-token'}),
})
}
async function failLogins(hono: Hono, peer: string, count: number, headers: Record<string, string> = {}) {
for (let index = 0; index < count; index++) expect((await login(hono, peer, 'wrong-password', headers)).status).toBe(401)
}
describe('login throttling', () => {
test('locks out the attacking address without locking the admin out elsewhere', async () => {
const hono = app(localConfig())
await failLogins(hono, '203.0.113.1', 5)
expect((await login(hono, '203.0.113.1', 'correct-horse-battery')).status).toBe(429)
expect((await login(hono, '198.51.100.1', 'correct-horse-battery')).status).toBe(200)
})
test('ignores X-Forwarded-For from a peer that is not a trusted proxy', async () => {
const hono = app(localConfig({FLOCI_UI_TRUSTED_PROXIES: NPM_PROXY}))
for (let index = 0; index < 5; index++) {
await login(hono, '203.0.113.2', 'wrong-password', {'x-forwarded-for': `198.51.100.${index + 10}`})
}
expect((await login(hono, '203.0.113.2', 'correct-horse-battery', {'x-forwarded-for': '198.51.100.99'})).status).toBe(429)
})
test('keys clients behind a trusted proxy by their forwarded address', async () => {
const hono = app(localConfig({FLOCI_UI_TRUSTED_PROXIES: `${NPM_PROXY}/32`}))
await failLogins(hono, NPM_PROXY, 5, {'x-forwarded-for': '203.0.113.3'})
expect((await login(hono, NPM_PROXY, 'correct-horse-battery', {'x-forwarded-for': '203.0.113.3'})).status).toBe(429)
expect((await login(hono, NPM_PROXY, 'correct-horse-battery', {'x-forwarded-for': '198.51.100.3'})).status).toBe(200)
})
test('uses the right-most untrusted forwarded address so a forged prefix is ignored', async () => {
const hono = app(localConfig({FLOCI_UI_TRUSTED_PROXIES: NPM_PROXY}))
for (let index = 0; index < 5; index++) {
await login(hono, NPM_PROXY, 'wrong-password', {'x-forwarded-for': `10.9.9.${index}, 203.0.113.4`})
}
expect((await login(hono, NPM_PROXY, 'correct-horse-battery', {'x-forwarded-for': '10.9.9.200, 203.0.113.4'})).status).toBe(429)
})
test('throttles one address that rotates through usernames', async () => {
const hono = app(localConfig())
for (let index = 0; index < 20; index++) {
await request(hono, '/api/auth/login', '203.0.113.5', {
method: 'POST',
headers: {cookie: 'floci_ui_csrf=csrf-token', 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
body: new URLSearchParams({username: `user-${index}`, password: 'wrong-password', csrf: 'csrf-token'}),
})
}
expect((await login(hono, '203.0.113.5', 'correct-horse-battery')).status).toBe(429)
})
test('rejects an invalid trusted proxy entry at startup', () => {
expect(() => localConfig({FLOCI_UI_TRUSTED_PROXIES: '192.168.1.0/33'})).toThrow('FLOCI_UI_TRUSTED_PROXIES')
expect(() => localConfig({FLOCI_UI_TRUSTED_PROXIES: 'npm.local'})).toThrow('FLOCI_UI_TRUSTED_PROXIES')
})
})
describe('post-login redirect', () => {
async function redirectAfterLogin(returnTo: string) {
const response = await request(app(localConfig()), '/api/auth/login', '198.51.100.40', {
method: 'POST',
headers: {cookie: 'floci_ui_csrf=csrf-token', 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
body: new URLSearchParams({username: 'admin', password: 'correct-horse-battery', csrf: 'csrf-token', returnTo}),
})
return (await response.json() as {redirectTo: string}).redirectTo
}
test('keeps same-origin paths with their query and fragment', async () => {
expect(await redirectAfterLogin('/cloud-explorer/aws/storage?search=logs#top')).toBe('/cloud-explorer/aws/storage?search=logs#top')
})
test('refuses destinations that a browser would resolve to another origin', async () => {
for (const returnTo of ['//evil.example', '/\\evil.example', '/\\/evil.example', '/\t/evil.example', 'https://evil.example', '/%0d%0a/evil.example'.replace('%0d%0a', '\r\n')]) {
expect(await redirectAfterLogin(returnTo)).toBe('/console/aws')
}
})
})
describe('OIDC authorization', () => {
let privateKey: CryptoKey
let jwk: JWK
beforeAll(async () => {
const pair = await generateKeyPair('RS256')
privateKey = pair.privateKey
jwk = {...await exportJWK(pair.publicKey), kid: 'key-1', alg: 'RS256', use: 'sig'}
})
function mockIdp(claims: (nonce: string) => JWTPayload, userinfo?: JWTPayload) {
let nonce = ''
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input instanceof Request ? input.url : input)
if (url.endsWith('/.well-known/openid-configuration')) {
return Response.json({authorization_endpoint: 'https://idp.test/authorize', token_endpoint: 'https://idp.test/token', jwks_uri: 'https://idp.test/jwks', userinfo_endpoint: 'https://idp.test/userinfo'})
}
if (url === 'https://idp.test/jwks') return Response.json({keys: [jwk]})
if (url === 'https://idp.test/token') {
expect(new URLSearchParams(String(init?.body)).get('code')).toBe('auth-code')
const idToken = await new SignJWT(claims(nonce)).setProtectedHeader({alg: 'RS256', kid: 'key-1'}).setIssuer(ISSUER).setAudience(CLIENT_ID).setIssuedAt().setExpirationTime('5m').sign(privateKey)
return Response.json({id_token: idToken, access_token: 'access-token'})
}
if (url === 'https://idp.test/userinfo' && userinfo) return Response.json(userinfo)
return new Response('not found', {status: 404})
}) as typeof fetch
return {setNonce: (value: string) => { nonce = value }}
}
async function signIn(hono: Hono, peer: string, idp: {setNonce: (value: string) => void}) {
const start = await request(hono, '/api/auth/oidc/login', peer)
const authorize = new URL(start.headers.get('location')!)
idp.setNonce(authorize.searchParams.get('nonce')!)
return request(hono, `/api/auth/oidc/callback?state=${authorize.searchParams.get('state')}&code=auth-code`, peer)
}
test('refuses to start in OIDC mode without an allow-list', () => {
expect(() => authConfig(oidcEnv())).toThrow('FLOCI_UI_OIDC_ALLOWED_GROUPS')
expect(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOW_ALL_USERS: 'true'})).oidc?.allowAllUsers).toBe(true)
})
test('admits a user in an allowed group', async () => {
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'})))
const idp = mockIdp((nonce) => ({sub: 'user-1', nonce, preferred_username: 'zak', groups: ['thecloud-admins']}))
const callback = await signIn(hono, '203.0.113.20', idp)
expect(callback.status).toBe(302)
expect(callback.headers.get('location')).toBe('/console/aws')
expect(callback.headers.get('set-cookie')).toContain('floci_ui_session=')
})
test('rejects a user outside the allowed groups', async () => {
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'})))
const idp = mockIdp((nonce) => ({sub: 'user-2', nonce, preferred_username: 'guest', groups: ['jellyfin-users']}))
const callback = await signIn(hono, '203.0.113.21', idp)
expect(callback.headers.get('location')).toBe('/login?error=forbidden')
expect(callback.headers.get('set-cookie') ?? '').not.toContain('floci_ui_session=')
})
test('reads groups from UserInfo when the ID token omits them', async () => {
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'})))
const idp = mockIdp((nonce) => ({sub: 'user-3', nonce, preferred_username: 'zak'}), {sub: 'user-3', groups: ['thecloud-admins']})
expect((await signIn(hono, '203.0.113.22', idp)).headers.get('location')).toBe('/console/aws')
})
test('ignores UserInfo groups that belong to a different subject', async () => {
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'})))
const idp = mockIdp((nonce) => ({sub: 'user-4', nonce, preferred_username: 'zak'}), {sub: 'someone-else', groups: ['thecloud-admins']})
expect((await signIn(hono, '203.0.113.23', idp)).headers.get('location')).toBe('/login?error=failed')
})
test('admits an allowed email only when it is verified', async () => {
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_EMAILS: 'Zak@Example.com'})))
const unverified = mockIdp((nonce) => ({sub: 'user-5', nonce, email: 'zak@example.com', email_verified: false}))
expect((await signIn(hono, '203.0.113.24', unverified)).headers.get('location')).toBe('/login?error=forbidden')
const verified = mockIdp((nonce) => ({sub: 'user-5', nonce, email: 'zak@example.com', email_verified: true}))
expect((await signIn(hono, '203.0.113.24', verified)).headers.get('location')).toBe('/console/aws')
})
test('redirects identity-provider errors and stale callbacks to the login page', async () => {
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'})))
mockIdp((nonce) => ({sub: 'user-6', nonce}))
expect((await request(hono, '/api/auth/oidc/callback?error=access_denied&state=x', '203.0.113.25')).headers.get('location')).toBe('/login?error=denied')
expect((await request(hono, '/api/auth/oidc/callback?state=unknown&code=auth-code', '203.0.113.25')).headers.get('location')).toBe('/login?error=expired')
})
test('turns a failed token exchange into a login error instead of a server error', async () => {
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'})))
const idp = mockIdp(() => ({sub: 'user-7', nonce: 'not-the-issued-nonce', groups: ['thecloud-admins']}))
expect((await signIn(hono, '203.0.113.26', idp)).headers.get('location')).toBe('/login?error=failed')
})
test('throttles repeated sign-in starts from one address', async () => {
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'})))
mockIdp((nonce) => ({sub: 'user-8', nonce}))
for (let index = 0; index < 30; index++) await request(hono, '/api/auth/oidc/login', '203.0.113.27')
expect((await request(hono, '/api/auth/oidc/login', '203.0.113.27')).headers.get('location')).toBe('/login?error=throttled')
expect((await request(hono, '/api/auth/oidc/login', '203.0.113.28')).headers.get('location')).toStartWith('https://idp.test/authorize')
})
})
describe('auth state cleanup', () => {
test('caps pending OIDC requests and sweeps expired state', async () => {
const config = authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOW_ALL_USERS: 'true'}))
const hono = app(config)
globalThis.fetch = (async () => Response.json({authorization_endpoint: 'https://idp.test/authorize', token_endpoint: 'https://idp.test/token', jwks_uri: 'https://idp.test/jwks'})) as unknown as typeof fetch
for (let index = 0; index < 1_050; index++) await request(hono, '/api/auth/oidc/login', `10.1.${Math.floor(index / 250)}.${index % 250}`)
expect(authStateSizes().oidcRequests).toBe(1_000)
sweepExpired(config, Date.now() + 24 * 60 * 60_000)
expect(authStateSizes()).toEqual({sessions: 0, attempts: 0, oidcRequests: 0})
})
})