From 84e940a9e460b7a6a09ff990d6f45399f5b4f8f0 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 18 Aug 2026 19:12:02 +0300 Subject: [PATCH] fix(mobile): bypass ngrok browser interstitial --- packages/ui/src/apps/mobileConnections.ts | 6 ++-- packages/ui/src/lib/runtime-fetch.test.ts | 34 ++++++++++++++++++++- packages/ui/src/lib/runtime-fetch.ts | 36 +++++++++++++++++++---- packages/web/server/index.js | 2 +- 4 files changed, 67 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts index 77c83c24..78b5851b 100644 --- a/packages/ui/src/apps/mobileConnections.ts +++ b/packages/ui/src/apps/mobileConnections.ts @@ -23,7 +23,7 @@ import type { PairingConnectionPayload, PairingEndpointCandidate } from '@/lib/c import { isCapacitorApp } from '@/lib/platform'; import { adoptRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel'; import { createRelayTunnelClient } from '@/lib/relay/tunnel-client'; -import { runtimeFetch } from '@/lib/runtime-fetch'; +import { addRuntimeProxyHeaders, runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { recordMobileConnectDebug } from './mobileConnectionDebug'; @@ -346,11 +346,11 @@ const nativeHttpRequest = async (url: string, init?: RequestInit): Promise { }); }); +describe('addRuntimeProxyHeaders', () => { + test('bypasses the ngrok browser interstitial for official ngrok hosts', () => { + const headers = addRuntimeProxyHeaders('https://demo.ngrok-free.app/health', new Headers()); + + expect(headers.get('ngrok-skip-browser-warning')).toBe('openchamber'); + }); + + test('does not add proxy headers to non-ngrok or lookalike hosts', () => { + expect(addRuntimeProxyHeaders('https://runtime.example/health', new Headers()).has('ngrok-skip-browser-warning')).toBe(false); + expect(addRuntimeProxyHeaders('https://ngrok-free.app.evil.example/health', new Headers()).has('ngrok-skip-browser-warning')).toBe(false); + }); +}); + describe('runtimeFetch transport contract', () => { + test('adds the ngrok bypass header to runtime requests', async () => { + const previous = getRuntimeUrlResolver(); + let capturedHeaders = new Headers(); + try { + configureRuntimeUrlResolver({ apiBaseUrl: 'https://demo.ngrok-free.app' }); + globalThis.fetch = async (_input, init) => { + capturedHeaders = new Headers(init?.headers); + return new Response(null, { status: 204 }); + }; + + await runtimeFetch('/health'); + + expect(capturedHeaders.get('ngrok-skip-browser-warning')).toBe('openchamber'); + } finally { + setRuntimeUrlResolver(previous); + globalThis.fetch = originalFetch; + } + }); + test('preserves bodies from actual SDK mutation requests on same-origin runtimes', async () => { const previous = getRuntimeUrlResolver(); const originalWindow = globalThis.window; diff --git a/packages/ui/src/lib/runtime-fetch.ts b/packages/ui/src/lib/runtime-fetch.ts index e4a4c396..287d924d 100644 --- a/packages/ui/src/lib/runtime-fetch.ts +++ b/packages/ui/src/lib/runtime-fetch.ts @@ -30,6 +30,20 @@ const isCurrentWindowUrl = (url: URL): boolean => { const isAbsoluteUrl = (value: string): boolean => /^[a-z][a-z\d+.-]*:\/\//i.test(value); +const isNgrokHost = (hostname: string): boolean => + /(^|\.)ngrok(?:-free)?\.(?:app|dev|io)$/i.test(hostname); + +export const addRuntimeProxyHeaders = (url: string, headers: Headers): Headers => { + try { + if (isNgrokHost(new URL(url).hostname) && !headers.has('ngrok-skip-browser-warning')) { + headers.set('ngrok-skip-browser-warning', 'openchamber'); + } + } catch { + // Relative and non-HTTP runtime paths do not need proxy-specific headers. + } + return headers; +}; + const appendRuntimeQuery = (url: URL, query?: RuntimeUrlQuery): void => { if (!query) return; const entries = query instanceof URLSearchParams ? Array.from(query.entries()) : Object.entries(query); @@ -266,13 +280,15 @@ export const runtimeFetch = async (input: string | URL | Request, init: RuntimeF const resolvedInput = resolveRuntimeFetchInput(input, query); const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined; const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput)); - doFetch = resolvedInput instanceof Request - ? () => fetch(new Request(resolvedInput, { ...requestInit, headers })) - : () => fetch(resolvedInput, { ...requestInit, headers }); - url = + const resolvedUrl = resolvedInput instanceof Request ? resolvedInput.url : resolvedInput instanceof URL ? resolvedInput.toString() : String(resolvedInput); + addRuntimeProxyHeaders(resolvedUrl, headers); + doFetch = resolvedInput instanceof Request + ? () => fetch(new Request(resolvedInput, { ...requestInit, headers })) + : () => fetch(resolvedInput, { ...requestInit, headers }); + url = resolvedUrl; method = String( requestInit.method ?? (resolvedInput instanceof Request ? resolvedInput.method : 'GET'), ).toUpperCase(); @@ -313,6 +329,7 @@ export const installRuntimeFetchBridge = (): void => { const url = new URL(input); if (isActiveRuntimeServiceUrl(url)) { const headers = await mergeHeaders(undefined, init?.headers); + addRuntimeProxyHeaders(url.toString(), headers); return nativeFetch(input, { ...init, headers }); } } catch { @@ -321,7 +338,9 @@ export const installRuntimeFetchBridge = (): void => { return nativeFetch(input, init); } const headers = await mergeHeaders(undefined, init?.headers); - return nativeFetch(buildRuntimeFetchUrl(input), { ...init, headers }); + const target = buildRuntimeFetchUrl(input); + addRuntimeProxyHeaders(target, headers); + return nativeFetch(target, { ...init, headers }); } if (input instanceof URL) { @@ -329,12 +348,15 @@ export const installRuntimeFetchBridge = (): void => { if (!shouldResolveFetchInput(raw)) { if (isActiveRuntimeServiceUrl(input)) { const headers = await mergeHeaders(undefined, init?.headers); + addRuntimeProxyHeaders(input.toString(), headers); return nativeFetch(input, { ...init, headers }); } return nativeFetch(input, init); } const headers = await mergeHeaders(undefined, init?.headers); - return nativeFetch(buildRuntimeFetchUrl(raw), { ...init, headers }); + const target = buildRuntimeFetchUrl(raw); + addRuntimeProxyHeaders(target, headers); + return nativeFetch(target, { ...init, headers }); } if (input instanceof Request) { @@ -343,6 +365,7 @@ export const installRuntimeFetchBridge = (): void => { const url = new URL(input.url); if (isActiveRuntimeServiceUrl(url)) { const headers = await mergeHeaders(input.headers, init?.headers); + addRuntimeProxyHeaders(url.toString(), headers); return nativeFetch(new Request(input, { ...init, headers })); } } catch { @@ -352,6 +375,7 @@ export const installRuntimeFetchBridge = (): void => { } const headers = await mergeHeaders(input.headers, init?.headers); const target = buildRuntimeFetchUrl(input.url); + addRuntimeProxyHeaders(target, headers); const request = target === input.url ? input : new Request(target, input); return nativeFetch(new Request(request, { ...init, headers })); } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index cb90dbfe..7f40d916 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1608,7 +1608,7 @@ async function main(options = {}) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,Accept,X-Requested-With,Cache-Control,X-OpenCode-Directory,X-OpenCode-Directory-Encoding'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,Accept,X-Requested-With,Cache-Control,X-OpenCode-Directory,X-OpenCode-Directory-Encoding,Ngrok-Skip-Browser-Warning'); res.setHeader('Access-Control-Expose-Headers', 'x-next-cursor'); res.setHeader('Vary', 'Origin'); if (req.method === 'OPTIONS') {