Skip to content

Commit 6900117

Browse files
authored
fix(daemon): confine remote HTTP trust boundaries (#2111)
* fix: confine remote HTTP trust boundaries * refactor: simplify trust policy plumbing * fix: remove remote host path install opt-in * test: cover remote HTTP trust boundaries * test: attest remote RPC tenant fixtures * test: cover malformed network addresses * fix: constrain proxy HTTP network policy * refactor: preserve fetch semantics in remote HTTP
1 parent 9abcd7f commit 6900117

17 files changed

Lines changed: 869 additions & 56 deletions

src/__tests__/daemon-proxy.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import assert from 'node:assert/strict';
33
import crypto from 'node:crypto';
44
import http from 'node:http';
55
import { createDaemonProxyServer } from '../remote/daemon-proxy.ts';
6+
import { createDaemonHttpServer } from '../daemon/server/http-server.ts';
7+
import { executeRunScriptHttpRequest } from '../daemon/adapters/maestro/run-script-http.ts';
8+
import {
9+
DAEMON_HTTP_NETWORK_ACCESS_HEADER,
10+
DAEMON_HTTP_PUBLIC_NETWORK_ACCESS,
11+
} from '../daemon/http-contract.ts';
612
import { DAEMON_RPC_PROTOCOL_VERSION } from '../daemon/http-health.ts';
713
import {
814
closeLoopbackServer,
@@ -24,6 +30,7 @@ test('daemon proxy forwards rpc requests with upstream daemon token', async (t)
2430

2531
let upstreamAuth = '';
2632
let upstreamTokenHeader = '';
33+
let upstreamNetworkAccess = '';
2734
let upstreamBody: Record<string, any> | undefined;
2835
const upstream = http.createServer((req, res) => {
2936
if (req.url === '/health') {
@@ -34,6 +41,7 @@ test('daemon proxy forwards rpc requests with upstream daemon token', async (t)
3441
assert.equal(req.url, '/rpc');
3542
upstreamAuth = String(req.headers.authorization ?? '');
3643
upstreamTokenHeader = String(req.headers['x-agent-device-token'] ?? '');
44+
upstreamNetworkAccess = String(req.headers[DAEMON_HTTP_NETWORK_ACCESS_HEADER] ?? '');
3745
let body = '';
3846
req.setEncoding('utf8');
3947
req.on('data', (chunk) => {
@@ -88,6 +96,7 @@ test('daemon proxy forwards rpc requests with upstream daemon token', async (t)
8896
});
8997
assert.equal(upstreamAuth, 'Bearer daemon-secret');
9098
assert.equal(upstreamTokenHeader, 'daemon-secret');
99+
assert.equal(upstreamNetworkAccess, DAEMON_HTTP_PUBLIC_NETWORK_ACCESS);
91100
assert.equal(upstreamBody?.params?.token, 'daemon-secret');
92101
assert.equal(upstreamBody?.params?.command, 'devices');
93102
} finally {
@@ -96,6 +105,74 @@ test('daemon proxy forwards rpc requests with upstream daemon token', async (t)
96105
}
97106
});
98107

108+
test('proxy enforces public-only Maestro HTTP policy on a local daemon', async (t) => {
109+
if (await skipWhenLoopbackUnavailable(t)) return;
110+
111+
let loopbackRequests = 0;
112+
const loopbackTarget = http.createServer((_req, res) => {
113+
loopbackRequests += 1;
114+
res.end('loopback-secret');
115+
});
116+
const env = { ...process.env };
117+
delete env.AGENT_DEVICE_HTTP_AUTH_HOOK;
118+
delete env.AGENT_DEVICE_HTTP_AUTH_EXPORT;
119+
const daemon = await createDaemonHttpServer({
120+
token: 'daemon-secret',
121+
env,
122+
handleRequest: async (request) => {
123+
const url = request.positionals[0] ?? '';
124+
return {
125+
ok: true,
126+
data: await executeRunScriptHttpRequest({
127+
method: 'GET',
128+
url,
129+
headers: {},
130+
publicNetworkOnly: request.internal?.publicNetworkOnly === true,
131+
}),
132+
};
133+
},
134+
});
135+
const targetPort = await listenOnLoopback(loopbackTarget);
136+
const daemonPort = await listenOnLoopback(daemon);
137+
const proxy = createDaemonProxyServer({
138+
upstreamBaseUrl: `http://127.0.0.1:${daemonPort}`,
139+
upstreamToken: 'daemon-secret',
140+
clientToken: 'proxy-secret',
141+
});
142+
143+
try {
144+
const proxyPort = await listenOnLoopback(proxy);
145+
const post = async (url: string) => {
146+
const response = await fetch(`http://127.0.0.1:${proxyPort}/agent-device/rpc`, {
147+
method: 'POST',
148+
headers: { 'content-type': 'application/json', authorization: 'Bearer proxy-secret' },
149+
body: JSON.stringify({
150+
jsonrpc: '2.0',
151+
id: 'proxy-trust',
152+
method: 'agent_device.command',
153+
params: {
154+
token: 'proxy-secret',
155+
command: 'run_script_http',
156+
positionals: [url],
157+
flags: {},
158+
},
159+
}),
160+
});
161+
return { status: response.status, body: (await response.json()) as Record<string, any> };
162+
};
163+
164+
const loopbackResponse = await post(`http://127.0.0.1:${targetPort}/secret`);
165+
assert.equal(loopbackResponse.status, 400, JSON.stringify(loopbackResponse.body));
166+
assert.equal(loopbackResponse.body.error?.data?.code, 'INVALID_ARGS');
167+
assert.match(loopbackResponse.body.error?.message ?? '', /non-public address/);
168+
assert.equal(loopbackRequests, 0, 'the proxy path must never reach a loopback target');
169+
} finally {
170+
await closeLoopbackServer(proxy);
171+
await closeLoopbackServer(daemon);
172+
await closeLoopbackServer(loopbackTarget);
173+
}
174+
});
175+
99176
test('daemon proxy rejects unauthenticated rpc requests', async (t) => {
100177
if (await skipWhenLoopbackUnavailable(t)) return;
101178

src/cli-schema/cli-help-topics.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,9 @@ test('usageForCommand resolves remote help topic', async () => {
464464
assert.match(help, /Multiple agents can share one proxy/);
465465
assert.match(help, /disconnect releases local connection state/);
466466
assert.match(help, /A busy direct-proxy device error means another agent owns the device/);
467+
assert.match(help, /AGENT_DEVICE_HTTP_AUTH_HOOK configured treats HTTP requests as remote/);
468+
assert.match(help, /host-path install sources are rejected/);
469+
assert.match(help, /uploaded artifacts remain supported/);
467470
assert.match(help, /Limrun, BrowserStack, and AWS Device Farm through local provider profiles/);
468471
assert.match(help, /Limrun uses LIMRUN_API_KEY/);
469472
assert.match(help, /BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY/);

src/cli-schema/cli-help.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,7 @@ Rules:
822822
disconnect releases local connection state; close releases the active session and device lease.
823823
A busy direct-proxy device error means another agent owns the device until it closes or its inactivity lease expires.
824824
Keep the proxy token secret. Anyone with the token can control the proxied daemon.
825+
A daemon with AGENT_DEVICE_HTTP_AUTH_HOOK configured treats HTTP requests as remote: host-path install sources are rejected, uploaded artifacts remain supported, and Maestro runScript HTTP helpers allow only public network destinations. No-hook local HTTP and socket flows retain their local behavior.
825826
If local/proxy iOS reports that the runner is already owned by another agent-device daemon after lease admission, retry after the owning session closes or after lease expiry. If the conflict repeats, clean stale daemon state on the machine with simulator access.
826827
Do not use --config as a remote profile flag. --config loads CLI defaults; --remote-config selects remote daemon/profile settings.
827828
For self-contained scripts, pass the same --remote-config to every operational command, including disconnect; a preceding connect is optional but not required.

src/daemon/__tests__/http-server-rpc-validation.test.ts

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
3+
import fs from 'node:fs';
4+
import path from 'node:path';
35
import { createDaemonHttpServer } from '../server/http-server.ts';
46
import type { DaemonRequest, DaemonResponse } from '../types.ts';
7+
import { cleanupUploadedArtifact, trackUploadedArtifact } from '../artifact-tracking.ts';
8+
import { resolveInstallSource } from '../install-source-resolution.ts';
59
import {
610
closeLoopbackServer,
711
listenOnLoopback,
812
skipWhenLoopbackUnavailable,
913
} from '../../__tests__/test-utils/loopback.ts';
14+
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';
1015

1116
type RpcErrorResponse = {
1217
jsonrpc: string;
@@ -137,6 +142,7 @@ async function withInstallFromSourceRpcServer(
137142
}>,
138143
) => Promise<void>,
139144
t: { skip(reason?: string): void },
145+
env?: NodeJS.ProcessEnv,
140146
): Promise<void> {
141147
if (await skipWhenLoopbackUnavailable(t)) return;
142148

@@ -145,7 +151,7 @@ async function withInstallFromSourceRpcServer(
145151
dispatched.push(req);
146152
return { ok: true, data: { ok: true } };
147153
};
148-
const server = await createDaemonHttpServer({ handleRequest });
154+
const server = await createDaemonHttpServer({ handleRequest, env });
149155

150156
try {
151157
const port = await listenOnLoopback(server);
@@ -210,3 +216,148 @@ test('install_from_source still admits github-actions-artifact sources', async (
210216
assert.equal(dispatched[0]?.meta?.installSource?.kind, 'github-actions-artifact');
211217
}, t);
212218
});
219+
220+
test('remote HTTP rejects host path install sources in the command RPC used by the CLI', async (t) => {
221+
if (await skipWhenLoopbackUnavailable(t)) return;
222+
223+
const root = mkdtempForTestSync('agent-device-http-command-path-default-');
224+
const hookPath = writeAllowingAuthHook(root);
225+
let handlerCalls = 0;
226+
const server = await createDaemonHttpServer({
227+
env: remoteHttpEnvironment(hookPath),
228+
handleRequest: async (): Promise<DaemonResponse> => {
229+
handlerCalls += 1;
230+
return { ok: true, data: {} };
231+
},
232+
});
233+
234+
try {
235+
const port = await listenOnLoopback(server);
236+
const response = await postCommandRpc(port, {
237+
command: 'install_source',
238+
positionals: [],
239+
flags: { platform: 'android' },
240+
meta: {
241+
installSource: { kind: 'path', path: path.join(root, 'app.apk') },
242+
},
243+
});
244+
assert.equal(response.status, 400);
245+
assert.equal(response.body.error?.code, -32602);
246+
assert.equal(response.body.error?.data?.code, 'INVALID_ARGS');
247+
assert.match(response.body.error?.message ?? '', /disabled on the remote HTTP surface/);
248+
assert.equal(handlerCalls, 0);
249+
} finally {
250+
await closeLoopbackServer(server);
251+
fs.rmSync(root, { recursive: true, force: true });
252+
}
253+
});
254+
255+
test('remote HTTP accepts an uploaded path artifact without resolving the client path', async (t) => {
256+
if (await skipWhenLoopbackUnavailable(t)) return;
257+
258+
const root = mkdtempForTestSync('agent-device-http-uploaded-path-');
259+
const artifactPath = path.join(root, 'uploaded.apk');
260+
fs.writeFileSync(artifactPath, 'uploaded');
261+
const uploadedArtifactId = trackUploadedArtifact({ artifactPath, tempDir: root });
262+
const hookPath = writeAllowingAuthHook(root);
263+
const received: DaemonRequest[] = [];
264+
const server = await createDaemonHttpServer({
265+
env: remoteHttpEnvironment(hookPath),
266+
handleRequest: async (request): Promise<DaemonResponse> => {
267+
received.push(request);
268+
const resolved = resolveInstallSource(request);
269+
try {
270+
assert.equal(resolved.source.kind, 'path');
271+
assert.equal(resolved.source.path, artifactPath);
272+
} finally {
273+
resolved.cleanup();
274+
}
275+
return { ok: true, data: {} };
276+
},
277+
});
278+
279+
try {
280+
const port = await listenOnLoopback(server);
281+
const response = await postCommandRpc(port, {
282+
command: 'install_source',
283+
positionals: [],
284+
flags: { platform: 'android' },
285+
meta: {
286+
installSource: { kind: 'path', path: '/etc/hosts' },
287+
uploadedArtifactId,
288+
},
289+
});
290+
assert.equal(response.status, 200);
291+
assert.equal(received.length, 1);
292+
} finally {
293+
await closeLoopbackServer(server);
294+
cleanupUploadedArtifact(uploadedArtifactId);
295+
fs.rmSync(root, { recursive: true, force: true });
296+
}
297+
});
298+
299+
test('local command RPC keeps host paths unrestricted', async (t) => {
300+
if (await skipWhenLoopbackUnavailable(t)) return;
301+
const received: DaemonRequest[] = [];
302+
const server = await createDaemonHttpServer({
303+
env: localHttpEnvironment(),
304+
handleRequest: async (request): Promise<DaemonResponse> => {
305+
received.push(request);
306+
return { ok: true, data: {} };
307+
},
308+
});
309+
310+
try {
311+
const port = await listenOnLoopback(server);
312+
const response = await postCommandRpc(port, {
313+
command: 'install_source',
314+
positionals: [],
315+
flags: { platform: 'android' },
316+
meta: { installSource: { kind: 'path', path: '/tmp/local.apk' } },
317+
});
318+
assert.equal(response.status, 200);
319+
assert.deepEqual(received[0]?.meta?.installSource, {
320+
kind: 'path',
321+
path: '/tmp/local.apk',
322+
});
323+
assert.equal(received[0]?.internal, undefined);
324+
} finally {
325+
await closeLoopbackServer(server);
326+
}
327+
});
328+
329+
function writeAllowingAuthHook(root: string): string {
330+
const hookPath = path.join(root, 'auth-hook.mjs');
331+
fs.writeFileSync(hookPath, "export default () => ({ tenantId: 'tenant-test' });\n");
332+
return hookPath;
333+
}
334+
335+
function remoteHttpEnvironment(hookPath: string): NodeJS.ProcessEnv {
336+
const env = localHttpEnvironment();
337+
env.AGENT_DEVICE_HTTP_AUTH_HOOK = hookPath;
338+
return env;
339+
}
340+
341+
function localHttpEnvironment(): NodeJS.ProcessEnv {
342+
const env = { ...process.env };
343+
delete env.AGENT_DEVICE_HTTP_AUTH_HOOK;
344+
delete env.AGENT_DEVICE_HTTP_AUTH_EXPORT;
345+
return env;
346+
}
347+
348+
async function postCommandRpc(
349+
port: number,
350+
params: Record<string, unknown>,
351+
): Promise<{ status: number; body: RpcErrorResponse }> {
352+
const response = await fetch(`http://127.0.0.1:${port}/rpc`, {
353+
method: 'POST',
354+
headers: { 'content-type': 'application/json' },
355+
body: JSON.stringify({
356+
jsonrpc: '2.0',
357+
id: 'command-install-source',
358+
method: 'agent_device.command',
359+
params,
360+
}),
361+
});
362+
return { status: response.status, body: (await response.json()) as RpcErrorResponse };
363+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import assert from 'node:assert/strict';
2+
import { fileURLToPath } from 'node:url';
3+
import { test } from 'vitest';
4+
import { runCmdSync } from '@agent-device/host-kit/command';
5+
import { runScriptHttpChild } from '../run-script-http-child.ts';
6+
7+
test('the packaged HTTP child reports malformed input', () => {
8+
assert.equal(typeof runScriptHttpChild, 'function');
9+
const childPath = fileURLToPath(new URL('../run-script-http-child.ts', import.meta.url));
10+
const result = runCmdSync(process.execPath, ['--experimental-strip-types', childPath], {
11+
stdin: '{',
12+
allowFailure: true,
13+
});
14+
15+
assert.notEqual(result.exitCode, 0);
16+
assert.match(result.stderr, /SyntaxError/);
17+
});

0 commit comments

Comments
 (0)