Skip to content

fix(security): block non-public destinations in URL fetching and !tplay - #203

Merged
Rushaway merged 3 commits into
masterfrom
fix/190-url-ssrf
Oct 14, 2026
Merged

Rushaway merged 3 commits into
masterfrom
fix/190-url-ssrf

Conversation

@Rushaway

Copy link
Copy Markdown
Member

URL fetching only reaches public addresses (URLInfo.py)

Why: URLFilter runs for every player (level -1) and fetched any URL posted in chat, including loopback, LAN, Docker-internal and cloud-metadata addresses. aiohttp also followed redirects, so checking only the first host would not have been enough.

  • get_url_data() now uses a TCPConnector with PublicOnlyResolver, a ThreadedResolver subclass that drops every address where ipaddress.ip_address(...).is_global is false (IPv4-mapped IPv6 is unwrapped first). It raises NonPublicURLError("URL points to a non-public address") when nothing public is left. Because the filter runs when the connection is opened, it also covers DNS rebinding.
  • Redirects are now followed by hand, up to 5 (MAX_REDIRECTS). Each hop's scheme must be http/https. aiohttp connects to IP-literal hosts without calling the resolver, so each hop's literal-IP host is checked explicitly, using the same IP-literal heuristic aiohttp uses. Legacy numeric forms such as 2130706433 and 127.1 are rejected too.
  • New helper assert_public_url(url) runs the same scheme and host checks up front and requires every resolved address to be public. It is reused by !tplay.
  • URLFilter already prints Error: <message>, so a refused URL shows a short, clear reason in chat.

!tplay accepts only public http(s) URLs (Commands.py, PlayMusic only)

Why: FFmpegAudioPlayer treats scheme-less strings and existing paths as local files, so !tplay /some/path played local files. Internal http:// hosts were reachable as well.

  • PlayMusic._func calls assert_public_url() after the !last substitution. On rejection it sends the player a private message (Cannot play this URL: ...), logs a warning and returns 1. real_time handling is unchanged.
  • Internal callers (VoiceTrigger, TTS, YouTube) build their own URIs and do not go through PlayMusic, so they are unaffected.

Robustness (URLInfo.py)

  • A malformed or negative Content-Length now gives -1 instead of raising ValueError.
  • Image.open() and its BytesIO are closed through with.

Behavior change

  • !tplay no longer plays local paths, file:// URIs, or URLs whose host resolves to loopback, private/LAN, link-local, CGNAT or other non-global addresses.
  • Chat link previews (and !dec <url>) no longer resolve such URLs. They print Error: URL points to a non-public address instead.
  • Link previews follow at most 5 redirects.

Known remaining gap, out of scope here: !tplay checks the URL it is given, but FFmpegAudioPlayer then streams it with its own aiohttp session, which follows redirects. Hardening that session belongs to the FFmpegAudioPlayer.py work in #181.

Test plan

  • ruff check ., ruff format --check . and mypy: no new findings (only the 3 known baseline errors on master, fixed by CI: Python Linting is red on master since the Python 3.12 bump (ruff UP041, mypy arg-type) #178).
  • Throwaway script (not committed), run against a local aiohttp server on 127.0.0.1:
    • get_url_data refuses 127.0.0.1, localhost, 10.0.0.1, 169.254.169.254, [::1], [::ffff:127.0.0.1], 192.168.1.1, 100.64.0.1, 0.0.0.0, 2130706433, 127.1, file:// and ftp://. The local server received no request.
    • A hostname that a mocked resolver maps to 127.0.0.1 is refused.
    • A redirect from an allowed first hop to a 127.0.0.1 literal, or to a hostname resolving to 127.0.0.1, is refused, and the redirect target is never requested.
    • A redirect loop stops with Too many redirects (max 5).
    • Malformed Content-Length values (abc, -5, empty) return -1.
    • Real public URLs still work: https://example.com gives [URL] Example Domain, and http://google.com (redirect) gives [URL] Google. Image metadata still renders.
  • PlayMusic._func with mocks: /etc/passwd, file:///x, http://127.0.0.1/, http://localhost:8191/, an empty argument and a Windows path are all rejected with a private message, and no AudioClip is created. !last → https://example.com/last.mp3?t=1m is accepted and plays with real_time=60.

No VERSION bump (batch policy, see #177).

Closes #190
Part of #177

🤖 Generated with Claude Code

URLInfo.get_url_data now fetches through a TCPConnector whose resolver
drops every non-global address, follows redirects by hand (max 5) and
checks each hop's scheme (http/https only) and literal-IP host. A new
assert_public_url() helper performs the same validation up front.

PlayMusic (!tplay) rejects anything that is not a public http(s) URL,
including local paths and file:// URIs, and tells the player why.

Content-Length is parsed defensively and the PIL image is closed via a
context manager.

Closes #190

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Rushaway
Rushaway merged commit f3c78fe into master Oct 14, 2026
2 checks passed
@Rushaway
Rushaway deleted the fix/190-url-ssrf branch October 14, 2026 11:40
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.

URL handling: any player can make the bot fetch internal URLs (SSRF), and !tplay accepts local paths

1 participant