fix: stop leaking connector OAuth session tokens to other origins

The connector OAuth popup posted the session token to window.opener with
a '*' target origin, so any page that opened the popup received it. An
attacker with an account on a multi-user deployment could start a flow for
their own pending session, get a victim to finish the provider consent, and
receive a token backed by the victim's Drive/SharePoint/Confluence tokens.

- Post popup results only to allowed frontend origins: the callback origin,
  OIDC_FRONTEND_URL, the new CONNECTOR_ALLOWED_ORIGINS, and localhost:5173
  when the callback runs on a loopback host.
- Render the success page from the callback itself so the token never
  appears in a URL; callback-status ignores session_token/user_email params.
- ConnectorAuth accepts messages only from the popup it opened, on the
  callback origin reported by /api/connectors/auth.
- /api/connectors/disconnect requires auth and only deletes the caller's
  session.
- /api/connectors/sync and /api/remote reject session tokens the caller
  does not own.

Fixes #2766
This commit is contained in:
Alex committed 2026-09-14 21:46:09 +01:00
1 parent d121eec2f5
commit 41b3afed14
14 files changed
+575 -87

No files matched your search

+14
View File
@@ -5,6 +5,7 @@ import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
import { useDarkTheme } from '../hooks';
import { selectToken } from '../preferences/preferenceSlice';
import { isTrustedConnectorMessage } from '../utils/connectorAuthUtils';
import { Button } from './ui/button';
interface ConnectorAuthProps {
@@ -34,6 +35,8 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
const completedRef = useRef(false);
const intervalRef = useRef<number | null>(null);
const authWindowRef = useRef<Window | null>(null);
// Origin the OAuth callback page is served from, as reported by the backend.
const callbackOriginRef = useRef<string | null>(null);
// Hold the exact listener identity so unmount cleanup removes the same fn.
const messageHandlerRef = useRef<((event: MessageEvent) => void) | null>(
null,
@@ -54,6 +57,16 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
};
const handleAuthMessage = (event: MessageEvent) => {
// Only the popup we opened, on the callback origin, may report a result.
if (
!isTrustedConnectorMessage(
event,
authWindowRef.current,
callbackOriginRef.current,
)
) {
return;
}
const successGeneric = event.data?.type === 'connector_auth_success';
const successProvider = event.data?.type === `${provider}_auth_success`;
const errorProvider = event.data?.type === `${provider}_auth_error`;
@@ -139,6 +152,7 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
onError(t('modals.uploadDoc.connectors.auth.authCancelled'));
return;
}
callbackOriginRef.current = authData.callback_origin || null;
authWindow.location.href = authData.authorization_url;
messageHandlerRef.current = handleAuthMessage;
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { isTrustedConnectorMessage } from './connectorAuthUtils';
const popup = {} as Window;
const otherWindow = {} as Window;
describe('isTrustedConnectorMessage', () => {
it('rejects messages when no auth popup is open', () => {
expect(
isTrustedConnectorMessage(
{ source: popup, origin: 'https://api.example.com' },
null,
'https://api.example.com',
),
).toBe(false);
});
it('rejects messages that do not come from the auth popup', () => {
expect(
isTrustedConnectorMessage(
{ source: otherWindow, origin: 'https://api.example.com' },
popup,
'https://api.example.com',
),
).toBe(false);
});
it('rejects messages from the popup when its origin is unexpected', () => {
expect(
isTrustedConnectorMessage(
{ source: popup, origin: 'https://evil.example.com' },
popup,
'https://api.example.com',
),
).toBe(false);
});
it('accepts messages from the popup on the callback origin', () => {
expect(
isTrustedConnectorMessage(
{ source: popup, origin: 'https://api.example.com' },
popup,
'https://api.example.com',
),
).toBe(true);
});
it('falls back to the popup identity check when no origin is known', () => {
expect(
isTrustedConnectorMessage(
{ source: popup, origin: 'https://api.example.com' },
popup,
null,
),
).toBe(true);
});
});
+12
View File
@@ -0,0 +1,12 @@
/**
* Whether a window message is the OAuth result posted by the connector popup we opened.
* `expectedOrigin` is the callback origin reported by the backend, when known.
*/
export const isTrustedConnectorMessage = (
event: Pick<MessageEvent, 'origin' | 'source'>,
authWindow: Window | null,
expectedOrigin?: string | null,
): boolean => {
if (!authWindow || event.source !== authWindow) return false;
return !expectedOrigin || event.origin === expectedOrigin;
};