feat(security): idempotent remediation script to rotate seeded admin123 on existing instances
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { hashPassword, verifyPassword } from '../utils/auth.js';
|
||||||
|
import { remediateSeedAdmin } from './remediate-seed-admin.js';
|
||||||
|
|
||||||
|
async function freshDbWithUser(opts: {
|
||||||
|
username: string; password: string; isAdmin: number; homeInstance: string | null;
|
||||||
|
}): Promise<Database.Database> {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec(`CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY, username TEXT, display_name TEXT, password_hash TEXT,
|
||||||
|
is_admin INTEGER DEFAULT 0, home_instance TEXT, created_at INTEGER
|
||||||
|
)`);
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO users (id, username, password_hash, is_admin, home_instance, created_at) VALUES (?,?,?,?,?,?)'
|
||||||
|
).run('1', opts.username, await hashPassword(opts.password), opts.isAdmin, opts.homeInstance, 1);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('remediateSeedAdmin', () => {
|
||||||
|
it('rotates the password when admin still uses admin123', async () => {
|
||||||
|
const db = await freshDbWithUser({ username: 'admin', password: 'admin123', isAdmin: 1, homeInstance: null });
|
||||||
|
const result = await remediateSeedAdmin(db);
|
||||||
|
expect(result.action).toBe('rotated');
|
||||||
|
expect(result.newPassword).toBeTruthy();
|
||||||
|
const row = db.prepare("SELECT password_hash FROM users WHERE username = 'admin'").get() as { password_hash: string };
|
||||||
|
expect(await verifyPassword('admin123', row.password_hash)).toBe(false);
|
||||||
|
expect(await verifyPassword(result.newPassword!, row.password_hash)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when the password is already changed', async () => {
|
||||||
|
const db = await freshDbWithUser({ username: 'admin', password: 'a-real-strong-pw', isAdmin: 1, homeInstance: null });
|
||||||
|
const result = await remediateSeedAdmin(db);
|
||||||
|
expect(result.action).toBe('noop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a federated user named admin', async () => {
|
||||||
|
const db = await freshDbWithUser({ username: 'admin', password: 'admin123', isAdmin: 0, homeInstance: 'other.example' });
|
||||||
|
const result = await remediateSeedAdmin(db);
|
||||||
|
expect(result.action).toBe('skipped-no-admin');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { verifyPassword, hashPassword } from '../utils/auth.js';
|
||||||
|
|
||||||
|
type AdminRow = { id: string; password_hash: string };
|
||||||
|
|
||||||
|
export async function remediateSeedAdmin(
|
||||||
|
db: Database.Database
|
||||||
|
): Promise<{ action: 'rotated' | 'noop' | 'skipped-no-admin'; newPassword?: string }> {
|
||||||
|
// Local admin named 'admin' only — replicated users (home_instance set) are never seed admins.
|
||||||
|
const admin = db
|
||||||
|
.prepare("SELECT id, password_hash FROM users WHERE username = 'admin' AND home_instance IS NULL AND is_admin = 1")
|
||||||
|
.get() as AdminRow | undefined;
|
||||||
|
|
||||||
|
if (!admin) return { action: 'skipped-no-admin' };
|
||||||
|
|
||||||
|
const stillDefault = await verifyPassword('admin123', admin.password_hash);
|
||||||
|
if (!stillDefault) return { action: 'noop' };
|
||||||
|
|
||||||
|
const newPassword = crypto.randomBytes(18).toString('base64url'); // 24-char strong password
|
||||||
|
const newHash = await hashPassword(newPassword);
|
||||||
|
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(newHash, admin.id);
|
||||||
|
|
||||||
|
return { action: 'rotated', newPassword };
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI entrypoint: run inside the container via
|
||||||
|
// docker exec -w /app/packages/server backspace node --import tsx/esm src/scripts/remediate-seed-admin.ts
|
||||||
|
const isMain = process.argv[1] && process.argv[1].endsWith('remediate-seed-admin.ts');
|
||||||
|
if (isMain) {
|
||||||
|
const dbPath = process.env.DB_PATH || '/app/data/backspace.db';
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
remediateSeedAdmin(db)
|
||||||
|
.then((r) => {
|
||||||
|
if (r.action === 'rotated') {
|
||||||
|
// No print-once lockout: also persist to a root-owned file next to the DB
|
||||||
|
// (on the bind-mount → visible on the host as data/seed-admin-rotated.txt).
|
||||||
|
const outFile = path.join(path.dirname(dbPath), 'seed-admin-rotated.txt');
|
||||||
|
fs.writeFileSync(outFile, `${r.newPassword}\n`, { mode: 0o600 });
|
||||||
|
console.log('Seed admin password ROTATED.');
|
||||||
|
console.log(` New password: ${r.newPassword}`);
|
||||||
|
console.log(` Also written to: ${outFile} (delete after you have stored it)`);
|
||||||
|
} else if (r.action === 'noop') {
|
||||||
|
console.log('Seed admin password already changed — nothing to do.');
|
||||||
|
} else {
|
||||||
|
console.log('No local seed admin found — nothing to do.');
|
||||||
|
}
|
||||||
|
db.close();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Remediation failed:', err);
|
||||||
|
db.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user