fix: harden logging, memory sync, config merge, and input validation

- Redact sensitive text in file log transport before writing to disk
- Validate Nextcloud Talk room tokens and message IDs against path traversal
- Propagate real errors from ensureDir instead of silently swallowing
- Use Promise.allSettled in memory sync so one bad file does not abort indexing
- Block __proto__/constructor/prototype keys in deepMerge (config includes + voice-call TTS)
This commit is contained in:
issuemakerable 2026-01-29 19:51:20 +09:00
parent 19823c5498
commit d5be6aa3f4
6 changed files with 21 additions and 5 deletions

View File

@ -48,6 +48,9 @@ function normalizeRoomToken(to: string): string {
} }
if (!normalized) throw new Error("Room token is required for Nextcloud Talk sends"); if (!normalized) throw new Error("Room token is required for Nextcloud Talk sends");
if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) {
throw new Error(`Invalid room token: contains disallowed characters`);
}
return normalized; return normalized;
} }
@ -177,6 +180,9 @@ export async function sendReactionNextcloudTalk(
account, account,
); );
const normalizedToken = normalizeRoomToken(roomToken); const normalizedToken = normalizeRoomToken(roomToken);
if (!/^[a-zA-Z0-9_-]+$/.test(messageId)) {
throw new Error("Invalid message ID: contains disallowed characters");
}
const body = JSON.stringify({ reaction }); const body = JSON.stringify({ reaction });
const { random, signature } = generateNextcloudTalkSignature({ const { random, signature } = generateNextcloudTalkSignature({

View File

@ -80,6 +80,7 @@ function deepMerge<T>(base: T, override: T): T {
const result: Record<string, unknown> = { ...base }; const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(override)) { for (const [key, value] of Object.entries(override)) {
if (value === undefined) continue; if (value === undefined) continue;
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
const existing = (base as Record<string, unknown>)[key]; const existing = (base as Record<string, unknown>)[key];
if (isPlainObject(existing) && isPlainObject(value)) { if (isPlainObject(existing) && isPlainObject(value)) {
result[key] = deepMerge(existing, value); result[key] = deepMerge(existing, value);

View File

@ -70,6 +70,7 @@ export function deepMerge(target: unknown, source: unknown): unknown {
if (isPlainObject(target) && isPlainObject(source)) { if (isPlainObject(target) && isPlainObject(source)) {
const result: Record<string, unknown> = { ...target }; const result: Record<string, unknown> = { ...target };
for (const key of Object.keys(source)) { for (const key of Object.keys(source)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
result[key] = key in result ? deepMerge(result[key], source[key]) : source[key]; result[key] = key in result ? deepMerge(result[key], source[key]) : source[key];
} }
return result; return result;

View File

@ -8,6 +8,7 @@ import type { MoltbotConfig } from "../config/types.js";
import type { ConsoleStyle } from "./console.js"; import type { ConsoleStyle } from "./console.js";
import { type LogLevel, levelToMinLevel, normalizeLogLevel } from "./levels.js"; import { type LogLevel, levelToMinLevel, normalizeLogLevel } from "./levels.js";
import { readLoggingConfig } from "./config.js"; import { readLoggingConfig } from "./config.js";
import { redactSensitiveText } from "./redact.js";
import { loggingState } from "./state.js"; import { loggingState } from "./state.js";
// Pin to /tmp so mac Debug UI and docs match; os.tmpdir() can be a per-user // Pin to /tmp so mac Debug UI and docs match; os.tmpdir() can be a per-user
@ -96,7 +97,8 @@ function buildLogger(settings: ResolvedSettings): TsLogger<LogObj> {
logger.attachTransport((logObj: LogObj) => { logger.attachTransport((logObj: LogObj) => {
try { try {
const time = logObj.date?.toISOString?.() ?? new Date().toISOString(); const time = logObj.date?.toISOString?.() ?? new Date().toISOString();
const line = JSON.stringify({ ...logObj, time }); const raw = JSON.stringify({ ...logObj, time });
const line = redactSensitiveText(raw);
fs.appendFileSync(settings.file, `${line}\n`, { encoding: "utf8" }); fs.appendFileSync(settings.file, `${line}\n`, { encoding: "utf8" });
} catch { } catch {
// never block on logging failures // never block on logging failures

View File

@ -19,9 +19,7 @@ export type MemoryChunk = {
}; };
export function ensureDir(dir: string): string { export function ensureDir(dir: string): string {
try { fsSync.mkdirSync(dir, { recursive: true });
fsSync.mkdirSync(dir, { recursive: true });
} catch {}
return dir; return dir;
} }

View File

@ -976,9 +976,17 @@ export class MemoryIndexManager {
progress?: MemorySyncProgressState; progress?: MemorySyncProgressState;
}) { }) {
const files = await listMemoryFiles(this.workspaceDir); const files = await listMemoryFiles(this.workspaceDir);
const fileEntries = await Promise.all( const settled = await Promise.allSettled(
files.map(async (file) => buildFileEntry(file, this.workspaceDir)), files.map(async (file) => buildFileEntry(file, this.workspaceDir)),
); );
const fileEntries = settled.flatMap((result, i) => {
if (result.status === "fulfilled") return [result.value];
log.warn("memory sync: skipping unreadable file", {
file: files[i],
error: String(result.reason),
});
return [];
});
log.debug("memory sync: indexing memory files", { log.debug("memory sync: indexing memory files", {
files: fileEntries.length, files: fileEntries.length,
needsFullReindex: params.needsFullReindex, needsFullReindex: params.needsFullReindex,