diff --git a/src/lib/HAXCMS.js b/src/lib/HAXCMS.js index 013f2ca8..c3840049 100644 --- a/src/lib/HAXCMS.js +++ b/src/lib/HAXCMS.js @@ -37,6 +37,51 @@ const SITE_FILE_NAME = 'site.json'; // previous refresh-token jti is still accepted, so concurrent multi-tab // refreshes don't mutually invalidate. Mirrors PHP HAXCMS_REFRESH_GRACE_SECONDS. const HAXCMS_REFRESH_GRACE_SECONDS = 30; +// Safeguard config.json loading (haxtheweb/issues#2967): the config.json +// read/boot path must NEVER write, copy, or overwrite anything on disk. A +// missing file, an empty file, and a corrupt/unparseable file are all just +// different triggers for the exact same in-memory-only fallback. +// +// Load config.json from configDirectory as a pure read (never writes, never +// creates, never copies anything to disk). Missing / empty / corrupt files +// are all normalized to the same in-memory-only fallback: read the shipped +// boilerplate config.json fresh from disk (read-only) and, if that also +// fails, fall back to a minimal hand-built object. The real cause is logged +// server-side via console.error only — never leaked to any HTTP response, and +// this function never throws. +function loadConfigJson(configDirectory) { + const configPath = path.join(configDirectory, 'config.json'); + if (fs.existsSync(configPath)) { + try { + const raw = fs.readFileSync(configPath, { encoding: 'utf8', flag: 'r' }); + return JSON.parse(raw); + } + catch (e) { + console.error('[HAXCMS] config.json exists but failed to parse; falling back to an in-memory default (no files were modified). Parse error: ' + e.message); + } + } + else { + console.error('[HAXCMS] config.json was not found; falling back to an in-memory default (no files were created).'); + } + // in-memory-only fallback shared by the missing and corrupt/empty cases. + // Read the boilerplate config.json fresh each time — read-only, never + // written anywhere. + try { + const boilerplateConfigPath = path.join(__dirname, '/../boilerplate/systemsetup/config.json'); + const raw = fs.readFileSync(boilerplateConfigPath, { encoding: 'utf8', flag: 'r' }); + return JSON.parse(raw); + } + catch (e) { + console.error('[HAXCMS] boilerplate config.json could not be read/parsed either; using a minimal built-in fallback. Cause: ' + e.message); + return { + themes: {}, + security: {}, + site: { settings: {}, git: {}, static: {}, publishers: {} }, + mcp: { enabled: true, readOnly: true }, + deploymentProfile: 'single-site', + }; + } +} // HAXCMSSite which overlaps heavily and is referenced here often const utf8 = require('utf8'); const JSONOutlineSchemaItem = require('./JSONOutlineSchemaItem.js'); @@ -3245,12 +3290,11 @@ class HAXCMSClass { // makes it easier to request a new item from the schema factory this.outlineSchema = new JSONOutlineSchema(); - // self healing if config is missing - if (!fs.existsSync(path.join(this.configDirectory, "config.json"))) { - fs.copyFileSync(path.join(__dirname, '/../boilerplate/systemsetup/config.json'), path.join(this.configDirectory, 'config.json')); - } - this.config = JSON.parse(fs.readFileSync(path.join(this.configDirectory, "config.json"), - {encoding:'utf8', flag:'r'}, 'utf8')); + // Safeguard (haxtheweb/issues#2967): never write, copy, or overwrite + // config.json on disk. Missing / empty / corrupt config.json are all + // treated the same way — an in-memory-only fallback is used and the + // real cause is logged server-side. See loadConfigJson above. + this.config = loadConfigJson(this.configDirectory); if (!this.config.themes) { this.config.themes = {}; } @@ -4959,4 +5003,4 @@ async function systemStructureContext(dir = null) { return null; } -module.exports = { HAXCMS, HAXCMSClass, HAXCMSSite, systemStructureContext }; +module.exports = { HAXCMS, HAXCMSClass, HAXCMSSite, systemStructureContext, loadConfigJson }; diff --git a/test/unit/config-safeguard.test.cjs b/test/unit/config-safeguard.test.cjs new file mode 100644 index 00000000..b51bdc56 --- /dev/null +++ b/test/unit/config-safeguard.test.cjs @@ -0,0 +1,149 @@ +'use strict' + +// Unit tests for the config.json safeguard loader (haxtheweb/issues#2967). +// +// Constraints honored: CommonJS (.cjs), require(), globalThis (not window), NO +// optional chaining (explicit && guards), node:test + node:assert/strict. +// +// CRITICAL invariant under test: the loader must NEVER write, copy, or +// overwrite anything on disk. Missing / empty / corrupt config.json are all +// just different triggers for the exact same in-memory-only fallback. + +process.env.haxcms_middleware = 'node-cli' + +const { test, describe } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('fs-extra') +const os = require('os') +const path = require('path') + +const { loadConfigJson } = require('../../src/lib/HAXCMS.js') + +function mkTmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)) +} + +function assertValidFallbackShape(config) { + assert.equal(typeof config, 'object') + assert.notEqual(config, null) + assert.equal(typeof config.themes, 'object') + assert.equal(typeof config.security, 'object') + assert.equal(typeof config.site, 'object') + assert.equal(typeof config.mcp, 'object') + assert.equal(typeof config.deploymentProfile, 'string') +} + +describe('loadConfigJson — missing config.json', () => { + test('missing file yields a valid in-memory fallback and creates nothing on disk', () => { + const tmpDir = mkTmpDir('haxcms-cfg-missing-') + try { + const configPath = path.join(tmpDir, 'config.json') + assert.equal(fs.existsSync(configPath), false) + + const config = loadConfigJson(tmpDir) + + assertValidFallbackShape(config) + + // the most important assertion: no file was created as a side effect + assert.equal(fs.existsSync(configPath), false) + assert.deepEqual(fs.readdirSync(tmpDir), []) + } finally { + fs.removeSync(tmpDir) + } + }) +}) + +describe('loadConfigJson — corrupt/malformed config.json', () => { + test('corrupt JSON does not throw, yields a valid fallback, and leaves the file byte-for-byte unchanged', (t) => { + const tmpDir = mkTmpDir('haxcms-cfg-corrupt-') + try { + const configPath = path.join(tmpDir, 'config.json') + const corruptContent = '{ "themes": { totally not valid json !!! ' + fs.writeFileSync(configPath, corruptContent) + + const originalStat = fs.statSync(configPath) + + // capture console.error output to confirm the real parse error is logged + const originalConsoleError = console.error + const loggedMessages = [] + console.error = (msg) => { loggedMessages.push(msg) } + + let config + try { + config = loadConfigJson(tmpDir) + } finally { + console.error = originalConsoleError + } + + assertValidFallbackShape(config) + + // content on disk must be byte-for-byte unchanged + const afterContent = fs.readFileSync(configPath, { encoding: 'utf8' }) + assert.equal(afterContent, corruptContent) + const afterStat = fs.statSync(configPath) + assert.equal(afterStat.size, originalStat.size) + assert.equal(afterStat.mtimeMs, originalStat.mtimeMs) + + // the real parse error was logged server-side + assert.ok(loggedMessages.length > 0, 'expected console.error to be called') + const joined = loggedMessages.join(' ') + assert.ok( + joined.indexOf('failed to parse') !== -1, + 'expected log message to mention parse failure: ' + joined, + ) + } finally { + fs.removeSync(tmpDir) + } + }) +}) + +describe('loadConfigJson — empty config.json', () => { + test('empty file does not throw, yields a valid fallback, and leaves the file unchanged', () => { + const tmpDir = mkTmpDir('haxcms-cfg-empty-') + try { + const configPath = path.join(tmpDir, 'config.json') + fs.writeFileSync(configPath, '') + + const originalConsoleError = console.error + const loggedMessages = [] + console.error = (msg) => { loggedMessages.push(msg) } + + let config + try { + config = loadConfigJson(tmpDir) + } finally { + console.error = originalConsoleError + } + + assertValidFallbackShape(config) + + const afterContent = fs.readFileSync(configPath, { encoding: 'utf8' }) + assert.equal(afterContent, '') + + assert.ok(loggedMessages.length > 0, 'expected console.error to be called') + } finally { + fs.removeSync(tmpDir) + } + }) +}) + +describe('loadConfigJson — valid config.json passes through', () => { + test('a valid config.json is parsed and returned as-is, with no writes', () => { + const tmpDir = mkTmpDir('haxcms-cfg-valid-') + try { + const configPath = path.join(tmpDir, 'config.json') + const validContent = JSON.stringify({ themes: { foo: {} }, deploymentProfile: 'single-site' }) + fs.writeFileSync(configPath, validContent) + + const config = loadConfigJson(tmpDir) + + assert.equal(config.deploymentProfile, 'single-site') + assert.deepEqual(config.themes, { foo: {} }) + + const afterContent = fs.readFileSync(configPath, { encoding: 'utf8' }) + assert.equal(afterContent, validContent) + } finally { + fs.removeSync(tmpDir) + } + }) +})