From 4b44996aacbedc092f45415e103b0f07b42114e1 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:15:39 +0200 Subject: [PATCH] feat(security): idempotent remediation script to rotate seeded admin123 on existing instances --- .../src/scripts/remediate-seed-admin.test.ts | 42 ++++++++++++++ .../src/scripts/remediate-seed-admin.ts | 58 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 packages/server/src/scripts/remediate-seed-admin.test.ts create mode 100644 packages/server/src/scripts/remediate-seed-admin.ts diff --git a/packages/server/src/scripts/remediate-seed-admin.test.ts b/packages/server/src/scripts/remediate-seed-admin.test.ts new file mode 100644 index 00000000..d2c24cbb --- /dev/null +++ b/packages/server/src/scripts/remediate-seed-admin.test.ts @@ -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 { + 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'); + }); +}); diff --git a/packages/server/src/scripts/remediate-seed-admin.ts b/packages/server/src/scripts/remediate-seed-admin.ts new file mode 100644 index 00000000..50f119cd --- /dev/null +++ b/packages/server/src/scripts/remediate-seed-admin.ts @@ -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); + }); +}