diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e0774b66c..751d134a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Normalize extracted project permissions so source archives cannot leave files unreadable by EAS workers. ([#4287](https://github.com/expo/eas-cli/pull/4287) by [@AbbanMustafa](https://github.com/AbbanMustafa)) - [eas-cli] Add dynamic retries to deploy command that scale up more generously, depending on deployment size ([#4299](https://github.com/expo/eas-cli/pull/4299) by [@kitten](https://github.com/kitten)) +- [build-tools] Require the serve-sim session token for device run session previews, so the tunnel no longer exposes the shell-exec route. ([#4306](https://github.com/expo/eas-cli/pull/4306) by [@gwdp](https://github.com/gwdp)) - [eas-cli] Print the session token in simulator preview links, so they still open once the preview is gated. ([#4312](https://github.com/expo/eas-cli/pull/4312) by [@gwdp](https://github.com/gwdp)) ### 🧹 Chores diff --git a/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts index 02113966bf..c260d8a96d 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts @@ -52,6 +52,25 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { stopAsync.mockResolvedValue(undefined); }); + it('reports the session token when serve-sim minted one', async () => { + jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({ + previewUrl: 'https://web-preview.example.test', + previewToken: 'tok-1', + stopAsync, + }); + + await runAsync(BuildRuntimePlatform.DARWIN); + + expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith( + expect.objectContaining({ + remoteConfig: { + previewUrl: 'https://web-preview.example.test', + previewToken: 'tok-1', + }, + }) + ); + }); + it.each([ [BuildRuntimePlatform.DARWIN, true], [BuildRuntimePlatform.LINUX, false], diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts index df0c8b6e64..9b5e41fa46 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts @@ -128,6 +128,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction( agentDeviceRemoteSessionUrl, agentDeviceRemoteSessionToken: daemonToken, webPreviewUrl: webPreview.previewUrl, + ...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}), }, logger, }); diff --git a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts index d50ac456cd..dd91c22557 100644 --- a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts @@ -149,6 +149,7 @@ export function createStartAppiumRemoteSessionBuildFunction( 'appium:udid': device.udid, }, webPreviewUrl: webPreview.previewUrl, + ...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}), }, logger, }); diff --git a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts index fd46ced6f4..ff3d5b2861 100644 --- a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts @@ -215,6 +215,7 @@ export function createStartArgentRemoteSessionBuildFunction( toolsUrl: publicToolsUrl, ...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}), webPreviewUrl: webPreview.previewUrl, + ...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}), }, logger, }); diff --git a/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts b/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts index e6d54810c4..b1c6088522 100644 --- a/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts @@ -64,7 +64,10 @@ export function createStartWebPreviewRemoteSessionBuildFunction( await uploadRemoteSessionConfigAsync({ ctx, deviceRunSessionId, - remoteConfig: { previewUrl: webPreview.previewUrl }, + remoteConfig: { + previewUrl: webPreview.previewUrl, + ...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}), + }, logger, }); diff --git a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts index 0376dcfd7f..942cc5f28c 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -11,6 +11,7 @@ import { setTimeout as setTimeoutAsync } from 'node:timers/promises'; import { CustomBuildContext } from '../../../customBuildContext'; import { Sentry } from '../../../sentry'; import { turtleFetch } from '../../../utils/turtleFetch'; +import { readServeSimServersAsync } from '../serveSimMetricsRecorder'; import { sleepAsync } from '../../../utils/retry'; import { createExpoDeviceHubArgs, @@ -32,6 +33,11 @@ jest.mock('../../../utils/turtleFetch'); jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() })); jest.mock('../../../sentry'); jest.mock('@expo/turtle-spawn'); +// Spyable so a test can stand in for the serve-sim state directory, which a local serve-sim owns. +jest.mock('../serveSimMetricsRecorder', () => { + const actual = jest.requireActual('../serveSimMetricsRecorder'); + return { ...actual, readServeSimServersAsync: jest.fn(actual.readServeSimServersAsync) }; +}); function createLoggerMock(): bunyan { return { @@ -129,6 +135,7 @@ describe(createServeSimArgs, () => { '4321', '--host', '127.0.0.1', + '--require-token', '--transport', 'webrtc', '--webrtc-codec', @@ -328,6 +335,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => { jest.mocked(spawn).mockReset(); jest.mocked(ngrok.forward).mockReset(); jest.mocked(turtleFetch).mockReset(); + jest + .mocked(readServeSimServersAsync) + .mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1', token: 'tok-1' }]); const spawnPromise = Object.assign(Promise.resolve(undefined), { child: { @@ -388,6 +398,63 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => { expect(close).toHaveBeenCalledTimes(1); }); + it('carries the serve-sim session token for Darwin', async () => { + jest.mocked(ngrok.forward).mockResolvedValue({ + url: () => 'https://preview.example.test', + close: jest.fn().mockResolvedValue(undefined), + } as never); + + const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), { + runtimePlatform: BuildRuntimePlatform.DARWIN, + baseDomain, + env, + logger: createLoggerMock(), + timeoutMs: 10_000, + }); + + expect(preview.previewToken).toBe('tok-1'); + expect(preview.previewUrl).toBe('https://preview.example.test'); + }); + + // serve-sim is always launched with --require-token, so a missing token means it is running + // ungated on a public tunnel. Failing beats handing out a preview that is dead or unprotected. + it('fails for Darwin when serve-sim reports no token', async () => { + jest + .mocked(readServeSimServersAsync) + .mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1' }]); + + await expect( + startDeviceWebPreviewWithTunnelAsync(createCtxMock(), { + runtimePlatform: BuildRuntimePlatform.DARWIN, + baseDomain, + env, + logger: createLoggerMock(), + timeoutMs: 10_000, + }) + ).rejects.toThrow(/started without a session token/); + }); + + // expo-device-hub mints no token, so the Android preview must not require one. + it('starts for Linux without a token, and does not gate expo-device-hub', async () => { + jest.mocked(readServeSimServersAsync).mockResolvedValue([]); + jest.mocked(ngrok.forward).mockResolvedValue({ + url: () => 'https://android-preview.example.test', + close: jest.fn().mockResolvedValue(undefined), + } as never); + + const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), { + runtimePlatform: BuildRuntimePlatform.LINUX, + baseDomain, + env, + logger: createLoggerMock(), + timeoutMs: 10_000, + }); + + expect(preview.previewToken).toBeUndefined(); + const [, args] = jest.mocked(spawn).mock.calls[0]; + expect(args).not.toContain('--require-token'); + }); + it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => { const packageVersion = '4.5.6'; const close = jest.fn().mockResolvedValue(undefined); diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimMetricsRecorder.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimMetricsRecorder.test.ts index 04d3f8b70b..3ed16cd667 100644 --- a/packages/build-tools/src/steps/utils/__tests__/serveSimMetricsRecorder.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimMetricsRecorder.test.ts @@ -101,6 +101,14 @@ describe(readServeSimServersAsync, () => { ]); }); + it('includes the session token when the state file carries one', async () => { + await writeServerStateAsync('E', { device: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' }); + + expect(await readServeSimServersAsync(stateDir)).toEqual([ + { udid: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' }, + ]); + }); + it('returns [] when the state dir does not exist', async () => { expect(await readServeSimServersAsync(path.join(workDir, 'nope'))).toEqual([]); }); @@ -120,6 +128,20 @@ describe(streamServeSimMetricsToFileAsync, () => { expect(String(jest.mocked(fetch).mock.calls[0][0])).toBe('https://sim.example/metrics'); }); + it('sends the session token as a bearer header when given one', async () => { + jest.mocked(fetch).mockResolvedValue(sseResponse()); + await streamServeSimMetricsToFileAsync({ + serveSimUrl: 'https://sim.example', + serveSimToken: 'tok-abc', + filePath: path.join(workDir, 'auth.ndjson'), + signal: new AbortController().signal, + logger: createLoggerMock(), + }); + expect(jest.mocked(fetch).mock.calls[0][1]?.headers).toMatchObject({ + Authorization: 'Bearer tok-abc', + }); + }); + it('writes nothing on a non-ok response', async () => { jest.mocked(fetch).mockResolvedValue(new Response('nope', { status: 502 })); const filePath = path.join(workDir, 'empty.ndjson'); diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 9216236b97..160220dede 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -17,6 +17,7 @@ import { CustomBuildContext } from '../../customBuildContext'; import { Sentry } from '../../sentry'; import { sleepAsync } from '../../utils/retry'; import { turtleFetch } from '../../utils/turtleFetch'; +import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder'; const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; const WEB_PREVIEW_HOST = '127.0.0.1'; @@ -627,6 +628,7 @@ export function createServeSimArgs({ String(port), '--host', WEB_PREVIEW_HOST, + '--require-token', '--transport', 'webrtc', '--webrtc-codec', @@ -712,7 +714,7 @@ export async function waitForWebPreviewReadyAsync({ serverName: string; port: number; timeoutMs: number; -}): Promise { +}): Promise { const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`; const deadline = Date.now() + timeoutMs; let lastError: unknown; @@ -729,8 +731,8 @@ export async function waitForWebPreviewReadyAsync({ retries: 0, timeout: 2_000, }); - WebPreviewReadyResponseSchema.parse(await response.json()); - return; + const ready = WebPreviewReadyResponseSchema.parse(await response.json()); + return ready.device; } catch (error) { lastError = error; } @@ -745,6 +747,8 @@ export async function waitForWebPreviewReadyAsync({ export type DeviceWebPreviewHandle = { previewUrl: string; + /** Session token gating the preview. Only serve-sim mints one. */ + previewToken?: string; stopAsync: () => Promise; }; @@ -760,6 +764,7 @@ async function startWebPreviewWithTunnelAsync( serverName, packageSpec, createArgs, + readPreviewTokenAsync, }: { baseDomain: string; env: BuildStepEnv; @@ -768,6 +773,7 @@ async function startWebPreviewWithTunnelAsync( serverName: string; packageSpec: string; createArgs: (port: number, turnArgs: string[]) => string[]; + readPreviewTokenAsync?: (device: string) => Promise; } ): Promise { const port = await findAvailablePortAsync(); @@ -781,7 +787,13 @@ async function startWebPreviewWithTunnelAsync( try { logger.info(`Waiting for ${serverName} to become ready.`); - await waitForWebPreviewReadyAsync({ previewServer, serverName, port, timeoutMs }); + const device = await waitForWebPreviewReadyAsync({ + previewServer, + serverName, + port, + timeoutMs, + }); + const previewToken = await readPreviewTokenAsync?.(device); const tunnel = await startNgrokTunnelAsync({ port, subdomainPrefix: 'web-preview', @@ -791,6 +803,7 @@ async function startWebPreviewWithTunnelAsync( }); return { previewUrl: tunnel.url, + previewToken, stopAsync: async () => { const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]); for (const result of results) { @@ -806,6 +819,14 @@ async function startWebPreviewWithTunnelAsync( } } +export async function readServeSimPreviewTokenAsync( + udid: string, + stateDir: string = SERVE_SIM_STATE_DIR +): Promise { + const servers = await readServeSimServersAsync(stateDir); + return servers.find(server => server.udid === udid)?.token; +} + export async function startServeSimWithTunnelAsync( ctx: CustomBuildContext, { @@ -832,6 +853,18 @@ export async function startServeSimWithTunnelAsync( packageSpec: createServeSimPackageSpec(packageVersion), createArgs: (port, turnArgs) => createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }), + readPreviewTokenAsync: async device => { + const previewToken = await readServeSimPreviewTokenAsync(device); + if (!previewToken) { + throw new SystemError( + `serve-sim started without a session token for device ${device}. It is always launched ` + + 'with --require-token, so this usually means the pinned @expo/serve-sim predates that ' + + 'flag and ignored it, leaving the preview ungated on a public tunnel. Pin a serve-sim ' + + 'version that supports --require-token.' + ); + } + return previewToken; + }, }); } diff --git a/packages/build-tools/src/steps/utils/serveSimMetricsRecorder.ts b/packages/build-tools/src/steps/utils/serveSimMetricsRecorder.ts index 4be6fb8b53..a613dccd93 100644 --- a/packages/build-tools/src/steps/utils/serveSimMetricsRecorder.ts +++ b/packages/build-tools/src/steps/utils/serveSimMetricsRecorder.ts @@ -13,9 +13,9 @@ const POLL_INTERVAL_MS = 2_000; const MAX_CONSECUTIVE_STREAM_FAILURES_PER_DEVICE = 10; const END_STREAM_TIMEOUT_MS = 2_000; // serve-sim's own per-device registry while serving: `${tmpdir}/serve-sim/server-.json`. -const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim'); +export const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim'); -type ServeSimServer = { udid: string; url: string }; +type ServeSimServer = { udid: string; url: string; token?: string }; type ServeSimMetricsSession = { logger: bunyan; @@ -130,6 +130,7 @@ async function pollServeSimMetricsAsync(session: ServeSimMetricsSession): Promis logger.info(`Collecting serve-sim metrics for ${server.udid}.`); const donePromise = streamServeSimMetricsToFileAsync({ serveSimUrl: server.url, + serveSimToken: server.token, filePath, signal: streamSignal, logger, @@ -172,9 +173,14 @@ export async function readServeSimServersAsync(stateDir: string): Promise | undefined; try { - const response = await fetch(new URL('/metrics', serveSimUrl).toString(), { signal }); + const response = await fetch(new URL('/metrics', serveSimUrl).toString(), { + signal, + ...(serveSimToken ? { headers: { Authorization: `Bearer ${serveSimToken}` } } : {}), + }); if (!response.ok || !response.body) { logger.warn(`serve-sim /metrics responded ${response.status} for ${serveSimUrl}.`); return { receivedData, metadata };