Make the Telegram API base URL configurable, and close what that opens - #316
Conversation
Coverage Report for CI Build 32996973277Coverage increased (+0.6%) to 86.663%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
umputun
left a comment
There was a problem hiding this comment.
last commit only, the other two are #315's.
shape is right, but making the host caller-supplied moves a token-bearing URL across a boundary that used to be frozen at api.telegram.org, and three things behind it weren't built for that.
bot token reaches the error text on a non-200 (:505-506, :523, same in v2). request redacts its own two paths then returns parseError bare, and parseError interpolates the upstream description raw. A proxy answering 502 with {"description":"proxy failed while forwarding /bot<token>/getMe"} gives:
unexpected telegram API status code 502, error: "proxy failed while forwarding /bot1234567:SECRET-TOK_EN-x/getMe"
which reaches the log via Run, GetUpdates and Send. Pls redact the whole error leaving request, scrubbing tg.token rather than trusting the URL-shape regex, since the leak is whatever the upstream echoed. Test with a proxy that echoes the request URI. The :529 comment says the avatarContentSaver hook exists to keep token-bearing URLs out of the avatar path, so this is a hole in a guard that was meant to be there.
base URL isn't validated. TrimSuffix is the whole guard, so https://api.telegram.org@evil.tld resolves to host evil.tld and ships /bot<token>/... there; http:// is accepted too. Config mistake rather than attacker, but same shape as the github provider guard in #311 today. Constructor is unmerged, so return an error: absolute http(s), host required, reject userinfo/query/fragment/opaque, keep an optional path prefix. Reject a nil client while you're there.
avatar download bypasses the caller's client. Bot methods use tg.client at :499, saveTelegramAvatar builds &http.Client{Timeout: 5 * time.Second} at :568. That drops custom CA, client certs, pinning, CheckRedirect and proxy policy, and the default transport picks up proxy env vars instead - so a proxy whose TLS material lives on the passed-in client gives working bot calls and identicons, one [WARN] line. Makes the new godoc's "production path is otherwise identical" untrue for the path the feature exists for.
no interface change needed, the optional-capability pattern is already at :529:
type telegramAvatarClientProvider interface{ avatarHTTPClient() *http.Client }on tgAPI only, type-asserted in saveTelegramAvatar. Assertion fails, keep today's client; advertised but nil, drop the avatar rather than silently switch transport. TelegramAPI untouched, moq still compiles. For the 5s cap use a child context.WithTimeout and call the client unchanged, so its Transport, CheckRedirect and Jar survive.
one thing to decide rather than fall into: if avatarHTTPClient returns tg.client, plain NewTelegramAPI also starts routing avatar downloads through its caller's client, which is a real behaviour change for existing users. Either take it as a consistency fix and say so, or give tgAPI a separate avatarClient field only the new constructor sets.
test: httptest.NewTLSServer, build with ts.Client(), drive saveTelegramAvatar with a content saver, assert the server saw /file/bot... and the bytes were stored. The current one stops at the URL string, which is why it passes.
small: the empty check runs before the trim, so "/" becomes an empty base and URLs come out as /bot<token>/getMe - trim first. And const TelegramAPIBaseURL sits between // TelegramHandler implements login via telegram and the type, so the type lost its godoc.
the redaction regex itself holds under a custom base, 14 shapes through it and nothing leaked on the build or transport paths; the response path above is the only gap. Both modules identical apart from the jwt v4/v5 block, build and lint clean in both, 13 Telegram tests pass. The two provider/ failures are #315's byte counts.
b477bf0 to
7590b8a
Compare
|
All three addressed, plus the two small ones. Token redaction. You are right that redacting by URL shape cannot hold when the leak is whatever the upstream echoed. The token itself is now scrubbed from anything leaving Base URL validation. Constructor returns an error now: absolute http or https, host required, no userinfo, query, fragment or opaque part, path prefix allowed. Nil client refused. Table covers Avatar client. Done through the capability pattern you pointed at, with the 5s cap moved to a child context so On the decision you flagged rather than falling into: I took the separate-field option. The test drives Small ones: the const no longer sits between the Both modules build, lint clean at v2.12.2, all Telegram tests pass in each. |
|
Rebased onto master. This was stacked on #315, which I have now closed, so it was carrying that feature's commits and would have merged Why #315 went: your review plus an end-to-end test I should have run first left it fixing something already fixed. With The Go 1.27 byte-count assertions from that PR are split out as #317, test-only, and this branch no longer carries them, so Everything from your review of this PR is unchanged and still in: token scrubbed from anything leaving |
7590b8a to
9e69c34
Compare
#2214 turned its two TLS cases into tables over anonymous and email, so both are exercised in a third-party frame with the reload and again under enforced partitioning. Telegram is the only one of the three still resting on the writer keying off X-JWT and not off the provider, with #2208 as the reason it cannot be measured and go-pkgz/auth#316 as what would change that.
|
Found a regression in my own branch while reviewing it, and pushed the fix.
The nil now falls through to the default client instead of aborting. A caller who supplied one still gets theirs. The gap that let it through is the plain one: the branch added a test for the constructor the branch adds and none for the constructor that already existed. Both modules now have Still yours to decide, and unchanged by this: whether the avatar download should reuse the API's client at all. You flagged it as something to decide instead of fall into, and I have not tried to settle it here. If you would rather the capability interface went away and the download always used a default client, that is a smaller diff than what is there now and I will cut it. |
687499d to
8526b6d
Compare
|
Two more holes from the same review pass, both in the safeguards the second commit added, both now fixed. The validator checked one string and the code used another. Exact-substring redaction cannot hold when the upstream picks the encoding. So the claim in the earlier commit message, that the token is scrubbed from whatever Both fixes carry tests in both modules, and both are mutation-checked: removing the |
|
An adversarial review pass over this branch turned up four more ways the bot token leaves the process. All four are fixed and pushed, each with a test in both modules and each mutation-checked. Flagging them individually because the first is the one I would want a second opinion on, and the last carries a behaviour change that is yours to accept or reverse. A redirect hands the token to the destination. Go copies the previous URL into A 200 response can publish the token through a public endpoint. The decode check I added last round only decoded once. A double-encoded echo came back as a single-encoded one and stayed just as readable, so
I also routed the two transport-error paths in The decision I would like you to make. Refusing redirects means a proxy that answers with one stops working, where previously it worked and leaked. I took refusing as the safer default, since the public API does not redirect and a caller-supplied base is precisely where an unexpected one would come from, but it is a behaviour change and making it configurable is a one-line diff if you would rather. Two things I did not act on. The avatar URL returned by |
|
One thing in your review I have deliberately not followed, and it deserves to be called out rather than left for you to spot. You wrote: The reason is that the prescription holds only if the capability is advertised conditionally. So the nil now falls through to the default client instead of aborting, which is your "assertion fails, keep today's client" branch reached by a different route. A caller who supplied a client still gets theirs. If you would rather have the behaviour you described, the way to get it is to advertise conditionally: give Both branches are covered now: |
#2214 turned its two TLS cases into tables over anonymous and email, so both are exercised in a third-party frame with the reload and again under enforced partitioning. Telegram is the only one of the three still resting on the writer keying off X-JWT and not off the provider, with #2208 as the reason it cannot be measured and go-pkgz/auth#316 as what would change that.
#2214 turned its two TLS cases into tables over anonymous and email, so both are exercised in a third-party frame with the reload and again under enforced partitioning. Telegram is the only one of the three still resting on the writer keying off X-JWT and not off the provider, with #2208 as the reason it cannot be measured and go-pkgz/auth#316 as what would change that.
#2214 turned its two TLS cases into tables over anonymous and email, so both are exercised in a third-party frame with the reload and again under enforced partitioning. Telegram is the only one of the three still resting on the writer keying off X-JWT and not off the provider, with #2208 as the reason it cannot be measured and go-pkgz/auth#316 as what would change that.
umputun
left a comment
There was a problem hiding this comment.
all five from the last round are in: response-path redaction, base URL validation with the error return, the avatar client through the capability with avatarClient set only by the new constructor, the context cap, the TLS test. The trim-before-empty and the godoc placement too. Two things left, one of them blocking.
tokenRecoverable fails open on malformed escaping (provider/telegram.go, same in v2)
it gives up on the first unescape error and returns false, meaning not recoverable, without having looked for the token. The PathUnescape fallback cannot rescue that: both functions reject the same inputs, the mode-specific errors are for encodeHost/encodeZone and the only query/path difference is +. So one bare % anywhere in the upstream text turns the guard off.
that is reachable, because the text is whatever the proxy echoed:
{"description":"forwarding %252Fbot1234567%253ASECRET-TOK_EN-x%252FgetMe at 100% load"}
ReplaceAll misses, token is double-encoded. redactBotURLInErr misses, % is outside /bot[A-Za-z0-9:_-]+/. tokenRecoverable aborts on % l. redactToken sees the message unchanged and returns the error as-is, and it goes out through BotInfo/GetUpdates/Send into the log at :98, :182, :216. Two decodes get the token back.
TestTelegram_APIErrorDoesNotLeakADoubleEncodedToken passes only because its description has no stray %. Append at 100% load to it and it fails.
fix is to fail closed on any decode error. After malformed escaping the function cannot prove the token is absent, so withholding is the only honest answer. Same for the five-layer cap when the text is still changing. Worth pinning with a malformed-percent-plus-double-encoded case in both modules.
redirect refusal overrides a policy the caller set
noRedirect is on request as well as the avatar path, so it replaces CheckRedirect for every call, including NewTelegramAPI callers. :700 says the client's CheckRedirect survives and :369 says the caller's redirect policy is why the avatar download uses their client; :753 replaces it.
refusing is right, Go copies the previous URL into Referer and every URL here carries the token. But make it the default rather than the rule: if the supplied client has a nil CheckRedirect, install the refusal; if it has a hook, that is the operator's explicit policy on a base URL he chose, leave it. Say beside the constructor that allowing a redirect can expose the token through Referer unless the hook strips or refuses it. Test both, and for the second one assert the hook is actually called rather than just still on the struct, since a wrapper in front of it would satisfy the weaker check. That also puts :700 and :369 back in agreement with the code.
minor: validateTelegramBaseURL puts the raw base URL in every rejection, so https://user:pass@proxy.example is rejected for carrying credentials and the error then carries them, into whatever logs the constructor failure. Same for the query case. Name the rejected property, or show a sanitised host, rather than echoing the value.
|
All three addressed in
The redirect refusal is now a default rather than a rule. A client arriving with The rejection errors no longer echo the value. Each names the property that was wrong; the scheme case keeps the scheme, since that is what makes it fixable and it is not a secret.
|
umputun
left a comment
There was a problem hiding this comment.
fail-closed and the redirect default are both right, and I'm taking them as settled: a hostile upstream already controls whether any description exists, so withholding costs nothing it could not already deny, and CheckRedirect is net/http's own policy surface with the constructor godoc now naming what a permitted hop exposes. The counter in TestTelegram_RedirectPolicyOfTheCallerIsKept is the right shape.
one thing left, in the third fix.
the parse-error branch still echoes the rejected URL (provider/telegram.go:436, same in v2)
u, err := neturl.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("invalid telegram api base url: %w", err)
}*url.Error prints its URL field verbatim, and this runs before the switch, so a base that is both credentialed and malformed never reaches the u.User case:
invalid telegram api base url: parse "https://alice:topsecret@example.com/%zz": invalid URL escape "%zz"
a % in a proxy password does it, as does a control character or an unclosed [. Same for the comment three lines down that says the value is never echoed back.
unwrapping to err.(*url.Error).Err looks like it keeps the useful half, and I thought so too until I ran it: https://proxy.example.com:s3cr3t/tg gives invalid port ":s3cr3t" after host, and the IPv6 zone path leaks input text as well. So the inner error is not safe either. errors.New("telegram api base url is not a valid url") is the answer, since the whole parse boundary is untrusted config.
and the assertion that would have caught it
TestTelegram_APIBaseURLRejectsUnusableValues asserts only assert.Error, so reverting all six errors.New calls back to fmt.Errorf("...: %s", baseURL) leaves everything green. One line in the existing loop covers that and the fix above:
assert.NotContains(t, err.Error(), base)plus a credentialed-and-malformed case, https://user:pa%zzss@api.telegram.org/, and an invalid port carrying a secret. Both modules.
that's all of it. Everything else in this round checks out: both fail-closed paths land, the redirect branch is right at request and at the avatar download, and the two halves are identical. Rebase onto master when you push, it's conflict-free and clears the six byte-count failures now that #317 is in.
The bot API host was formatted inline in two places, the bot methods and the file downloads, so nothing outside this package could redirect them. That makes the Telegram provider unreachable from any test that is not willing to talk to the live API, and it leaves operators behind a proxy with no way in either. NewTelegramAPIWithBaseURL takes the base and derives both forms from it. NewTelegramAPI keeps its signature and its behaviour, delegating with the public API, so existing callers are unaffected. An empty base falls back to the public API rather than producing requests against nothing, and a trailing slash is trimmed so callers need not care. The test stands up a substitute API and checks every call reaches it, including the avatar download, which is the second URL and the one easy to miss. Mirrored in v1 and v2 per the project rule for shared behaviour.
… client Making the host caller-supplied moved a token-bearing URL across a boundary that used to be frozen, and three things behind it were not built for that. The bot token could reach the error text. request redacted its own two paths and then returned parseError bare, which interpolates the upstream description raw, so a proxy answering 502 with the request URI in it put the token into the log through Run, GetUpdates and Send. The token itself is now scrubbed from anything leaving request, rather than matching a URL shape, since the leak is whatever the upstream chose to echo. Covered by a server that echoes its request URI. The base URL was unvalidated, so "https://api.telegram.org@evil.tld" resolved to evil.tld and shipped /bot<token>/ there. The constructor returns an error now: absolute http or https, host required, no userinfo, query, fragment or opaque part, an optional path prefix allowed for a proxy mounted under one. A nil client is refused too. The trim also runs before the empty check, so "/" falls back to the public API rather than producing /bot<token>/getMe against nothing. And the avatar download built its own client, dropping the custom CA, client certificates, redirect policy and proxy settings that live on the one the caller passed, which made "the production path is otherwise identical" untrue for the path the feature exists for. It now takes the client through the optional-capability pattern already used for the content saver, with the 5s cap applied through the context so Transport, CheckRedirect and Jar survive. Deliberately a separate field set only by the new constructor, so NewTelegramAPI callers keep the transport they have always had rather than silently switching. Advertised but nil drops the avatar instead. The test drives it against a TLS server only the supplied client can reach, and asserts the bytes were stored, which the previous one could not since it stopped at the URL string. TelegramAPIBaseURL also had its comment sitting between the TelegramHandler godoc and the type, so the type had lost its own.
*tgAPI satisfies telegramAvatarClientProvider whichever constructor built it, so an API from NewTelegramAPI answered the capability with a nil client and saveTelegramAvatar treated that as "no client available" and dropped the avatar. That is every existing caller, since NewTelegramAPIWithBaseURL is new in this branch, and on master the download always ran against a default client. The nil now falls through to that default, which is the behaviour the field's own comment describes. Both modules gain the companion test to the supplied-client one. The gap that let this through was covering only the constructor the branch adds.
Two holes a review found, both in the second commit's own safeguards. The validator inspected the parsed URL while request and Avatar formatted the original string, so shapes that parse to something empty and serialise to something else went straight through. A trailing "?" sets ForceQuery with RawQuery empty, and put the whole /bot<token>/method into the query string, which is the part access logs, CDNs and referrers capture most eagerly. A trailing "#" is the mirror image: every request became a fragment that is never sent, so the operator saw a 404 loop with nothing pointing at the base. Both are now rejected, and the accepted value is rebuilt from the parsed URL so the string checked is the string used. redactToken matched the token as an exact substring, which the upstream can defeat by choosing an encoding: a proxy echoing the percent-encoded request URI got the token past both the substitution and the /bot<token>/ shape regex. It now scrubs the query- and path-escaped forms too, and then checks a decoded, case-folded copy; if the token is still recoverable the text is withheld instead of forwarded, since no fixed set of substitutions can cover an encoding the other side picks.
An adversarial pass over the base-URL feature found that pointing the API at a caller-supplied host widens more than the request destination, because the answers now come from a host the library does not control. BotInfo returned whatever the upstream put in result.username, and LoginHandler hands that to an unauthenticated caller in its "bot" field. An upstream echoing the request URI would therefore publish the bot token through a public endpoint. The username now has to match Telegram's own shape before it is accepted. tokenRecoverable decoded once, so a double-encoded echo came back as a single-encoded one and stayed just as readable. It now decodes until the text stops changing, with a cap. validateTelegramBaseURL checked u.Host, which is non-empty for "http://:9000", an unspecified remote that resolves to the local machine. It checks Hostname now. Also routes the two transport-error paths in request through redactToken as well as the shape regex, since the regex only matches a token sitting inside a /bot.../ path segment. Each fix has a test in both modules, and each is mutation-checked: removing the username check, weakening Hostname back to Host, and reducing the decode loop to one pass each fail their own test and nothing else.
Go copies the previous URL into Referer on every redirect hop except https-to-http, and both Telegram URL forms carry the token in the path, so a redirect hands the destination host the bot token, normally into its access log. Verified against net/http rather than inferred: a probe with two TLS test servers shows the target receiving Referer: https://host/bot1234567:SECRET-TOK/getMe. The public API does not redirect, so nothing legitimate is lost by refusing, and a caller-supplied base is exactly where an unexpected redirect could come from. noRedirect takes a shallow copy of the client so the caller's own is left alone and its Transport, and with it any custom CA, client certificate or proxy setting, is still used. Applied to the bot-method path and to the avatar download, which carries the token in its URL too. Worth an explicit decision on your side: this makes a proxy that answers with a redirect stop working, where before it worked and leaked. Refusing is the safer default, and it is a one-line change to make it configurable if you would rather it were.
…r's redirect policy
tokenRecoverable gave up on the first unescape error and answered "not
recoverable", so the message went out. The text is whatever the upstream
echoed, and one bare "%" anywhere in it is enough to stop the decoder before
it reaches an encoded token:
{"description":"forwarding %252Fbot<token>%252FgetMe at 100% load"}
ReplaceAll misses the double encoding, redactBotURLInErr misses it because the
"%" is outside /bot[A-Za-z0-9:_-]+/, and the guard that exists for exactly this
aborts on "% l". Two decodes recover the token from what reaches the log.
It now withholds on any decode error, and on the five-layer cap running out
while the text is still changing: after either, absence was never established,
and withholding is the only answer that cannot leak.
noRedirect replaced CheckRedirect on every call, so a client arriving with a
policy of its own lost it, which contradicted both the constructor godoc and
the reason the avatar download uses the caller's client. Refusing stays the
default for a client that expressed nothing; a hook already on the client is
the operator's decision about a base URL they chose, and it survives. The
godoc says beside the constructor what a permitted redirect exposes through
Referer.
And validateTelegramBaseURL echoed the rejected value, so the case that
refuses a base URL for carrying credentials wrote those credentials into
whatever logs the constructor failure. It names the property instead, keeping
the scheme where that is what makes the error fixable.
Both new tests fail against the code as it was: the malformed-escaping one
releases the double-encoded token, and the redirect one is overridden by the
refusal. The caller's hook is asserted to have run rather than to be present,
since a wrapper in front of it would satisfy that.
…choes the base
*url.Error prints its URL field verbatim, and the parse branch runs before
every check below it, so a base that is both credentialed and malformed never
reaches the userinfo case and carries its credentials into whatever logs the
constructor failure:
invalid telegram api base url: parse "https://alice:topsecret@example.com/%zz":
invalid URL escape "%zz"
A "%" in a proxy password is enough, as is a control character or an unclosed
bracket. Unwrapping to the inner error is no safer: an invalid port quotes it
back as `invalid port ":s3cr3t" after host`, and the IPv6 zone path does the
same. The whole parse boundary is untrusted configuration, so the error says
the base url is not valid and says nothing else.
The test asserted only that an error came back, so reverting every rejection
to interpolate the value again left it green. It now also requires the message
not to contain the base it refused, which covers all seven rejection paths,
and carries the two shapes that reach the parse branch: credentials beside
malformed escaping, and a secret where the port belongs.
591b0c4 to
57bb40d
Compare
|
Done in The parse branch no longer wraps. You are right that unwrapping does not save it: I checked the two shapes you named before changing anything, and The assertion that would have caught it. Reverting every rejection to interpolate the value again now fails seven of seven rejection subtests with Both modules identical apart from the jwt block. |
umputun
left a comment
There was a problem hiding this comment.
that closes it. Dropping the parse error is right, and your commit message reaches the invalid-port case on its own, which is the part that makes unwrapping look safe until you try it.
the NotContains across all seven paths is the assertion the whole thing was missing.
lgtm
|
It is reachable the same way as its neighbour: the download URL interpolates the file path the metadata response chose, so a path like Both sites now go through the same helper. For what it is worth on the "would this cause another round" question: three supervised review rounds ran over this branch, on Opus and codex |
The Telegram provider hard-codes
https://api.telegram.org, which means nothing can stand in for the API: no proxy in front of it, and no stub for an end-to-end test.NewTelegramAPIWithBaseURLmakes the base configurable, and every request goes through it, avatar downloads included, so the production path stays the code under test. An empty base falls back to the public API and existing callers are unaffected.That is the feature. Most of this PR is what the feature turned out to require, because moving the base off a constant moves a token-bearing URL across a boundary that used to be frozen, and the answers now come from a host the library does not control.
Six ways the bot token could leave the process, found across your review and two adversarial passes, each fixed with tests in both modules and each mutation-checked.
parseErrorinterpolated the upstreamdescriptionraw, so a proxy answering 502 with the request URI in it published the token into the log. Scrubbed by token value, not by URL shape, since the leak is whatever the upstream echoed./bot<token>/regex. It now scrubs the escaped forms too and then checks a decoded, case-folded copy, withholding the text entirely if the token is still recoverable. Decoding repeats until the text stops changing, because one pass turns%253Aback into%3A.BotInfoaccepted whatever the upstream put inresult.username, andLoginHandlerreturns that to an unauthenticated caller in itsbotfield, so an upstream echoing the request URI published the token through a public endpoint. Every guard was on the error path; this is the success path. The username now has to match Telegram's own shape.Refereron every hop except https-to-http, and both URL forms carry the token in the path. Verified againstnet/httpwith two TLS servers rather than inferred. The public API does not redirect, so these requests now refuse to follow one.http://:9000passed validation, sinceu.Hostis non-empty whileu.Hostname()is empty, and an unspecified remote resolves to the local machine.?setForceQuerywithRawQueryempty and sentGET /tg?/bot<token>/getMe, putting the token in the query string. A trailing#was the mirror image, burying every request in a fragment that is never sent. The accepted value is now rebuilt from the parsed URL.One behaviour change to accept or reject. Refusing redirects means a proxy that answers with one stops working, where before it worked and leaked. I took refusing as the safer default; making it configurable is a one-line diff.
One place I did not follow your review, deliberately. You asked for "advertised but nil, drop the avatar rather than silently switch transport". That holds only if the capability is advertised conditionally, and
avatarHTTPClientis a plain method on*tgAPI, so every instance advertises it. Implemented literally, it dropped Telegram avatars for every caller ofNewTelegramAPI, which is all of them, including the repo's own_example/main.goand the README snippet. The nil now falls through to the default client. If you would rather have the behaviour you described, the way to get it is a distinct type from the new constructor so a plaintgAPIfails the assertion; that is a larger diff and the same decision you flagged as one to take deliberately.Also in here: the constructor rejects a nil client, trims before the empty check so
"/"does not become an empty base, andTelegramAPIBaseURLno longer sits betweenTelegramHandler's doc comment and the type.