Skip to content

Fix critical bugs in ttyd_proxy.py - #4

Merged
axisrow merged 1 commit into
mainfrom
fix/ttyd-proxy-bugs
Mar 16, 2026
Merged

Fix critical bugs in ttyd_proxy.py#4
axisrow merged 1 commit into
mainfrom
fix/ttyd-proxy-bugs

Conversation

@axisrow

@axisrow axisrow commented Mar 16, 2026

Copy link
Copy Markdown
Owner

Summary

  • RateLimiter memory leak (CRITICAL): attempts dict keys (IP addresses) were never purged, causing unbounded memory growth. Added periodic cleanup of stale keys every 100 calls.
  • UnicodeDecodeError crash (HIGH): Invalid UTF-8 in POST body crashed the login handler without responding. Now catches the exception and returns 400.
  • Info disclosure in 502 responses (MEDIUM): str(exc) leaked internal details (IPs, ports, OS errors) to clients. Removed detail field, errors logged to stderr instead.

Test plan

  • All 93 existing tests pass
  • Manual: send POST with invalid UTF-8 body to /login, verify 400 response
  • Manual: simulate TTYD down, verify 502 response has no detail field
  • Manual: long-running instance with many unique IPs — verify memory stays bounded

🤖 Generated with Claude Code

- RateLimiter: add periodic purge of stale keys to prevent OOM under
  sustained unique-IP traffic
- Login handler: catch UnicodeDecodeError on POST body decode, return 400
- TTYD proxy: remove internal error details from 502 responses, log to
  stderr instead

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@axisrow

axisrow commented Mar 16, 2026

Copy link
Copy Markdown
Owner Author

@claude review. Focus on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems. Do NOT nitpick style, naming conventions, minor formatting, or subjective preferences — only flag issues that could break functionality or cause real harm in production.

@claude

claude Bot commented Mar 16, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

@axisrow

axisrow commented Mar 16, 2026

Copy link
Copy Markdown
Owner Author

@claude review this PR. Focus only on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems.

@claude

claude Bot commented Mar 16, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

@axisrow

axisrow commented Mar 16, 2026

Copy link
Copy Markdown
Owner Author

@claude review. Focus on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems. Do NOT nitpick style, naming conventions, minor formatting, or subjective preferences — only flag issues that could break functionality or cause real harm in production.

@claude

claude Bot commented Mar 16, 2026

Copy link
Copy Markdown

Claude finished @axisrow's task in 1m 49s —— View job


PR Review

  • Gather context
  • Read changed file
  • Analyze changes
  • Post review

Summary

All three fixes are correct. No critical issues introduced by this PR.


Fix 1: RateLimiter memory leak (app/ttyd_proxy.py:78-87)

Correct. The cleanup logic is sound:

  • Runs under the existing lock — thread-safe.
  • all(current_time - t >= self.window_seconds for t in timestamps) correctly identifies keys whose entire history is outside the rate window. Empty-list case (all() on []True) is not a problem: after every successful is_allowed() call, the key gets a timestamp appended, so keys never remain empty between calls.
  • Cleanup runs before per-key filtering, which is the right order (avoids a TOCTOU gap).

No issues.


Fix 2: UnicodeDecodeError crash (app/ttyd_proxy.py:453-457)

Correct. The exception is caught in the right place, the client gets a proper 400, and the handler returns cleanly. No issues.


Fix 3: Info disclosure in 502 responses (app/ttyd_proxy.py:865-867, 1028-1030)

Correct. Internal error details are removed from client responses and redirected to stderr.

One pre-existing bug worth noting (not introduced by this PR): the second except OSError block at line 1028-1030 wraps code that includes send_response(), send_header(), and end_headers(). If an OSError occurs after those calls have already written partial response headers to the socket (e.g., during wfile.write(data)), the subsequent send_json(502, ...) call will attempt to write a second HTTP response on an already-started stream, corrupting it. The send_json call will silently fail or produce garbage. Since this is pre-existing and outside the scope of this PR, it's not a blocker — but worth a follow-up fix. Fix this →


Verdict: LGTM. All three fixes are correct and well-scoped. The pre-existing double-response issue on late OSError is the only thing to watch, but it's not introduced here.

