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
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ confluence init --email "user@example.com" --token "your-api-token"
- `--tls-ca-cert <path>` - 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.

Expand Down Expand Up @@ -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 (`<a data-card-appearance="inline">`) on Cloud and standard `<a href>` 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`:
Expand Down Expand Up @@ -1036,7 +1065,7 @@ confluence stats
| `export <pageId_or_url>` | Export a page to a directory with its attachments | `--format <html\|text\|markdown>`, `--dest <directory>`, `--file <filename>`, `--attachments-dir <name>`, `--pattern <glob>`, `--exclude-attachments <patterns>`, `--referenced-only`, `--skip-attachments`, `-r, --recursive`, `--max-depth <depth>`, `--exclude <patterns>`, `--delay-ms <ms>`, `--dry-run`, `--overwrite` |
| `profile list` | List all configuration profiles | |
| `profile use <name>` | Set the active configuration profile | |
| `profile add <name>` | Add a new configuration profile | `-d, --domain`, `-p, --api-path`, `-a, --auth-type`, `-e, --email`, `-t, --token`, `--protocol`, `--read-only`, `--keychain` |
| `profile add <name>` | 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 <name>` | Remove a configuration profile | |
| `api <endpoint>` | Make an authenticated API request (relative path uses apiPath; absolute path bypasses it; full URL must be same-origin) | `-X, --method <method>`, `-f, --field <key=value>`, `-H, --header <key:value>`, `--input <file>`, `--jq <expression>`, `-i, --include`, `--silent` |
| `convert` | Convert between content formats locally (no server required) | `--input-file <path>`, `--output-file <path>`, `--input-format <markdown\|storage\|html>`, `--output-format <markdown\|storage\|html\|text>` |
Expand Down
1 change: 1 addition & 0 deletions bin/commands/profile.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function registerProfileCommands(program, { withLocal }) {
.description('Add a new configuration profile interactively')
.option('-d, --domain <domain>', 'Confluence domain')
.option('--protocol <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 <path>', 'REST API path')
.option('-a, --auth-type <type>', 'Authentication type (basic, bearer, mtls, or cookie)')
.option('-e, --email <email>', 'Email or username for basic auth')
Expand Down
1 change: 1 addition & 0 deletions bin/confluence.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ program
.description('Initialize Confluence CLI configuration')
.option('-d, --domain <domain>', 'Confluence domain')
.option('--protocol <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 <path>', 'REST API path')
.option('-a, --auth-type <type>', 'Authentication type (basic, bearer, mtls, cookie, or none)')
.option('-e, --email <email>', 'Email or username for basic auth')
Expand Down
43 changes: 40 additions & 3 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} };

Expand Down Expand Up @@ -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
Expand All @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -853,6 +877,7 @@ async function initConfig(cliOptions = {}) {
cookie: providedValues.cookie,
mtls: providedValues.mtls,
readOnly,
allowInsecureHttp,
storeTokenInKeychain
};

Expand All @@ -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));
Expand All @@ -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);
Expand All @@ -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');
Expand Down Expand Up @@ -969,6 +1000,7 @@ function getConfig(profileName, { throwOnError = false } = {}) {
mtls: envMtls,
readOnly: envReadOnly === 'true',
forceCloud: envForceCloud === 'true',
allowInsecureHttp: envAllowInsecureHttp === 'true',
linkStyle: envLinkStyle,
plantumlFormat: envPlantumlFormat
};
Expand Down Expand Up @@ -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}"`);

Expand All @@ -1087,6 +1123,7 @@ function getConfig(profileName, { throwOnError = false } = {}) {
mtls,
readOnly,
forceCloud,
allowInsecureHttp,
linkStyle,
plantumlFormat
};
Expand Down
6 changes: 5 additions & 1 deletion lib/confluence-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 && !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';
this.token = config.token;
this.email = config.email;
this.cookie = config.cookie;
Expand Down
1 change: 1 addition & 0 deletions tests/api-origin-guard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
47 changes: 42 additions & 5 deletions tests/confluence-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
});

Expand All @@ -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');
});
Expand All @@ -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');
});
Expand Down
39 changes: 38 additions & 1 deletion tests/profile.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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
Expand Down Expand Up @@ -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';
Expand Down