diff --git a/README.md b/README.md index 169b849..f402221 100644 --- a/README.md +++ b/README.md @@ -735,7 +735,7 @@ Make arbitrary authenticated requests to any Confluence REST endpoint, modeled a - **Relative path** (no leading slash) → resolved against the configured `apiPath` (the default for most calls). - **Absolute path** (leading `/`) → bypasses `apiPath`; resolved against the host. On Confluence Cloud, `apiPath` is typically `/wiki/rest/api`, so absolute endpoints must include the `/wiki` prefix. -- **Full URL** (`https://…`) → used as-is. +- **Full URL** (`https://…`) → used as-is, but only when it is **same-origin** with the configured host. A full URL pointing at a different origin (or an `http://` downgrade of an `https` host) is refused, so your credentials are never sent to an unexpected server. ```bash # List labels on a page (relative — uses apiPath) @@ -818,7 +818,7 @@ confluence stats | `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` | | `profile remove ` | Remove a configuration profile | | -| `api ` | Make an authenticated API request (relative path uses apiPath; absolute path bypasses it) | `-X, --method `, `-f, --field `, `-H, --header `, `--input `, `--jq `, `-i, --include`, `--silent` | +| `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 ` | | `stats` | View your usage statistics | | diff --git a/bin/commands/api.js b/bin/commands/api.js index 5a779ef..8a4bd22 100644 --- a/bin/commands/api.js +++ b/bin/commands/api.js @@ -29,7 +29,9 @@ Endpoint resolution: - Absolute path (leading "/") → bypasses apiPath; resolved against the host. On Confluence Cloud, apiPath is typically /wiki/rest/api, so absolute endpoints must include the /wiki prefix (e.g. /wiki/rest/api/content/123). - - Full URL (https://...) → used as-is. + - Full URL (https://...) → used as-is, but only when same-origin + with the configured host. A cross-origin URL (or an http:// downgrade of an + https host) is refused so credentials are not leaked to another server. `) .action(async (endpoint, options) => { const analytics = new Analytics(); diff --git a/lib/confluence-client.js b/lib/confluence-client.js index e0abb00..1efaac8 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -2105,6 +2105,13 @@ class ConfluenceClient { let url; if (/^https?:\/\//i.test(endpoint)) { url = endpoint; + // A full URL is sent through the authenticated client, whose default + // headers carry the Authorization (and Cookie) credentials. Refuse to + // send them to anything but the configured origin so a crafted endpoint + // (or an http:// downgrade) can't exfiltrate the token. Mirrors the + // download-path guard. Relative/absolute-path endpoints resolve against + // the configured host below and are unaffected. + this.assertSameOrigin(url); } else if (endpoint.startsWith('/')) { url = `${this.protocol}://${this.domain}${endpoint}`; } else { diff --git a/plugins/confluence/skills/confluence/SKILL.md b/plugins/confluence/skills/confluence/SKILL.md index 72a543f..9d10400 100644 --- a/plugins/confluence/skills/confluence/SKILL.md +++ b/plugins/confluence/skills/confluence/SKILL.md @@ -627,6 +627,29 @@ confluence profile remove staging --- +### `api ` + +Make an authenticated request to a Confluence REST endpoint that the CLI does not wrap directly. + +```sh +confluence api [-X ] [-f ] [-H ] [--input ] [--jq ] [-i] [--silent] +``` + +Endpoint resolution: +- Relative paths use the configured `apiPath`. +- Absolute paths starting with `/` bypass `apiPath` and resolve against the configured host. +- Full `http://` or `https://` URLs are allowed only when they are same-origin with the configured host; cross-origin URLs and `http://` downgrades from an `https` profile are refused before credentials are sent. + +```sh +confluence api content/123456789/label +confluence api /wiki/api/v2/pages -f spaceKey=DEV -f limit=10 -X GET +confluence api content/123456789/label --jq '.results[].name' +``` + +Read-only profiles block write methods (`POST`, `PUT`, `PATCH`, `DELETE`) while allowing `GET` and `HEAD`. + +--- + ### `stats` Show local usage statistics. diff --git a/tests/api-origin-guard.test.js b/tests/api-origin-guard.test.js new file mode 100644 index 0000000..5230589 --- /dev/null +++ b/tests/api-origin-guard.test.js @@ -0,0 +1,88 @@ +const http = require('http'); +const ConfluenceClient = require('../lib/confluence-client'); + +// Spin up a throwaway HTTP server that records every request it receives, +// so we can assert whether credentials reached a given origin. +function startServer() { + const received = []; + const server = http.createServer((req, res) => { + received.push({ + url: req.url, + authorization: req.headers['authorization'] || null, + cookie: req.headers['cookie'] || null, + }); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end('{}'); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve({ server, port: server.address().port, received }); + }); + }); +} + +function closeServer(server) { + return new Promise((resolve) => server.close(resolve)); +} + +describe('rawRequest cross-origin credential guard', () => { + let configured; + let attacker; + + beforeEach(async () => { + configured = await startServer(); + attacker = await startServer(); + }); + + afterEach(async () => { + await closeServer(configured.server); + await closeServer(attacker.server); + }); + + function makeClient(overrides = {}) { + return new ConfluenceClient({ + domain: `127.0.0.1:${configured.port}`, + protocol: 'http', + apiPath: '/rest/api', + authType: 'basic', + email: 'victim@example.com', + token: 'SUPER_SECRET_TOKEN', + ...overrides, + }); + } + + test('allows a same-origin absolute URL and sends the auth header', async () => { + const client = makeClient(); + const res = await client.rawRequest('GET', `http://127.0.0.1:${configured.port}/rest/api/content/1`); + expect(res.status).toBe(200); + expect(configured.received).toHaveLength(1); + expect(configured.received[0].authorization).toMatch(/^Basic /); + }); + + test('refuses a cross-origin absolute URL and does NOT leak the auth header', async () => { + const client = makeClient(); + await expect( + client.rawRequest('GET', `http://127.0.0.1:${attacker.port}/exfil`) + ).rejects.toThrow(/does not match the configured Confluence origin/); + // The foreign host must have received nothing — the token was not leaked. + expect(attacker.received).toHaveLength(0); + }); + + test('refuses an http downgrade against an https-configured client', async () => { + const client = makeClient({ protocol: 'https' }); + await expect( + client.rawRequest('GET', `http://127.0.0.1:${configured.port}/rest/api/content/1`) + ).rejects.toThrow(/does not match the configured Confluence origin/); + // No request reached the server over the downgraded scheme. + expect(configured.received).toHaveLength(0); + }); + + test('still resolves relative endpoints against the configured host', async () => { + const client = makeClient(); + const res = await client.rawRequest('GET', 'content/1'); + expect(res.status).toBe(200); + expect(configured.received).toHaveLength(1); + expect(configured.received[0].authorization).toMatch(/^Basic /); + }); +}); diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 5df7844..e9ea55b 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -2742,17 +2742,32 @@ describe('ConfluenceClient', () => { mock.restore(); }); - test('full URL used as-is', async () => { + test('same-origin full URL used as-is', async () => { const mock = new MockAdapter(client.client); - mock.onGet('https://other.example.com/api/data').reply(200, { ok: true }); + mock.onGet('https://test.atlassian.net/wiki/api/v2/pages').reply(200, { ok: true }); - const result = await client.rawRequest('GET', 'https://other.example.com/api/data'); + const result = await client.rawRequest('GET', 'https://test.atlassian.net/wiki/api/v2/pages'); expect(result.status).toBe(200); expect(result.data).toEqual({ ok: true }); mock.restore(); }); + test('refuses a cross-origin full URL so credentials are not leaked', async () => { + const mock = new MockAdapter(client.client); + // If the guard ever regresses, this mock would let the (now-leaked) + // request succeed; the rejection assertion below is what protects us. + mock.onGet('https://other.example.com/api/data').reply(200, { ok: true }); + + await expect( + client.rawRequest('GET', 'https://other.example.com/api/data') + ).rejects.toThrow(/does not match the configured Confluence origin/); + + expect(mock.history.get).toHaveLength(0); + + mock.restore(); + }); + test('custom headers sent alongside auth headers', async () => { const mock = new MockAdapter(client.client); mock.onGet('/content').reply(config => {