Files
openmaic/OpenMAIC/tests/server/classroom-storage-security.test.ts
2026-08-16 14:58:47 +08:00

171 lines
5.9 KiB
TypeScript

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/);
});
});