import { describe, test, expect, beforeEach, mock } from 'bun:test' import { randomUUID, createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { resolve } from 'node:path' import { mockConfigModule } from './helpers/mock-config' // Mock config before imports mock.module('../config', () => mockConfigModule()) import { Hono } from 'hono' let authRoutes: Hono | undefined let webAuth: Hono | undefined let resetDbSingleton: (() => void) | undefined let initDatabase: ((path?: string) => import('bun:sqlite').Database) | undefined let getDb: (() => import('bun:sqlite').Database) | undefined let resetRateLimits: (() => void) | undefined try { const dbMod = await import('../db/sqlite') initDatabase = dbMod.initDatabase getDb = dbMod.getDb resetDbSingleton = dbMod.resetDbSingleton } catch { // Module may not exist yet } try { const mod = await import('../routes/web/auth-routes') authRoutes = mod.default resetRateLimits = mod._resetRateLimits } catch { // Module may not exist yet } try { const mod = await import('../routes/web/auth') webAuth = mod.default } catch { // Module may not exist yet } function freshDb() { if (resetDbSingleton) { try { resetDbSingleton() } catch { // ignore } } if (initDatabase) { // Use a unique temp file per test to avoid polluting the production // default DB path (tmpdir()/rcs-dev.db). Without this, getDb() would // resolve to that file and tests would write real users into it. const uniquePath = resolve( tmpdir(), `rcs-sec-fixes-${randomUUID().replace(/-/g, '').slice(0, 8)}.db`, ) const db = initDatabase(uniquePath) try { db.exec('DELETE FROM session_tokens') db.exec('DELETE FROM users') } catch { // tables may not exist yet } return db } throw new Error('initDatabase not available') } function createApp(): Hono { const app = new Hono() if (webAuth) app.route('/web', webAuth) if (authRoutes) app.route('/web/auth', authRoutes) return app } // =========================================================================== // Task 1: /web/bind returns 410 Gone // =========================================================================== describe('Security: /web/bind removed', () => { let app: Hono beforeEach(() => { freshDb() app = createApp() }) test('POST /web/bind returns 410 Gone', async () => { const res = await app.request('/web/bind', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'ses_123', uuid: 'some-uuid' }), }) expect(res.status).toBe(410) const body = await res.json() expect(body.error).toBeDefined() }) }) // =========================================================================== // Task 3: /login timing side-channel mitigation // =========================================================================== describe('Security: /login timing side-channel', () => { let app: Hono beforeEach(async () => { freshDb() if (resetRateLimits) resetRateLimits() app = createApp() // Create admin via setup await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin', password: 'admin-password-123', }), }) }) test('both non-existent user and wrong password return same error message', async () => { const resNonExistent = await app.request('/web/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'nonexistent_user', password: 'some-password-123', }), }) const bodyNonExistent = await resNonExistent.json() const resWrongPass = await app.request('/web/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'wrong-password-123', }), }) const bodyWrongPass = await resWrongPass.json() // Both return 401 expect(resNonExistent.status).toBe(401) expect(resWrongPass.status).toBe(401) // Both return the same generic error message (does not leak user existence) expect(bodyNonExistent.error).toBe(bodyWrongPass.error) }) test( 'non-existent user login takes similar time to wrong-password login', async () => { // Measure timing for non-existent user (should run dummy hash) const startNonExistent = performance.now() for (let i = 0; i < 3; i++) { await app.request('/web/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': `10.0.0.${10 + i}`, }, body: JSON.stringify({ username: 'nonexistent_user_xyz', password: 'some-password-123', }), }) } const elapsedNonExistent = performance.now() - startNonExistent // Measure timing for wrong password const startWrongPass = performance.now() for (let i = 0; i < 3; i++) { await app.request('/web/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': `10.0.1.${10 + i}`, }, body: JSON.stringify({ username: 'admin', password: 'wrong-password-xyz', }), }) } const elapsedWrongPass = performance.now() - startWrongPass // Non-existent user should take at least 50% of the time of wrong-password // (both should invoke argon2id verification) // This is a loose bound to avoid flaky tests — the point is that // non-existent user is NOT an order of magnitude faster. const ratio = elapsedNonExistent / elapsedWrongPass expect(ratio).toBeGreaterThan(0.3) }, { timeout: 60000 }, ) }) // =========================================================================== // Task 2: consumed_at semantics — max_uses > 1 invitations // =========================================================================== describe('Security: consumed_at semantics for multi-use invitations', () => { let app: Hono let inviteToken: string beforeEach(async () => { freshDb() if (resetRateLimits) resetRateLimits() app = createApp() // Setup admin await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin', password: 'admin-password-123', }), }) // Create a max_uses=3 invitation const db = getDb!() inviteToken = `inv_multi_${randomUUID().slice(0, 8)}` const tokenHash = createHash('sha256').update(inviteToken).digest('hex') const futureExpiry = new Date(Date.now() + 86400000).toISOString() const adminRow = db .query("SELECT id FROM users WHERE username = 'admin'") .get() as { id: string } db.exec( `INSERT INTO invitations (token_hash, role, expires_at, max_uses, uses, created_by) VALUES ('${tokenHash}', 'member', '${futureExpiry}', 3, 0, '${adminRow.id}')`, ) }) test( 'multi-use invitation (max_uses=3) allows multiple joins', async () => { // First join should succeed const res1 = await app.request('/web/auth/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteToken, username: 'user_one', password: 'user-one-password-123', }), }) expect(res1.status).toBe(200) // Second join with same invitation should also succeed const res2 = await app.request('/web/auth/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteToken, username: 'user_two', password: 'user-two-password-123', }), }) expect(res2.status).toBe(200) // Third join should succeed (uses 3 of 3) const res3 = await app.request('/web/auth/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteToken, username: 'user_three', password: 'user-three-password-123', }), }) expect(res3.status).toBe(200) // Fourth join should fail (max_uses reached) const res4 = await app.request('/web/auth/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteToken, username: 'user_four', password: 'user-four-password-123', }), }) expect(res4.status).toBe(400) // Verify 4 users exist: admin + 3 joined const db = getDb!() const count = db.query('SELECT COUNT(*) as count FROM users').get() as { count: number } expect(count.count).toBe(4) // Verify invitation uses count const tokenHash = createHash('sha256').update(inviteToken).digest('hex') const inv = db .query( 'SELECT uses, max_uses, consumed_at FROM invitations WHERE token_hash = $hash', ) .get({ $hash: tokenHash }) as { uses: number max_uses: number consumed_at: string | null } expect(inv.uses).toBe(3) expect(inv.max_uses).toBe(3) // consumed_at should be set after max_uses reached expect(inv.consumed_at).not.toBeNull() }, { timeout: 60000 }, ) }) // =========================================================================== // Task 4: sessionAuth isAdmin from DB role // =========================================================================== describe('Security: sessionAuth isAdmin from DB role', () => { let app: Hono let adminAccessToken: string beforeEach(async () => { freshDb() if (resetRateLimits) resetRateLimits() app = createApp() // Setup admin await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin', password: 'admin-password-123', }), }) // Login as admin const loginRes = await app.request('/web/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'admin-password-123', }), }) const loginBody = await loginRes.json() adminAccessToken = loginBody.accessToken || loginBody.access_token || '' }) test('cookie-based admin login sets isAdmin=true', async () => { // Create a protected route that exposes isAdmin const { sessionAuth } = await import('../auth/middleware') const testApp = new Hono() testApp.get('/protected', async (c, next) => { const result = await sessionAuth(c, next) if (result) return result return c.json({ userId: c.get('userId'), isAdmin: c.get('isAdmin'), }) }) // Use Bearer token auth (simulates cookie-based resolution via DB) const res = await testApp.request('/protected', { headers: { Authorization: `Bearer ${adminAccessToken}` }, }) expect(res.status).toBe(200) const body = await res.json() expect(body.isAdmin).toBe(true) }) }) // =========================================================================== // Task 5: /join username UNIQUE race returns 409 not 500 // =========================================================================== describe('Security: /join username race returns 409', () => { let app: Hono let inviteToken1: string let inviteToken2: string beforeEach(async () => { freshDb() if (resetRateLimits) resetRateLimits() app = createApp() // Setup admin await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin', password: 'admin-password-123', }), }) // Create two separate max_uses=1 invitations so two joins can proceed // simultaneously — the only conflict will be username uniqueness const db = getDb!() const futureExpiry = new Date(Date.now() + 86400000).toISOString() const adminRow = db .query("SELECT id FROM users WHERE username = 'admin'") .get() as { id: string } inviteToken1 = `inv_race1_${randomUUID().slice(0, 8)}` const hash1 = createHash('sha256').update(inviteToken1).digest('hex') db.exec( `INSERT INTO invitations (token_hash, role, expires_at, max_uses, uses, created_by) VALUES ('${hash1}', 'member', '${futureExpiry}', 1, 0, '${adminRow.id}')`, ) inviteToken2 = `inv_race2_${randomUUID().slice(0, 8)}` const hash2 = createHash('sha256').update(inviteToken2).digest('hex') db.exec( `INSERT INTO invitations (token_hash, role, expires_at, max_uses, uses, created_by) VALUES ('${hash2}', 'member', '${futureExpiry}', 1, 0, '${adminRow.id}')`, ) // Pre-insert a user with the conflicting username db.exec( `INSERT INTO users (id, username, password_hash, role, created_at) VALUES ('usr_existing', 'duplicate_user', 'hash', 'member', '2026-01-01T00:00:00Z')`, ) }) test('join with already-taken username returns 409 not 500', async () => { const res = await app.request('/web/auth/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteToken: inviteToken1, username: 'duplicate_user', password: 'some-password-123', }), }) // Should return 409 Conflict, not 500 expect(res.status).toBe(409) const body = await res.json() expect(body.error).toMatch(/[Uu]sername/) }) }) // =========================================================================== // Task 8: CSRF middleware // =========================================================================== describe('Security: CSRF middleware', () => { test('allows GET requests without Origin', async () => { const { csrfCheck } = await import('../auth/csrf') const app = new Hono() app.use('/web/*', csrfCheck) app.get('/web/ping', c => c.text('ok')) const res = await app.request('/web/ping') expect(res.status).toBe(200) }) test('allows POST with matching Origin', async () => { const { csrfCheck } = await import('../auth/csrf') const app = new Hono() app.use('/web/*', csrfCheck) app.post('/web/action', c => c.json({ ok: true })) const res = await app.request('http://localhost:3000/web/action', { method: 'POST', headers: { Origin: 'http://localhost:3000', 'Content-Type': 'application/json', }, }) expect(res.status).toBe(200) }) test('rejects POST with mismatched Origin', async () => { const { csrfCheck } = await import('../auth/csrf') const app = new Hono() app.use('/web/*', csrfCheck) app.post('/web/action', c => c.json({ ok: true })) const res = await app.request('http://localhost:3000/web/action', { method: 'POST', headers: { Origin: 'https://attacker.example', 'Content-Type': 'application/json', }, }) expect(res.status).toBe(403) const body = await res.json() expect(body.error).toContain('CSRF') }) test('allows POST without Origin (non-browser client)', async () => { const { csrfCheck } = await import('../auth/csrf') const app = new Hono() app.use('/web/*', csrfCheck) app.post('/web/action', c => c.json({ ok: true })) const res = await app.request('http://localhost:3000/web/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }) expect(res.status).toBe(200) }) test('allows DELETE with matching Origin', async () => { const { csrfCheck } = await import('../auth/csrf') const app = new Hono() app.use('/web/*', csrfCheck) app.delete('/web/resource/:id', c => c.json({ ok: true })) const res = await app.request('http://localhost:3000/web/resource/abc', { method: 'DELETE', headers: { Origin: 'http://localhost:3000', }, }) expect(res.status).toBe(200) }) test('rejects PUT with mismatched Origin', async () => { const { csrfCheck } = await import('../auth/csrf') const app = new Hono() app.use('/web/*', csrfCheck) app.put('/web/resource/:id', c => c.json({ ok: true })) const res = await app.request('http://localhost:3000/web/resource/abc', { method: 'PUT', headers: { Origin: 'https://evil.example', 'Content-Type': 'application/json', }, }) expect(res.status).toBe(403) }) })