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
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export class AdminIntegrationsController {
credentialUpdatedAt: credential?.updatedAt,
clientIdHint: credential?.clientIdHint,
clientSecretHint: credential?.clientSecretHint,
encryptedClientId: credential?.encryptedClientId,
encryptedClientSecret: credential?.encryptedClientSecret,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API exposes encrypted credentials enabling full plaintext retrieval

High Severity

The listIntegrations endpoint now returns encryptedClientId and encryptedClientSecret to the browser. These are then decrypted via a 'use server' action and the full plaintext OAuth client secrets end up in browser memory, SWR cache, and the DOM. This contradicts the existing security design — the sibling getIntegration endpoint on line 114 explicitly says // don't expose actual credentials. Previously only masked clientIdHint/clientSecretHint values were sent. The full secrets are now accessible to any XSS vector, browser extension, or session-hijack targeting an admin user.

Additional Locations (2)
Fix in Cursor Fix in Web

existingCustomSettings:
(credential as { customSettings?: Record<string, unknown> } | undefined)
?.customSettings || undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '@trycompai/design-system/icons';
import Image from 'next/image';
import { useState } from 'react';
import { View, ViewOff } from '@trycompai/design-system/icons';

interface AdditionalOAuthSetting {
id: string;
Expand Down Expand Up @@ -38,6 +39,8 @@ export interface Integration {
credentialUpdatedAt?: string;
clientIdHint?: string;
clientSecretHint?: string;
decryptedClientId?: string;
decryptedClientSecret?: string;
existingCustomSettings?: Record<string, unknown>;
setupInstructions?: string;
createAppUrl?: string;
Expand Down Expand Up @@ -152,6 +155,13 @@ export function IntegrationCard({
)}
</div>

{integration.hasCredentials && integration.decryptedClientId && (
<CardCredentialsSummary
clientId={integration.decryptedClientId}
clientSecret={integration.decryptedClientSecret}
/>
)}

{integration.authType === 'oauth2' && (
<OAuthConfig
integration={integration}
Expand Down Expand Up @@ -355,6 +365,42 @@ function OAuthConfig({
);
}

function CardCredentialsSummary({
clientId,
clientSecret,
}: {
clientId: string;
clientSecret?: string;
}) {
const [showSecret, setShowSecret] = useState(false);

return (
<div className="space-y-1.5 rounded-lg bg-muted p-3">
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-xs text-muted-foreground">Client ID</span>
<code className="min-w-0 truncate rounded border bg-background px-2 py-0.5 text-xs select-all">
{clientId}
</code>
</div>
{clientSecret && (
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-xs text-muted-foreground">Secret</span>
<code className="min-w-0 truncate rounded border bg-background px-2 py-0.5 text-xs select-all">
{showSecret ? clientSecret : `${'•'.repeat(Math.min(clientSecret.length, 20))}${clientSecret.slice(-4)}`}
</code>
<button
type="button"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => setShowSecret(!showSecret)}
>
{showSecret ? <ViewOff size={14} /> : <View size={14} />}
</button>
</div>
)}
</div>
);
}

function CredentialsDisplay({
clientIdHint,
clientSecretHint,
Expand Down
34 changes: 32 additions & 2 deletions apps/app/src/app/(app)/[orgId]/admin/integrations/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { api } from '@/lib/api-client';
import { decrypt, type EncryptedData } from '@/lib/encryption';
import {
Button,
Card,
Expand All @@ -19,6 +20,34 @@ import { useState } from 'react';
import useSWR from 'swr';
import { IntegrationCard, type Integration } from './components/IntegrationCard';

interface ApiIntegration extends Integration {
encryptedClientId?: EncryptedData;
encryptedClientSecret?: EncryptedData;
}

async function decryptIntegrations(integrations: ApiIntegration[]): Promise<Integration[]> {
return Promise.all(
integrations.map(async (integration) => {
if (!integration.hasCredentials || !integration.encryptedClientId) {
return integration;
}

try {
const [decryptedClientId, decryptedClientSecret] = await Promise.all([
decrypt(integration.encryptedClientId),
integration.encryptedClientSecret
? decrypt(integration.encryptedClientSecret)
: Promise.resolve(undefined),
]);

return { ...integration, decryptedClientId, decryptedClientSecret };
} catch {
return integration;
}
}),
);
}

export default function AdminIntegrationsPage() {
const [searchQuery, setSearchQuery] = useState('');

Expand All @@ -28,9 +57,10 @@ export default function AdminIntegrationsPage() {
isLoading,
mutate,
} = useSWR<Integration[]>('admin-integrations', async () => {
const response = await api.get<Integration[]>('/v1/admin/integrations');
const response = await api.get<ApiIntegration[]>('/v1/admin/integrations');
if (response.error) throw new Error(response.error);
return response.data || [];
const raw = response.data || [];
return decryptIntegrations(raw);
});

const filteredIntegrations = integrations?.filter((i) => {
Expand Down
6 changes: 6 additions & 0 deletions packages/docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -21289,6 +21289,11 @@
"description": "Whether member is active",
"example": true
},
"deactivated": {
"type": "boolean",
"description": "Whether member is deactivated",
"example": false
},
"fleetDmLabelId": {
"type": "object",
"description": "FleetDM label ID for member devices",
Expand All @@ -21313,6 +21318,7 @@
"department",
"jobTitle",
"isActive",
"deactivated",
"fleetDmLabelId",
"user"
]
Expand Down
Loading