feat(security): remove hardcoded admin/admin123 seed; first registered user is admin
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { ensureDefaults } from './migrate.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function applyMigrations(db: Database.Database): void {
|
||||||
|
const migrationsDir = path.resolve(__dirname, '../../drizzle');
|
||||||
|
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
|
||||||
|
for (const f of files) {
|
||||||
|
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
|
||||||
|
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
|
||||||
|
const clean = stmt.trim();
|
||||||
|
if (clean) db.exec(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('fresh instance has no seeded credentials', () => {
|
||||||
|
let sqlite: Database.Database;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sqlite = new Database(':memory:');
|
||||||
|
sqlite.pragma('foreign_keys = ON');
|
||||||
|
applyMigrations(sqlite);
|
||||||
|
ensureDefaults(sqlite);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates no admin user and no default space on fresh boot', () => {
|
||||||
|
const users = sqlite.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number };
|
||||||
|
const spaces = sqlite.prepare('SELECT COUNT(*) AS n FROM spaces').get() as { n: number };
|
||||||
|
expect(users.n).toBe(0);
|
||||||
|
expect(spaces.n).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no user named "admin"', () => {
|
||||||
|
const admin = sqlite.prepare("SELECT id FROM users WHERE username = 'admin'").get();
|
||||||
|
expect(admin).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still creates the singleton instance_settings row', () => {
|
||||||
|
const row = sqlite.prepare('SELECT id, worker_id FROM instance_settings WHERE id = 1').get() as
|
||||||
|
{ id: number; worker_id: number | null } | undefined;
|
||||||
|
expect(row?.id).toBe(1);
|
||||||
|
expect(row?.worker_id).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { getDb, schema } from './index.js';
|
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
|
||||||
import { hashPassword } from '../utils/auth.js';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { DEFAULT_EVERYONE_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
|
||||||
|
|
||||||
export async function seedDatabase(): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const existingSpaces = db.select().from(schema.spaces).all();
|
|
||||||
if (existingSpaces.length > 0) {
|
|
||||||
console.log('Database already has data, skipping seed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Seeding database with default data...');
|
|
||||||
|
|
||||||
const adminId = generateSnowflake();
|
|
||||||
const adminPasswordHash = await hashPassword('admin123');
|
|
||||||
|
|
||||||
db.insert(schema.users).values({
|
|
||||||
id: adminId,
|
|
||||||
username: 'admin',
|
|
||||||
displayName: 'Admin',
|
|
||||||
passwordHash: adminPasswordHash,
|
|
||||||
status: 'offline',
|
|
||||||
isAdmin: 1,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
}).run();
|
|
||||||
|
|
||||||
const spaceId = generateSnowflake();
|
|
||||||
db.insert(schema.spaces).values({
|
|
||||||
id: spaceId,
|
|
||||||
name: 'Backspace',
|
|
||||||
ownerId: adminId,
|
|
||||||
inviteCode: 'backspace',
|
|
||||||
createdAt: Date.now(),
|
|
||||||
}).run();
|
|
||||||
|
|
||||||
db.insert(schema.spaceMembers).values({
|
|
||||||
spaceId: spaceId,
|
|
||||||
userId: adminId,
|
|
||||||
joinedAt: Date.now(),
|
|
||||||
}).run();
|
|
||||||
|
|
||||||
const generalChannelId = generateSnowflake();
|
|
||||||
db.insert(schema.channels).values({
|
|
||||||
id: generalChannelId,
|
|
||||||
spaceId: spaceId,
|
|
||||||
name: 'general',
|
|
||||||
type: 'text',
|
|
||||||
topic: 'General discussion',
|
|
||||||
position: 0,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
}).run();
|
|
||||||
|
|
||||||
const voiceChannelId = generateSnowflake();
|
|
||||||
db.insert(schema.channels).values({
|
|
||||||
id: voiceChannelId,
|
|
||||||
spaceId: spaceId,
|
|
||||||
name: 'General Voice',
|
|
||||||
type: 'voice',
|
|
||||||
position: 1,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
}).run();
|
|
||||||
|
|
||||||
// Create @everyone role (id === spaceId convention)
|
|
||||||
db.insert(schema.roles).values({
|
|
||||||
id: spaceId,
|
|
||||||
spaceId: spaceId,
|
|
||||||
name: '@everyone',
|
|
||||||
color: '#b9bbbe',
|
|
||||||
position: 0,
|
|
||||||
permissions: permissionsToString(DEFAULT_EVERYONE_PERMISSIONS),
|
|
||||||
createdAt: Date.now(),
|
|
||||||
}).run();
|
|
||||||
|
|
||||||
console.log('Database seeded successfully');
|
|
||||||
console.log(` Default space: Backspace (invite code: backspace)`);
|
|
||||||
console.log(` Admin user: admin / admin123`);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import websocket from '@fastify/websocket';
|
|||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { getDb, getRawDb } from './db/index.js';
|
import { getDb, getRawDb } from './db/index.js';
|
||||||
import { seedDatabase } from './db/seed.js';
|
|
||||||
import { checkFfmpeg } from './utils/thumbnail.js';
|
import { checkFfmpeg } from './utils/thumbnail.js';
|
||||||
import { authRoutes } from './routes/auth.js';
|
import { authRoutes } from './routes/auth.js';
|
||||||
import { userRoutes } from './routes/users.js';
|
import { userRoutes } from './routes/users.js';
|
||||||
@@ -108,7 +107,6 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
getDb();
|
getDb();
|
||||||
await seedDatabase();
|
|
||||||
|
|
||||||
// Reset orphaned `users.status` rows for locally-homed users. The previous
|
// Reset orphaned `users.status` rows for locally-homed users. The previous
|
||||||
// process's in-memory disconnect timers are gone, so any non-offline row
|
// process's in-memory disconnect timers are gone, so any non-offline row
|
||||||
|
|||||||
@@ -488,3 +488,60 @@ describe('POST /api/auth/register — federation gate split', () => {
|
|||||||
expect(reds).toHaveLength(0);
|
expect(reds).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /api/auth/register — first-user-admin promotion', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Start from a completely empty users table (no pre-seeded admin from outer beforeEach).
|
||||||
|
testDb.delete(schema.users).run();
|
||||||
|
// Ensure instance_settings row so registration can proceed.
|
||||||
|
testDb.delete(schema.instanceSettings).run();
|
||||||
|
testDb.insert(schema.instanceSettings).values({
|
||||||
|
id: 1,
|
||||||
|
registrationOpen: 1,
|
||||||
|
federatedRegistrationOpen: 1,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('first locally-registered user receives isAdmin = 1', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/register',
|
||||||
|
payload: { username: 'firstuser', password: 'password123' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
|
||||||
|
const user = testDb.select().from(schema.users).where(eq(schema.users.username, 'firstuser')).get();
|
||||||
|
expect(user?.isAdmin).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('second locally-registered user does NOT receive isAdmin', async () => {
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/register',
|
||||||
|
payload: { username: 'firstuser', password: 'password123' },
|
||||||
|
});
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/register',
|
||||||
|
payload: { username: 'seconduser', password: 'password123' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
|
||||||
|
const second = testDb.select().from(schema.users).where(eq(schema.users.username, 'seconduser')).get();
|
||||||
|
expect(second?.isAdmin).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('federated user (homeInstance set) is never promoted to admin even if first in DB', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/register',
|
||||||
|
payload: { username: 'feduser@remote.example', password: 'password123', homeInstance: 'remote.example' },
|
||||||
|
});
|
||||||
|
// Federated registration is open (federatedRegistrationOpen: 1)
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
|
||||||
|
const user = testDb.select().from(schema.users).where(eq(schema.users.username, 'feduser@remote.example')).get();
|
||||||
|
expect(user?.isAdmin).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user