Skip to content

fix(mobile): validate runtime resize frames on the adb-shell WebSocket - #1476

Closed
sxh313 wants to merge 3 commits into
TencentCloud:developfrom
sxh313:fix-mobile-shell-resize-guard
Closed

sxh313 wants to merge 3 commits into
TencentCloud:developfrom
sxh313:fix-mobile-shell-resize-guard

Conversation

@sxh313

@sxh313 sxh313 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

What this fixes

One malformed resize frame cost the user the whole remote Android shell.

/api/mobile/adb/shell/ws validates the handshake cols/rows (ge=20, le=500 / ge=5, le=200) but
converts and applies a runtime resize frame with no guard at all, and _set_winsize there has no
try — unlike the same-named helper in routers/terminal.py, which logs "Errors are non-fatal". The
receive loop's only handler is except WebSocketDisconnect, so anything else escapes the handler, and
the finally then closes the master fd and SIGTERMs the adb shell process group. uvicorn answers
1011; the terminal is gone.

This is #1317's request applied to the second PTY-over-WebSocket surface in the repo. #1318 implements
exactly this shape for terminal.py; this PR mirrors that diff for the mobile route, and changes
nothing in terminal.py, which #1318 owns.

Found from the shipped code, filed as #1475.

Changes

file change
src/octop/api/routers/mobile/shell_ws.py hoist the handshake's literals into _MIN_COLS/_MAX_COLS/_MIN_ROWS/_MAX_ROWS, reference them from the Query declarations, and in the resize branch drop a frame that fails to convert or falls outside those bounds — same continue shape as #1318
tests/unit/mobile/test_shell_ws_resize.py 11 handler-level tests
CHANGELOG.md one ### 修复 bullet

OverflowError is in the except clause in addition to #1318's (TypeError, ValueError), because
json.loads accepts the non-standard Infinity literal and int(float("inf")) raises it. Mutation M3
below shows that clause is load-bearing.

Red on develop @ a02e3e18

7 failed, 4 passed in 4.29s — the 4 passing cases are the frames that should be applied, so the
suite is not uniformly red by construction. Verbatim:

frame failure on develop
{"cols":70000,"rows":32} struct.error: 'H' format requires 0 <= number <= 65535
{"cols":-1,"rows":32} struct.error: 'H' format requires 0 <= number <= 65535
{"cols":"abc","rows":32} ValueError: invalid literal for int() with base 10: 'abc'
{"cols":[1],"rows":32} TypeError: int() argument must be a string, a bytes-like object or a real number, not 'list'
{"cols":Infinity,"rows":32} OverflowError: cannot convert float infinity to integer
{"cols":NaN,"rows":32} ValueError: cannot convert float NaN to integer
{"cols":100,"rows":1} then {"cols":100,"rows":9999} no crash — assert [(41, 120, 32), (41, 100, 1), (41, 100, 9999), (41, 80, 24)] == [(41, 120, 32), (41, 80, 24)]

The last row is a second, quieter defect that the range check also closes: 1 and 9999 fit in an
unsigned short, so those frames do not kill the session, they resize the PTY to a geometry no
terminal can use.

Note on NaN: float('nan') or 120 evaluates to nan because NaN is truthy, so the or fallback
does not rescue it. Recorded on #1475.

Green

11 passed in 0.40s

Neighbourhood lanes on this branch: tests/unit/mobile tests/unit/api → 366 passed, 10 skipped in 37.52s.

Full non-live unit suite (pytest tests/unit -m "not live" -n 4) is still running as this PR opens;
its read-out, including how any failure relates to this diff, follows in a comment on this PR.

How each clause was shown to be necessary

Each mutation was applied to the fixed file, run, and reverted; the source was then confirmed
byte-identical to the measured-green copy (sha1 61684fd9…):

mutation result
M1 remove the try/except 4 failed — test_string_cols_is_ignored, test_array_cols_is_ignored, test_infinity_cols_is_ignored, test_nan_cols_is_ignored
M2 remove the range check 3 failed — test_oversized_cols_is_ignored_and_the_session_survives, test_negative_cols_is_ignored_and_the_previous_size_is_kept, test_out_of_range_rows_is_ignored
M3 narrow the clause to #1318's (TypeError, ValueError) 1 failed — test_infinity_cols_is_ignored
M4 _MAX_COLS = 500 → 100 2 failed — test_resize_without_dimensions_falls_back_to_the_handshake_size, test_endpoints_of_the_handshake_range_are_still_accepted

