Skip to content

Fix stored XSS via hapi_url injection in TTYD proxy - #3

Merged
axisrow merged 1 commit into
mainfrom
fix/stored-xss-hapi-url
Feb 8, 2026
Merged

Fix stored XSS via hapi_url injection in TTYD proxy#3
axisrow merged 1 commit into
mainfrom
fix/stored-xss-hapi-url

Conversation

@axisrow

@axisrow axisrow commented Feb 8, 2026

Copy link
Copy Markdown
Owner

💪 What

  • Fixes a stored XSS vulnerability in the TTYD proxy menu page (app/ttyd_proxy.py)
  • Adds URL scheme validation — only http:// and https:// are allowed for the hapi URL read from /home/hapi/url
  • HTML-escapes hapi_url (with quote=True) before interpolation into the <a href="..."> attribute
  • HTML-escapes username before template substitution as defense-in-depth

🤔 Why

  • The contents of /home/hapi/url were interpolated directly into HTML without any sanitization
  • An attacker with write access to that file (SSH user, hapi process) could inject arbitrary HTML/JS via attribute breakout ("></a><script>...) or javascript: URI scheme
  • CSP includes script-src 'unsafe-inline', so injected scripts execute in the context of any authenticated user visiting the menu page
  • This enables session hijacking (cookie theft) and actions on behalf of the victim

👩‍🔬 How to validate

  1. Build and run the container:
    ./build.sh && docker run -p 8080:8080 clihost
  2. Write a malicious payload to the url file inside the container:
    docker exec <id> bash -c 'echo "javascript:alert(1)" > /home/hapi/url'
  3. Log in at http://localhost:8080 — the HAPI Server link should render as "not available" (scheme rejected)
  4. Try an attribute-breakout payload:
    docker exec <id> bash -c 'echo "\"><script>alert(1)</script>" > /home/hapi/url'
  5. Refresh the menu page — inspect the HTML source; the payload should be escaped (&quot;&gt;&lt;script&gt;...) and no alert fires
  6. Write a legitimate URL and confirm the link renders correctly:
    docker exec <id> bash -c 'echo "https://example.com" > /home/hapi/url'

🤖 Generated with Claude Code

Sanitize URL read from /home/hapi/url before rendering into HTML:
- Validate URL scheme (allow only http/https) to block javascript: URIs
- HTML-escape URL and username before template substitution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@axisrow

axisrow commented Feb 8, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@claude

claude Bot commented Feb 8, 2026

Copy link
Copy Markdown

Claude finished @axisrow's task in 52s —— View job


Code Review ✅

Reviewing the stored XSS fix in the TTYD proxy...

Todo List

  • Read the modified file (app/ttyd_proxy.py)
  • Analyze the git diff to understand exact changes
  • Review the security fix for completeness
  • Check for edge cases and potential bypasses
  • Provide comprehensive feedback

Security Review

Excellent security fix! This PR effectively addresses the stored XSS vulnerability with multiple layers of defense. Here's my detailed analysis:

✅ Strengths

  1. Defense-in-depth approach - Multiple complementary protections:

    • URL scheme validation (lines 565-569)
    • HTML entity escaping with quote=True for href attribute (line 576)
    • Username escaping as additional hardening (line 573)
  2. Proper use of html.escape() - The quote=True parameter is critical here and correctly used, as it escapes " characters that could break out of the href attribute.

  3. Scheme validation is effective - Rejecting anything except http and https prevents both:

    • javascript: URI scheme attacks
    • Other exotic schemes like data:, vbscript:, file:, etc.
  4. Graceful degradation - When validation fails, the link shows as "not available" rather than causing errors.

🔍 Edge Cases Considered

The fix handles these attack vectors correctly:

  • Attribute breakout: "><script>alert(1)</script> → Escaped to &quot;&gt;&lt;script&gt;...
  • JavaScript scheme: javascript:alert(1) → Scheme rejected, URL set to None
  • Data URI: data:text/html,<script>alert(1)</script> → Scheme rejected
  • Protocol-relative: //evil.com/malicious.js → Scheme is empty string, rejected
  • Mixed case bypass: Javascript:alert(1)urlparse() normalizes scheme to lowercase, so this is caught

💡 Minor Observations

  1. Import placement (line 27): The html as html_module import is well-named to avoid conflicts with the variable name html used throughout the code.

  2. Consistent escaping (line 573): Escaping username is good defense-in-depth, though in practice it's already validated by is_valid_username() at line 545, which only allows [a-zA-Z0-9_.-].

  3. URL validation timing: The validation happens after reading the file but before rendering, which is the correct approach - fail closed if validation fails.

🎯 Validation Steps

The provided validation steps in the PR description are comprehensive and test the key attack vectors:

  • JavaScript URI scheme injection
  • Attribute breakout with HTML tags
  • Legitimate URL rendering

📋 Final Verdict

LGTM (Looks Good To Me) - This is a textbook example of how to fix stored XSS:

  • ✅ Validates input (URL scheme allowlist)
  • ✅ Encodes output (HTML entity escaping with quote=True)
  • ✅ Defense-in-depth (multiple layers)
  • ✅ Fails closed (sets to None on validation failure)
  • ✅ No regression risk (graceful degradation)

The fix is production-ready and effectively mitigates the stored XSS vulnerability.


@axisrow
axisrow marked this pull request as ready for review February 8, 2026 16:50
@axisrow
axisrow merged commit 7ca275d into main Feb 8, 2026
2 checks passed
@axisrow
axisrow deleted the fix/stored-xss-hapi-url branch February 8, 2026 16:52
@claude claude Bot mentioned this pull request Jun 12, 2026
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