From e7b3ba07bb424db91ffeb3b94b3c39b210c8b316 Mon Sep 17 00:00:00 2001 From: Emad Doughan Date: Wed, 22 Jul 2026 20:17:04 +0400 Subject: [PATCH] fix(config-protection): match protected filenames case-insensitively (#2543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a case-insensitive filesystem (macOS APFS/HFS+, Windows NTFS) a write to `.ESLINTRC.JS` lands on the exact same inode as `.eslintrc.js`, but the guard looked the basename up in PROTECTED_FILES with a case-sensitive `Set.has`. Every entry in that Set is lowercase, so any case-variant path missed the branch entirely and returned exit 0 — a single Write silently overwrote a live config while the hook reported success. Reproduced on macOS APFS: `.eslintrc.js` and `.ESLINTRC.JS` share one inode, yet the hook returned exit 2 for the former and exit 0 for the latter, and the uppercase write replaced the real config's contents. This is a one-step bypass of the whole guard and needs no shell access, unlike the known delete-then-recreate route. Fix: also test `basename.toLowerCase()`. All 32 PROTECTED_FILES entries are already lowercase, so the fallback is exact. On a genuinely case-sensitive filesystem this costs at most a false positive on a distinct file whose name differs from a protected one by case alone. Behaviour deliberately unchanged: first-time creation is still allowed (the bootstrap affordance), non-config paths still pass through, and the existing lstat/ENOENT fail-closed semantics are untouched. Test: adds a case-variant case that asserts exit 2. It guards itself with an inode comparison and skips on case-sensitive filesystems rather than asserting something untrue there. Verified in both directions — it FAILS against the unpatched hook (`Got 0; 0 !== 2`) and passes with the fix. Suite: 9/9. --- scripts/hooks/config-protection.js | 9 ++++- tests/hooks/config-protection.test.js | 50 ++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/scripts/hooks/config-protection.js b/scripts/hooks/config-protection.js index 625da3b71..2da5358c2 100644 --- a/scripts/hooks/config-protection.js +++ b/scripts/hooks/config-protection.js @@ -94,7 +94,14 @@ function run(inputOrRaw, options = {}) { if (!filePath) return { exitCode: 0 }; const basename = path.basename(filePath); - if (PROTECTED_FILES.has(basename)) { + // Match case-insensitively. Every PROTECTED_FILES entry is lowercase, and on + // case-insensitive filesystems (macOS APFS/HFS+, Windows NTFS) a write to + // `.ESLINTRC.JS` lands on the very same inode as `.eslintrc.js`. A + // case-sensitive Set lookup therefore let a single case-variant Write + // silently overwrite the real config while the guard returned exit 0. + // On genuinely case-sensitive filesystems this only costs a false positive + // on a distinct file that differs from a protected name by case alone. + if (PROTECTED_FILES.has(basename) || PROTECTED_FILES.has(basename.toLowerCase())) { // Allow first-time creation — there's no existing config to weaken. // The hook's purpose is blocking modifications; writing a brand-new // config file in a project that has none is a legitimate bootstrap diff --git a/tests/hooks/config-protection.test.js b/tests/hooks/config-protection.test.js index ec57c1375..e383753ec 100644 --- a/tests/hooks/config-protection.test.js +++ b/tests/hooks/config-protection.test.js @@ -234,10 +234,52 @@ function runTests() { const result = runHook(input); assert.strictEqual(result.code, 2, `Expected exit 2 for dangling symlink, got ${result.code}; stderr: ${result.stderr}`); assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input'); - assert.ok( - result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), - `Expected block message, got: ${result.stderr}` - ); + assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } + }) + ) + passed++; + else failed++; + + if ( + test('blocks case-variant writes that resolve to an existing protected config', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-')); + try { + const realPath = path.join(tmpDir, '.eslintrc.js'); + const variantPath = path.join(tmpDir, '.ESLINTRC.JS'); + fs.writeFileSync(realPath, 'module.exports = { rules: { "no-explicit-any": "error" } };'); + + // Only meaningful on a case-insensitive filesystem (macOS APFS/HFS+, + // Windows NTFS), where the uppercase path is the SAME inode. On a + // case-sensitive filesystem the variant is a genuinely different file + // and the write is harmless, so skip rather than assert. + let sameFile = false; + try { + sameFile = fs.lstatSync(variantPath).ino === fs.lstatSync(realPath).ino; + } catch { + sameFile = false; + } + if (!sameFile) { + console.log(' (skipped: case-sensitive filesystem)'); + return; + } + + const result = runHook({ + tool_name: 'Write', + tool_input: { + file_path: variantPath, + content: 'module.exports = { rules: {} }; // WEAKENED' + } + }); + + assert.strictEqual(result.code, 2, `Case-variant write must be blocked: it overwrites ${path.basename(realPath)} on this filesystem. Got ${result.code}; stderr: ${result.stderr}`); + assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input'); } finally { try { fs.rmSync(tmpDir, { recursive: true, force: true });