No test is a tautology: M1/M2/M3/M4 each kill a different subset, and the two clauses cover different
rows of the table above.

Gates

  • ruff check src tests → All checks passed
  • ruff format --check src tests → 1120 files already formatted
  • mypy --strict src/octop → Success: no issues found in 544 source files

What is not measured

The route refuses non-POSIX at shell_ws.py:69 and needs a real adb, a connected device and a real
PTY, so this box cannot produce a live 1011 capture. The tests instead drive the handler with the PTY,
subprocess and auth layers stubbed, and the set_winsize stub contains the same
struct.pack("HHHH", rows, cols, 0, 0) statement that posix_compat.set_winsize:103 runs in
production — so the exception under test is the one production raises, while the teardown consequence
(the finally killing the process group) is read from the code, not observed.

CI

These checks run make install && make lint && make typecheck && make test on Linux and Windows.
make test is pytest -n $(PYTEST_JOBS) -m "not live", so it does cover the new test file on both
platforms; the new tests are not platform-gated, unlike terminal.py's @posix_only ones.

A cols/rows value that the PTY layer cannot represent raised out of the receive
loop (struct.error for anything outside an unsigned short, ValueError/TypeError
for strings and arrays, OverflowError for the Infinity literal json.loads
accepts). The only handler there is except WebSocketDisconnect, so the exception
reached the finally, which closes the master fd and SIGTERMs adb shell: one
malformed frame cost the user the whole terminal.

Validate the frame against the same bounds the handshake already enforces and
ignore it otherwise, so the previously applied size survives a bad frame.

The regression tests drive the handler with the PTY and subprocess layers
stubbed, which lets them run on a non-POSIX host; the set_winsize stub contains
the same struct.pack("HHHH", rows, cols, 0, 0) statement posix_compat runs in
production, so an unvalidated size fails the way it fails there.

Fixes TencentCloud#1475
@sxh313

sxh313 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Full non-live unit suite read-out (promised in the PR body)

pytest tests/unit -m "not live" -n 4 on CPython 3.12.13, Chinese Windows (locale cp936), branch
head f33ff71e:

23 failed, 3354 passed, 103 skipped, 16 warnings in 121.70s (0:02:01)

All 23 are pre-existing, and the way this PR proves it is a set comparison rather than an assertion:
the 23 failing node ids are byte-identical (empty symmetric difference over comm -3, 23 vs 23)
to the 23 measured on develop @ a02e3e18 itself in a throwaway worktree earlier today, before this
branch existed. The passed count reconciles exactly too — 3350 - 7 + 11 = 3354: the 7 from the
desktop-scroll tests of the sibling branch #1474 (this checkout does not contain them) out, the 11 new
tests in this PR in.

Two known lanes, matching those node ids:

count node ids tracked by
19 tests/unit/db/*, tests/unit/backup/* — all raising UnicodeDecodeError: 'gbk' codec can't decode byte 0x92 (18 at position 134, 1 at position 247), raised from pathlib.py:1028 inside the test's own read_text() of a migration .sql fixture, e.g. tests/unit/db/test_db_pool.py:397 #1072 (open; its title is literally "…不再红 19 个")
4 tests/unit/test_vendor_wheels_argv.py::test_download_line_is_reached[*] — TypeError: argument of type 'NoneType' is not iterable at :82 #1462 (issue), fix open in my #1463

The 19 were re-read on this tree rather than carried over from the earlier run: selecting exactly those
node ids gives 19 failed in 3.97s.

Neither lane shares a file with this PR, measured rather than argued:
comm -12 <(git diff --name-only a02e3e18 HEAD) <(the failing node ids with ::… stripped) prints
nothing — this diff is src/octop/api/routers/mobile/shell_ws.py, a new
tests/unit/mobile/test_shell_ws_resize.py, and one CHANGELOG.md line.

Targeted lanes on this branch, for completeness:

tests/unit/mobile/test_shell_ws_resize.py        11 passed in 0.40s
tests/unit/mobile tests/unit/api        366 passed, 10 skipped in 37.52s

ruff check src tests clean, ruff format --check src tests → 1120 files already formatted,
mypy --strict src/octop → Success, 544 source files.

@jubaoliang jubaoliang closed this Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants