fix: prevent duplicate preview URL auth tokens

Stops embedded browser URLs from accumulating repeated oc_url_token params
Strips preview-only query params before persisting browser navigation
Adds regression coverage for replacing URL auth tokens
This commit is contained in:
Bohdan Triapitsyn
2026-06-28 00:10:35 +03:00
parent 757c704956
commit 1b67aa3c21
3 changed files with 40 additions and 10 deletions
@@ -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<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
@@ -593,6 +608,8 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ 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<DesktopBrowserPaneProps> = ({ 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<DesktopBrowserPaneProps> = ({ 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<DesktopBrowserPaneProps> = ({ 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 '';
}
+13
View File
@@ -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 {
+6 -8
View File
@@ -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 => {