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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -818,7 +818,7 @@ confluence stats
| `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` |
| `profile remove <name>` | Remove a configuration profile | |
| `api <endpoint>` | Make an authenticated API request (relative path uses apiPath; absolute path bypasses it) | `-X, --method <method>`, `-f, --field <key=value>`, `-H, --header <key:value>`, `--input <file>`, `--jq <expression>`, `-i, --include`, `--silent` |
| `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>` |
| `stats` | View your usage statistics | |

Expand Down
4 changes: 3 additions & 1 deletion bin/commands/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
7 changes: 7 additions & 0 deletions lib/confluence-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions plugins/confluence/skills/confluence/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,29 @@ confluence profile remove staging

---

### `api <endpoint>`

Make an authenticated request to a Confluence REST endpoint that the CLI does not wrap directly.

```sh
confluence api <endpoint> [-X <method>] [-f <key=value>] [-H <key:value>] [--input <file>] [--jq <expression>] [-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.
Expand Down
88 changes: 88 additions & 0 deletions tests/api-origin-guard.test.js
Original file line number Diff line number Diff line change
@@ -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 /);
});
});
21 changes: 18 additions & 3 deletions tests/confluence-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down