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
98 changes: 98 additions & 0 deletions src/doctor/checks/dashboard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

const { checkDashboardSettings, isCredentialSafeBaseUrl } = await import('./dashboard.js');

import type { DoctorOptions, EnvironmentRaw } from '../types.js';

const options: DoctorOptions = { installDir: '/tmp/project', skipApi: false };

function raw(overrides: Partial<EnvironmentRaw> = {}): EnvironmentRaw {
return { apiKey: 'sk_test_abc', clientId: null, baseUrl: 'https://api.workos.com', ...overrides };
}

describe('isCredentialSafeBaseUrl', () => {
it('allows the default WorkOS API host', () => {
expect(isCredentialSafeBaseUrl('https://api.workos.com')).toBe(true);
});

it('allows any HTTPS *.workos.com subdomain', () => {
expect(isCredentialSafeBaseUrl('https://api.workos.com/')).toBe(true);
expect(isCredentialSafeBaseUrl('https://auth.workos.com')).toBe(true);
expect(isCredentialSafeBaseUrl('https://workos.com')).toBe(true);
});

it('allows a trusted host with a trailing root-label dot', () => {
expect(isCredentialSafeBaseUrl('https://api.workos.com.')).toBe(true);
expect(isCredentialSafeBaseUrl('https://api.workos.com.attacker.example.')).toBe(false);
});

it('allows localhost for internal development over any scheme', () => {
expect(isCredentialSafeBaseUrl('http://localhost:8001')).toBe(true);
expect(isCredentialSafeBaseUrl('http://127.0.0.1:3000')).toBe(true);
});

it('rejects non-WorkOS hosts', () => {
expect(isCredentialSafeBaseUrl('https://workos-diag.attacker.example')).toBe(false);
expect(isCredentialSafeBaseUrl('https://evil.example')).toBe(false);
});

it('rejects look-alike hosts that only suffix-match the string', () => {
expect(isCredentialSafeBaseUrl('https://api.workos.com.attacker.example')).toBe(false);
expect(isCredentialSafeBaseUrl('https://notworkos.com')).toBe(false);
});

it('rejects non-HTTPS remote hosts', () => {
expect(isCredentialSafeBaseUrl('http://api.workos.com')).toBe(false);
});

it('rejects unparsable values', () => {
expect(isCredentialSafeBaseUrl('not a url')).toBe(false);
expect(isCredentialSafeBaseUrl('')).toBe(false);
});
});

describe('checkDashboardSettings base URL guard', () => {
const mockFetch = vi.fn();
const originalFetch = globalThis.fetch;

beforeEach(() => {
globalThis.fetch = mockFetch;
mockFetch.mockReset();
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

it('does not send the credential to an attacker-controlled base URL', async () => {
const result = await checkDashboardSettings(
options,
'staging',
raw({ baseUrl: 'https://workos-diag.attacker.example' }),
);

expect(mockFetch).not.toHaveBeenCalled();
expect(result.settings).toBeNull();
expect(result.error).toContain('untrusted');
});

it('proceeds for a trusted WorkOS base URL', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ list_metadata: { total_count: 0 } }),
} as Response);

await checkDashboardSettings(options, 'staging', raw());

expect(mockFetch).toHaveBeenCalled();
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl.startsWith('https://api.workos.com/')).toBe(true);
});

it('still skips production keys before any URL handling', async () => {
const result = await checkDashboardSettings(options, 'production', raw());
expect(mockFetch).not.toHaveBeenCalled();
expect(result.error).toContain('production');
});
});
43 changes: 42 additions & 1 deletion src/doctor/checks/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,39 @@ import type {

const WORKOS_API_URL = 'https://api.workos.com';

/**
* Hosts the resolved API key may be sent to as a Bearer credential.
*
* `baseUrl` is resolved from the project's `.env`/`.env.local`, which is
* attacker-controllable: a poisoned repo can commit
* `WORKOS_BASE_URL=https://evil.example` while the key comes from the
* developer's own secrets. Since the key and the destination originate from
* different trust domains, never attach the credential to anything outside
* this allowlist.
*/
export function isCredentialSafeBaseUrl(baseUrl: string): boolean {
let url: URL;
try {
url = new URL(baseUrl);
} catch {
return false;
}

const host = url.hostname.toLowerCase();

// Localhost is used for internal WorkOS API development and cannot be pointed
// at attacker-controlled infrastructure, so allow it over any scheme.
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') {
return true;
}

// Everything else must be an HTTPS WorkOS host. Strip a trailing root-label
// dot so a valid FQDN like `api.workos.com.` is still recognized.
if (url.protocol !== 'https:') return false;
const normalizedHost = host.endsWith('.') ? host.slice(0, -1) : host;
return normalizedHost === 'workos.com' || normalizedHost.endsWith('.workos.com');
}

export async function checkDashboardSettings(
options: DoctorOptions,
apiKeyType: 'staging' | 'production' | null,
Expand All @@ -27,8 +60,16 @@ export async function checkDashboardSettings(
return { settings: null, error: 'No API key configured' };
}

// Refuse to send the credential to an untrusted base URL. WORKOS_BASE_URL can
// be supplied by an attacker-controlled project `.env`, so a non-WorkOS host
// would exfiltrate the developer's API key.
const baseUrl = raw.baseUrl ?? WORKOS_API_URL;
if (!isCredentialSafeBaseUrl(baseUrl)) {
return { settings: null, error: `Skipped (untrusted WORKOS_BASE_URL: ${baseUrl})` };
}

try {
return await fetchDashboardSettings(apiKey, raw.baseUrl);
return await fetchDashboardSettings(apiKey, baseUrl);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return { settings: null, error: message };
Expand Down