diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index ca1767cb..d45f993d 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -447,6 +447,21 @@ const stripPreviewTokenFromUrl = (value: string): string => { return value; } }; + +const stripPreviewQueryParams = (value: string): string => { + if (!value) return value; + try { + const parsed = new URL(value); + parsed.searchParams.delete('ocPreview'); + parsed.searchParams.delete('oc_preview_token'); + parsed.searchParams.delete('oc_client_token'); + parsed.searchParams.delete('oc_url_token'); + return parsed.toString(); + } catch { + return value; + } +}; + const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { const { t } = useI18n(); const { currentTheme } = useThemeSystem(); @@ -593,6 +608,8 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { ? (() => { const path = normalizedUrl.pathname || '/'; const searchParams = new URLSearchParams(normalizedUrl.search); + searchParams.delete('oc_url_token'); + searchParams.delete('oc_client_token'); searchParams.set('ocPreview', String(reloadNonce)); searchParams.set('oc_preview_token', proxyState.previewToken || ''); const search = searchParams.toString(); @@ -1394,6 +1411,8 @@ const IframeBrowserPane: React.FC = ({ initialUrl, dire const parsed = new URL(currentUrl); const path = parsed.pathname || '/'; const searchParams = new URLSearchParams(parsed.search); + searchParams.delete('oc_url_token'); + searchParams.delete('oc_client_token'); searchParams.set('ocPreview', String(reloadNonce)); searchParams.set('oc_preview_token', proxyState.previewToken || ''); const search = searchParams.toString(); @@ -1418,7 +1437,7 @@ const IframeBrowserPane: React.FC = ({ initialUrl, dire const rest = parsedFrameUrl.pathname.slice(proxyBasePath.length) || '/'; const upstreamOrigin = new URL(currentUrl).origin; - return new URL(`${rest}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString(); + return stripPreviewQueryParams(new URL(`${rest}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString()); } catch { return ''; } @@ -1440,7 +1459,7 @@ const IframeBrowserPane: React.FC = ({ initialUrl, dire return ''; } - return new URL(`${parsedFrameUrl.pathname}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString(); + return stripPreviewQueryParams(new URL(`${parsedFrameUrl.pathname}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString()); } catch { return ''; } diff --git a/packages/ui/src/lib/runtime-url.test.ts b/packages/ui/src/lib/runtime-url.test.ts index 631076cf..2b30836c 100644 --- a/packages/ui/src/lib/runtime-url.test.ts +++ b/packages/ui/src/lib/runtime-url.test.ts @@ -120,6 +120,19 @@ describe('createRuntimeUrlResolver', () => { } }); + test('replaces existing short-lived URL auth query on relative authenticated URLs', () => { + setRuntimeUrlAuthToken('oc_url_secret', Date.now() + 60_000); + try { + const urls = createRuntimeUrlResolver(); + + expect(urls.authenticatedAsset('/api/preview/proxy/abc/?oc_url_token=stale&x=1#top')).toBe( + '/api/preview/proxy/abc/?oc_url_token=oc_url_secret&x=1#top', + ); + } finally { + setRuntimeUrlAuthToken(null, null); + } + }); + test('does not put the long-lived client token in URLs', () => { setRuntimeBearerToken('oc_client_secret'); try { diff --git a/packages/ui/src/lib/runtime-url.ts b/packages/ui/src/lib/runtime-url.ts index d1bc46ab..ba3797c0 100644 --- a/packages/ui/src/lib/runtime-url.ts +++ b/packages/ui/src/lib/runtime-url.ts @@ -91,14 +91,12 @@ const withUrlAuth = (urlValue: string): string => { const token = getRuntimeUrlAuthTokenSync(); if (!token) return urlValue; - if (ABSOLUTE_URL_PATTERN.test(urlValue)) { - const url = new URL(urlValue); - url.searchParams.set('oc_url_token', token); - return url.toString(); - } - - const separator = urlValue.includes('?') ? '&' : '?'; - return `${urlValue}${separator}oc_url_token=${encodeURIComponent(token)}`; + const url = ABSOLUTE_URL_PATTERN.test(urlValue) + ? new URL(urlValue) + : new URL(urlValue, 'http://openchamber.local'); + url.searchParams.set('oc_url_token', token); + if (ABSOLUTE_URL_PATTERN.test(urlValue)) return url.toString(); + return `${url.pathname}${url.search}${url.hash}`; }; const toWebSocketUrl = (candidate: string, config: RuntimeUrlConfig): string => {