Repository navigation
fix(security): block non-public destinations in URL fetching and !tplay - #203
Merged
Merged
Conversation
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>
16 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
URL fetching only reaches public addresses (
URLInfo.py)Why:
URLFilterruns 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 aTCPConnectorwithPublicOnlyResolver, aThreadedResolversubclass that drops every address whereipaddress.ip_address(...).is_globalis false (IPv4-mapped IPv6 is unwrapped first). It raisesNonPublicURLError("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.MAX_REDIRECTS). Each hop's scheme must behttp/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 as2130706433and127.1are rejected too.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.URLFilteralready printsError: <message>, so a refused URL shows a short, clear reason in chat.!tplayaccepts only public http(s) URLs (Commands.py,PlayMusiconly)Why:
FFmpegAudioPlayertreats scheme-less strings and existing paths as local files, so!tplay /some/pathplayed local files. Internalhttp://hosts were reachable as well.PlayMusic._funccallsassert_public_url()after the!lastsubstitution. On rejection it sends the player a private message (Cannot play this URL: ...), logs a warning and returns 1.real_timehandling is unchanged.VoiceTrigger, TTS, YouTube) build their own URIs and do not go throughPlayMusic, so they are unaffected.Robustness (
URLInfo.py)Content-Lengthnow gives-1instead of raisingValueError.Image.open()and itsBytesIOare closed throughwith.Behavior change
!tplayno longer plays local paths,file://URIs, or URLs whose host resolves to loopback, private/LAN, link-local, CGNAT or other non-global addresses.!dec <url>) no longer resolve such URLs. They printError: URL points to a non-public addressinstead.Known remaining gap, out of scope here:
!tplaychecks the URL it is given, butFFmpegAudioPlayerthen streams it with its own aiohttp session, which follows redirects. Hardening that session belongs to theFFmpegAudioPlayer.pywork in #181.Test plan
ruff check .,ruff format --check .andmypy: 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).127.0.0.1:get_url_datarefuses127.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://andftp://. The local server received no request.127.0.0.1is refused.127.0.0.1literal, or to a hostname resolving to127.0.0.1, is refused, and the redirect target is never requested.Too many redirects (max 5).abc,-5, empty) return-1.https://example.comgives[URL] Example Domain, andhttp://google.com(redirect) gives[URL] Google. Image metadata still renders.PlayMusic._funcwith 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 noAudioClipis created.!last→https://example.com/last.mp3?t=1mis accepted and plays withreal_time=60.No VERSION bump (batch policy, see #177).
Closes #190
Part of #177
🤖 Generated with Claude Code