@axisrow

axisrow commented Mar 16, 2026

Copy link
Copy Markdown
Owner Author

@claude review this PR. Focus only on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems.

@claude

claude Bot commented Mar 16, 2026

Copy link
Copy Markdown

Claude finished @axisrow's task in 1m 15s —— View job


PR Review

  • Gather context
  • Read changed file
  • Analyze changes
  • Post review

Summary

All three fixes are correct. No critical issues introduced by this PR.


Fix 1: RateLimiter memory leak (app/ttyd_proxy.py:78-87)

Correct. The cleanup logic is sound:

  • Runs under the existing lock — thread-safe.
  • all(current_time - t >= self.window_seconds for t in timestamps) correctly identifies keys whose entire history is outside the rate window. The empty-list edge case (all() on []True) is harmless: keys with empty timestamp lists would be deleted, but defaultdict(list) re-creates them on next access anyway, and the new attempt is then appended.
  • Cleanup runs before per-key filtering, so if the current request's key is stale and gets deleted, defaultdict recreates it as empty — the request is correctly allowed and the new timestamp recorded.

No issues.


Fix 2: UnicodeDecodeError crash (app/ttyd_proxy.py:456-460)

Correct. Exception caught in the right place, client gets a proper 400, handler returns cleanly. No issues.


Fix 3: Info disclosure in 502 responses (app/ttyd_proxy.py:865-867, 1028-1030)

Correct. Internal details removed from client responses and redirected to stderr.

One pre-existing bug (not introduced by this PR): the second except OSError block at line 1028-1030 wraps code that includes send_response(), send_header(), and end_headers(). If an OSError fires during self.wfile.write(data) at line 1027 — i.e., after headers are already written — the subsequent send_json(502, ...) call will attempt to write a second HTTP response on an already-started stream, corrupting it. send_json will silently fail or produce garbage. Pre-existing and out of scope here, but worth a follow-up. Fix this →


Verdict: LGTM. All three fixes are correct and well-scoped.

@axisrow
axisrow merged commit 209bf38 into main Mar 16, 2026
1 check failed
@axisrow
axisrow deleted the fix/ttyd-proxy-bugs branch March 16, 2026 07:29
axisrow added a commit that referenced this pull request Jun 29, 2026
…ину слёта auth #69 (#86)

* test(entrypoint): pin Claude Code auth root cause #69 (mount /home/hapi, not /home)

Root cause established by reproducing the "auth keeps resetting" report in
Docker: the ONLY trigger is the persistent volume mounted at the parent
`/home` instead of `/home/hapi`. A Docker volume is seeded from the image
only while empty, so once a platform's persistent storage (Dokku/Railway)
survives the first deploy and stays non-empty, a `/home` mount is no longer
re-seeded — the entrypoint recreates `/home/hapi/.claude` empty on top of
the volume and the saved login is gone. A `/home/hapi` mount keeps the home
dir itself as the persisted unit and survives recreate (verified both ways).

