fix(api): harden sign-in throttling, OIDC authorization and auth state
- Key login throttling on the real peer address; read X-Forwarded-For only from FLOCI_UI_TRUSTED_PROXIES (right-most untrusted hop). Add a per-IP limit across usernames so one attacker can no longer lock out the admin. - Require an OIDC allow-list (FLOCI_UI_OIDC_ALLOWED_GROUPS / _EMAILS, or FLOCI_UI_OIDC_ALLOW_ALL_USERS=true); fall back to UserInfo for groups when the ID token omits them. Redirect IdP and token errors to /login instead of returning 500s; cache discovery and JWKS. - Sweep expired sessions, attempts and pending OIDC requests every 60s, cap each map, and throttle OIDC sign-in starts per IP. - Fix the .ts import extension that broke the API type-check. - Add auth.test.ts and document the new settings.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
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('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})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user