fix(config-protection): match protected filenames case-insensitively (#2543)

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.
This commit is contained in:
Emad Doughan
2026-07-22 12:17:04 -04:00
committed by GitHub
parent 96789caaf9
commit e7b3ba07bb
2 changed files with 54 additions and 5 deletions
+8 -1
View File
@@ -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
+46 -4
View File
@@ -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 });