mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(config-backup): ignore a web tab's lastOpenedAt, audit multi-user restores, refuse symlinked or loosened snapshots, keep the folder settings on restore
This commit is contained in:
1 parent
71fcefbaf2
commit
c5f1e99036
5 files changed
+146
-6
No files matched your search
+46
-2
@@ -206,11 +206,12 @@ const VOLATILE_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
'custom-model-hosts.json': ['lastDiscoveredAt'],
|
||||
// Rewritten by activity, not by anything the user set: a Basic-auth login (at most once a minute
|
||||
// per user), a session started in another case, every captured prompt, and a Docker case's
|
||||
// conversation id after a /clear.
|
||||
// conversation id after a /clear, and a web tab being opened.
|
||||
'users.json': ['lastLoginAt'],
|
||||
'settings.json': ['lastUsedCase'],
|
||||
'intents.json': ['recentPrompts', 'updatedAt'],
|
||||
'docker-cases.json': ['lastClaudeSessionId'],
|
||||
'webviews.json': ['lastOpenedAt'],
|
||||
};
|
||||
|
||||
function withoutKeys(value: unknown, drop: ReadonlySet<string>): unknown {
|
||||
@@ -412,6 +413,45 @@ export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] {
|
||||
export class ConfigBackupNotFoundError extends Error {}
|
||||
export class ConfigBackupCorruptError extends Error {}
|
||||
|
||||
/**
|
||||
* Restore refuses what this module never writes. Snapshots are 0700/0600 real files, and a restore
|
||||
* writes the file as 0600: a symlink, or a file with a group/world bit, was planted or loosened
|
||||
* after the snapshot, and rewriting it as 0600 would turn it into trusted config (`clis.json`
|
||||
* selects the binaries Codeman spawns; `isUnsafePermissions` refuses the same bits there).
|
||||
* POSIX only, like `isUnsafePermissions`.
|
||||
*/
|
||||
function assertTrustedSnapshotEntry(path: string, label: string): void {
|
||||
if (process.platform === 'win32') return;
|
||||
const st = lstatSync(path, { throwIfNoEntry: false });
|
||||
if (!st) return; // missing is reported by the read that follows
|
||||
if (st.isSymbolicLink()) throw new ConfigBackupCorruptError(`${label} is a symlink, so it is not restored`);
|
||||
if ((st.mode & 0o077) !== 0) {
|
||||
throw new ConfigBackupCorruptError(`${label} is readable or writable by group or others, so it is not restored`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The folder's own backup settings stay as they are now: restoring an old `configBackupDir` hides the `pre-restore` copy. */
|
||||
const BACKUP_SETTING_KEYS = ['configBackupEnabled', 'configBackupDir', 'configBackupKeepCount', 'configBackupKeepDays'];
|
||||
|
||||
function keepCurrentBackupSettings(snapshot: Buffer, livePath: string): Buffer {
|
||||
try {
|
||||
const restored = JSON.parse(snapshot.toString('utf8')) as Record<string, unknown>;
|
||||
const live = JSON.parse(readFileSync(livePath, 'utf8')) as Record<string, unknown>;
|
||||
if (!restored || typeof restored !== 'object' || !live || typeof live !== 'object') return snapshot;
|
||||
let changed = false;
|
||||
for (const key of BACKUP_SETTING_KEYS) {
|
||||
if (JSON.stringify(restored[key]) === JSON.stringify(live[key])) continue;
|
||||
changed = true;
|
||||
if (key in live) restored[key] = live[key];
|
||||
else delete restored[key];
|
||||
}
|
||||
if (!changed) return snapshot; // byte-identical to the snapshot when the folder settings did not move
|
||||
return Buffer.from(JSON.stringify(restored, null, 2) + '\n');
|
||||
} catch {
|
||||
return snapshot; // not JSON, or no live file to take the values from
|
||||
}
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
restored: string[];
|
||||
/** Tracked files present now that the snapshot did not hold; a restore leaves them as they are. */
|
||||
@@ -437,9 +477,11 @@ export function restoreConfigBackup(opts: {
|
||||
|
||||
const allowed = new Set<string>(CONFIG_BACKUP_FILES);
|
||||
const loaded: Array<{ name: string; data: Buffer }> = [];
|
||||
assertTrustedSnapshotEntry(join(opts.backupDir, opts.id), `Backup ${opts.id}`);
|
||||
for (const f of manifest.files) {
|
||||
if (!allowed.has(f.name)) continue;
|
||||
let data: Buffer;
|
||||
assertTrustedSnapshotEntry(join(opts.backupDir, opts.id, f.name), `${f.name} in ${opts.id}`);
|
||||
try {
|
||||
data = readFileSync(join(opts.backupDir, opts.id, f.name));
|
||||
} catch {
|
||||
@@ -466,7 +508,9 @@ export function restoreConfigBackup(opts: {
|
||||
const target = join(opts.dataDir, f.name);
|
||||
const tmp = `${target}.restore-${process.pid}`;
|
||||
try {
|
||||
writeFileSync(tmp, f.data, { mode: 0o600 });
|
||||
writeFileSync(tmp, f.name === 'settings.json' ? keepCurrentBackupSettings(f.data, target) : f.data, {
|
||||
mode: 0o600,
|
||||
});
|
||||
renameSync(tmp, target);
|
||||
} catch (err) {
|
||||
rmSync(tmp, { force: true });
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { getAuthUser, isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { appendAdminAudit } from '../admin-audit.js';
|
||||
import { reloadCliRegistry } from '../../config/cli-registry/registry.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
@@ -207,6 +208,16 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
|
||||
if (out.restored.includes('intents.json')) intentStore.reload();
|
||||
// restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile).
|
||||
if (out.restored.includes('clis.json')) reloadCliRegistry();
|
||||
// A restore of users.json replaces accounts, password hashes, roles and bypass grants.
|
||||
if (isMultiUserMode()) {
|
||||
void appendAdminAudit({
|
||||
admin: getAuthUser(req).username,
|
||||
action: 'config_backup_restore',
|
||||
target: id,
|
||||
ip: req.ip,
|
||||
detail: { restored: out.restored },
|
||||
});
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
Reference in new issue
Block a user