fix(telegram): never expose bot token in avatar URL#286
Conversation
Coverage Report for CI Build 25587623060Coverage increased (+0.03%) to 84.897%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
30890f5 to
1f3f9ee
Compare
umputun
left a comment
There was a problem hiding this comment.
two real bugs found that need to land before merge, both mirrored across v1 and v2:
-
typed-nil
*avatar.Proxypanics on Telegram login. Whenauth.NewServiceis created withoutOpts.AvatarStore,auth.go:135-145skips initializingres.avatarProxy, sos.avatarProxyends up as a typed-nil*avatar.Proxyand flows intoParams.AvatarSaver. The new type assertion atprovider/telegram.go:517(saver, ok := th.AvatarSaver.(avatarContentSaver)) returnsok=truebecause interface satisfaction is structural and*avatar.Proxyhas the right method set regardless of nil-ness. Thensaver.PutContent(userID, resp.Body)invokes the method on a nil receiver, andPutContent(avatar/avatar.go:88) dereferencesp.Store→ panic. The legacysetAvatarpath atprovider/service.go:75had an explicit guardif ava == nil || ava == (*avatar.Proxy)(nil); the new path lost it. Same code in v2.Fix: replicate that guard before the assertion, e.g.
if th.AvatarSaver == nil || th.AvatarSaver == (*avatar.Proxy)(nil) { return "" } saver, ok := th.AvatarSaver.(avatarContentSaver)
And add a regression test:
var p *avatar.Proxy; th.AvatarSaver = pthen driveprocessUpdates, assert no panic + empty Picture + the existing[WARN]log line. -
double avatar pipeline silently overwrites the real Telegram avatar with an identicon. After
processUpdatesstores the bytes viaPutContentand setsPicture = "<local-proxy-URL>",LoginHandleratprovider/telegram.go:306still unconditionally callssetAvatar(th.AvatarSaver, *authUser, ...), which callsProxy.Put. That triggersp.load(u.Picture, client), which HTTP-GETs the local URL. In split-DNS / unreachable-internal-Opts.URLdeployments, the GET fails,Proxy.Putfalls back togenIdenticonatavatar.go:66, and overwrites the Telegram avatar bytes at the same store path with the identicon. The JWT then carries a Picture URL pointing at the identicon. Tests miss it becausemockAvatarSaver.Put(provider/oauth2_test.go:365) returns the same canned URL regardless of input. Same code in v2.Fix: skip
setAvatarinLoginHandlerwhenPicturewas already set bysaveTelegramAvatar. For example, add apictureProxied boolontgAuthRequest, or short-circuit whenauthUser.Pictureis already prefixed by the local proxy URL. Add a test with a realavatar.ProxywhoseURLis unreachable to confirm the Telegram avatar is not replaced.
other findings, not blocking:
-
coverage gaps on realistic error paths. The 13 uncovered new lines include the non-200 status branch from the Telegram file API (expired file_id, insufficient bot rights) and the
PutContent/Store.Puterror branches (disk full, store down). Worth adding two negative-path subtests, ~30 lines each, copy-paste-ready from the existing happy-path test. -
body-size cap.
saveTelegramAvatarstreamsresp.Bodyunbounded intoresize. Same gap exists inProxy.Put. Suggest a separate follow-up to addhttp.MaxBytesReaderto both fetch paths symmetrically rather than only here. -
test fixture lint regression.
avatar/avatar_test.go:77has a deliberatehttps://x:y@example.com/path?q=1to verifyredactAvatarURLstrips userinfo. The gosec G101 check flags it as a hardcoded credential. Either annotate with// #nosec G101 -- deliberate test fixture for userinfo redactionor rewrite the assertion to use a builder that avoids the literal. -
minor nits:
redactAvatarURLdoc says it handles "secrets in path or query" but the impl returns hostname only. Tighten doc.bot[A-Za-z0-9:_-]+regex over-matches words likebotFather. Consider/bot[A-Za-z0-9:_-]+/for path-anchored matching, or update the doc to acknowledge the broader sweep.dumbAvatarSavertest type, informal name;legacyAvatarSaverreads better.- the new
&http.Client{Timeout: 5 * time.Second}is allocated per call; the existingtgAPI.clientcould be reused. - v1
telegram_test.gohas 7 inline comments v2 lacks; mirror or strip.
-
README behavior-change framing. The "Telegram avatar storage" section honestly documents the new contract but undersells the breaking change for custom AvatarSaver implementers. Pre-PR they got Telegram avatars (with embedded token), post-PR they don't. Worth a release-note line explicitly calling out the behavior change for that case, not just a README paragraph.
design choices noted as fine to keep:
- the
avatarContentSaveroptional-interface duck-typing pattern is idiomatic Go (mirrorshttp.Flusher,io.WriterTo). Promoting to an exportedAvatarContentSaverwould lock the contract into the public API for a one-off use case. The README documents the required signature. - the silent-drop fallback (when AvatarSaver doesn't implement PutContent) with
[WARN]is the right trade-off vs leaking the token; documented in README.
5042bbe to
4b1c0d0
Compare
|
addressed both blocking items and most of the non-blocking ones in the last push: blocking, both fixed in v1+v2:
non-blocking, folded in:
6a.
deferred to follow-ups: body-size cap (#4 -- intentionally separate per your suggestion, touches both telegram and Proxy.Put symmetrically); regex/naming/client-reuse nits (#6b-e -- bikeshed cleanup, separate PR if useful). go fix + race + lint clean in both modules. |
4b1c0d0 to
f610e42
Compare
tgAPI.Avatar returned a URL with the bot token embedded in its path:
https://api.telegram.org/file/bot{TOKEN}/photos/file_X.jpg
The token is a bearer credential for the entire bot API. The URL flowed
into User.Picture and from there:
* Into avatar.Proxy.Put debug logs ("[DEBUG] saved avatar from <url>"
and the corresponding load-failure line) regardless of whether avatar
saving succeeded.
* Into the JWT claims and the user JSON returned to the browser when
no AvatarSaver was configured (User.Picture is in the User struct).
Either path leaks the bot token to anyone with log access, anyone who
can read the JWT (the user themselves on the device, plus anyone
intercepting browser/devtools), or any third-party observability stack.
Two-part fix in v1 and v2:
1. avatar/avatar.go: redact the URL in Put's two debug log lines via a
new redactAvatarURL helper (hostname only). Add Proxy.PutContent so
pre-fetched bytes can be saved without the URL-fetch round trip.
2. provider/telegram.go: in processUpdates, never assign the bot URL
to User.Picture. Pass it to a new saveTelegramAvatar method that
fetches the bytes server-side and stores them via the new content-
saver interface (avatar.Proxy implements it). The call returns a
clean local proxy URL or "" — whatever lands in Picture is safe to
log and to send to the client.
A graceful fallback path warns and drops the avatar when the
configured AvatarSaver does not implement PutContent (custom external
implementations) — never exposes the token to satisfy the avatar
feature.
Tests in both modules:
* TestSaveTelegramAvatar_BotTokenNeverLogged — unit-level table for
the helper covering the success, fallback-without-PutContent and
empty-URL paths.
* TestTelegramProcessUpdates_BotTokenNeverInUserPicture — regression
test for the property: drive processUpdates with a mock that returns
a URL containing a bot-token marker; assert the marker never lands
in user.Picture and never appears in any captured log line.
Reverting the saveTelegramAvatar redirection makes this test fail
with a clear assertion message.
f610e42 to
bbfb793
Compare
|
folded all the non-blocking nits into this PR:
skipped intentionally:
go fix + race + lint clean in both modules. |
umputun
left a comment
There was a problem hiding this comment.
both blockers fixed with the exact-shape regression tests I asked for:
- typed-nil guard at
provider/telegram.go:359(and v2 mirror) matches the legacysetAvatarshape;TestSaveTelegramAvatar_TypedNilAvatarSaverDoesNotPanicdrives processUpdates withvar p *avatar.Proxyand asserts no panic. LoginHandlernow skipssetAvatarwhen Picture was already proxied;TestTelegramLoginHandler_DoesNotOverwriteSavedAvataruses a realavatar.Proxywith unreachable URL to pin the identicon-overwrite scenario.
non-blocking items: all 5 folded in across the two pushes. Body-size cap is symmetric in Proxy.load + saveTelegramAvatar (both v1 and v2, 10 MiB, +1 overflow trick on LimitReader, [WARN] on drop). Regex anchored to /bot[A-Za-z0-9:_-]+/ with the botFather/botanic and bot1234:abc-def_99 pin tests. Rename + #nosec + doc rewrite + README behavior callout all in. Closing PR #288 by folding it here is the right call.
skipped items (HTTP client reuse, v1/v2 inline-comment parity) are explicitly bikeshed and the justification is correct - reusing tgAPI.client would push API surface for one-call-per-login.
LGTM, thx.
…dance Followups to #281 (verify replay protection) raised on the post-merge review. None blocking, all small. 1. Service-level typed-nil VerifConfirmationStoreFunc guard. The handler-level guard added in round 2 normalizes a typed-nil func to nil, but the AddVerifProvider check at auth.go was still a plain `s.opts.VerifConfirmationStore != nil` test. A typed-nil VerifConfirmationStoreFunc is a non-nil interface wrapping a nil func, so it survived that check, the in-memory default was skipped, and at redemption the handler's typed-nil guard normalized it to nil — net result: a user who wrote `Opts{VerifConfirmationStore: VerifConfirmationStoreFunc(nil)}` got neither their func nor the default, and replay protection was silently disabled for that exact configuration. Same shape as the *avatar.Proxy typed-nil case fixed in #286 with a different consequence (silent loss of protection vs panic). Apply the same shape of guard one layer up. New regression test TestService_AddVerifProvider_TypedNilStoreFuncFallsBackToDefault in both v1 and v2. 2. gofmt -w on auth.go / v2/auth.go. The verifConfirmStoreO -> verifConfirmStoreOnce rename in #281 made the field longer than the surrounding column alignment. Cosmetic; CI doesn't enforce gofmt but a noisy IDE-on-save diff for the next contributor. 3. scrubTokenFromRequest unit test (TestScrubTokenFromRequest) in both v1 and v2 — covers the defensive early-return that coveralls flagged as uncovered after #281 (token-missing returns r unchanged, nil r returns nil, token-present returns redacted clone with other query params preserved). 4. Adapter-author guidance on VerifConfirmationStore.MarkUsed godoc: tells external Redis/DB adapter authors not to embed the supplied key in returned errors, since the handler logs err on the fail-closed branch and the key is the SHA-256 of a still-live JWT.
Summary
tgAPI.Avatarreturned a URL with the bot token embedded in its path:The bot token is a bearer credential for the entire Telegram bot API. The URL flowed into
User.Pictureand from there:avatar.Proxy.Putdebug logs (both load-success and load-failure lines).AvatarSaverwas configured.Either path leaks the bot token to log-aggregators, anyone who can read the JWT (the user themselves on the device, plus anyone intercepting browser devtools), or third-party observability.
Change
Two parts, both v1 and v2 in single PR:
1.
avatar/avatar.goredactAvatarURL(raw string) stringhelper — hostname only.[DEBUG] saved avatar from %s/[DEBUG] failed to fetch avatar from the orig %slines so attacker-/credential-bearing path/query never reaches logs.Proxy.PutContent(userID string, content io.Reader) (avatarURL string, err error)so providers that fetch with credentials can hand the bytes to the store directly, bypassing the URL-fetch path.2.
provider/telegram.goprocessUpdates, never assign the bot URL toUser.Picture. Pass the URL to a newsaveTelegramAvatarmethod that fetches the bytes server-side (the bot URL stays inside one function call), then stores the bytes via the new content-saver interface, returning a clean local proxy URL.AvatarSaverdoes not implementPutContent(custom external implementation), the avatar is dropped and a[WARN]is logged — we never expose the token to keep the avatar feature working.Test
TestSaveTelegramAvatar_BotTokenNeverLogged— unit-level coverage of the helper across success, fallback-without-PutContent, and empty-URL paths.TestTelegramProcessUpdates_BotTokenNeverInUserPicture— regression-style property test: drivesprocessUpdateswith a TelegramAPI mock that returns a URL containing asecret-bot-token-marker; asserts the marker never lands inuser.Pictureand never appears in any captured log line.Confirmed locally that reverting the
saveTelegramAvatarredirection makes the property test fail with:Full
go test -race ./...green in both modules;golangci-lint run --new-from-rev=master0 issues.