The three rival hypotheses from #69 were disproven by code:
- entrypoint chown -R on ~/.claude is correct (restores ownership), non-destructive
- cleanup_runner_state is scoped to ~/.hapi/*.json, never touches .claude
- bin/glm exports ANTHROPIC_* only inside the wrapper and exec's claude — no leak

Changes:
- README.md + CLAUDE.md: explicit warning to mount /home/hapi, NOT /home,
  with the volume-seeding mechanism that makes the wrong mount lose creds.
- tests/unit/test_shell_scripts.py: new TestClaudeAuthRootCause69 pinning the
  three artifacts that keep the bug fixed — CLAUDE_CONFIG_DIR path, the docs
  warning (both files), and the glm exec-wrapper contract (hypothesis #4).

pytest tests/ -> 344 passed, 194 subtests.

Refs #69

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vecu5xo9D9Cs2vmeohKVKv

* feat(entrypoint): warn on console when volume mounted at /home not /home/hapi (#69)

Make the #69 footgun self-announcing instead of docs-only. The wrong mount is a
runtime condition, so the entrypoint now detects it at startup and prints a loud
WARNING to stderr (visible in `docker logs` / Dokku deploy logs):

  WARNING: a volume is mounted at /home, NOT at /home/hapi.

Detection reads /proc/mounts and keys off exact mountpoints: a separate mount AT
/home with no separate mount at /home/hapi means the volume is one level too high.
Warn-only, never abort — running without a volume (no mount at either) is valid,
and a usable container beats a refused start.

Verified in Docker across all three cases: warns on `-v ...:/home`, silent on
`-v ...:/home/hapi`, silent with no volume.

- entrypoint.sh: warn_if_volume_mounted_at_parent_home(), called at startup.
- README.md: note the runtime warning alongside the existing mount guidance.
- tests: TestClaudeAuthRootCause69.test_entrypoint_warns_on_parent_home_mount.

pytest tests/ -> 345 passed, 194 subtests.

Refs #69

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vecu5xo9D9Cs2vmeohKVKv

* fix(entrypoint): warn about volume migration footgun + behavioral detector test (#69)

Cycle-review (PR #86) — Codex flagged a critical practical hole: the warning told
operators to "mount at /home/hapi", but for an EXISTING /home volume that data
lives under the volume's hapi/ subdir. Retargeting the same volume to /home/hapi
buries it at /home/hapi/hapi/ — invisible to the app — so the redeploy meant to
fix persistence looks like it lost the creds. Confirmed against the volume layout
observed in earlier Docker repro.

- entrypoint.sh: warning now spells out the migration step (move the volume's
  hapi/ contents to its root, or use a fresh /home/hapi volume).
- README.md + CLAUDE.md: same migration caveat alongside the mount guidance.
- tests: test_warning_explains_volume_migration pins the migration text in all
  three places; test_detector_warns_only_on_parent_home_mount is BEHAVIORAL —
  it extracts the real detector and runs the awk logic against five synthetic
  /proc/mounts tables (closes the static-grep coverage gap noted by the Claude
  reviewer). Verified in Docker: the migration warning prints on a /home mount.

pytest tests/ -> 347 passed, 201 subtests.

Refs #69

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vecu5xo9D9Cs2vmeohKVKv

---------

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
axisrow added a commit that referenced this pull request Jul 8, 2026
)

Six P1 correctness/reliability fixes from the #101 code review.

#3 (proxy.py): enable SO_KEEPALIVE on both tunnel sockets so a client that
vanishes without a FIN (laptop sleep, NAT timeout) is detected by the OS and the
idle tunnel thread + sockets are freed instead of leaking forever.

#4 (ratelimit.py): use time.monotonic() instead of wall-clock time.time(), so a
backward clock jump (NTP correction, suspend/resume, VM migration) cannot freeze
the window and lock out legitimate users past 60s/300s.

#5 (app.py): _check_auth now honours redirect=True in the removed-user branch —
a browser navigation with a valid cookie whose account was deleted/renamed lands
on /login (302) instead of a raw 403 JSON blob.

#6 (proxy.py): add "cookie" to HOP_BY_HOP_HEADERS so the client's signed
ttyd_session + csrf_token is not forwarded to the internal ttyd (defense depth).

#10 (bin/clihost-sync.sh): the remote symlink guards were byte-identical
triplicates (local pair + one copy in each of two `bash -s` heredocs), so
hardening one silently bypassed the others — the class of bug caught 3× before
(#88/#90/#95). Define them once in a shared emit_remote_prelude injected into
both remote payloads.

#11 (.env.example): document SESSION_TIMEOUT, CLEANUP_ROOT, ROOT_PASSWORD,
HAPI_USER, HERMES_AUTO_UPDATE (read by code + CLAUDE.md but missing from the
example), plus the REQUEST_TIMEOUT slowloris knob for completeness.

New/changed env vars documented in .env.example: SESSION_TIMEOUT, CLEANUP_ROOT,
ROOT_PASSWORD, HAPI_USER, HERMES_AUTO_UPDATE, REQUEST_TIMEOUT. No port or volume
changes.

Closes #101 findings #3, #4, #5, #6, #10, #11.


Claude-Session: https://claude.ai/code/session_01KgvJhzd8gEhXVE4MVu18nn

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant