From e3dd3d2d48a54fd18c6e46b138797feedd0d2719 Mon Sep 17 00:00:00 2001 From: anupamme Date: Wed, 30 Sep 2026 01:30:18 +0000 Subject: [PATCH 1/2] fix: multi_agent.cwe-319 security vulnerability Automated security fix generated by OrbisAI Security --- lib/confluence-client.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/confluence-client.js b/lib/confluence-client.js index 86bacdf..e994116 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -113,7 +113,11 @@ class ConfluenceClient { constructor(config) { this.domain = config.domain; const rawProtocol = (config.protocol || 'https').trim().toLowerCase(); - this.protocol = (rawProtocol === 'http' || rawProtocol === 'https') ? rawProtocol : 'https'; + const allowInsecureHttp = config.allowInsecureHttp === true || process.env.CONFLUENCE_ALLOW_INSECURE_HTTP === 'true'; + if (rawProtocol === 'http' && !allowInsecureHttp) { + console.warn('Warning: HTTP protocol disabled by default to avoid sending credentials in plaintext. Falling back to HTTPS. Set allowInsecureHttp to override.'); + } + this.protocol = (rawProtocol === 'http' && allowInsecureHttp) ? 'http' : 'https'; this.token = config.token; this.email = config.email; this.cookie = config.cookie; From e33798ed67701e2b72373537bdb4bd17d4981b36 Mon Sep 17 00:00:00 2001 From: Anupam Mediratta Date: Thu, 1 Oct 2026 13:40:17 +0530 Subject: [PATCH 2/2] fix: propagate allowInsecureHttp through profile/CLI config path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CWE-319 fix in e3dd3d2 made ConfluenceClient require an explicit allowInsecureHttp opt-in for HTTP, but config.js never read or wrote that field, so profiles with protocol: "http" + allowInsecureHttp: true were silently upgraded to HTTPS — only the CONFLUENCE_ALLOW_INSECURE_HTTP env var worked. Wire it through saveConfig/getConfig/initConfig, add --allow-insecure-http to init/profile add, suppress the HTTPS-fallback warning in --json mode so it stops breaking structured-error tests, fix the two test suites that relied on unlabeled HTTP, and add regression coverage for the full default/opt-in/env-override matrix plus the profile -> getConfig -> ConfluenceClient path. Co-Authored-By: Claude Sonnet 5 --- README.md | 31 +++++++++++++++++++++- bin/commands/profile.js | 1 + bin/confluence.js | 1 + lib/config.js | 43 +++++++++++++++++++++++++++--- lib/confluence-client.js | 2 +- tests/api-origin-guard.test.js | 1 + tests/confluence-client.test.js | 47 +++++++++++++++++++++++++++++---- tests/profile.test.js | 39 ++++++++++++++++++++++++++- 8 files changed, 154 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 500b0ed..2c268c6 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,7 @@ confluence init --email "user@example.com" --token "your-api-token" - `--tls-ca-cert ` - Optional CA certificate chain for mTLS authentication - `--read-only` - Enable read-only mode (blocks all write operations) - `--keychain` - Store the token in the macOS Keychain instead of `config.json` (macOS only, see Option 5) +- `--allow-insecure-http` - Allow `--protocol http` to actually use HTTP for this profile (see [HTTP transport](#http-transport-allowinsecurehttp) below). Without it, `http` silently falls back to HTTPS. ⚠️ **Security note:** While flags work, storing tokens in shell history is risky. Prefer environment variables (Option 3) for production environments, or the macOS Keychain (Option 5) for a workstation. @@ -391,6 +392,34 @@ Or add `"forceCloud": true` to your profile in the config file (see [Config file } ``` +**HTTP transport (`allowInsecureHttp`):** + +HTTPS is used by default even if a profile or `CONFLUENCE_PROTOCOL` requests `http` — connecting over plain HTTP sends your credentials (basic auth header, bearer token, cookie) in plaintext, including through transparent corporate proxies. Setting `protocol: "http"` without an explicit opt-in prints a warning and silently falls back to HTTPS. + +If you genuinely need HTTP — for example a local reverse-authentication proxy on `127.0.0.1` or an internal network you trust — opt in explicitly via any of: + +```bash +# 1. CLI flag on init / profile add +confluence init --domain "127.0.0.1:8080" --protocol http --allow-insecure-http ... + +# 2. Environment variable (overrides the profile's allowInsecureHttp setting) +export CONFLUENCE_ALLOW_INSECURE_HTTP=true +``` + +Or set `"allowInsecureHttp": true` directly in the profile: + +```json +{ + "profiles": { + "local-proxy": { + "domain": "127.0.0.1:8080", + "protocol": "http", + "allowInsecureHttp": true + } + } +} +``` + **Link rendering (`linkStyle`):** External links use Cloud smart links (``) on Cloud and standard `` tags on Server/Data Center and local conversions. Some Cloud instances — particularly custom-domain Cloud setups — fail to render smart links and show "Cannot handle: DefaultLink" errors instead. If you hit this, set `linkStyle` to `plain`: @@ -1036,7 +1065,7 @@ confluence stats | `export ` | Export a page to a directory with its attachments | `--format `, `--dest `, `--file `, `--attachments-dir `, `--pattern `, `--exclude-attachments `, `--referenced-only`, `--skip-attachments`, `-r, --recursive`, `--max-depth `, `--exclude `, `--delay-ms `, `--dry-run`, `--overwrite` | | `profile list` | List all configuration profiles | | | `profile use ` | Set the active configuration profile | | -| `profile add ` | Add a new configuration profile | `-d, --domain`, `-p, --api-path`, `-a, --auth-type`, `-e, --email`, `-t, --token`, `--protocol`, `--read-only`, `--keychain` | +| `profile add ` | Add a new configuration profile | `-d, --domain`, `-p, --api-path`, `-a, --auth-type`, `-e, --email`, `-t, --token`, `--protocol`, `--allow-insecure-http`, `--read-only`, `--keychain` | | `profile remove ` | Remove a configuration profile | | | `api ` | Make an authenticated API request (relative path uses apiPath; absolute path bypasses it; full URL must be same-origin) | `-X, --method `, `-f, --field `, `-H, --header `, `--input `, `--jq `, `-i, --include`, `--silent` | | `convert` | Convert between content formats locally (no server required) | `--input-file `, `--output-file `, `--input-format `, `--output-format ` | diff --git a/bin/commands/profile.js b/bin/commands/profile.js index c99defd..282be7a 100644 --- a/bin/commands/profile.js +++ b/bin/commands/profile.js @@ -43,6 +43,7 @@ function registerProfileCommands(program, { withLocal }) { .description('Add a new configuration profile interactively') .option('-d, --domain ', 'Confluence domain') .option('--protocol ', 'Protocol (http or https)') + .option('--allow-insecure-http', 'Allow HTTP for this profile; credentials are sent in plaintext (not recommended)') .option('-p, --api-path ', 'REST API path') .option('-a, --auth-type ', 'Authentication type (basic, bearer, mtls, or cookie)') .option('-e, --email ', 'Email or username for basic auth') diff --git a/bin/confluence.js b/bin/confluence.js index 5f282c6..5a873a0 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -205,6 +205,7 @@ program .description('Initialize Confluence CLI configuration') .option('-d, --domain ', 'Confluence domain') .option('--protocol ', 'Protocol (http or https)') + .option('--allow-insecure-http', 'Allow HTTP for this profile; credentials are sent in plaintext (not recommended)') .option('-p, --api-path ', 'REST API path') .option('-a, --auth-type ', 'Authentication type (basic, bearer, mtls, cookie, or none)') .option('-e, --email ', 'Email or username for basic auth') diff --git a/lib/config.js b/lib/config.js index 7f16530..e92808a 100644 --- a/lib/config.js +++ b/lib/config.js @@ -440,6 +440,10 @@ const saveConfig = (configData, profileName) => { config.readOnly = true; } + if (configData.allowInsecureHttp) { + config.allowInsecureHttp = true; + } + // Read existing config file (or create new structure) const fileData = readConfigFile() || { activeProfile: DEFAULT_PROFILE, profiles: {} }; @@ -473,7 +477,7 @@ const saveConfig = (configData, profileName) => { }; // Helper function to prompt for missing values -const promptForMissingValues = async (providedValues, { storeTokenInKeychain = false } = {}) => { +const promptForMissingValues = async (providedValues, { storeTokenInKeychain = false, allowInsecureHttp = false } = {}) => { const questions = []; // Protocol question @@ -487,6 +491,18 @@ const promptForMissingValues = async (providedValues, { storeTokenInKeychain = f }); } + // HTTP opt-in question (only when protocol resolves to http and the CLI + // flag wasn't already passed) + if (!allowInsecureHttp) { + questions.push({ + type: 'confirm', + name: 'allowInsecureHttp', + message: 'HTTP sends credentials in plaintext. Allow HTTP for this profile?', + default: false, + when: (responses) => (providedValues.protocol || responses.protocol) === 'http' + }); + } + // Domain question if (!providedValues.domain) { questions.push({ @@ -654,6 +670,7 @@ async function initConfig(cliOptions = {}) { } const readOnly = cliOptions.readOnly || false; + const allowInsecureHttp = Boolean(cliOptions.allowInsecureHttp); // Extract provided values from CLI options. // Normalize authType up front so downstream case-insensitive checks @@ -695,6 +712,13 @@ async function initConfig(cliOptions = {}) { choices: PROTOCOL_CHOICES, default: 'https' }, + { + type: 'confirm', + name: 'allowInsecureHttp', + message: 'HTTP sends credentials in plaintext. Allow HTTP for this profile?', + default: false, + when: (responses) => responses.protocol === 'http' + }, { type: 'input', name: 'domain', @@ -853,6 +877,7 @@ async function initConfig(cliOptions = {}) { cookie: providedValues.cookie, mtls: providedValues.mtls, readOnly, + allowInsecureHttp, storeTokenInKeychain }; @@ -872,7 +897,7 @@ async function initConfig(cliOptions = {}) { } console.log('Completing configuration with interactive prompts:\n'); - const mergedValues = await promptForMissingValues(providedValues, { storeTokenInKeychain }); + const mergedValues = await promptForMissingValues(providedValues, { storeTokenInKeychain, allowInsecureHttp }); // Normalize auth type mergedValues.authType = normalizeAuthType(mergedValues.authType, Boolean(mergedValues.email)); @@ -890,7 +915,12 @@ async function initConfig(cliOptions = {}) { if (storeTokenInKeychain && !(mergedValues.token && mergedValues.token.trim())) { console.error(chalk.yellow('⚠ --keychain ignored: no token was provided for this profile.')); } - saveConfig({ ...mergedValues, readOnly, storeTokenInKeychain: useKeychain }, profileName); + saveConfig({ + ...mergedValues, + readOnly, + allowInsecureHttp: allowInsecureHttp || Boolean(mergedValues.allowInsecureHttp), + storeTokenInKeychain: useKeychain + }, profileName); } catch (error) { console.error(chalk.red(`❌ ${error.message}`)); process.exit(1); @@ -909,6 +939,7 @@ function getConfig(profileName, { throwOnError = false } = {}) { const envApiPath = process.env.CONFLUENCE_API_PATH; const envProtocol = process.env.CONFLUENCE_PROTOCOL; const envReadOnly = process.env.CONFLUENCE_READ_ONLY; + const envAllowInsecureHttp = process.env.CONFLUENCE_ALLOW_INSECURE_HTTP; const envForceCloud = process.env.CONFLUENCE_FORCE_CLOUD; const envLinkStyle = normalizeLinkStyle(process.env.CONFLUENCE_LINK_STYLE, 'from CONFLUENCE_LINK_STYLE'); const envPlantumlFormat = normalizePlantumlFormat(process.env.CONFLUENCE_PLANTUML_FORMAT, 'from CONFLUENCE_PLANTUML_FORMAT'); @@ -969,6 +1000,7 @@ function getConfig(profileName, { throwOnError = false } = {}) { mtls: envMtls, readOnly: envReadOnly === 'true', forceCloud: envForceCloud === 'true', + allowInsecureHttp: envAllowInsecureHttp === 'true', linkStyle: envLinkStyle, plantumlFormat: envPlantumlFormat }; @@ -1070,6 +1102,10 @@ function getConfig(profileName, { throwOnError = false } = {}) { ? envForceCloud === 'true' : Boolean(storedConfig.forceCloud); + const allowInsecureHttp = envAllowInsecureHttp !== undefined + ? envAllowInsecureHttp === 'true' + : Boolean(storedConfig.allowInsecureHttp); + const linkStyle = envLinkStyle ?? normalizeLinkStyle(storedConfig.linkStyle, `in profile "${targetProfile}"`); @@ -1087,6 +1123,7 @@ function getConfig(profileName, { throwOnError = false } = {}) { mtls, readOnly, forceCloud, + allowInsecureHttp, linkStyle, plantumlFormat }; diff --git a/lib/confluence-client.js b/lib/confluence-client.js index e994116..dfab508 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -114,7 +114,7 @@ class ConfluenceClient { this.domain = config.domain; const rawProtocol = (config.protocol || 'https').trim().toLowerCase(); const allowInsecureHttp = config.allowInsecureHttp === true || process.env.CONFLUENCE_ALLOW_INSECURE_HTTP === 'true'; - if (rawProtocol === 'http' && !allowInsecureHttp) { + if (rawProtocol === 'http' && !allowInsecureHttp && !isJsonMode()) { console.warn('Warning: HTTP protocol disabled by default to avoid sending credentials in plaintext. Falling back to HTTPS. Set allowInsecureHttp to override.'); } this.protocol = (rawProtocol === 'http' && allowInsecureHttp) ? 'http' : 'https'; diff --git a/tests/api-origin-guard.test.js b/tests/api-origin-guard.test.js index 5230589..cae5a79 100644 --- a/tests/api-origin-guard.test.js +++ b/tests/api-origin-guard.test.js @@ -44,6 +44,7 @@ describe('rawRequest cross-origin credential guard', () => { return new ConfluenceClient({ domain: `127.0.0.1:${configured.port}`, protocol: 'http', + allowInsecureHttp: true, apiPath: '/rest/api', authType: 'basic', email: 'victim@example.com', diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 3400f8e..492ef3c 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -57,11 +57,12 @@ describe('ConfluenceClient', () => { expect(client.baseURL).toMatch(/^https:\/\//); }); - test('uses http protocol when configured', () => { + test('uses http protocol when configured with allowInsecureHttp', () => { const httpClient = new ConfluenceClient({ domain: 'internal.example.com', token: 'token', - protocol: 'http' + protocol: 'http', + allowInsecureHttp: true }); expect(httpClient.protocol).toBe('http'); expect(httpClient.baseURL).toBe('http://internal.example.com/rest/api'); @@ -77,12 +78,46 @@ describe('ConfluenceClient', () => { expect(invalidClient.baseURL).toBe('https://example.com/rest/api'); }); - test('buildUrl uses configured protocol', () => { + test('falls back to https and warns when http is requested without an opt-in', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const httpClient = new ConfluenceClient({ domain: 'internal.example.com', token: 'token', protocol: 'http' }); + expect(httpClient.protocol).toBe('https'); + expect(httpClient.baseURL).toBe('https://internal.example.com/rest/api'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('allowInsecureHttp')); + warnSpy.mockRestore(); + }); + + test('uses http protocol when CONFLUENCE_ALLOW_INSECURE_HTTP env var is set', () => { + const original = process.env.CONFLUENCE_ALLOW_INSECURE_HTTP; + process.env.CONFLUENCE_ALLOW_INSECURE_HTTP = 'true'; + try { + const httpClient = new ConfluenceClient({ + domain: 'internal.example.com', + token: 'token', + protocol: 'http' + }); + expect(httpClient.protocol).toBe('http'); + expect(httpClient.baseURL).toBe('http://internal.example.com/rest/api'); + } finally { + if (original === undefined) { + delete process.env.CONFLUENCE_ALLOW_INSECURE_HTTP; + } else { + process.env.CONFLUENCE_ALLOW_INSECURE_HTTP = original; + } + } + }); + + test('buildUrl uses configured protocol', () => { + const httpClient = new ConfluenceClient({ + domain: 'internal.example.com', + token: 'token', + protocol: 'http', + allowInsecureHttp: true + }); expect(httpClient.buildUrl('/wiki/spaces/TEST')).toBe('http://internal.example.com/wiki/spaces/TEST'); }); @@ -94,7 +129,8 @@ describe('ConfluenceClient', () => { const httpClient = new ConfluenceClient({ domain: 'internal.example.com', token: 'token', - protocol: 'http' + protocol: 'http', + allowInsecureHttp: true }); expect(httpClient.toAbsoluteUrl('/download/file.pdf')).toBe('http://internal.example.com/download/file.pdf'); }); @@ -103,7 +139,8 @@ describe('ConfluenceClient', () => { const httpClient = new ConfluenceClient({ domain: 'internal.example.com', token: 'token', - protocol: 'http' + protocol: 'http', + allowInsecureHttp: true }); expect(httpClient.toAbsoluteUrl('https://cdn.example.com/file.pdf')).toBe('https://cdn.example.com/file.pdf'); }); diff --git a/tests/profile.test.js b/tests/profile.test.js index 825e20e..005ed09 100644 --- a/tests/profile.test.js +++ b/tests/profile.test.js @@ -21,6 +21,7 @@ const { isValidProfileName, CONFIG_FILE, } = require('../lib/config'); +const ConfluenceClient = require('../lib/confluence-client'); // Save and restore all relevant env vars around each test const ENV_KEYS = [ @@ -30,7 +31,8 @@ const ENV_KEYS = [ 'CONFLUENCE_AUTH_TYPE', 'CONFLUENCE_API_PATH', 'CONFLUENCE_PROTOCOL', 'CONFLUENCE_PROFILE', 'CONFLUENCE_TLS_CA_CERT', 'CONFLUENCE_TLS_CLIENT_CERT', - 'CONFLUENCE_TLS_CLIENT_KEY', 'CONFLUENCE_PLANTUML_FORMAT' + 'CONFLUENCE_TLS_CLIENT_KEY', 'CONFLUENCE_PLANTUML_FORMAT', + 'CONFLUENCE_ALLOW_INSECURE_HTTP' ]; // Helper to create a multi-profile config @@ -181,6 +183,41 @@ describe('Profile management', () => { expect(config.protocol).toBe('http'); }); + test('profile with protocol http and allowInsecureHttp true propagates to ConfluenceClient as http', () => { + const data = multiProfileConfig(); + data.profiles.staging.allowInsecureHttp = true; + mockConfigFile(data); + + const config = getConfig('staging'); + expect(config.protocol).toBe('http'); + expect(config.allowInsecureHttp).toBe(true); + + const client = new ConfluenceClient(config); + expect(client.protocol).toBe('http'); + }); + + test('profile with protocol http and no allowInsecureHttp falls back to https in ConfluenceClient', () => { + mockConfigFile(multiProfileConfig()); + + const config = getConfig('staging'); + expect(config.protocol).toBe('http'); + expect(config.allowInsecureHttp).toBe(false); + + const client = new ConfluenceClient(config); + expect(client.protocol).toBe('https'); + }); + + test('CONFLUENCE_ALLOW_INSECURE_HTTP overrides a profile with no allowInsecureHttp field', () => { + mockConfigFile(multiProfileConfig()); + process.env.CONFLUENCE_ALLOW_INSECURE_HTTP = 'true'; + + const config = getConfig('staging'); + expect(config.allowInsecureHttp).toBe(true); + + const client = new ConfluenceClient(config); + expect(client.protocol).toBe('http'); + }); + test('reads plantumlFormat from the profile', () => { const config = multiProfileConfig(); config.profiles.default.plantumlFormat = 'plantumlcloud';