From f7d558c95b99db6df2679ef456db8613dc69d62a Mon Sep 17 00:00:00 2001 From: James Peter Date: Tue, 12 May 2026 16:53:41 +1000 Subject: [PATCH] Bind 12ui API keys to auth origins --- src/server/twelveUi.ts | 19 +++++-- src/server/twelveUiAuthStore.test.ts | 80 ++++++++++++++++++++++++++++ src/server/twelveUiAuthStore.ts | 31 +++++++++-- 3 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 src/server/twelveUiAuthStore.test.ts diff --git a/src/server/twelveUi.ts b/src/server/twelveUi.ts index 30f5566..fcf38bd 100644 --- a/src/server/twelveUi.ts +++ b/src/server/twelveUi.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import type { HandoverResult } from '../shared/types.js'; import { runDir } from './runStore.js'; import { getTwelveUiOrigin } from './connection.js'; -import { getTwelveUiApiKey } from './twelveUiAuthStore.js'; +import { getTwelveUiApiKey, isTwelveUiAuthOrigin, readStoredTwelveUiAuth } from './twelveUiAuthStore.js'; type FetchLike = typeof fetch; @@ -32,6 +32,16 @@ const isLocalOrigin = (origin: string): boolean => { const devSessionToken = (): string => process.env.DEV_SESSION_TOKEN?.trim() || 'devtoken'; +const readBoundTwelveUiApiKey = (origin: string): string => { + const apiKey = getTwelveUiApiKey(origin); + if (apiKey) return apiKey; + const storedAuth = readStoredTwelveUiAuth(); + if (storedAuth && !isTwelveUiAuthOrigin(storedAuth.origin, origin)) { + throw new Error(`Stored 12ui authentication is bound to ${storedAuth.origin}; reconnect 12ui before handing over to ${origin}.`); + } + return ''; +}; + const localAssetPath = (runId: string, assetId: string): string => ( `/api/design/extract-runs/${encodeURIComponent(runId)}/assets/${encodeURIComponent(assetId)}` ); @@ -144,7 +154,7 @@ export const submitTwelveUiHandover = async (args: { fetchImpl?: FetchLike; }): Promise => { const origin = getTwelveUiOrigin(); - const apiKey = getTwelveUiApiKey(); + const apiKey = readBoundTwelveUiApiKey(origin); if (!apiKey && isLocalOrigin(origin)) { return submitLocalDesignExtract(args); } @@ -278,10 +288,11 @@ export const fetchHandoverAsset = async (args: { if (raw.mode === 'local-design-extract') return fetchLocalHandoverAsset(args); const url = handoverAssetUrl(args.handover, args.asset); if (!url) throw new Error(`Handover asset ${args.asset} is not available.`); + const targetUrl = new URL(url, getTwelveUiOrigin()); const headers: Record = {}; - const apiKey = getTwelveUiApiKey(); + const apiKey = readBoundTwelveUiApiKey(targetUrl.origin); if (apiKey) headers.authorization = `Bearer ${apiKey}`; - const response = await (args.fetchImpl ?? fetch)(new URL(url, getTwelveUiOrigin()), { headers }); + const response = await (args.fetchImpl ?? fetch)(targetUrl, { headers }); if (!response.ok) throw new Error(`Handover asset ${args.asset} returned ${response.status}.`); return response; }; diff --git a/src/server/twelveUiAuthStore.test.ts b/src/server/twelveUiAuthStore.test.ts new file mode 100644 index 0000000..a5707d0 --- /dev/null +++ b/src/server/twelveUiAuthStore.test.ts @@ -0,0 +1,80 @@ +import { mkdtemp } from 'node:fs/promises'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const originalDataDir = process.env.CODEX_12UI_DATA_DIR; +const originalApiKey = process.env.TWELVE_UI_API_KEY; +const originalOrigin = process.env.TWELVE_UI_ORIGIN; + +const loadWithDataDir = async () => { + vi.resetModules(); + const dataDir = await mkdtemp(path.join(tmpdir(), '12ui-auth-store-')); + process.env.CODEX_12UI_DATA_DIR = dataDir; + delete process.env.TWELVE_UI_API_KEY; + return import('./twelveUiAuthStore.js'); +}; + +afterEach(() => { + vi.resetModules(); + if (originalDataDir === undefined) delete process.env.CODEX_12UI_DATA_DIR; + else process.env.CODEX_12UI_DATA_DIR = originalDataDir; + if (originalApiKey === undefined) delete process.env.TWELVE_UI_API_KEY; + else process.env.TWELVE_UI_API_KEY = originalApiKey; + if (originalOrigin === undefined) delete process.env.TWELVE_UI_ORIGIN; + else process.env.TWELVE_UI_ORIGIN = originalOrigin; +}); + +describe('twelveUiAuthStore', () => { + it('returns an environment API key only for the configured 12ui origin', async () => { + vi.resetModules(); + process.env.TWELVE_UI_API_KEY = 'ENV_12UI_KEY'; + process.env.TWELVE_UI_ORIGIN = 'https://configured.12ui.example'; + + const authStore = await import('./twelveUiAuthStore.js'); + + expect(authStore.getTwelveUiApiKey('https://configured.12ui.example/app')).toBe('ENV_12UI_KEY'); + expect(authStore.getTwelveUiApiKey('https://attacker.example')).toBe(''); + }); + + it('returns a stored API key only for the origin it was issued for', async () => { + const authStore = await loadWithDataDir(); + await authStore.writeStoredTwelveUiAuth({ + origin: 'https://legit.12ui.example/app?ignored=1', + apiKey: 'SECRET_STORED_12UI_KEY', + clientId: 'client-1', + organizationId: 'org-1', + createdAt: '2026-05-11T00:00:00.000Z', + }); + + expect(authStore.getTwelveUiApiKey('https://legit.12ui.example/other')).toBe('SECRET_STORED_12UI_KEY'); + expect(authStore.getTwelveUiApiKey('https://attacker.example')).toBe(''); + }); + + it('stops handover before reading assets or posting when stored auth belongs to another origin', async () => { + vi.resetModules(); + const dataDir = await mkdtemp(path.join(tmpdir(), '12ui-handover-auth-')); + process.env.CODEX_12UI_DATA_DIR = dataDir; + process.env.TWELVE_UI_ORIGIN = 'https://attacker.example'; + delete process.env.TWELVE_UI_API_KEY; + + const authStore = await import('./twelveUiAuthStore.js'); + await authStore.writeStoredTwelveUiAuth({ + origin: 'https://legit.12ui.example', + apiKey: 'SECRET_STORED_12UI_KEY', + clientId: 'client-1', + organizationId: 'org-1', + createdAt: '2026-05-11T00:00:00.000Z', + }); + const { submitTwelveUiHandover } = await import('./twelveUi.js'); + const fetchImpl = vi.fn(); + + await expect(submitTwelveUiHandover({ + runId: 'missing-run', + designId: 'design-1', + assetPath: 'design.png', + fetchImpl: fetchImpl as unknown as typeof fetch, + })).rejects.toThrow('Stored 12ui authentication is bound to https://legit.12ui.example'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/twelveUiAuthStore.ts b/src/server/twelveUiAuthStore.ts index 5974a15..5087fe9 100644 --- a/src/server/twelveUiAuthStore.ts +++ b/src/server/twelveUiAuthStore.ts @@ -52,10 +52,35 @@ export const readStoredTwelveUiAuth = (): TwelveUiStoredAuth | null => { } }; -export const getTwelveUiApiKey = (): string => { +const normalizeAuthOrigin = (origin: string): string | null => { + try { + const url = new URL(origin.trim()); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + url.pathname = ''; + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); + } catch { + return null; + } +}; + +export const isTwelveUiAuthOrigin = (storedOrigin: string, requestedOrigin: string): boolean => { + const normalizedStoredOrigin = normalizeAuthOrigin(storedOrigin); + const normalizedRequestedOrigin = normalizeAuthOrigin(requestedOrigin); + return Boolean( + normalizedStoredOrigin + && normalizedRequestedOrigin + && normalizedStoredOrigin === normalizedRequestedOrigin, + ); +}; + +export const getTwelveUiApiKey = (origin: string): string => { const envKey = serverConfig.twelveUiApiKey.trim(); - if (envKey) return envKey; - return readStoredTwelveUiAuth()?.apiKey ?? ''; + if (envKey) return isTwelveUiAuthOrigin(serverConfig.twelveUiOrigin, origin) ? envKey : ''; + const stored = readStoredTwelveUiAuth(); + if (!stored || !isTwelveUiAuthOrigin(stored.origin, origin)) return ''; + return stored.apiKey; }; export const getTwelveUiAuthStatus = (): TwelveUiAuthStatus => {