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:
+46
-36
@@ -1,7 +1,8 @@
|
||||
import { RestPlaygroundSchemaFetchEffect } from '@/settings/playground/components/RestPlaygroundSchemaFetchEffect';
|
||||
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||
import { type PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useContext, lazy, Suspense } from 'react';
|
||||
import { useContext, useState, lazy, Suspense } from 'react';
|
||||
import { styled } from '@linaria/react';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
@@ -52,50 +53,59 @@ type RestPlaygroundProps = {
|
||||
export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
|
||||
const { theme, colorScheme } = useContext(ThemeContext);
|
||||
const playgroundApiKey = useAtomStateValue(playgroundApiKeyState);
|
||||
const [specContent, setSpecContent] = useState<object | null>(null);
|
||||
|
||||
if (!playgroundApiKey) {
|
||||
onError();
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallback = (
|
||||
<SkeletonTheme
|
||||
baseColor={theme.background.tertiary}
|
||||
highlightColor={theme.background.transparent.lighter}
|
||||
borderRadius={4}
|
||||
>
|
||||
<Skeleton width="100%" height="100%" />
|
||||
</SkeletonTheme>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<Suspense
|
||||
fallback={
|
||||
<SkeletonTheme
|
||||
baseColor={theme.background.tertiary}
|
||||
highlightColor={theme.background.transparent.lighter}
|
||||
borderRadius={4}
|
||||
>
|
||||
<Skeleton width="100%" height="100%" />
|
||||
</SkeletonTheme>
|
||||
}
|
||||
>
|
||||
<ApiReferenceReact
|
||||
configuration={{
|
||||
spec: {
|
||||
url: `${REACT_APP_SERVER_BASE_URL}/rest/open-api/${schema}?token=${playgroundApiKey}`,
|
||||
},
|
||||
authentication: {
|
||||
http: {
|
||||
bearer: playgroundApiKey
|
||||
? { token: playgroundApiKey }
|
||||
: undefined,
|
||||
<RestPlaygroundSchemaFetchEffect
|
||||
schema={schema}
|
||||
apiKey={playgroundApiKey}
|
||||
onSchemaLoaded={setSpecContent}
|
||||
onError={onError}
|
||||
/>
|
||||
{specContent === null ? (
|
||||
fallback
|
||||
) : (
|
||||
<Suspense fallback={fallback}>
|
||||
<ApiReferenceReact
|
||||
configuration={{
|
||||
spec: {
|
||||
content: specContent,
|
||||
},
|
||||
},
|
||||
baseServerURL: REACT_APP_SERVER_BASE_URL + '/' + schema,
|
||||
forceDarkModeState: colorScheme === 'dark' ? 'dark' : 'light',
|
||||
hideClientButton: true,
|
||||
hideDarkModeToggle: true,
|
||||
hideModels: schema === 'metadata',
|
||||
pathRouting: {
|
||||
basePath: getSettingsPath(SettingsPath.RestPlayground, {
|
||||
schema,
|
||||
}),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
authentication: {
|
||||
http: {
|
||||
bearer: { token: playgroundApiKey },
|
||||
},
|
||||
},
|
||||
baseServerURL: REACT_APP_SERVER_BASE_URL + '/' + schema,
|
||||
forceDarkModeState: colorScheme === 'dark' ? 'dark' : 'light',
|
||||
hideClientButton: true,
|
||||
hideDarkModeToggle: true,
|
||||
hideModels: schema === 'metadata',
|
||||
pathRouting: {
|
||||
basePath: getSettingsPath(SettingsPath.RestPlayground, {
|
||||
schema,
|
||||
}),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { type PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||
import { useEffect } from 'react';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
|
||||
type RestPlaygroundSchemaFetchEffectProps = {
|
||||
schema: PlaygroundSchemas;
|
||||
apiKey: string;
|
||||
onSchemaLoaded: (document: object | null) => void;
|
||||
onError: () => void;
|
||||
};
|
||||
|
||||
// Fetch via header so the token is never in a URL (logs, history, Referer).
|
||||
export const RestPlaygroundSchemaFetchEffect = ({
|
||||
schema,
|
||||
apiKey,
|
||||
onSchemaLoaded,
|
||||
onError,
|
||||
}: RestPlaygroundSchemaFetchEffectProps) => {
|
||||
useEffect(() => {
|
||||
onSchemaLoaded(null);
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
fetch(`${REACT_APP_SERVER_BASE_URL}/rest/open-api/${schema}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then((response) => (response.ok ? response.json() : Promise.reject()))
|
||||
.then(onSchemaLoaded)
|
||||
.catch((error) => {
|
||||
if (error?.name !== 'AbortError') {
|
||||
onError();
|
||||
}
|
||||
});
|
||||
|
||||
return () => abortController.abort();
|
||||
}, [schema, apiKey, onSchemaLoaded, onError]);
|
||||
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user