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
17 changes: 6 additions & 11 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ import profileRouter from './routes/profile.router';
import toolsRouter from './routes/tools.router';
import trackRouter from './routes/track.router';
import webhookRouter from './routes/webhook.router';
import { HttpError, normalizeError } from './utils/errors';
import {
buildErrorRequestContext,
HttpError,
normalizeError,
} from './utils/errors';
import { logger } from './utils/logger';

declare module 'fastify' {
Expand Down Expand Up @@ -412,16 +416,7 @@ export async function buildApp(
// log as warn so they don't drown out real server errors.
const label =
error instanceof HttpError ? 'internal server error' : 'request error';
const reqCtx = {
id: request.id,
url: request.url,
method: request.method,
query: request.query,
headers: request.headers,
body:
(request as FastifyRequest & { rawBody?: string }).rawBody ??
request.body,
};
const reqCtx = buildErrorRequestContext(request);
if (status >= 500) {
request.log.error({ err: error, req: reqCtx }, label);
} else {
Expand Down
62 changes: 62 additions & 0 deletions apps/api/src/hooks/request-logging.hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Tests for requestLoggingHook.
*
* The behaviour guarded here: the logged payload never carries a query-string
* credential. The tRPC branch drops the query entirely; every other request
* goes through sanitizeUrl.
*/

import type { FastifyReply, FastifyRequest } from 'fastify';
import { describe, expect, it, vi } from 'vitest';
import { requestLoggingHook } from './request-logging.hook';

const SECRET = 'c3VwZXItc2VjcmV0LXRva2Vu';

function makeReq(url: string, overrides: Partial<FastifyRequest> = {}) {
const info = vi.fn();
const request = {
url,
method: 'GET',
headers: {},
log: { info },
...overrides,
} as unknown as FastifyRequest;
return { request, info };
}

const reply = { elapsedTime: 12 } as unknown as FastifyReply;

describe('requestLoggingHook', () => {
it('does not log the value of a sensitive query parameter', async () => {
const { request, info } = makeReq(`/mcp?token=${SECRET}&projectId=p1`);

await requestLoggingHook(request, reply);

expect(info).toHaveBeenCalledTimes(1);
const payload = info.mock.calls[0]?.[0];
expect(JSON.stringify(payload)).not.toContain(SECRET);
expect(payload.url).toBe('/mcp?token=[REDACTED]&projectId=p1');
});

it('logs the bare path for tRPC requests', async () => {
const { request, info } = makeReq(`/trpc/report.get?token=${SECRET}`);

await requestLoggingHook(request, reply);

const payload = info.mock.calls[0]?.[0];
expect(payload.url).toBe('/trpc/report.get');
expect(JSON.stringify(payload)).not.toContain(SECRET);
});

it('still recognises /track by path when the query is filtered', async () => {
const { request, info } = makeReq(`/track?token=${SECRET}`, {
body: { type: 'track' },
} as Partial<FastifyRequest>);

await requestLoggingHook(request, reply);

const payload = info.mock.calls[0]?.[0];
expect(payload.body).toEqual({ type: 'track' });
expect(payload.url).toBe('/track?token=[REDACTED]');
});
});
3 changes: 2 additions & 1 deletion apps/api/src/hooks/request-logging.hook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { path, pick } from 'ramda';
import { sanitizeUrl } from '../utils/sanitize-url';

const ignoreLog = ['/healthcheck', '/healthz', '/metrics', '/misc'];
const ignoreMethods = ['OPTIONS'];
Expand Down Expand Up @@ -46,7 +47,7 @@ export async function requestLoggingHook(
clientIpHeader: string;
userAgent: string;
} = {
url: request.url,
url: sanitizeUrl(request.url),
method: request.method,
elapsed: reply.elapsedTime,
headers: pick(
Expand Down
39 changes: 39 additions & 0 deletions apps/api/src/utils/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Tests for buildErrorRequestContext — the request context the error handler
* attaches to error logs.
*/

import type { FastifyRequest } from 'fastify';
import { describe, expect, it } from 'vitest';
import { buildErrorRequestContext } from './errors';

const SECRET = 'c3VwZXItc2VjcmV0LXRva2Vu';

describe('buildErrorRequestContext', () => {
it('does not carry the value of a sensitive query parameter in the url', () => {
const ctx = buildErrorRequestContext({
id: 'req-1',
url: `/mcp?token=${SECRET}&projectId=p1`,
method: 'POST',
query: {},
headers: {},
body: undefined,
} as unknown as FastifyRequest);

expect(ctx.url).toBe('/mcp?token=[REDACTED]&projectId=p1');
});

it('prefers the raw body when fastify captured one', () => {
const ctx = buildErrorRequestContext({
id: 'req-2',
url: '/track',
method: 'POST',
query: {},
headers: {},
rawBody: '{"type":"track"}',
body: { type: 'track' },
} as unknown as FastifyRequest);

expect(ctx.body).toBe('{"type":"track"}');
});
});
21 changes: 21 additions & 0 deletions apps/api/src/utils/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { FastifyRequest } from 'fastify';
import { sanitizeUrl } from './sanitize-url';

export class LogError extends Error {
public readonly payload?: Record<string, unknown>;

Expand Down Expand Up @@ -90,3 +93,21 @@ export function normalizeError(error: unknown): NormalizedError {
errorName: 'Error',
};
}

/**
* The request context attached to error logs. `query` and `headers` are
* objects, so the logger redacts sensitive entries by key; the URL is a
* string and has to be filtered here.
*/
export function buildErrorRequestContext(request: FastifyRequest) {
return {
id: request.id,
url: sanitizeUrl(request.url),
method: request.method,
query: request.query,
headers: request.headers,
body:
(request as FastifyRequest & { rawBody?: string }).rawBody ??
request.body,
};
}
41 changes: 41 additions & 0 deletions apps/api/src/utils/rate-limiter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Tests for the rate limiter's onExceeded log line — it records the request
* URL, which on some routes carries a credential in the query string.
*/

import { describe, expect, it, vi } from 'vitest';

vi.mock('@openpanel/redis', () => ({ getRedisCache: vi.fn() }));

const { activateRateLimiter } = await import('./rate-limiter');

const SECRET = 'c3VwZXItc2VjcmV0LXRva2Vu';

async function captureOptions() {
const register = vi.fn();
await activateRateLimiter({
fastify: { register } as never,
max: 10,
});
return register.mock.calls[0]?.[1] as {
onExceeded: (req: unknown) => void;
};
}

describe('activateRateLimiter', () => {
it('does not log the value of a sensitive query parameter', async () => {
const options = await captureOptions();
const warn = vi.fn();

options.onExceeded({
headers: { 'openpanel-client-id': 'client-1' },
socket: { remoteAddress: '127.0.0.1' },
url: `/mcp?token=${SECRET}&projectId=p1`,
log: { warn },
});

const payload = warn.mock.calls[0]?.[0];
expect(JSON.stringify(payload)).not.toContain(SECRET);
expect(payload.url).toBe('/mcp?token=[REDACTED]&projectId=p1');
});
});
3 changes: 2 additions & 1 deletion apps/api/src/utils/rate-limiter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getTrustedIpFromHeaders } from '@openpanel/common/server/get-client-ip';
import { getRedisCache } from '@openpanel/redis';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { sanitizeUrl } from './sanitize-url';

export async function activateRateLimiter<T extends FastifyRequest>({
fastify,
Expand Down Expand Up @@ -49,7 +50,7 @@ export async function activateRateLimiter<T extends FastifyRequest>({
clientId: req.headers['openpanel-client-id'],
ip,
ipHeader: header,
url: req.url,
url: sanitizeUrl(req.url),
userAgent: req.headers['user-agent'],
},
'rate limit exceeded',
Expand Down
64 changes: 64 additions & 0 deletions apps/api/src/utils/sanitize-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Tests for sanitizeUrl — the helper used wherever a raw request URL is
* logged. Query strings on some routes carry credentials, and the logger only
* redacts by object key, so the value has to be filtered before it is logged.
*/

import { describe, expect, it } from 'vitest';
import { sanitizeUrl } from './sanitize-url';

describe('sanitizeUrl', () => {
it('replaces a token value and keeps the path', () => {
expect(sanitizeUrl('/mcp?token=abc123')).toBe('/mcp?token=[REDACTED]');
});

it('keeps parameters that are not sensitive', () => {
expect(sanitizeUrl('/mcp?token=abc123&projectId=proj-1')).toBe(
'/mcp?token=[REDACTED]&projectId=proj-1'
);
});

it('matches parameter names case-insensitively', () => {
expect(sanitizeUrl('/mcp?TOKEN=abc&Token=def&accessToken=ghi')).toBe(
'/mcp?TOKEN=[REDACTED]&Token=[REDACTED]&accessToken=[REDACTED]'
);
});

it('replaces every occurrence of a repeated parameter', () => {
expect(sanitizeUrl('/mcp?token=abc&token=def')).toBe(
'/mcp?token=[REDACTED]&token=[REDACTED]'
);
});

it('replaces several sensitive parameters in one URL', () => {
expect(sanitizeUrl('/x?token=abc&client_secret=shh&apikey=k&page=2')).toBe(
'/x?token=[REDACTED]&client_secret=[REDACTED]&apikey=[REDACTED]&page=2'
);
});

it('leaves a URL without a query string untouched', () => {
expect(sanitizeUrl('/mcp')).toBe('/mcp');
});

it('leaves an empty query string untouched', () => {
expect(sanitizeUrl('/mcp?')).toBe('/mcp?');
});

it('does not throw on a malformed query string', () => {
expect(sanitizeUrl('/x?%zz=1&&=&token')).toBe(
'/x?%zz=1&&=&token=[REDACTED]'
);
});

it('keeps the encoding of values it does not touch', () => {
expect(sanitizeUrl('/x?path=%2Fhome%3Fa%3Db&token=abc')).toBe(
'/x?path=%2Fhome%3Fa%3Db&token=[REDACTED]'
);
});

it('works on absolute URLs too', () => {
expect(sanitizeUrl('https://api.openpanel.dev/mcp?token=abc')).toBe(
'https://api.openpanel.dev/mcp?token=[REDACTED]'
);
});
});
10 changes: 10 additions & 0 deletions apps/api/src/utils/sanitize-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Request URLs are logged as plain strings, so the logger's key-based
* redaction never looks inside them. Some routes take credentials in the
* query string (the MCP endpoint accepts `?token=`), which would otherwise
* land verbatim in request logs.
*
* Re-exported from the logger package so the sensitive-parameter list has a
* single definition.
*/
export { sanitizeUrlQuery as sanitizeUrl } from '@openpanel/logger';
29 changes: 22 additions & 7 deletions apps/public/content/docs/mcp/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,20 @@ https://api.openpanel.dev/mcp

## Authentication

The token can be passed as a query parameter or an `Authorization` header — both are equivalent:
Pass the token as an `Authorization` header:

```
https://api.openpanel.dev/mcp?token=YOUR_TOKEN
Authorization: Bearer YOUR_TOKEN
```

If your client can't set headers, pass it as a query parameter instead:

```
Authorization: Bearer YOUR_TOKEN
https://api.openpanel.dev/mcp?token=YOUR_TOKEN
```

Both work. Prefer the header: query strings travel through browser history, shell history and proxy logs, so a token in the URL is easier to leak by accident.

### Token format

The token is a **base64-encoded** string of your client ID and client secret joined by a colon:
Expand All @@ -44,7 +48,13 @@ The easiest way to get your MCP token is directly from the dashboard — no term
echo -n "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" | base64
```

Then append it to the MCP URL:
Then send it with every request:

```
Authorization: Bearer <base64-encoded-token>
```

Or, for clients that can't set headers, append it to the MCP URL:

```
https://api.openpanel.dev/mcp?token=<base64-encoded-token>
Expand Down Expand Up @@ -72,22 +82,27 @@ Add the following to your `claude_desktop_config.json`:
"mcpServers": {
"openpanel": {
"type": "streamable-http",
"url": "https://api.openpanel.dev/mcp?token=YOUR_TOKEN"
"url": "https://api.openpanel.dev/mcp",
"headers": {
"Authorization": "Bearer YOUR_TOKEN"
}
}
}
}
```

If your client doesn't read `headers`, drop that block and put the token in the URL instead: `"url": "https://api.openpanel.dev/mcp?token=YOUR_TOKEN"`.

## Connecting with Claude Code CLI

With the Claude CLI you can use the `--header` flag to pass the token via `Authorization: Bearer` rather than embedding it in the URL:
Use the `--header` flag to pass the token via `Authorization: Bearer`:

```bash
claude mcp add --transport http openpanel https://api.openpanel.dev/mcp \
--header "Authorization: Bearer YOUR_TOKEN"
```

Or with the token in the URL (equivalent):
Or with the token in the URL:

```bash
claude mcp add --transport http openpanel "https://api.openpanel.dev/mcp?token=YOUR_TOKEN"
Expand Down
Loading
Loading