Skip to content

Commit 1c08904

Browse files
committed
fix: device_deeplink url injection (Phase 134.2-followup)
Phase 134.2 validated packageName but missed `url`. Deepsec revalidation 20260512193352 re-flagged device-deeplink.ts as HIGH because url still flowed unescaped into the Android remote shell — `adb shell am start -d <url>` joins argv with spaces and re-interprets as a raw command line, so url='myapp://path;reboot' would execute `reboot` after the `am start` completed. Two-layer defense: - Reject urls containing control chars / newlines / >4096 chars at the handler boundary (those break out of the POSIX-quoted string). - POSIX-single-quote the url before adb argv — same pattern as device-interact.ts:524 (buildAdbInputTextArgv). Every shell metacharacter becomes inert. Legitimate URLs with &, ?, =, # continue to work — the quote wrap makes those literal args to `am start`, not shell expansion targets. 4 new unit tests. Full suite: 1308 → 1312 passing, 0 failing. Closes the LAST HIGH-severity finding from the original deepsec scan. Post-merge state: CRITICAL = 0, HIGH = 0. Versions: plugin 0.44.35 → 0.44.36, cdp-bridge 0.38.30 → 0.38.31, marketplace synced.
1 parent 2f7e64a commit 1c08904

7 files changed

Lines changed: 160 additions & 6 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
{
1010
"name": "rn-dev-agent",
1111
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
12-
"version": "0.44.35",
12+
"version": "0.44.36",
1313
"source": "./",
1414
"category": "mobile-development",
1515
"homepage": "https://github.com/Lykhoyda/rn-dev-agent"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent",
3-
"version": "0.44.35",
3+
"version": "0.44.36",
44
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
55
"author": {
66
"name": "Anton Lykhoyda",

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,41 @@ All notable changes to rn-dev-agent will be documented in this file.
44

55
Format follows [Keep a Changelog](https://keepachangelog.com/).
66

7+
## [0.44.36] — 2026-05-12
8+
9+
### Fixed (Phase 134.2-followup — device_deeplink url injection)
10+
11+
- **`device_deeplink` now POSIX-quotes the caller-supplied `url`** before
12+
passing it through `adb shell am start -d <url>`. The Phase 134.2 fix
13+
validated `packageName` but missed `url`. Deepsec revalidation
14+
(run `20260512193352`) re-flagged the deeplink as HIGH because the
15+
`url` arg still flowed unescaped into the Android remote shell, where
16+
argv is joined with spaces and re-interpreted as a raw command line.
17+
A URL like `myapp://path;reboot` would have executed `reboot` after
18+
the `am start` completed.
19+
- Two-layer defense:
20+
1. **Validation at the handler boundary**: reject any `url` that
21+
contains control characters or newlines (which would break out of
22+
the POSIX-quoted string itself), or exceeds 4096 chars.
23+
2. **POSIX single-quote wrap**: every shell metacharacter inside the
24+
URL (`;`, `|`, `$`, `` ` ``, `&`) becomes inert. Same pattern as
25+
`device-interact.ts:524` (`buildAdbInputTextArgv`).
26+
- Legitimate URLs with `&`, `?`, `=`, `#` continue to work — the quote
27+
wrap makes those literal arguments to `am start -d`, not shell
28+
expansion targets.
29+
30+
### Internal
31+
32+
- 4 new unit tests in `phase-134-2-adb-shell-arg-hardening.test.js`:
33+
- newline-injected URL rejected
34+
- control-char-bearing URL rejected
35+
- oversized URL (>4096 chars) rejected
36+
- legitimate URL with query+fragment passes validation
37+
- Full unit suite: 1308 → 1312 passing, 0 failing.
38+
- Closes the **last HIGH-severity** finding from the original deepsec
39+
scan. Post-merge: **CRITICAL = 0, HIGH = 0** (100% security-class
40+
findings closed).
41+
742
## [0.44.35] — 2026-05-12
843

944
### Fixed (Phase 134.5 — workflow + correctness sweep, closes 3 MEDIUM + 2 BUG)

scripts/cdp-bridge/dist/tools/device-deeplink.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,27 @@ async function openIosDeeplink(url) {
2222
return failResult(`xcrun simctl openurl failed: ${msg}`, { code: 'DEEPLINK_FAILED', platform: 'ios', url });
2323
}
2424
}
25+
/**
26+
* Phase 134.2-followup (deepsec HIGH revalidation 20260512193352): the
27+
* Phase 134.2 fix validated `packageName` but missed `url`. `adb shell
28+
* <argv...>` joins argv with spaces and sends the result to the Android
29+
* remote shell as a raw command line — it does NOT per-argument escape.
30+
* Without quoting, `url='myapp://path;reboot'` produces a shell command
31+
* `am start ... -d myapp://path;reboot` where `;reboot` runs after
32+
* `am start` completes.
33+
*
34+
* Two-layer defense: validate URL shape first (reject newlines / control
35+
* chars), then POSIX single-quote the resolved URL so any remaining shell
36+
* metacharacters are inert. Same quoting pattern as
37+
* device-interact.ts:524 (`buildAdbInputTextArgv`).
38+
*/
39+
function posixSingleQuote(s) {
40+
return `'${s.replace(/'/g, "'\\''")}'`;
41+
}
2542
async function openAndroidDeeplink(url, packageName) {
2643
const serial = getAdbSerial();
27-
const args = [...serial, 'shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url];
44+
const quotedUrl = posixSingleQuote(url);
45+
const args = [...serial, 'shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', quotedUrl];
2846
if (packageName)
2947
args.push('-n', packageName);
3048
try {
@@ -62,6 +80,17 @@ export function createDeviceDeeplinkHandler() {
6280
if (!args.url || args.url.length === 0) {
6381
return failResult('url is required', { code: 'INVALID_ARGS' });
6482
}
83+
// Phase 134.2-followup (deepsec HIGH revalidation 20260512193352):
84+
// `url` flows into the adb shell command line. POSIX-quoting in
85+
// openAndroidDeeplink covers the shell-metachar layer, but a URL
86+
// containing a newline or control char would break out of the
87+
// quoted string entirely. Reject those at the boundary.
88+
if (typeof args.url !== 'string' || /[\u0000-\u001F\u0085\u2028\u2029]/.test(args.url)) {
89+
return failResult(`url contains control characters or newlines — refuse to pass to adb shell (Phase 134.2-followup)`, { code: 'INVALID_ARGS' });
90+
}
91+
if (args.url.length > 4096) {
92+
return failResult('url too long (max 4096 chars)', { code: 'INVALID_ARGS' });
93+
}
6594
// Phase 134.2 (deepsec HIGH): `packageName` reaches `adb shell am start
6695
// -n <packageName>`, where the remote Android shell re-interprets argv.
6796
// Validate against the strict bundle-ID regex. packageName remains

scripts/cdp-bridge/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent-cdp",
3-
"version": "0.38.30",
3+
"version": "0.38.31",
44
"type": "module",
55
"main": "dist/index.js",
66
"scripts": {

scripts/cdp-bridge/src/tools/device-deeplink.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,28 @@ async function openIosDeeplink(url: string): Promise<ToolResult> {
3535
}
3636
}
3737

38+
/**
39+
* Phase 134.2-followup (deepsec HIGH revalidation 20260512193352): the
40+
* Phase 134.2 fix validated `packageName` but missed `url`. `adb shell
41+
* <argv...>` joins argv with spaces and sends the result to the Android
42+
* remote shell as a raw command line — it does NOT per-argument escape.
43+
* Without quoting, `url='myapp://path;reboot'` produces a shell command
44+
* `am start ... -d myapp://path;reboot` where `;reboot` runs after
45+
* `am start` completes.
46+
*
47+
* Two-layer defense: validate URL shape first (reject newlines / control
48+
* chars), then POSIX single-quote the resolved URL so any remaining shell
49+
* metacharacters are inert. Same quoting pattern as
50+
* device-interact.ts:524 (`buildAdbInputTextArgv`).
51+
*/
52+
function posixSingleQuote(s: string): string {
53+
return `'${s.replace(/'/g, "'\\''")}'`;
54+
}
55+
3856
async function openAndroidDeeplink(url: string, packageName?: string): Promise<ToolResult> {
3957
const serial = getAdbSerial();
40-
const args = [...serial, 'shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url];
58+
const quotedUrl = posixSingleQuote(url);
59+
const args = [...serial, 'shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', quotedUrl];
4160
if (packageName) args.push('-n', packageName);
4261
try {
4362
const { stdout, stderr } = await execFile('adb', args, { timeout: EXEC_TIMEOUT_MS });
@@ -74,6 +93,20 @@ export function createDeviceDeeplinkHandler(): (args: DeeplinkArgs) => Promise<T
7493
if (!args.url || args.url.length === 0) {
7594
return failResult('url is required', { code: 'INVALID_ARGS' });
7695
}
96+
// Phase 134.2-followup (deepsec HIGH revalidation 20260512193352):
97+
// `url` flows into the adb shell command line. POSIX-quoting in
98+
// openAndroidDeeplink covers the shell-metachar layer, but a URL
99+
// containing a newline or control char would break out of the
100+
// quoted string entirely. Reject those at the boundary.
101+
if (typeof args.url !== 'string' || /[\u0000-\u001F\u0085\u2028\u2029]/.test(args.url)) {
102+
return failResult(
103+
`url contains control characters or newlines — refuse to pass to adb shell (Phase 134.2-followup)`,
104+
{ code: 'INVALID_ARGS' },
105+
);
106+
}
107+
if (args.url.length > 4096) {
108+
return failResult('url too long (max 4096 chars)', { code: 'INVALID_ARGS' });
109+
}
77110
// Phase 134.2 (deepsec HIGH): `packageName` reaches `adb shell am start
78111
// -n <packageName>`, where the remote Android shell re-interprets argv.
79112
// Validate against the strict bundle-ID regex. packageName remains

scripts/cdp-bridge/test/unit/phase-134-2-adb-shell-arg-hardening.test.js

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,64 @@ test('Phase 134.2: device_snapshot action=open rejects shell-metachar appId', as
215215
assert.equal(r.isError, true);
216216
});
217217

218-
test('Phase 134.2: device_deeplink without packageName works (optional arg unchanged)', async () => {
218+
// ── Phase 134.2-followup: device_deeplink URL injection ─────────────
219+
// The original 134.2 fix validated packageName but missed `url`.
220+
// Deepsec revalidation 20260512193352 flagged `url` as a separate
221+
// injection vector: a URL like 'myapp://path;reboot' would break out
222+
// of the adb shell command after `am start` completes. Fix: validate
223+
// + POSIX single-quote the url.
224+
225+
test('Phase 134.2-followup: device_deeplink rejects url with newline-injection', async () => {
226+
const { createDeviceDeeplinkHandler } = await import('../../dist/tools/device-deeplink.js');
227+
const handler = createDeviceDeeplinkHandler();
228+
const r = await handler({
229+
url: 'myapp://path\nreboot',
230+
platform: 'android',
231+
});
232+
assert.equal(r.isError, true);
233+
const env = parseEnvelope(r);
234+
assert.match(env.error, /control characters|newlines/i);
235+
});
236+
237+
test('Phase 134.2-followup: device_deeplink rejects url with control characters', async () => {
238+
const { createDeviceDeeplinkHandler } = await import('../../dist/tools/device-deeplink.js');
239+
const handler = createDeviceDeeplinkHandler();
240+
const r = await handler({
241+
url: 'myapp://pathevil',
242+
platform: 'android',
243+
});
244+
assert.equal(r.isError, true);
245+
});
246+
247+
test('Phase 134.2-followup: device_deeplink rejects oversized url', async () => {
248+
const { createDeviceDeeplinkHandler } = await import('../../dist/tools/device-deeplink.js');
249+
const handler = createDeviceDeeplinkHandler();
250+
const r = await handler({
251+
url: 'myapp://' + 'a'.repeat(5000),
252+
platform: 'android',
253+
});
254+
assert.equal(r.isError, true);
255+
const env = parseEnvelope(r);
256+
assert.match(env.error, /too long/i);
257+
});
258+
259+
test('Phase 134.2-followup: device_deeplink accepts urls with legitimate special chars (& ? = #)', async () => {
260+
// URL query params with & and = are legitimate; POSIX-quoting keeps them inert.
261+
const { createDeviceDeeplinkHandler } = await import('../../dist/tools/device-deeplink.js');
262+
const handler = createDeviceDeeplinkHandler();
263+
const r = await handler({
264+
url: 'myapp://path?key=value&other=thing#fragment',
265+
platform: 'android',
266+
});
267+
// May error from adb-not-installed but NOT from input validation.
268+
if (r.isError) {
269+
const env = parseEnvelope(r);
270+
assert.doesNotMatch(env.error ?? '', /control characters|newlines|too long/i,
271+
'Legitimate URL with query/fragment must pass input validation');
272+
}
273+
});
274+
275+
test('Phase 134.2-followup: device_deeplink without packageName works (optional arg unchanged)', async () => {
219276
// packageName is optional — when omitted, no validation is needed.
220277
const { createDeviceDeeplinkHandler } = await import('../../dist/tools/device-deeplink.js');
221278
const handler = createDeviceDeeplinkHandler();

0 commit comments

Comments
 (0)