fix(security): reject ?token= URL query parameter for authentication (#20154)
## Summary
Removes the `?token=` URL query-parameter fallback from JWT
authentication. Every authenticated route (`/graphql`, `/metadata`,
REST, etc.) used to accept a full workspace JWT in the URL alongside the
`Authorization` header. The fallback was intended for the REST API
Playground only, but it was wired into the global Passport JWT extractor
and applied to every route.
URL-borne tokens leak into:
- Server access logs (nginx / Apache / CDN / proxy / load balancer)
- Log aggregators (Datadog, CloudWatch, Loki, Sumo, …)
- Browser history (and synced across devices)
- `Referer` headers when navigating to external pages
- Browser extensions with `tabs`/`webNavigation` permissions
A leaked log line was equivalent to a leaked workspace credential for
the lifetime of the token.
## What changed
- **`jwt-wrapper.service.ts`** — `extractJwtFromRequest()` is now
header-only (`ExtractJwt.fromAuthHeaderAsBearerToken()`). No URL
fallback anywhere in the system.
- **`open-api.service.ts` / `base-schema.utils.ts`** — Dropped the
`token?: string` plumbing that propagated the URL token into the schema
description. The "Authentication" section gains a "Never put your token
in a URL" warning. The "Usage with LLMs" section is rewritten to point
at the **Twenty MCP server** (header-authenticated, exposes typed tools
— the right tool for AI agents) instead of telling users to paste
tokenized OpenAPI URLs into Cursor/ChatGPT.
- **`RestPlayground.tsx`** — Now fetches the OpenAPI schema with
`Authorization: Bearer ${playgroundApiKey}` and passes the JSON document
to Scalar via `spec.content` instead of constructing a URL with
`?token=`. Aborts in-flight fetches on unmount/key change.
- **New integration test** — Asserts `?token=` is rejected on `/rest/*`,
`/graphql`, `/metadata`, and that `/rest/open-api/core?token=` returns
the unauthenticated base schema (no workspace object paths).
## Why not keep `?token=` scoped to the OpenAPI endpoint only
The first instinct was to narrow the fallback to just
`/rest/open-api/*`, since that endpoint is what the Scalar playground
component fetches. But the same log-leakage attack still applies to that
endpoint — the workspace JWT would still sit in access logs, just from
one URL pattern instead of all of them. The cleaner long-term fix is to
remove the URL pattern entirely and let the playground fetch with a
header (Scalar supports `spec.content` natively). For LLM agent use, the
MCP server is a strictly better path — typed tools, OAuth or
header-based API key auth, no tokens in URLs anywhere.
## Not affected
File downloads at `file-url.service.ts` also use `?token=` URLs but with
separate, short-lived `FILE`-typed tokens validated by
`file-by-id.guard.ts` directly (not via `extractJwtFromRequest`). That
mechanism is scoped per-file with limited TTL and is acceptable.
## Action required for users
Anyone who previously pasted `?token=` URLs into LLM tools, scripts,
bookmarks, or shared configs should rotate their workspace API keys.
Those tokens are likely captured in server logs / chat histories
somewhere.
## Test plan
- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx nx typecheck twenty-front` — clean
- [x] `npx nx lint:diff-with-main twenty-server` — clean
- [x] `npx nx lint:diff-with-main twenty-front` — clean
- [x] OpenAPI utils unit tests + snapshots — 11/11 pass
- [ ] Run the new integration test against a live server: `nx run
twenty-server:test:integration:with-db-reset` and verify
`url-token-auth-rejection.integration-spec.ts` passes
- [ ] Manually open Settings → Playground → REST, confirm the schema
loads (now via Bearer header instead of `?token=` URL)
- [ ] Manually verify `POST /metadata?token=<jwt>` (no Authorization
header) returns Forbidden, and the same request with the token in the
header returns the user
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,6 @@ import {
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { type Request as ExpressRequest } from 'express';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { ExtractJwt, type JwtFromRequestFunction } from 'passport-jwt';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -131,18 +130,6 @@ export class JwtWrapperService {
|
||||
}
|
||||
|
||||
extractJwtFromRequest(): JwtFromRequestFunction {
|
||||
return (request: ExpressRequest) => {
|
||||
// First try to extract token from Authorization header
|
||||
const tokenFromHeader = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
||||
|
||||
if (tokenFromHeader) {
|
||||
return tokenFromHeader;
|
||||
}
|
||||
|
||||
// If not found in header, try to extract from URL query parameter
|
||||
// This is for edge cases where we don't control the origin request
|
||||
// (e.g. the REST API playground)
|
||||
return ExtractJwt.fromUrlQueryParameter('token')(request);
|
||||
};
|
||||
return ExtractJwt.fromAuthHeaderAsBearerToken();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,12 +100,7 @@ export class OpenApiService {
|
||||
serverUrlFallback: `${request.protocol}://${request.get('host')}`,
|
||||
});
|
||||
|
||||
const tokenFromQuery = request.query.token;
|
||||
const schema = baseSchema(
|
||||
'core',
|
||||
baseUrl,
|
||||
typeof tokenFromQuery === 'string' ? tokenFromQuery : undefined,
|
||||
);
|
||||
const schema = baseSchema('core', baseUrl);
|
||||
|
||||
const workspace = await this.getWorkspaceFromRequest(request);
|
||||
|
||||
@@ -263,12 +258,7 @@ export class OpenApiService {
|
||||
serverUrlFallback: `${request.protocol}://${request.get('host')}`,
|
||||
});
|
||||
|
||||
const tokenFromQuery = request.query.token;
|
||||
const schema = baseSchema(
|
||||
'metadata',
|
||||
baseUrl,
|
||||
typeof tokenFromQuery === 'string' ? tokenFromQuery : undefined,
|
||||
);
|
||||
const schema = baseSchema('metadata', baseUrl);
|
||||
|
||||
const workspace = await this.getWorkspaceFromRequest(request);
|
||||
|
||||
|
||||
+17
-13
@@ -7,7 +7,6 @@ export const API_Version = 'v0.1';
|
||||
export const baseSchema = (
|
||||
schemaName: 'core' | 'metadata',
|
||||
serverUrl: string,
|
||||
token?: string,
|
||||
): OpenAPIV3_1.Document => {
|
||||
return {
|
||||
openapi: '3.1.1',
|
||||
@@ -31,6 +30,10 @@ curl -H 'Authorization: Bearer <token>' <server>/rest/core/companies
|
||||
|
||||
Tokens can be generated in Settings → Playground and are workspace-scoped.
|
||||
|
||||
> **Never put your token in a URL.** Tokens passed as query parameters end up in
|
||||
> server access logs, browser history, and \`Referer\` headers, which is why the
|
||||
> API only accepts tokens from the \`Authorization\` header.
|
||||
|
||||
|
||||
## Filters
|
||||
|
||||
@@ -101,24 +104,25 @@ order_by=id[AscNullsFirst],createdAt[DescNullsLast]
|
||||
|
||||
## Usage with LLMs
|
||||
|
||||
You can use AI to generate code based on the OpenAPI schema with the following URLs:
|
||||
The recommended way to give an LLM agent (Claude Desktop, Cursor, Windsurf, …)
|
||||
access to your workspace is the **Twenty MCP server**, not this OpenAPI schema.
|
||||
The MCP server exposes typed tools the agent can call directly with proper
|
||||
header-based auth (OAuth or API key), no tokens in URLs.
|
||||
|
||||
Configure it from **Settings → AI → MCP** in your workspace. The endpoint is:
|
||||
|
||||
\`\`\`text
|
||||
Core: ${serverUrl}/rest/open-api/core?token=${token ?? '<your_token>'}
|
||||
Metadata: ${serverUrl}/rest/open-api/metadata?token=${token ?? '<your_token>'}
|
||||
${serverUrl}/mcp
|
||||
\`\`\`
|
||||
|
||||
Quick prompt example (Cursor or any agent):
|
||||
If you specifically need the raw OpenAPI document (for code generation, type
|
||||
generation, etc.), download it locally with a header-authenticated request and
|
||||
hand the file to your tool — never paste a tokenized URL into a chat:
|
||||
|
||||
\`\`\`text
|
||||
Here is an OpenAPI schema for the Twenty REST API:\n${serverUrl}/rest/open-api/core?token=${token ?? '<your_token>'}
|
||||
|
||||
Use it to list companies created after 2024-01-01, ordered by createdAt desc, and include only 20 results.
|
||||
\`\`\`bash
|
||||
curl -H 'Authorization: Bearer <token>' \\
|
||||
${serverUrl}/rest/open-api/${schemaName} > twenty-${schemaName}.json
|
||||
\`\`\`
|
||||
|
||||
Notes:
|
||||
- Treat the token like a secret; prefer a short-lived Playground token.
|
||||
- Most editors can fetch and process the schema even if it's large.
|
||||
`,
|
||||
termsOfService:
|
||||
'https://github.com/twentyhq/twenty?tab=coc-ov-file#readme',
|
||||
|
||||
Reference in New Issue
Block a user