Skip to content

Commit 9cbba95

Browse files
authored
fix: require auth for non-loopback remote daemons (#369)
* fix: require auth for non-loopback remote daemons * fix: allow mapped loopback remote daemons * refactor: use net blocklist for loopback checks
1 parent 877aaef commit 9cbba95

6 files changed

Lines changed: 111 additions & 32 deletions

File tree

skills/agent-device/references/remote-tenancy.md

Lines changed: 9 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@ Open this file for remote daemon HTTP flows, including `--remote-config` launche
99
- `agent-device open <app> --remote-config <path> --relaunch`
1010
- `AGENT_DEVICE_DAEMON_BASE_URL=...`
1111
- `AGENT_DEVICE_DAEMON_AUTH_TOKEN=...`
12-
- `curl ... agent_device.lease.allocate`
13-
- `curl ... agent_device.lease.heartbeat`
14-
- `curl ... agent_device.lease.release`
1512
- `agent-device --tenant ... --session-isolation tenant --run-id ... --lease-id ...`
1613

1714
## Most common mistake to avoid
@@ -33,14 +30,9 @@ agent-device open com.example.myapp --remote-config ./agent-device.remote.json -
3330
## Lease flow example
3431

3532
```bash
36-
export AGENT_DEVICE_DAEMON_BASE_URL=http://mac-host.example:4310
33+
export AGENT_DEVICE_DAEMON_BASE_URL=<trusted-daemon-base-url>
3734
export AGENT_DEVICE_DAEMON_AUTH_TOKEN=<token>
3835

39-
curl -sS "${AGENT_DEVICE_DAEMON_BASE_URL}/rpc" \
40-
-H "content-type: application/json" \
41-
-H "Authorization: Bearer <token>" \
42-
-d '{"jsonrpc":"2.0","id":"alloc-1","method":"agent_device.lease.allocate","params":{"tenantId":"acme","runId":"run-123","ttlMs":60000}}'
43-
4436
agent-device \
4537
--tenant acme \
4638
--session-isolation tenant \
@@ -49,34 +41,20 @@ agent-device \
4941
session list --json
5042
```
5143

52-
Heartbeat and release example:
44+
Low-level lease operations exist for host-side automation, but do not point them at arbitrary hosts. The remote daemon executes device-control commands, so only use a trusted daemon base URL and an auth token managed by the same operator boundary.
5345

54-
```bash
55-
curl -sS "${AGENT_DEVICE_DAEMON_BASE_URL}/rpc" \
56-
-H "content-type: application/json" \
57-
-H "Authorization: Bearer <token>" \
58-
-d '{"jsonrpc":"2.0","id":"hb-1","method":"agent_device.lease.heartbeat","params":{"leaseId":"<lease-id>","ttlMs":60000}}'
59-
60-
curl -sS "${AGENT_DEVICE_DAEMON_BASE_URL}/rpc" \
61-
-H "content-type: application/json" \
62-
-H "Authorization: Bearer <token>" \
63-
-d '{"jsonrpc":"2.0","id":"rel-1","method":"agent_device.lease.release","params":{"leaseId":"<lease-id>"}}'
64-
```
46+
Lease lifecycle methods exposed by the daemon:
6547

66-
Session-locked RPC command example:
67-
68-
```bash
69-
curl -sS "${AGENT_DEVICE_DAEMON_BASE_URL}/rpc" \
70-
-H "content-type: application/json" \
71-
-H "Authorization: Bearer <token>" \
72-
-d '{"jsonrpc":"2.0","id":"cmd-1","method":"agent_device.command","params":{"session":"qa-ios","command":"snapshot","positionals":[],"meta":{"lockPolicy":"reject","lockPlatform":"ios","tenantId":"acme","runId":"run-123","leaseId":"<lease-id>"}}}'
73-
```
48+
- `agent_device.lease.allocate`
49+
- `agent_device.lease.heartbeat`
50+
- `agent_device.lease.release`
51+
- `agent_device.command`
7452

7553
## Transport prerequisites
7654

7755
- Start the daemon in HTTP mode with `AGENT_DEVICE_DAEMON_SERVER_MODE=http|dual`.
7856
- Point the client at the remote host with `AGENT_DEVICE_DAEMON_BASE_URL=http(s)://host:port[/base-path]`.
79-
- Use `AGENT_DEVICE_DAEMON_AUTH_TOKEN` or `--daemon-auth-token` when the client should send the shared daemon token automatically.
57+
- For non-loopback remote hosts, set `AGENT_DEVICE_DAEMON_AUTH_TOKEN` or `--daemon-auth-token`. The client rejects non-loopback remote daemon URLs without auth.
8058
- Direct JSON-RPC callers can authenticate with request params, `Authorization: Bearer <token>`, or `x-agent-device-token`.
8159
- Prefer an auth hook such as `AGENT_DEVICE_HTTP_AUTH_HOOK` when the host needs caller validation or tenant injection.
8260

@@ -117,4 +95,5 @@ The CLI sends `AGENT_DEVICE_DAEMON_AUTH_TOKEN` in both the JSON-RPC request toke
11795
- Missing tenant, run, or lease fields in tenant-isolation mode should fail as `INVALID_ARGS`.
11896
- Inactive or scope-mismatched leases should fail as `UNAUTHORIZED`.
11997
- Inspect logs on the remote host during remote debugging. Client-side `--debug` does not tail a local daemon log once `AGENT_DEVICE_DAEMON_BASE_URL` is set.
98+
- Do not point `AGENT_DEVICE_DAEMON_BASE_URL` at untrusted hosts. Remote daemon requests can launch apps and execute interaction commands.
12099
- Treat daemon auth tokens and lease identifiers as sensitive operational data.

src/__tests__/cli-diagnostics.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@ test('cli does not tail local daemon log when remote daemon base URL is set', as
9494
fs.writeFileSync(daemonPaths.logPath, 'REMOTE_TAIL_SENTINEL\n', 'utf8');
9595

9696
const previousBaseUrl = process.env.AGENT_DEVICE_DAEMON_BASE_URL;
97+
const previousAuthToken = process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
9798
process.env.AGENT_DEVICE_DAEMON_BASE_URL = 'http://remote-mac.example.test:7777/agent-device';
99+
process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = 'remote-secret';
98100

99101
try {
100102
const result = await runCliCapture(
@@ -113,6 +115,8 @@ test('cli does not tail local daemon log when remote daemon base URL is set', as
113115
} finally {
114116
if (previousBaseUrl === undefined) delete process.env.AGENT_DEVICE_DAEMON_BASE_URL;
115117
else process.env.AGENT_DEVICE_DAEMON_BASE_URL = previousBaseUrl;
118+
if (previousAuthToken === undefined) delete process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
119+
else process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = previousAuthToken;
116120
fs.rmSync(stateDir, { recursive: true, force: true });
117121
}
118122
});

src/daemon-client.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ const IOS_RUNNER_XCODEBUILD_KILL_PATTERNS = [
9191
'xcodebuild .*AgentDeviceRunner\\.env\\.session-',
9292
'xcodebuild build-for-testing .*ios-runner/AgentDeviceRunner/AgentDeviceRunner\\.xcodeproj',
9393
];
94+
const LOOPBACK_BLOCK_LIST = new net.BlockList();
95+
LOOPBACK_BLOCK_LIST.addSubnet('127.0.0.0', 8, 'ipv4');
96+
LOOPBACK_BLOCK_LIST.addAddress('::1', 'ipv6');
97+
LOOPBACK_BLOCK_LIST.addSubnet('::ffff:127.0.0.0', 104, 'ipv6');
9498

9599
export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> {
96100
const requestId = req.meta?.requestId ?? createRequestId();
@@ -394,6 +398,7 @@ function resolveClientSettings(req: Omit<DaemonRequest, 'token'>): DaemonClientS
394398
req.flags?.daemonBaseUrl ?? process.env.AGENT_DEVICE_DAEMON_BASE_URL,
395399
);
396400
const remoteAuthToken = req.flags?.daemonAuthToken ?? process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
401+
validateRemoteDaemonTrust(remoteBaseUrl, remoteAuthToken);
397402
const rawTransport = req.flags?.daemonTransport ?? process.env.AGENT_DEVICE_DAEMON_TRANSPORT;
398403
const transportPreference = resolveDaemonTransportPreference(rawTransport);
399404
if (remoteBaseUrl && transportPreference === 'socket') {
@@ -1113,6 +1118,35 @@ function resolveRemoteDaemonBaseUrl(raw: string | undefined): string | undefined
11131118
return parsed.toString().replace(/\/+$/, '');
11141119
}
11151120

1121+
function validateRemoteDaemonTrust(
1122+
remoteBaseUrl: string | undefined,
1123+
remoteAuthToken: string | undefined,
1124+
): void {
1125+
if (!remoteBaseUrl) return;
1126+
const hostname = new URL(remoteBaseUrl).hostname;
1127+
if (isLoopbackHostname(hostname)) return;
1128+
if (typeof remoteAuthToken === 'string' && remoteAuthToken.trim().length > 0) return;
1129+
throw new AppError(
1130+
'INVALID_ARGS',
1131+
'Remote daemon base URL for non-loopback hosts requires daemon authentication',
1132+
{
1133+
daemonBaseUrl: remoteBaseUrl,
1134+
hint: 'Provide --daemon-auth-token or AGENT_DEVICE_DAEMON_AUTH_TOKEN when using a non-loopback remote daemon URL.',
1135+
},
1136+
);
1137+
}
1138+
1139+
function isLoopbackHostname(hostname: string): boolean {
1140+
const normalized = hostname
1141+
.trim()
1142+
.toLowerCase()
1143+
.replace(/^\[(.*)\]$/, '$1');
1144+
if (normalized === 'localhost') return true;
1145+
if (net.isIPv4(normalized)) return LOOPBACK_BLOCK_LIST.check(normalized, 'ipv4');
1146+
if (net.isIPv6(normalized)) return LOOPBACK_BLOCK_LIST.check(normalized, 'ipv6');
1147+
return false;
1148+
}
1149+
11161150
function buildDaemonHttpUrl(baseUrl: string, route: 'health' | 'rpc'): string {
11171151
// URL(base, relative) treats a base without trailing slash as a file path, so normalize to a directory-like base.
11181152
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;

src/utils/__tests__/daemon-client.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,58 @@ test('sendToDaemon rejects socket transport when remote daemon base URL is set',
344344
}
345345
});
346346

347+
test('sendToDaemon treats IPv4-mapped loopback remote daemon URLs as local', async () => {
348+
const previousBaseUrl = process.env.AGENT_DEVICE_DAEMON_BASE_URL;
349+
const previousAuthToken = process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
350+
process.env.AGENT_DEVICE_DAEMON_BASE_URL = 'http://[::ffff:127.0.0.1]:4310/agent-device';
351+
delete process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
352+
353+
try {
354+
await assert.rejects(
355+
async () =>
356+
await sendToDaemon({
357+
session: 'default',
358+
command: 'remote-smoke',
359+
positionals: [],
360+
flags: { daemonTransport: 'socket' },
361+
meta: { requestId: 'req-remote-mapped-loopback' },
362+
}),
363+
/only supports HTTP transport/,
364+
);
365+
} finally {
366+
if (previousBaseUrl === undefined) delete process.env.AGENT_DEVICE_DAEMON_BASE_URL;
367+
else process.env.AGENT_DEVICE_DAEMON_BASE_URL = previousBaseUrl;
368+
if (previousAuthToken === undefined) delete process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
369+
else process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = previousAuthToken;
370+
}
371+
});
372+
373+
test('sendToDaemon requires auth for non-loopback remote daemon URLs', async () => {
374+
const previousBaseUrl = process.env.AGENT_DEVICE_DAEMON_BASE_URL;
375+
const previousAuthToken = process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
376+
process.env.AGENT_DEVICE_DAEMON_BASE_URL = 'https://remote-mac.example.test/agent-device';
377+
delete process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
378+
379+
try {
380+
await assert.rejects(
381+
async () =>
382+
await sendToDaemon({
383+
session: 'default',
384+
command: 'remote-smoke',
385+
positionals: [],
386+
flags: {},
387+
meta: { requestId: 'req-remote-auth' },
388+
}),
389+
/requires daemon authentication/,
390+
);
391+
} finally {
392+
if (previousBaseUrl === undefined) delete process.env.AGENT_DEVICE_DAEMON_BASE_URL;
393+
else process.env.AGENT_DEVICE_DAEMON_BASE_URL = previousBaseUrl;
394+
if (previousAuthToken === undefined) delete process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
395+
else process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = previousAuthToken;
396+
}
397+
});
398+
347399
test('sendToDaemon uploads local install artifacts for remote daemons and passes upload id to RPC', async () => {
348400
const seenPaths: string[] = [];
349401
let uploadBodySize = 0;
@@ -553,7 +605,9 @@ test('sendToDaemon preserves explicit remote install paths without uploading', a
553605
}) as typeof http.request;
554606

555607
const previousBaseUrl = process.env.AGENT_DEVICE_DAEMON_BASE_URL;
608+
const previousAuthToken = process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
556609
process.env.AGENT_DEVICE_DAEMON_BASE_URL = 'http://remote-mac.example.test:7777/agent-device';
610+
process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = 'remote-secret';
557611

558612
try {
559613
const response = await sendToDaemon({
@@ -572,6 +626,8 @@ test('sendToDaemon preserves explicit remote install paths without uploading', a
572626
(http as unknown as { request: typeof http.request }).request = originalHttpRequest;
573627
if (previousBaseUrl === undefined) delete process.env.AGENT_DEVICE_DAEMON_BASE_URL;
574628
else process.env.AGENT_DEVICE_DAEMON_BASE_URL = previousBaseUrl;
629+
if (previousAuthToken === undefined) delete process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
630+
else process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = previousAuthToken;
575631
}
576632
});
577633

@@ -630,7 +686,9 @@ test('sendToDaemon preserves install_source payload metadata for remote HTTP RPC
630686
}) as typeof http.request;
631687

632688
const previousBaseUrl = process.env.AGENT_DEVICE_DAEMON_BASE_URL;
689+
const previousAuthToken = process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
633690
process.env.AGENT_DEVICE_DAEMON_BASE_URL = 'http://remote-mac.example.test:7777/agent-device';
691+
process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = 'remote-secret';
634692

635693
try {
636694
const response = await sendToDaemon({
@@ -663,6 +721,8 @@ test('sendToDaemon preserves install_source payload metadata for remote HTTP RPC
663721
(http as unknown as { request: typeof http.request }).request = originalHttpRequest;
664722
if (previousBaseUrl === undefined) delete process.env.AGENT_DEVICE_DAEMON_BASE_URL;
665723
else process.env.AGENT_DEVICE_DAEMON_BASE_URL = previousBaseUrl;
724+
if (previousAuthToken === undefined) delete process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
725+
else process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = previousAuthToken;
666726
}
667727
});
668728

website/docs/docs/commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ agent-device app-switcher
5353
- In `batch`, steps that omit `platform` still inherit the parent batch `--platform`; lock-mode defaults do not override that parent setting.
5454
- Tenant-scoped daemon runs can pass `--tenant`, `--session-isolation tenant`, `--run-id`, and `--lease-id` to enforce lease admission.
5555
- Remote daemon clients can pass `--daemon-base-url http(s)://host:port[/base-path]` to skip local daemon discovery/startup and call a remote HTTP daemon directly.
56-
- Use `--daemon-auth-token <token>` (or `AGENT_DEVICE_DAEMON_AUTH_TOKEN`) when the remote daemon expects the shared daemon token over HTTP; the client sends it in both the JSON-RPC request token and HTTP auth headers.
56+
- Use `--daemon-auth-token <token>` (or `AGENT_DEVICE_DAEMON_AUTH_TOKEN`) for non-loopback remote daemon URLs; the client sends it in both the JSON-RPC request token and HTTP auth headers.
5757
- `open <app> --remote-config <path> --relaunch` is the canonical remote Metro-backed launch flow for sandbox agents. The remote profile supplies the remote host + Metro settings, `open` prepares Metro locally when needed, derives platform runtime hints, and forwards them inline to the remote daemon before launch.
5858
- `metro prepare --remote-config <path>` remains available for inspection and debugging. It prints JSON runtime hints to stdout, `--json` wraps them in the standard `{ success, data }` envelope, and `--runtime-file <path>` persists the same payload when callers need an artifact.
5959
- Android React Native relaunch flows require an installed package name for `open --relaunch`; install/reinstall the APK first, then relaunch by package. `open <apk|aab> --relaunch` is rejected because runtime hints are written through the installed app sandbox.

website/docs/docs/configuration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@ Example:
4848
"device": "iPhone 16",
4949
"session": "qa-ios",
5050
"snapshotDepth": 3,
51-
"daemonBaseUrl": "http://mac-host.example:4310/agent-device"
51+
"daemonBaseUrl": "http://127.0.0.1:4310/agent-device"
5252
}
5353
```
5454

55+
For non-loopback remote daemon URLs, also set `daemonAuthToken` or `AGENT_DEVICE_DAEMON_AUTH_TOKEN`. The client rejects non-loopback remote daemon URLs without auth.
56+
5557
Common keys include:
5658
- `stateDir`
5759
- `daemonBaseUrl`

0 commit comments

Comments
 (0)