feat: sync HTTP callback OIDC setup

This commit is contained in:
A 2026-07-21 18:18:33 +08:00
parent bf72c246b6
commit f53579416e
26 changed files with 557 additions and 133 deletions

View file

@ -22,7 +22,7 @@ func newBotFatherTelegramLoginService(t *testing.T) *telegramloginapp.Service {
pepper := make([]byte, 32)
pepper[0] = 2
service, err := telegramloginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, telegramloginapp.Config{
Issuer: "http://localhost:2404", AppScheme: "telesrv", AllowLoopbackHTTP: true,
Issuer: "http://192.0.2.25:2404", AppScheme: "telesrv", AllowHTTP: true,
ClientSecretPepper: pepper, Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
})
if err != nil {
@ -52,13 +52,13 @@ func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
if len(secret) < 32 {
t.Fatalf("client secret is unexpectedly short: %q", secret)
}
if reply := sendToBotFather(t, svc, messages, owner, "add origin http://localhost:3000"); !strings.Contains(reply, "Success!") {
if reply := sendToBotFather(t, svc, messages, owner, "add origin http://rp.example.test:3000"); !strings.Contains(reply, "Success!") {
t.Fatalf("add origin reply = %q", reply)
}
sendToBotFather(t, svc, messages, owner, "/setlogin")
sendToBotFather(t, svc, messages, owner, "login_demo_bot")
if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://localhost:3000/auth/callback"); !strings.Contains(reply, "Success!") {
if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://192.0.2.26:3000/auth/callback"); !strings.Contains(reply, "Success!") {
t.Fatalf("add redirect reply = %q", reply)
}
@ -83,7 +83,7 @@ func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
sendToBotFather(t, svc, messages, owner, "/logininfo")
info := sendToBotFather(t, svc, messages, owner, "login_demo_bot")
for _, want := range []string{"Signing algorithm: ES256", "web_origin http://localhost:3000", "redirect_uri http://localhost:3000/auth/callback", "dev.bedolaga.demo", "Bedolaga iOS Demo", "Bedolaga Android Demo"} {
for _, want := range []string{"Signing algorithm: ES256", "web_origin http://rp.example.test:3000", "redirect_uri http://192.0.2.26:3000/auth/callback", "dev.bedolaga.demo", "Bedolaga iOS Demo", "Bedolaga Android Demo"} {
if !strings.Contains(info, want) {
t.Fatalf("login info = %q, missing %q", info, want)
}

View file

@ -293,9 +293,10 @@ func (r *SigningKeyRing) sign(algorithm domain.TelegramLoginSigningAlgorithm, to
}
type IDTokenIssuerConfig struct {
Issuer string
TTL time.Duration
Now func() time.Time
Issuer string
TTL time.Duration
Now func() time.Time
AllowHTTP bool
}
type IDTokenIssuer struct {
@ -337,7 +338,7 @@ func NewIDTokenIssuer(keys *SigningKeyRing, cfg IDTokenIssuerConfig) (*IDTokenIs
if keys == nil {
return nil, errors.New("telegram login signing key ring is required")
}
issuer, err := NormalizeWebOrigin(cfg.Issuer, true)
issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowHTTP)
if err != nil {
return nil, fmt.Errorf("telegram login ID token issuer: %w", err)
}

View file

@ -167,6 +167,21 @@ func TestIDTokenIssuerScopeProjectionAndVerification(t *testing.T) {
}
}
func TestIDTokenIssuerAcceptsHTTPIPOnlyWhenEnabled(t *testing.T) {
now := time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC)
ring := telegramLoginTestSigningKeys(t, &now)
if _, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{Issuer: "http://192.0.2.25:2401"}); err == nil {
t.Fatal("HTTP issuer was accepted while AllowHTTP was false")
}
issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{Issuer: "http://192.0.2.25:2401", AllowHTTP: true})
if err != nil {
t.Fatal(err)
}
if issuer.Issuer() != "http://192.0.2.25:2401" {
t.Fatalf("issuer=%q", issuer.Issuer())
}
}
func TestSigningKeyRingRejectsWrongCurveAndDuplicateActiveKey(t *testing.T) {
p384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {

View file

@ -45,7 +45,7 @@ func normalizeNativeVerificationID(platform domain.TelegramLoginNativePlatform,
// non-web custom scheme registered for a native application. Query and
// fragment components are forbidden because OAuth response fields are
// appended by the provider and must not collide with application input.
func NormalizeNativeCallbackURI(raw string, allowLoopbackHTTP bool) (string, error) {
func NormalizeNativeCallbackURI(raw string, allowHTTP bool) (string, error) {
if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
return "", domain.ErrTelegramLoginURLInvalid
}
@ -54,7 +54,7 @@ func NormalizeNativeCallbackURI(raw string, allowLoopbackHTTP bool) (string, err
return "", domain.ErrTelegramLoginURLInvalid
}
if strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "https") {
normalized, _, err := NormalizeRedirectURI(raw, allowLoopbackHTTP)
normalized, _, err := NormalizeRedirectURI(raw, allowHTTP)
return normalized, err
}
scheme := strings.ToLower(u.Scheme)

View file

@ -36,7 +36,7 @@ var telegramLoginMatchCodePool = []string{
type Config struct {
Issuer string
AppScheme string
AllowLoopbackHTTP bool
AllowHTTP bool
ClientSecretPepper []byte
SupportedSigningAlgorithms []domain.TelegramLoginSigningAlgorithm
RequestTTL time.Duration
@ -49,7 +49,7 @@ type Service struct {
sealer *CodeSealer
issuer string
appScheme string
allowLoopbackHTTP bool
allowHTTP bool
clientSecretPepper []byte
signingAlgorithms []domain.TelegramLoginSigningAlgorithm
signingAlgorithmSet map[domain.TelegramLoginSigningAlgorithm]struct{}
@ -62,7 +62,7 @@ func NewService(loginStore store.TelegramLoginStore, sealer *CodeSealer, cfg Con
if loginStore == nil || sealer == nil || len(cfg.ClientSecretPepper) < 32 {
return nil, fmt.Errorf("telegram login dependencies are incomplete")
}
issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowLoopbackHTTP)
issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowHTTP)
if err != nil {
return nil, fmt.Errorf("telegram login issuer: %w", err)
}
@ -93,7 +93,7 @@ func NewService(loginStore store.TelegramLoginStore, sealer *CodeSealer, cfg Con
}
return &Service{
store: loginStore, sealer: sealer, issuer: issuer, appScheme: strings.ToLower(cfg.AppScheme),
allowLoopbackHTTP: cfg.AllowLoopbackHTTP,
allowHTTP: cfg.AllowHTTP,
clientSecretPepper: append([]byte(nil), cfg.ClientSecretPepper...),
signingAlgorithms: append([]domain.TelegramLoginSigningAlgorithm(nil), cfg.SupportedSigningAlgorithms...),
signingAlgorithmSet: signingAlgorithmSet,
@ -248,9 +248,9 @@ func (s *Service) AddAllowedURL(ctx context.Context, botUserID int64, kind domai
var err error
switch kind {
case domain.TelegramLoginAllowedWebOrigin:
normalized, err = NormalizeWebOrigin(raw, s.allowLoopbackHTTP)
normalized, err = NormalizeWebOrigin(raw, s.allowHTTP)
case domain.TelegramLoginAllowedRedirectURI:
normalized, _, err = NormalizeRedirectURI(raw, s.allowLoopbackHTTP)
normalized, _, err = NormalizeRedirectURI(raw, s.allowHTTP)
default:
err = domain.ErrTelegramLoginURLInvalid
}
@ -267,9 +267,9 @@ func (s *Service) DeleteAllowedURL(ctx context.Context, botUserID int64, kind do
var err error
switch kind {
case domain.TelegramLoginAllowedWebOrigin:
normalized, err = NormalizeWebOrigin(raw, s.allowLoopbackHTTP)
normalized, err = NormalizeWebOrigin(raw, s.allowHTTP)
case domain.TelegramLoginAllowedRedirectURI:
normalized, _, err = NormalizeRedirectURI(raw, s.allowLoopbackHTTP)
normalized, _, err = NormalizeRedirectURI(raw, s.allowHTTP)
default:
err = domain.ErrTelegramLoginURLInvalid
}
@ -311,7 +311,7 @@ func (s *Service) AddNativeApp(ctx context.Context, botUserID int64, platform do
if err != nil {
return domain.TelegramLoginNativeApp{}, err
}
callbackURI, err = NormalizeNativeCallbackURI(callbackURI, s.allowLoopbackHTTP)
callbackURI, err = NormalizeNativeCallbackURI(callbackURI, s.allowHTTP)
if err != nil {
return domain.TelegramLoginNativeApp{}, err
}
@ -335,7 +335,7 @@ func (s *Service) DeleteNativeApp(ctx context.Context, botUserID, appID int64) (
}
func (s *Service) matchNativeApp(ctx context.Context, botUserID int64, platform domain.TelegramLoginNativePlatform, rawCallbackURI string) (domain.TelegramLoginNativeApp, string, bool, error) {
callbackURI, err := NormalizeNativeCallbackURI(rawCallbackURI, s.allowLoopbackHTTP)
callbackURI, err := NormalizeNativeCallbackURI(rawCallbackURI, s.allowHTTP)
if err != nil {
return domain.TelegramLoginNativeApp{}, "", false, nil
}
@ -362,7 +362,7 @@ func (s *Service) ValidateMessageButton(ctx context.Context, botUserID int64, ra
if !found || !client.Enabled {
return "", "", domain.ErrTelegramLoginClientDisabled
}
normalizedURL, domainName, err = NormalizeRedirectURI(rawURL, s.allowLoopbackHTTP)
normalizedURL, domainName, err = NormalizeRedirectURI(rawURL, s.allowHTTP)
if err != nil {
return "", "", err
}
@ -370,7 +370,7 @@ func (s *Service) ValidateMessageButton(ctx context.Context, botUserID int64, ra
if err != nil {
return "", "", domain.ErrTelegramLoginURLInvalid
}
origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowLoopbackHTTP)
origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowHTTP)
if err != nil {
return "", "", err
}
@ -415,7 +415,7 @@ func (s *Service) AuthorizeMessageButton(ctx context.Context, params domain.Tele
if err != nil {
return domain.TelegramLoginMessageButtonResult{}, domain.ErrTelegramLoginURLInvalid
}
origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowLoopbackHTTP)
origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowHTTP)
if err != nil {
return domain.TelegramLoginMessageButtonResult{}, err
}
@ -552,7 +552,7 @@ func (s *Service) CreateAuthorization(ctx context.Context, params CreateAuthoriz
}
var allowed, isApp bool
var nativeApp domain.TelegramLoginNativeApp
redirectURI, domainName, redirectErr := NormalizeRedirectURI(params.RedirectURI, s.allowLoopbackHTTP)
redirectURI, domainName, redirectErr := NormalizeRedirectURI(params.RedirectURI, s.allowHTTP)
if params.ResponseType == "code" && redirectErr == nil && !params.NativePlatform.Valid() {
allowed, err = s.store.IsTelegramLoginURLAllowed(ctx, client.BotUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI)
if err != nil {
@ -603,14 +603,14 @@ func (s *Service) CreateAuthorization(ctx context.Context, params CreateAuthoriz
u, _ := url.Parse(redirectURI)
origin = u.Scheme + "://" + u.Host
}
origin, err = NormalizeWebOrigin(origin, s.allowLoopbackHTTP)
origin, err = NormalizeWebOrigin(origin, s.allowHTTP)
if err != nil {
return CreatedAuthorization{}, err
}
}
if params.ResponseType == "post_message" {
redirectURL, _ := url.Parse(redirectURI)
redirectOrigin, redirectOriginErr := NormalizeWebOrigin(redirectURL.Scheme+"://"+redirectURL.Host, s.allowLoopbackHTTP)
redirectOrigin, redirectOriginErr := NormalizeWebOrigin(redirectURL.Scheme+"://"+redirectURL.Host, s.allowHTTP)
if redirectOriginErr != nil || redirectOrigin != origin {
return CreatedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
@ -627,7 +627,7 @@ func (s *Service) CreateAuthorization(ctx context.Context, params CreateAuthoriz
if isApp {
return CreatedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
inAppOrigin, err = NormalizeWebOrigin(params.InAppOrigin, s.allowLoopbackHTTP)
inAppOrigin, err = NormalizeWebOrigin(params.InAppOrigin, s.allowHTTP)
if err != nil {
return CreatedAuthorization{}, err
}
@ -693,7 +693,7 @@ func (s *Service) ResolveAuthorizationErrorTarget(ctx context.Context, clientID,
redirectURI, safe, err := s.safeCodeRedirect(ctx, client.BotUserID, rawRedirectURI)
return AuthorizationErrorTarget{ResponseType: responseType, RedirectURI: redirectURI}, safe, err
case "post_message":
redirectURI, _, err := NormalizeRedirectURI(rawRedirectURI, s.allowLoopbackHTTP)
redirectURI, _, err := NormalizeRedirectURI(rawRedirectURI, s.allowHTTP)
if err != nil {
return AuthorizationErrorTarget{}, false, nil
}
@ -702,12 +702,12 @@ func (s *Service) ResolveAuthorizationErrorTarget(ctx context.Context, clientID,
redirect, _ := url.Parse(redirectURI)
origin = redirect.Scheme + "://" + redirect.Host
}
origin, err = NormalizeWebOrigin(origin, s.allowLoopbackHTTP)
origin, err = NormalizeWebOrigin(origin, s.allowHTTP)
if err != nil {
return AuthorizationErrorTarget{}, false, nil
}
redirect, _ := url.Parse(redirectURI)
redirectOrigin, err := NormalizeWebOrigin(redirect.Scheme+"://"+redirect.Host, s.allowLoopbackHTTP)
redirectOrigin, err := NormalizeWebOrigin(redirect.Scheme+"://"+redirect.Host, s.allowHTTP)
if err != nil || redirectOrigin != origin {
return AuthorizationErrorTarget{}, false, nil
}
@ -725,7 +725,7 @@ func (s *Service) ResolveAuthorizationErrorTarget(ctx context.Context, clientID,
}
func (s *Service) safeCodeRedirect(ctx context.Context, botUserID int64, raw string) (string, bool, error) {
if redirectURI, _, err := NormalizeRedirectURI(raw, s.allowLoopbackHTTP); err == nil {
if redirectURI, _, err := NormalizeRedirectURI(raw, s.allowHTTP); err == nil {
allowed, err := s.store.IsTelegramLoginURLAllowed(ctx, botUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI)
if err != nil || allowed {
return redirectURI, allowed, err
@ -856,7 +856,7 @@ func (s *Service) RequestByDeepLinkForOrigin(ctx context.Context, rawURL, rawOri
if rawOrigin == "" || request.InAppOrigin == "" {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginOriginNotAllowed
}
origin, err := NormalizeWebOrigin(rawOrigin, s.allowLoopbackHTTP)
origin, err := NormalizeWebOrigin(rawOrigin, s.allowHTTP)
if err != nil {
return domain.TelegramLoginRequest{}, err
}
@ -1067,7 +1067,7 @@ func (s *Service) ExchangeInAppTokenAndIssue(ctx context.Context, token, rawOrig
if issuer == nil || len(token) < 16 || len(token) > 1024 || strings.IndexFunc(token, func(r rune) bool { return r <= 0x20 || r == 0x7f }) >= 0 {
return IssuedAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
origin, err := NormalizeWebOrigin(rawOrigin, s.allowLoopbackHTTP)
origin, err := NormalizeWebOrigin(rawOrigin, s.allowHTTP)
if err != nil {
return IssuedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
@ -1294,7 +1294,7 @@ func (s *Service) exchangeAuthorizationCode(ctx context.Context, params Exchange
return ExchangedAuthorization{}, "", domain.ErrTelegramLoginCodeInvalid
}
} else {
redirectURI, _, err = NormalizeRedirectURI(params.RedirectURI, s.allowLoopbackHTTP)
redirectURI, _, err = NormalizeRedirectURI(params.RedirectURI, s.allowHTTP)
if err != nil {
return ExchangedAuthorization{}, "", err
}

View file

@ -94,7 +94,7 @@ func newTelegramLoginTestServiceWithAlgorithms(t *testing.T, now *time.Time, alg
pepper[0] = 9
service, err := NewService(loginStore, sealer, Config{
Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv",
AllowLoopbackHTTP: true, ClientSecretPepper: pepper,
AllowHTTP: true, ClientSecretPepper: pepper,
SupportedSigningAlgorithms: algorithms,
Now: func() time.Time { return *now },
})

View file

@ -14,8 +14,8 @@ import (
const maxTelegramLoginURLLength = 4096
func NormalizeRedirectURI(raw string, allowLoopbackHTTP bool) (normalized, domainName string, err error) {
u, err := parseWebURL(raw, allowLoopbackHTTP)
func NormalizeRedirectURI(raw string, allowHTTP bool) (normalized, domainName string, err error) {
u, err := parseWebURL(raw, allowHTTP)
if err != nil {
return "", "", err
}
@ -34,8 +34,8 @@ func NormalizeRedirectURI(raw string, allowLoopbackHTTP bool) (normalized, domai
return u.String(), u.Hostname(), nil
}
func NormalizeWebOrigin(raw string, allowLoopbackHTTP bool) (string, error) {
u, err := parseWebURL(raw, allowLoopbackHTTP)
func NormalizeWebOrigin(raw string, allowHTTP bool) (string, error) {
u, err := parseWebURL(raw, allowHTTP)
if err != nil {
return "", err
}
@ -46,7 +46,7 @@ func NormalizeWebOrigin(raw string, allowLoopbackHTTP bool) (string, error) {
return u.String(), nil
}
func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) {
func parseWebURL(raw string, allowHTTP bool) (*url.URL, error) {
if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
return nil, domain.ErrTelegramLoginURLInvalid
}
@ -78,7 +78,7 @@ func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) {
port = ""
}
case "http":
if !allowLoopbackHTTP || !isLoopbackHost(host) {
if !allowHTTP {
return nil, domain.ErrTelegramLoginURLInvalid
}
if port == "80" {
@ -99,14 +99,6 @@ func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) {
return u, nil
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func AppendAuthorizationResult(redirectURI, code, state string) (string, error) {
u, err := url.Parse(redirectURI)
if err != nil || !u.IsAbs() || code == "" {

View file

@ -18,8 +18,9 @@ func TestNormalizeRedirectURIIsExactAndRejectsOpenRedirectShapes(t *testing.T) {
}{
{name: "https canonical", raw: "https://EXAMPLE.com:443/callback?tenant=one", want: "https://example.com/callback?tenant=one", valid: true},
{name: "idna", raw: "https://例子.测试/callback", want: "https://xn--fsqu00a.xn--0zwm56d/callback", valid: true},
{name: "loopback dev", raw: "http://127.0.0.1:8080/callback", allowHTTP: true, want: "http://127.0.0.1:8080/callback", valid: true},
{name: "http production", raw: "http://example.com/callback"},
{name: "http hostname enabled", raw: "http://example.com:8080/callback", allowHTTP: true, want: "http://example.com:8080/callback", valid: true},
{name: "http ipv4 enabled", raw: "http://192.0.2.25:3000/callback", allowHTTP: true, want: "http://192.0.2.25:3000/callback", valid: true},
{name: "http disabled", raw: "http://example.com/callback"},
{name: "userinfo", raw: "https://user@example.com/callback"},
{name: "fragment", raw: "https://example.com/callback#token"},
{name: "reserved code", raw: "https://example.com/callback?code=attacker"},
@ -64,19 +65,19 @@ func TestNormalizeWebOriginRejectsPathAndQuery(t *testing.T) {
}
}
func TestNormalizeLoopbackIPv6PreservesURLBrackets(t *testing.T) {
origin, err := NormalizeWebOrigin("http://[0:0:0:0:0:0:0:1]:80/", true)
func TestNormalizeHTTPIPv6PreservesURLBrackets(t *testing.T) {
origin, err := NormalizeWebOrigin("http://[2001:db8::25]:80/", true)
if err != nil {
t.Fatal(err)
}
if origin != "http://[0:0:0:0:0:0:0:1]" {
if origin != "http://[2001:db8::25]" {
t.Fatalf("origin=%q", origin)
}
redirect, domainName, err := NormalizeRedirectURI("http://[::1]/callback", true)
redirect, domainName, err := NormalizeRedirectURI("http://[2001:db8::26]:3000/callback", true)
if err != nil {
t.Fatal(err)
}
if redirect != "http://[::1]/callback" || domainName != "::1" {
if redirect != "http://[2001:db8::26]:3000/callback" || domainName != "2001:db8::26" {
t.Fatalf("redirect=%q domain=%q", redirect, domainName)
}
}

View file

@ -1077,11 +1077,18 @@ func validateWebhookURL(raw string) error {
return errors.New("WEBHOOK_URL_INVALID")
}
u, err := neturl.ParseRequestURI(raw)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
if err != nil {
return errors.New("WEBHOOK_URL_INVALID")
}
if port := u.Port(); port != "" && port != "443" && port != "80" && port != "88" && port != "8443" {
return errors.New("WEBHOOK_PORT_NOT_ALLOWED")
scheme := strings.ToLower(u.Scheme)
if (scheme != "http" && scheme != "https") || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
return errors.New("WEBHOOK_URL_INVALID")
}
if port := u.Port(); port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return errors.New("WEBHOOK_URL_INVALID")
}
}
return nil
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
@ -977,6 +978,23 @@ func TestSetWebhookPersistsConfigReportsInfoAndConflictsWithPolling(t *testing.T
}
}
func TestSetWebhookAcceptsHTTPHostIPAndArbitraryPort(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
for _, rawURL := range []string{
"http://bot.example.test:3000/hook",
"http://192.0.2.25:18080/hook",
"http://[2001:db8::25]:28080/hook",
"HTTP://bot.example.test:3100/hook",
} {
gateway := &fakeBotAPIGateway{}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "setWebhook", fmt.Sprintf(`{"url":%q}`, rawURL))
if rec.Code != http.StatusOK || !gateway.webhookFound || gateway.webhook.URL != rawURL {
t.Fatalf("setWebhook url=%q status=%d body=%s config=%#v", rawURL, rec.Code, rec.Body.String(), gateway.webhook)
}
}
}
func TestSetWebhookRejectsUnsafeParameters(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
h := (&handler{bots: bots, gateway: &fakeBotAPIGateway{}}).routes()
@ -984,8 +1002,9 @@ func TestSetWebhookRejectsUnsafeParameters(t *testing.T) {
body string
want string
}{
{`{"url":"http://example.test/hook"}`, "WEBHOOK_URL_INVALID"},
{`{"url":"https://example.test:444/hook"}`, "WEBHOOK_PORT_NOT_ALLOWED"},
{`{"url":"ftp://example.test/hook"}`, "WEBHOOK_URL_INVALID"},
{`{"url":"http://user@example.test/hook"}`, "WEBHOOK_URL_INVALID"},
{`{"url":"http://example.test:0/hook"}`, "WEBHOOK_URL_INVALID"},
{`{"url":"https://example.test/hook","secret_token":"bad secret"}`, "SECRET_TOKEN_INVALID"},
{`{"url":"https://example.test/hook","max_connections":101}`, "MAX_CONNECTIONS_INVALID"},
}

View file

@ -4,7 +4,6 @@ package config
import (
"bufio"
"fmt"
"net"
"net/netip"
"net/url"
"os"
@ -93,9 +92,11 @@ type Config struct {
// TelegramLoginEnabled mounts the self-hosted Telegram Login/OIDC provider
// on PublicLinkWebAddr. Secrets are file-backed so they are not exposed in
// process listings or accidentally copied into tracked .env templates.
TelegramLoginEnabled bool
TelegramLoginIssuer string
TelegramLoginAllowLoopbackHTTP bool
TelegramLoginEnabled bool
TelegramLoginIssuer string
// TelegramLoginAllowHTTP permits HTTP issuers and registered Login URLs on
// any valid host/IP and port. HTTPS remains mandatory when false.
TelegramLoginAllowHTTP bool
TelegramLoginSigningKeysFile string
TelegramLoginCodeKeysFile string
TelegramLoginSecretPepperFile string
@ -491,7 +492,7 @@ func Load() (Config, error) {
PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""),
TelegramLoginEnabled: envBoolOr("TELESRV_TELEGRAM_LOGIN_ENABLE", false),
TelegramLoginIssuer: strings.TrimSuffix(envOr("TELESRV_TELEGRAM_LOGIN_ISSUER", publicBaseURL), "/"),
TelegramLoginAllowLoopbackHTTP: envBoolOr("TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP", false),
TelegramLoginAllowHTTP: envBoolOr("TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP", false),
TelegramLoginSigningKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "data/telegram-login/signing-keys.json"),
TelegramLoginCodeKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "data/telegram-login/code-keys.json"),
TelegramLoginSecretPepperFile: envOr("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "data/telegram-login/client-secret-pepper"),
@ -681,10 +682,8 @@ func validateTelegramLoginConfig(cfg Config) error {
switch issuer.Scheme {
case "https":
case "http":
host := issuer.Hostname()
ip := net.ParseIP(host)
if !cfg.TelegramLoginAllowLoopbackHTTP || (host != "localhost" && (ip == nil || !ip.IsLoopback())) {
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER http is allowed only for explicit loopback development")
if !cfg.TelegramLoginAllowHTTP {
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER http requires TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true")
}
default:
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER must use https")

View file

@ -435,8 +435,8 @@ func TestLoadTelegramLoginConfig(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_PUBLIC_LINK_WEB_ADDR", "127.0.0.1:2401")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ENABLE", "true")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ISSUER", "http://127.0.0.1:2401/")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP", "true")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ISSUER", "http://192.0.2.25:2401/")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP", "true")
t.Setenv("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "secrets/signing.json")
t.Setenv("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "secrets/codes.json")
t.Setenv("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "secrets/pepper")
@ -452,8 +452,8 @@ func TestLoadTelegramLoginConfig(t *testing.T) {
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.TelegramLoginEnabled || cfg.TelegramLoginIssuer != "http://127.0.0.1:2401" || !cfg.TelegramLoginAllowLoopbackHTTP {
t.Fatalf("telegram login endpoint config = enabled:%v issuer:%q loopback:%v", cfg.TelegramLoginEnabled, cfg.TelegramLoginIssuer, cfg.TelegramLoginAllowLoopbackHTTP)
if !cfg.TelegramLoginEnabled || cfg.TelegramLoginIssuer != "http://192.0.2.25:2401" || !cfg.TelegramLoginAllowHTTP {
t.Fatalf("telegram login endpoint config = enabled:%v issuer:%q allow_http:%v", cfg.TelegramLoginEnabled, cfg.TelegramLoginIssuer, cfg.TelegramLoginAllowHTTP)
}
if cfg.TelegramLoginSigningKeysFile != "secrets/signing.json" || cfg.TelegramLoginCodeKeysFile != "secrets/codes.json" || cfg.TelegramLoginSecretPepperFile != "secrets/pepper" {
t.Fatalf("telegram login secret files = %q / %q / %q", cfg.TelegramLoginSigningKeysFile, cfg.TelegramLoginCodeKeysFile, cfg.TelegramLoginSecretPepperFile)
@ -484,11 +484,7 @@ func TestValidateTelegramLoginConfigRejectsUnsafeOrUnboundedSettings(t *testing.
}{
{name: "missing listener", mutate: func(c *Config) { c.PublicLinkWebAddr = "" }},
{name: "issuer path", mutate: func(c *Config) { c.TelegramLoginIssuer = "https://login.example.test/oauth" }},
{name: "public http", mutate: func(c *Config) {
c.TelegramLoginIssuer = "http://login.example.test"
c.TelegramLoginAllowLoopbackHTTP = true
}},
{name: "loopback http disabled", mutate: func(c *Config) { c.TelegramLoginIssuer = "http://127.0.0.1:2401" }},
{name: "http disabled", mutate: func(c *Config) { c.TelegramLoginIssuer = "http://192.0.2.25:2401" }},
{name: "missing key file", mutate: func(c *Config) { c.TelegramLoginSigningKeysFile = "" }},
{name: "request ttl too long", mutate: func(c *Config) { c.TelegramLoginRequestTTL = 16 * time.Minute }},
{name: "code ttl too short", mutate: func(c *Config) { c.TelegramLoginCodeTTL = 29 * time.Second }},
@ -508,6 +504,22 @@ func TestValidateTelegramLoginConfigRejectsUnsafeOrUnboundedSettings(t *testing.
}
}
func TestValidateTelegramLoginConfigAcceptsHTTPHostAndIPWhenEnabled(t *testing.T) {
valid := Config{
TelegramLoginEnabled: true, TelegramLoginAllowHTTP: true, PublicLinkWebAddr: "127.0.0.1:2401",
TelegramLoginSigningKeysFile: "signing.json", TelegramLoginCodeKeysFile: "codes.json", TelegramLoginSecretPepperFile: "pepper",
TelegramLoginRequestTTL: 5 * time.Minute, TelegramLoginCodeTTL: 2 * time.Minute, TelegramLoginIDTokenTTL: time.Hour,
TelegramLoginRetention: 7 * 24 * time.Hour, TelegramLoginSweepInterval: 5 * time.Minute, TelegramLoginSweepBatch: 500,
}
for _, issuer := range []string{"http://login.example.test:3000", "http://192.0.2.25:2401", "http://[2001:db8::25]:2401"} {
cfg := valid
cfg.TelegramLoginIssuer = issuer
if err := validateTelegramLoginConfig(cfg); err != nil {
t.Fatalf("issuer %q was rejected: %v", issuer, err)
}
}
}
func TestLoadRejectsInvalidPublicBaseURL(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_PUBLIC_BASE_URL", "https://links.example.test/root?tenant=one")

View file

@ -2,7 +2,6 @@ package domain
import (
"errors"
"net"
"net/url"
"strings"
"unicode/utf8"
@ -397,8 +396,9 @@ func validateButtonURL(raw string) error {
// validateLoginButtonURL performs only the protocol-shape validation shared by
// Bot API and MTProto input buttons. The Telegram Login service remains the
// authority for the deployment policy: it rejects loopback HTTP unless the
// explicit development switch is enabled and the exact origin is registered.
// authority for the deployment policy: HTTP is accepted here as a protocol
// shape, then allowed only when the Login HTTP switch is enabled and the exact
// origin is registered.
func validateLoginButtonURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > MaxBotMenuButtonURLLen {
@ -408,15 +408,8 @@ func validateLoginButtonURL(raw string) error {
if err != nil || u.Host == "" || u.User != nil {
return ErrButtonURLInvalid
}
if u.Scheme == "https" {
return nil
}
if u.Scheme != "http" {
return ErrButtonURLInvalid
}
host := strings.ToLower(u.Hostname())
ip := net.ParseIP(host)
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return ErrButtonURLInvalid
}
return nil

View file

@ -28,7 +28,8 @@ func TestValidateReplyMarkup(t *testing.T) {
{"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid},
{"login url loopback http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://127.0.0.1:8080/login"}}}}, nil},
{"login url localhost http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://localhost:8080/login"}}}}, nil},
{"login url public http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://example.com/login"}}}}, ErrButtonURLInvalid},
{"login url public http host ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://example.com:3000/login"}}}}, nil},
{"login url public http ip ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://192.0.2.25:18080/login"}}}}, nil},
{"login url credentials bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "https://user@example.com/login"}}}}, ErrButtonURLInvalid},
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "rainbow", Text: "x"}}}}, ErrButtonTypeInvalid},
{"reply keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Resize: true, Persistent: true, Placeholder: "Choose"}, nil},

View file

@ -261,7 +261,7 @@ func TestTelegramLoginMessageButtonRereadSignsAndGrantsWriteAccess(t *testing.T)
pepper[0] = 8
loginStore := memory.NewTelegramLoginStore(telegramLoginBotPermissionAdapter{bots: f.router.deps.Bots})
login, err := telegramloginapp.NewService(loginStore, sealer, telegramloginapp.Config{
Issuer: "https://oauth.test", AppScheme: "telesrv", ClientSecretPepper: pepper,
Issuer: "http://192.0.2.25:2401", AppScheme: "telesrv", AllowHTTP: true, ClientSecretPepper: pepper,
Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
})
if err != nil {
@ -270,13 +270,13 @@ func TestTelegramLoginMessageButtonRereadSignsAndGrantsWriteAccess(t *testing.T)
if _, err := login.CreateClient(f.ctx, f.bot.ID, domain.TelegramLoginSigningRS256); err != nil {
t.Fatal(err)
}
if _, err := login.AddAllowedURL(f.ctx, f.bot.ID, domain.TelegramLoginAllowedWebOrigin, "https://rp.test"); err != nil {
if _, err := login.AddAllowedURL(f.ctx, f.bot.ID, domain.TelegramLoginAllowedWebOrigin, "http://rp.test:3000"); err != nil {
t.Fatal(err)
}
f.router.deps.TelegramLogin = login
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
Type: domain.MarkupButtonLoginURL, Text: "Log in", URL: "https://rp.test/login?next=%2Fhome", RequestWriteAccess: true,
Type: domain.MarkupButtonLoginURL, Text: "Log in", URL: "http://rp.test:3000/login?next=%2Fhome", RequestWriteAccess: true,
}}}}
if _, err := f.router.BotAPISendMessage(f.ctx, f.bot.ID, f.owner.ID, "Authorize", nil, markup, false, false, 0); err != nil {
t.Fatalf("BotAPISendMessage: %v", err)

View file

@ -40,18 +40,18 @@ type Config struct {
AppName string
Logger *zap.Logger
TrustedProxyCIDRs []string
AllowLoopbackHTTP bool
AllowHTTP bool
}
type Handler struct {
service *loginapp.Service
tokens *loginapp.IDTokenIssuer
appName string
logger *zap.Logger
limiter RateLimiter
trustedProxies []netip.Prefix
allowLoopbackHTTP bool
mux *http.ServeMux
service *loginapp.Service
tokens *loginapp.IDTokenIssuer
appName string
logger *zap.Logger
limiter RateLimiter
trustedProxies []netip.Prefix
allowHTTP bool
mux *http.ServeMux
}
type RateLimiter interface {
@ -76,7 +76,7 @@ func NewHandler(cfg Config) (*Handler, error) {
}
trustedProxies = append(trustedProxies, prefix.Masked())
}
h := &Handler{service: cfg.Service, tokens: cfg.Tokens, appName: strings.TrimSpace(cfg.AppName), logger: cfg.Logger, limiter: cfg.Limiter, trustedProxies: trustedProxies, allowLoopbackHTTP: cfg.AllowLoopbackHTTP}
h := &Handler{service: cfg.Service, tokens: cfg.Tokens, appName: strings.TrimSpace(cfg.AppName), logger: cfg.Logger, limiter: cfg.Limiter, trustedProxies: trustedProxies, allowHTTP: cfg.AllowHTTP}
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/openid-configuration", h.discovery)
mux.HandleFunc("GET /.well-known/jwks.json", h.jwks)
@ -333,7 +333,7 @@ func (h *Handler) inApp(w http.ResponseWriter, r *http.Request) {
writeOAuthError(w, http.StatusBadRequest, "unsupported_response_type", "only id_token is supported")
return
}
origin, err := loginapp.NormalizeWebOrigin(values["origin"], h.allowLoopbackHTTP)
origin, err := loginapp.NormalizeWebOrigin(values["origin"], h.allowHTTP)
if err != nil || r.Header.Get("Origin") != origin {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "in-app origin is invalid")
return
@ -511,7 +511,7 @@ func (h *Handler) authorizeStatusOrigin(w http.ResponseWriter, r *http.Request,
if origin == "" {
return true
}
issuerOrigin, err := loginapp.NormalizeWebOrigin(h.tokens.Issuer(), h.allowLoopbackHTTP)
issuerOrigin, err := loginapp.NormalizeWebOrigin(h.tokens.Issuer(), h.allowHTTP)
if err == nil && origin == issuerOrigin {
return true
}

View file

@ -109,7 +109,7 @@ func newTelegramLoginHTTPFixture(t *testing.T) telegramLoginHTTPFixture {
if err != nil {
t.Fatal(err)
}
handler, err := NewHandler(Config{Service: service, Tokens: tokens, AppName: "Telesrv", AllowLoopbackHTTP: true})
handler, err := NewHandler(Config{Service: service, Tokens: tokens, AppName: "Telesrv", AllowHTTP: true})
if err != nil {
t.Fatal(err)
}