eyehands 1.5.0: security hardening

by Fireal Software · ~7 min read

1.5.0 was a focused pass on security. eyehands is a local HTTP server that has access to screen capture and keyboard/mouse input — the privilege-escalation surface is real, even for a local-only tool. This release closed a bunch of small issues that, individually, were probably not exploitable, but collectively made me uncomfortable.

Here’s what changed and why.

DNS rebinding defense tightened

Before 1.5.0, the Host header check matched via startswith("127.0.0.1"). This means a Host header like 127.0.0.1.attacker.com would pass the startswith check, which is exactly the kind of prefix-match bypass DNS rebinding attacks rely on. Empty Host headers were also silently accepted.

After 1.5.0, the Host check exact-matches one of a known set of loopback literals: 127.0.0.1, localhost, ::1, [::1]. Port suffixes are parsed correctly (so 127.0.0.1:7331 matches). Empty Host is rejected. IPv6 bracketed and bare forms both work.

A new test (TestHostHeaderOk) pins this behavior with 7 regression cases.

--no-auth no longer disables Host checking

Before 1.5.0, the --no-auth flag short-circuited all the security checks, including the DNS rebinding defense. The rationale was “if you’re running without auth, you probably don’t care about security”. But I realized that’s the wrong assumption — someone running --no-auth in a dev environment is probably doing it for convenience, not because they’ve accepted DNS rebinding as a risk.

After 1.5.0, _AUTH_DISABLED and _host_check_enabled are separate module globals. --no-auth only flips the first. The Host check still runs unless you explicitly bind to a non-loopback address via --host, which now prints a warning so you explicitly own the risk.

Constant-time token comparison

All three paths that verify the bearer token (header, query parameter, session cookie) now go through secrets.compare_digest instead of ==. This prevents timing attacks where an attacker could theoretically measure response times to narrow down the token byte-by-byte.

Is this exploitable in practice over a loopback interface? Probably not. Would I rather use the stdlib function that’s explicitly designed for this than ==? Yes, every time.

Startup banner no longer prints the token

Before 1.5.0, the startup banner printed:

Auth token: abc123xyz... (saved to .eyehands-token)

The problem: if you pasted your startup output into a GitHub issue or a screenshot to debug a problem, you’d be pasting your bearer token too. And .eyehands-token is a file on disk anyway, so printing it at startup isn’t actually useful.

After 1.5.0, the banner points at the file on disk:

Auth token saved to C:\...\.eyehands-token
Use: curl -H "Authorization: Bearer $(cat ...\.eyehands-token)" http://127.0.0.1:7331/ping

No secret in the banner. No secret in screenshots. No secret in GitHub issues.

/view uses HttpOnly session cookies

Before 1.5.0, /view?token=X accepted the token in a URL query parameter. The URL persisted in browser history, so once you’d visited /view in Chrome, the token was saved forever in history.

After 1.5.0, /view?token=X still works for initial access (via browser address bar), but it’s immediately 302-redirected to /view with a Set-Cookie: eyehands_session=X; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600 header. The token never shows up in the final browser URL — it’s in a session cookie instead.

HttpOnly means JavaScript on the page can’t read it. SameSite=Strict means cross-origin requests (e.g. from a malicious website) can’t send it. Max-Age=3600 means it expires in an hour.

Request body size cap

Before 1.5.0, POST bodies were read without any size check. An attacker (or a buggy client) could send a 10 GB body and wedge the server on read_body().

After 1.5.0, bodies are capped at 10 MiB (_MAX_BODY_BYTES). Content-Length is validated before reading. Exceeding the cap returns a 413.

10 MiB is generous — realistic eyehands requests are usually a few KB. But it’s finite, and that’s the point.

/smooth_move and /ui/* step/depth caps

Before 1.5.0, /smooth_move accepted any steps value. An attacker could send {"steps": 10_000_000} and the server would loop for minutes. Same for /ui/tree?depth=100 — the UIA tree walker would recurse deeply and consume a lot of memory.

After 1.5.0, steps is capped at 1000 and depth at 20. Requests exceeding the caps return 400.

CLI parameter validation

--port, --max-w, --quality, and --fps are now range-checked at argparse time. Invalid values (negative port, --max-w 0, --quality 200) get a clear error instead of a cryptic runtime crash.

_kill_existing_instance now checks the process name

Before 1.5.0, if a stale PID file pointed at a PID that Windows had since recycled for a completely unrelated process, eyehands would taskkill that unrelated process on startup. Very bad if the recycled PID happened to be your Chrome instance or your dev server.

After 1.5.0, _kill_existing_instance() runs tasklist /FI "PID eq <pid>" first and only kills the process if its name looks like Python or eyehands. Stale PID files can no longer kill unrelated processes.

_grab_hwnd GDI handle leak fixed

Window-scoped capture via /screenshot?hwnd=<id> uses Win32 PrintWindow, which requires a GDI bitmap handle. The old code called DeleteObject on the bitmap without first reversing the SelectObject that made it the current object, which leaked the handle.

After 1.5.0, the argtypes/restype are declared at module load (inside a try/except so non-Windows imports still work), and SelectObject is reversed before DeleteObject / DeleteDC. No more GDI handle leak.

FrameBuffer lazy base64

Before 1.5.0, every captured frame (20 per second) was eagerly base64-encoded so /latest_b64 could return it immediately. That’s 1.8 million bytes of base64 work per second for frames nobody might ever fetch.

After 1.5.0, latest_b64() computes the base64 lazily on first access, cached under the frame lock. CPU savings are visible on CPU-limited hardware.

Non-Windows imports work in CI

input.py and capture.py now import cleanly on Linux / macOS (the SendInput binding and the virtual-desktop metric reads are guarded in try/except). This lets the test suite run under windows-latest and under ubuntu-latest in CI, which matters because GitHub Actions’ Windows runners are ~5× slower than Ubuntu.

Module-level functions like _send_mouse() still raise OSError("SendInput unavailable") if actually invoked without a binding, so you can’t use the modules on non-Windows — but you can import them, which is enough for testing helper functions like _normalize_format and _host_header_ok.

.github/workflows/release.yml installs the built wheel

Before, the release workflow built the wheel and published it to PyPI without verifying it actually imports. After 1.5.0, it installs the freshly built wheel into a clean venv and runs python -c "import eyehands" before publishing. This catches the class of bug where a missing package_data entry would make the uploaded wheel unusable.

New .github/workflows/test.yml

Runs python -m unittest discover tests on windows-latest for every push to master and every PR. Total: 38 tests covering pure helpers (version tuple parsing, format negotiation, DNS rebinding defense, install-skill idempotency, etc.).

What didn’t make it into 1.5.0

Upgrade

pip install --upgrade eyehands
# or, if the server is running:
curl -X POST -H "Authorization: Bearer $(cat .eyehands-token)" \
  http://127.0.0.1:7331/update

The self-update endpoint spawns a detached helper that waits for the parent to exit, runs pip install --upgrade eyehands, and relaunches via python -m eyehands. Failures are logged to .update_log.txt and the previous version stays installed.


*eyehands is maintained by one person (me, Fireal Software). Security reports should go to [fireal6353@gmail.com](mailto:fireal6353@gmail.com) — I'll respond within 24h. Public issues are for bugs and feature requests.*

Give Claude eyes and hands on Windows

eyehands is a local HTTP server for screen capture, mouse control, and keyboard input. Open source with a Pro tier.

Try eyehands