mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(files): the editor never reaches Codeman's own .env by another spelling, and a save keeps the file's group (#797)
The four review items of #797, applied at merge. The editor's name rule lowercases, the data dir's .env pattern did not: .codeman/.ENV passed both, and on a case-insensitive filesystem (the macOS default) that is ~/.codeman/.env, the login file codeman attach reads. A session whose workspace is the home folder could open and save it. The pattern is case-insensitive now, pinned in the blocklist table and on the three editor routes. A save writes a new file and renames it over the old one, so the file took the server's own group: a 0640 .env owned you:www-data came back you:you and the web server lost read access. The save now puts the old user and group back where the account may (best effort), and the wiki says mode and group instead of permissions. Tests: an .env in an outside worktree is refused on edit=1 and PUT too, now that the editor's own gate lets dotenv through; the comment on why .env.example is blocked for downloads and attachments is back. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
18b309fc02
commit
59d4bbca2d
7 files changed
+89
-6
No files matched your search
@@ -137,6 +137,38 @@ describe('file routes for a worktree outside the session folder', () => {
|
||||
expect(plainRaw.body).toBe('plain');
|
||||
});
|
||||
|
||||
// The editor's own gate lets a dotenv file through (#796), so for a file in an outside
|
||||
// worktree `validateLocalSessionFilePath()` is the ONLY refusal on the edit path.
|
||||
it('refuses to open or save a dotenv file in the worktree through the editor', async () => {
|
||||
const target = join(sibling, '.env');
|
||||
writeFileSync(target, 'TOKEN=abc');
|
||||
const edit = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: sessionUrl(`file-content?edit=1&path=${encodeURIComponent(target)}`),
|
||||
});
|
||||
expect(edit.json().success).toBe(false);
|
||||
expect(edit.body).not.toContain('abc');
|
||||
|
||||
const put = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: sessionUrl('file-content'),
|
||||
payload: { path: target, content: 'TOKEN=changed', baseHash: sha256('TOKEN=abc') },
|
||||
});
|
||||
expect(put.statusCode).not.toBe(200);
|
||||
expect(put.json().success).toBe(false);
|
||||
expect(readFileSync(target, 'utf8')).toBe('TOKEN=abc');
|
||||
|
||||
// The same file in the session's own folder is the one #796 opened.
|
||||
writeFileSync(join(repo, '.env'), 'TOKEN=abc');
|
||||
const own = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: sessionUrl('file-content'),
|
||||
payload: { path: '.env', content: 'TOKEN=changed', baseHash: sha256('TOKEN=abc') },
|
||||
});
|
||||
expect(own.statusCode).toBe(200);
|
||||
expect(readFileSync(join(repo, '.env'), 'utf8')).toBe('TOKEN=changed');
|
||||
});
|
||||
|
||||
it('still resolves a relative path against the session folder only', async () => {
|
||||
expect((await readContent('src/main-only.ts')).statusCode).toBe(200);
|
||||
const escaped = await readContent('../myapp-feature/src/feature-only.ts');
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
readFileSync,
|
||||
symlinkSync,
|
||||
chmodSync,
|
||||
chownSync,
|
||||
statSync,
|
||||
lstatSync,
|
||||
readlinkSync,
|
||||
@@ -323,7 +324,16 @@ describe('file viewer edit mode (real fs)', () => {
|
||||
const original = 'TEST_VALUE=original\nTEST_LABEL=café';
|
||||
const updated = 'TEST_VALUE=updated\nTEST_LABEL=été';
|
||||
const editablePaths = ['.env', '.env.local', '.env.example', 'config.env', 'app/.env'];
|
||||
const forbiddenPaths = ['.ssh/.env', '.codeman/.env', '.codeman-dev/.env', '.npmrc', 'credentials.json'];
|
||||
const forbiddenPaths = [
|
||||
'.ssh/.env',
|
||||
'.codeman/.env',
|
||||
'.codeman-dev/.env',
|
||||
// Spelled in capitals: the same login file on a case-insensitive filesystem.
|
||||
'.codeman/.ENV',
|
||||
'.CODEMAN/.env',
|
||||
'.npmrc',
|
||||
'credentials.json',
|
||||
];
|
||||
|
||||
const getPreview = (path: string) =>
|
||||
harness.app.inject({
|
||||
@@ -388,6 +398,21 @@ describe('file viewer edit mode (real fs)', () => {
|
||||
expect(statSync(path).mode & 0o777).toBe(mode);
|
||||
});
|
||||
|
||||
// The save publishes a NEW file. Without help it takes this process's own group, so a
|
||||
// `.env` shared with a web server by group (you:www-data, 0640) came back you:you.
|
||||
const otherGroup = process.getgroups?.().find((gid) => gid !== process.getgid?.());
|
||||
it.skipIf(otherGroup === undefined)('keeps the group of a dotenv file across a save', async () => {
|
||||
const path = join(workDir, '.env');
|
||||
writeFileSync(path, original);
|
||||
chownSync(path, -1, otherGroup!);
|
||||
chmodSync(path, 0o640);
|
||||
const res = await putFile('.env', { content: updated, baseHash: sha256(original) });
|
||||
expect.soft(res.statusCode).toBe(200);
|
||||
expect.soft(readFileSync(path)).toEqual(Buffer.from(updated));
|
||||
expect.soft(statSync(path).mode & 0o777).toBe(0o640);
|
||||
expect(statSync(path).gid).toBe(otherGroup);
|
||||
});
|
||||
|
||||
it('rejects a stale dotenv baseHash with 409 and leaves the bytes unchanged', async () => {
|
||||
writeFileSync(join(workDir, '.env'), original);
|
||||
const res = await putFile('.env', { content: updated, baseHash: sha256('TEST_VALUE=older') });
|
||||
|
||||
@@ -25,6 +25,10 @@ const dotenvPaths: Array<[string, string]> = [
|
||||
['dotenv', '/srv/app/.env'],
|
||||
['local dotenv', '/srv/app/.env.local'],
|
||||
['suffixed dotenv', '/srv/app/.env.production'],
|
||||
// Deliberate: `.env.*` is blocked wholesale for downloads and attachments, so even a
|
||||
// committed `.env.example` is refused rather than risking the one repo whose "example"
|
||||
// holds a live key. Only the File Viewer editor lifts it (`allowDotenv`), and only for
|
||||
// a file the same user already reads in the preview.
|
||||
['a dotenv example', '/srv/app/.env.example'],
|
||||
['nested dotenv', '/srv/app/sub/dir/.env'],
|
||||
];
|
||||
@@ -151,6 +155,11 @@ describe('isSensitivePath', () => {
|
||||
'/home/u/.codeman-dev/.env',
|
||||
'/home/u/.codeman-beta/.env',
|
||||
'/srv/x/.codeman/.env',
|
||||
// The editor's name rule lowercases, and on a case-insensitive filesystem (the macOS
|
||||
// default) these two ARE the file above.
|
||||
'/home/u/.codeman/.ENV',
|
||||
'/home/u/.CODEMAN/.env',
|
||||
'/home/u/.Codeman-Dev/.Env',
|
||||
])('blocks %s', (path) => {
|
||||
expect(isSensitivePath(path, options)).toBe(true);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user