Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,12 @@ function readConfigFile() {
// Write the full multi-profile config structure
function saveConfigFile(data) {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce directory mode when config directory exists

Using mkdirSync(..., { recursive: true, mode: 0o700 }) does not correct permissions on an already-existing ~/.confluence-cli directory, so installs with older/broader directory modes (for example 0755) keep those permissions and are not fully hardened. Add an explicit chmod for the existing directory case.

Useful? React with 👍 / 👎.

} else {
fs.chmodSync(CONFIG_DIR, 0o700);
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Tighten permissions on existing config files

This call only enforces 0o600 when config.json is newly created; if the file already exists (the common upgrade path), writeFileSync preserves its current mode, so previously permissive files like 0644 remain readable by other users in multi-user environments. The fix needs an explicit chmod path for existing files after writing.

Useful? React with 👍 / 👎.

fs.chmodSync(CONFIG_FILE, 0o600);
}

// Helper function to validate CLI-provided options
Expand Down
82 changes: 76 additions & 6 deletions tests/profile.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ jest.mock('fs', () => {
readFileSync: jest.fn((...args) => actual.readFileSync(...args)),
writeFileSync: jest.fn((...args) => actual.writeFileSync(...args)),
mkdirSync: jest.fn((...args) => actual.mkdirSync(...args)),
chmodSync: jest.fn(),
};
});

Expand Down Expand Up @@ -84,13 +85,31 @@ function mockConfigFile(data) {
// Capture what was written to config file
function captureConfigWrite() {
let captured = null;
fs.writeFileSync.mockImplementation((filePath, content) => {
let capturedOptions = null;
let capturedMkdirOptions = null;
fs.writeFileSync.mockImplementation((filePath, content, options) => {
if (filePath === CONFIG_FILE) {
captured = JSON.parse(content);
capturedOptions = options;
}
});
fs.mkdirSync.mockImplementation(() => {});
return () => captured;
fs.mkdirSync.mockImplementation((_path, options) => {
capturedMkdirOptions = options;
});
return {
getWritten: () => captured,
getWriteOptions: () => capturedOptions,
getMkdirOptions: () => capturedMkdirOptions,
};
}

// Capture chmodSync calls
function captureChmod() {
const calls = [];
fs.chmodSync.mockImplementation((filePath, mode) => {
calls.push({ filePath, mode });
});
return { getCalls: () => calls };
}

describe('Profile management', () => {
Expand Down Expand Up @@ -261,7 +280,7 @@ describe('Profile management', () => {
describe('setActiveProfile', () => {
test('switches active profile', () => {
mockConfigFile(multiProfileConfig());
const getWritten = captureConfigWrite();
const { getWritten } = captureConfigWrite();

setActiveProfile('staging');

Expand All @@ -285,7 +304,7 @@ describe('Profile management', () => {
describe('deleteProfile', () => {
test('removes named profile', () => {
mockConfigFile(multiProfileConfig());
const getWritten = captureConfigWrite();
const { getWritten } = captureConfigWrite();

deleteProfile('staging');

Expand All @@ -296,7 +315,7 @@ describe('Profile management', () => {

test('switches active profile if deleted profile was active', () => {
mockConfigFile(multiProfileConfig());
const getWritten = captureConfigWrite();
const { getWritten } = captureConfigWrite();

deleteProfile('default');

Expand Down Expand Up @@ -332,4 +351,55 @@ describe('Profile management', () => {
expect(() => deleteProfile('default')).toThrow('No configuration file found');
});
});

describe('file permissions', () => {
test('saves config file with 0o600 permissions', () => {
mockConfigFile(multiProfileConfig());
const { getWriteOptions } = captureConfigWrite();

setActiveProfile('staging');

expect(getWriteOptions()).toEqual({ mode: 0o600 });
});

test('creates config directory with 0o700 permissions when it does not exist', () => {
const { CONFIG_DIR } = require('../lib/config');
mockConfigFile(multiProfileConfig());
// CONFIG_FILE exists (to allow reading), but CONFIG_DIR does not exist (to trigger mkdir)
fs.existsSync.mockImplementation((filePath) => filePath !== CONFIG_DIR);
const { getMkdirOptions } = captureConfigWrite();

setActiveProfile('staging');

expect(getMkdirOptions()).toMatchObject({ mode: 0o700 });
});

test('chmods existing config directory to 0o700', () => {
const { CONFIG_DIR } = require('../lib/config');
mockConfigFile(multiProfileConfig());
// Both CONFIG_DIR and CONFIG_FILE exist
fs.existsSync.mockImplementation(() => true);
captureConfigWrite();
const { getCalls } = captureChmod();

setActiveProfile('staging');

const dirChmod = getCalls().find(c => c.filePath === CONFIG_DIR);
expect(dirChmod).toBeDefined();
expect(dirChmod.mode).toBe(0o700);
});

test('chmods config file to 0o600 on every save', () => {
mockConfigFile(multiProfileConfig());
fs.existsSync.mockImplementation(() => true);
captureConfigWrite();
const { getCalls } = captureChmod();

setActiveProfile('staging');

const fileChmod = getCalls().find(c => c.filePath === CONFIG_FILE);
expect(fileChmod).toBeDefined();
expect(fileChmod.mode).toBe(0o600);
});
});
});
Loading