171 lines
5.9 KiB
TypeScript
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/);
|
|
});
|
|
});
|