import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; import { NextRequest } from 'next/server'; let tempRoot: string; let classroomsDir: string; beforeEach(async () => { tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'classroom-storage-security-')); classroomsDir = path.join(tempRoot, 'classrooms'); vi.resetModules(); vi.stubEnv('CLASSROOM_DATA_DIR', classroomsDir); vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'all'); }); afterEach(async () => { vi.unstubAllEnvs(); await fs.rm(tempRoot, { recursive: true, force: true }); }); describe('classroom storage path boundary', () => { test('ignores forwarded origin headers unless the proxy is explicitly trusted', async () => { const { buildRequestOrigin } = await import('@/lib/server/classroom-storage'); const request = new NextRequest('https://ops.example/api/classroom', { headers: { 'x-forwarded-host': 'attacker.example', 'x-forwarded-proto': 'https', }, }); vi.stubEnv('ACCESS_CODE_TRUST_PROXY_HEADERS', 'false'); expect(buildRequestOrigin(request)).toBe('https://ops.example'); vi.stubEnv('ACCESS_CODE_TRUST_PROXY_HEADERS', 'true'); expect(buildRequestOrigin(request)).toBe('https://attacker.example'); }); test('rejects traversal ids at the API boundary without writing outside the data root', async () => { const sentinelPath = path.join(tempRoot, 'sentinel.json'); await fs.writeFile(sentinelPath, '{"safe":true}', 'utf-8'); const { POST } = await import('@/app/api/classroom/route'); const response = await POST( new NextRequest('http://localhost/api/classroom', { method: 'POST', body: JSON.stringify({ stage: { id: '../sentinel', name: 'malicious classroom' }, scenes: [], }), headers: { 'content-type': 'application/json' }, }), ); expect(response.status).toBe(400); await expect(fs.readFile(sentinelPath, 'utf-8')).resolves.toBe('{"safe":true}'); await expect(fs.readdir(classroomsDir)).rejects.toMatchObject({ code: 'ENOENT' }); }); test('storage read, write and delete all reject ids outside the direct child namespace', async () => { const { deleteClassroom, persistClassroom, readClassroom } = await import('@/lib/server/classroom-storage'); const maliciousId = '../../package'; await expect(readClassroom(maliciousId)).rejects.toThrow('Invalid classroom id'); await expect( persistClassroom( { id: maliciousId, stage: { id: maliciousId, name: 'malicious classroom', createdAt: 1, updatedAt: 1, }, scenes: [], }, 'http://localhost', ), ).rejects.toThrow('Invalid classroom id'); await expect(deleteClassroom(maliciousId)).rejects.toThrow('Invalid classroom id'); }); test('preserves normal classroom persistence inside the configured root', async () => { const { deleteClassroom, persistClassroom, readClassroom } = await import('@/lib/server/classroom-storage'); const id = 'classroom_safe-1'; const saved = await persistClassroom( { id, stage: { id, name: 'Safe classroom', createdAt: 1, updatedAt: 1 }, scenes: [], }, 'http://localhost', ); expect(saved.url).toBe(`http://localhost/classroom/${id}`); await expect(readClassroom(id)).resolves.toMatchObject({ id }); await expect(deleteClassroom(id)).resolves.toBe(true); await expect(readClassroom(id)).resolves.toBeNull(); }); test('persists ownership outside Stage and migrates a proven guest exactly once', async () => { const { migrateClassroomOwnership, persistClassroom, readClassroom } = await import('@/lib/server/classroom-storage'); const id = 'classroom_guest'; const saved = await persistClassroom( { id, stage: { id, name: 'Guest classroom', createdAt: 1, updatedAt: 1 }, scenes: [], }, 'http://localhost', { guestPrincipalId: 'device-1' }, ); expect(saved).toMatchObject({ guestPrincipalId: 'device-1', ownershipBoundAt: expect.any(String), }); expect(saved.stage).not.toHaveProperty('guestPrincipalId'); await expect( migrateClassroomOwnership(id, { ownerPrincipalId: 'account-1', expectedGuestPrincipalId: 'wrong-device', }), ).rejects.toThrow(/Guest ownership changed/); const migrated = await migrateClassroomOwnership(id, { ownerPrincipalId: 'account-1', expectedGuestPrincipalId: 'device-1', }); expect(migrated).toMatchObject({ ownerPrincipalId: 'account-1', ownershipBoundAt: saved.ownershipBoundAt, ownershipMigratedAt: expect.any(String), }); expect(migrated).not.toHaveProperty('guestPrincipalId'); await expect(readClassroom(id)).resolves.toMatchObject({ ownerPrincipalId: 'account-1' }); }); test('requires explicit opt-in for legacy classroom ownership and rejects conflicting owners', async () => { const { migrateClassroomOwnership, persistClassroom } = await import('@/lib/server/classroom-storage'); const id = 'classroom_legacy'; const saved = await persistClassroom( { id, stage: { id, name: 'Legacy classroom', createdAt: 1, updatedAt: 1 }, scenes: [], }, 'http://localhost', ); await expect(migrateClassroomOwnership(id, { ownerPrincipalId: 'account-1' })).rejects.toThrow( /explicit administrative migration/, ); await fs.writeFile( path.join(classroomsDir, `${id}.json`), JSON.stringify({ ...saved, ownerPrincipalId: 'account-1', guestPrincipalId: 'device-1' }), 'utf8', ); await expect( migrateClassroomOwnership(id, { ownerPrincipalId: 'account-1', expectedGuestPrincipalId: 'device-1', }), ).rejects.toThrow(/conflicting ownership subjects/); }); });