Security hardening and admin check tests

- Webhook paths now include token-derived secret (prevents spoofed updates)
- HTTP server with Read/Write/Idle timeouts (prevents slowloris)
- Shared HTTP client with 60s timeout for all uploads/downloads
- Removed tokens and sensitive data from debug logs
- Retry loop respects context cancellation instead of blocking sleep
- Pending bridge keys expire after 1 hour (migration 000003)
- Increased bridge key entropy from 32 to 64 bits (16 hex chars)
- Docker container runs as non-root user
- Extracted admin check helpers with unit tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Andrey Lugovskoy 2026-02-15 15:05:41 +03:00
parent cd625155ef
commit 62a4233027
15 changed files with 225 additions and 39 deletions

View file

@ -12,6 +12,9 @@ RUN CGO_ENABLED=1 go build -o /max-telegram-bridge-bot .
FROM alpine:3.21
RUN apk add --no-cache ca-certificates
RUN adduser -D -h /app bridge
USER bridge
WORKDIR /app
COPY --from=builder /max-telegram-bridge-bot /usr/local/bin/max-telegram-bridge-bot

28
admin.go Normal file
View file

@ -0,0 +1,28 @@
package main
import maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
// isTgGroup returns true if the TG chat type indicates a group.
func isTgGroup(chatType string) bool {
return chatType == "group" || chatType == "supergroup"
}
// isTgAdmin returns true if the TG ChatMember status indicates admin rights.
func isTgAdmin(memberStatus string) bool {
return memberStatus == "creator" || memberStatus == "administrator"
}
// isMaxGroup returns true if the MAX chat type indicates a group.
func isMaxGroup(chatType maxschemes.ChatType) bool {
return chatType == maxschemes.CHAT || chatType == maxschemes.CHANNEL
}
// isMaxUserAdmin returns true if userID is found in the admin members list.
func isMaxUserAdmin(members []maxschemes.ChatMember, userID int64) bool {
for _, m := range members {
if m.UserId == userID {
return true
}
}
return false
}

109
admin_test.go Normal file
View file

@ -0,0 +1,109 @@
package main
import (
"testing"
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
)
func TestIsTgGroup(t *testing.T) {
tests := []struct {
chatType string
want bool
}{
{"group", true},
{"supergroup", true},
{"private", false},
{"channel", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.chatType, func(t *testing.T) {
if got := isTgGroup(tt.chatType); got != tt.want {
t.Errorf("isTgGroup(%q) = %v, want %v", tt.chatType, got, tt.want)
}
})
}
}
func TestIsTgAdmin(t *testing.T) {
tests := []struct {
status string
want bool
}{
{"creator", true},
{"administrator", true},
{"member", false},
{"restricted", false},
{"left", false},
{"kicked", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.status, func(t *testing.T) {
if got := isTgAdmin(tt.status); got != tt.want {
t.Errorf("isTgAdmin(%q) = %v, want %v", tt.status, got, tt.want)
}
})
}
}
func TestIsMaxGroup(t *testing.T) {
tests := []struct {
name string
chatType maxschemes.ChatType
want bool
}{
{"chat", maxschemes.CHAT, true},
{"channel", maxschemes.CHANNEL, true},
{"dialog", maxschemes.DIALOG, false},
{"empty", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isMaxGroup(tt.chatType); got != tt.want {
t.Errorf("isMaxGroup(%q) = %v, want %v", tt.chatType, got, tt.want)
}
})
}
}
func TestIsMaxUserAdmin(t *testing.T) {
admins := []maxschemes.ChatMember{
{UserId: 100, Name: "Owner", IsOwner: true, IsAdmin: true},
{UserId: 200, Name: "Admin", IsAdmin: true},
{UserId: 300, Name: "Bot", IsBot: true, IsAdmin: true},
}
tests := []struct {
name string
userID int64
want bool
}{
{"owner is admin", 100, true},
{"admin is admin", 200, true},
{"bot admin", 300, true},
{"non-admin user", 999, false},
{"zero id", 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isMaxUserAdmin(admins, tt.userID); got != tt.want {
t.Errorf("isMaxUserAdmin(admins, %d) = %v, want %v", tt.userID, got, tt.want)
}
})
}
}
func TestIsMaxUserAdmin_EmptyList(t *testing.T) {
if isMaxUserAdmin(nil, 100) {
t.Error("isMaxUserAdmin(nil, 100) = true, want false")
}
if isMaxUserAdmin([]maxschemes.ChatMember{}, 100) {
t.Error("isMaxUserAdmin([], 100) = true, want false")
}
}

View file

@ -2,6 +2,8 @@ package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"log/slog"
"net/http"
"sync"
@ -14,31 +16,49 @@ import (
// Config — настройки bridge, читаемые из env.
type Config struct {
MaxToken string // токен MAX API (нужен для direct-send/upload)
TgBotURL string // ссылка на TG-бота для /help
MaxBotURL string // ссылка на MAX-бота для /help
MaxToken string // токен MAX API (нужен для direct-send/upload)
TgBotURL string // ссылка на TG-бота для /help
MaxBotURL string // ссылка на MAX-бота для /help
WebhookURL string // базовый URL для webhook (если пусто — long polling)
WebhookPort string // порт для webhook сервера
}
// Bridge — основная структура, объединяющая зависимости.
type Bridge struct {
cfg Config
repo Repository
tgBot *tgbotapi.BotAPI
maxApi *maxbot.Api
cfg Config
repo Repository
tgBot *tgbotapi.BotAPI
maxApi *maxbot.Api
httpClient *http.Client
whSecret string // random path segment for webhook URLs
}
// NewBridge создаёт экземпляр Bridge.
func NewBridge(cfg Config, repo Repository, tgBot *tgbotapi.BotAPI, maxApi *maxbot.Api) *Bridge {
// Derive webhook secret from tokens (stable across restarts)
h := sha256.Sum256([]byte(cfg.MaxToken + tgBot.Token))
secret := hex.EncodeToString(h[:8])
return &Bridge{
cfg: cfg,
repo: repo,
tgBot: tgBot,
maxApi: maxApi,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
whSecret: secret,
}
}
func (b *Bridge) tgWebhookPath() string {
return "/tg-webhook-" + b.whSecret
}
func (b *Bridge) maxWebhookPath() string {
return "/max-webhook-" + b.whSecret
}
// Run запускает TG и MAX listener'ы + периодическую очистку.
func (b *Bridge) Run(ctx context.Context) {
go func() {
@ -57,8 +77,14 @@ func (b *Bridge) Run(ctx context.Context) {
if b.cfg.WebhookURL != "" {
go func() {
addr := ":" + b.cfg.WebhookPort
srv := &http.Server{
Addr: addr,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
slog.Info("Webhook server starting", "addr", addr)
if err := http.ListenAndServe(addr, nil); err != nil {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("Webhook server failed", "err", err)
}
}()

View file

@ -32,7 +32,7 @@ func envOr(key, fallback string) string {
}
func genKey() string {
b := make([]byte, 4)
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}

View file

@ -35,8 +35,8 @@ func TestEnvOr(t *testing.T) {
func TestGenKey(t *testing.T) {
key := genKey()
if len(key) != 8 {
t.Errorf("genKey() length = %d, want 8", len(key))
if len(key) != 16 {
t.Errorf("genKey() length = %d, want 16", len(key))
}
// Keys should be unique

16
max.go
View file

@ -17,15 +17,16 @@ func (b *Bridge) listenMax(ctx context.Context) {
var updates <-chan maxschemes.UpdateInterface
if b.cfg.WebhookURL != "" {
whURL := strings.TrimRight(b.cfg.WebhookURL, "/") + "/max-webhook"
whPath := b.maxWebhookPath()
whURL := strings.TrimRight(b.cfg.WebhookURL, "/") + whPath
ch := make(chan maxschemes.UpdateInterface, 100)
http.HandleFunc("/max-webhook", b.maxApi.GetHandler(ch))
http.HandleFunc(whPath, b.maxApi.GetHandler(ch))
if _, err := b.maxApi.Subscriptions.Subscribe(ctx, whURL, nil, ""); err != nil {
slog.Error("MAX webhook subscribe failed", "err", err)
return
}
updates = ch
slog.Info("MAX webhook mode", "url", whURL)
slog.Info("MAX webhook mode")
} else {
updates = b.maxApi.GetUpdates(ctx)
slog.Info("MAX polling mode")
@ -122,17 +123,12 @@ func (b *Bridge) listenMax(ctx context.Context) {
}
// Проверка прав админа в группах
isGroup := msgUpd.Message.Recipient.ChatType == maxschemes.CHAT || msgUpd.Message.Recipient.ChatType == maxschemes.CHANNEL
isGroup := isMaxGroup(msgUpd.Message.Recipient.ChatType)
isAdmin := false
if isGroup {
admins, err := b.maxApi.Chats.GetChatAdmins(ctx, chatID)
if err == nil {
for _, m := range admins.Members {
if m.UserId == msgUpd.Message.Sender.UserId {
isAdmin = true
break
}
}
isAdmin = isMaxUserAdmin(admins.Members, msgUpd.Message.Sender.UserId)
}
}

View file

@ -0,0 +1 @@
ALTER TABLE pending DROP COLUMN created_at;

View file

@ -0,0 +1 @@
ALTER TABLE pending ADD COLUMN created_at BIGINT NOT NULL DEFAULT 0;

View file

@ -0,0 +1,4 @@
CREATE TABLE pending_backup (key TEXT PRIMARY KEY, platform TEXT NOT NULL, chat_id INTEGER NOT NULL);
INSERT INTO pending_backup SELECT key, platform, chat_id FROM pending;
DROP TABLE pending;
ALTER TABLE pending_backup RENAME TO pending;

View file

@ -0,0 +1 @@
ALTER TABLE pending ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0;

View file

@ -40,7 +40,7 @@ func (r *pgRepo) Register(key, platform string, chatID int64) (bool, string, err
return false, existing, nil
}
generated := genKey()
_, err = r.db.Exec("INSERT INTO pending (key, platform, chat_id) VALUES ($1, $2, $3)", generated, platform, chatID)
_, err = r.db.Exec("INSERT INTO pending (key, platform, chat_id, created_at) VALUES ($1, $2, $3, $4)", generated, platform, chatID, time.Now().Unix())
return false, generated, err
}
@ -105,6 +105,7 @@ func (r *pgRepo) LookupTgMsgID(maxMsgID string) (int64, int, bool) {
func (r *pgRepo) CleanOldMessages() {
r.db.Exec("DELETE FROM messages WHERE created_at < $1", time.Now().Unix()-48*3600)
r.db.Exec("DELETE FROM pending WHERE created_at > 0 AND created_at < $1", time.Now().Unix()-3600)
}
func (r *pgRepo) HasPrefix(platform string, chatID int64) bool {

View file

@ -37,7 +37,7 @@ func (r *sqliteRepo) Register(key, platform string, chatID int64) (bool, string,
return false, existing, nil
}
generated := genKey()
_, err = r.db.Exec("INSERT INTO pending (key, platform, chat_id) VALUES (?, ?, ?)", generated, platform, chatID)
_, err = r.db.Exec("INSERT INTO pending (key, platform, chat_id, created_at) VALUES (?, ?, ?, ?)", generated, platform, chatID, time.Now().Unix())
return false, generated, err
}
@ -96,6 +96,7 @@ func (r *sqliteRepo) LookupTgMsgID(maxMsgID string) (int64, int, bool) {
func (r *sqliteRepo) CleanOldMessages() {
r.db.Exec("DELETE FROM messages WHERE created_at < ?", time.Now().Unix()-48*3600)
r.db.Exec("DELETE FROM pending WHERE created_at > 0 AND created_at < ?", time.Now().Unix()-3600)
}
func (r *sqliteRepo) HasPrefix(platform string, chatID int64) bool {

View file

@ -16,7 +16,8 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
var updates tgbotapi.UpdatesChannel
if b.cfg.WebhookURL != "" {
whURL := strings.TrimRight(b.cfg.WebhookURL, "/") + "/tg-webhook"
whPath := b.tgWebhookPath()
whURL := strings.TrimRight(b.cfg.WebhookURL, "/") + whPath
wh, err := tgbotapi.NewWebhook(whURL)
if err != nil {
slog.Error("TG webhook config error", "err", err)
@ -26,8 +27,8 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
slog.Error("TG set webhook failed", "err", err)
return
}
updates = b.tgBot.ListenForWebhook("/tg-webhook")
slog.Info("TG webhook mode", "url", whURL)
updates = b.tgBot.ListenForWebhook(whPath)
slog.Info("TG webhook mode")
} else {
// Удаляем webhook если был, переключаемся на polling
b.tgBot.Request(tgbotapi.DeleteWebhookConfig{})
@ -103,7 +104,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
}
// Проверка прав админа в группах
isGroup := msg.Chat.Type == "group" || msg.Chat.Type == "supergroup"
isGroup := isTgGroup(msg.Chat.Type)
isAdmin := false
if isGroup && msg.From != nil {
member, err := b.tgBot.GetChatMember(tgbotapi.GetChatMemberConfig{
@ -113,7 +114,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
},
})
if err == nil {
isAdmin = member.Status == "creator" || member.Status == "administrator"
isAdmin = isTgAdmin(member.Status)
}
}

View file

@ -25,7 +25,7 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up
}
req.Header.Set("Authorization", b.cfg.MaxToken)
resp, err := http.DefaultClient.Do(req)
resp, err := b.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("get upload url: %w", err)
}
@ -39,7 +39,7 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up
if err := json.NewDecoder(resp.Body).Decode(&endpoint); err != nil {
return nil, fmt.Errorf("decode upload endpoint: %w", err)
}
slog.Debug("MAX upload endpoint", "url", endpoint.Url, "token", endpoint.Token)
slog.Debug("MAX upload endpoint", "url", endpoint.Url)
// 2. Загружаем файл на CDN (multipart)
var buf bytes.Buffer
@ -53,14 +53,20 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up
}
writer.Close()
cdnResp, err := http.Post(endpoint.Url, writer.FormDataContentType(), &buf)
cdnReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.Url, &buf)
if err != nil {
return nil, fmt.Errorf("create CDN request: %w", err)
}
cdnReq.Header.Set("Content-Type", writer.FormDataContentType())
cdnResp, err := b.httpClient.Do(cdnReq)
if err != nil {
return nil, fmt.Errorf("upload to CDN: %w", err)
}
defer cdnResp.Body.Close()
cdnBody, _ := io.ReadAll(cdnResp.Body)
slog.Debug("MAX CDN response", "status", cdnResp.StatusCode, "body", string(cdnBody))
slog.Debug("MAX CDN response", "status", cdnResp.StatusCode)
// 3. Парсим CDN ответ (fileId в camelCase)
var cdnResult struct {
@ -68,11 +74,11 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up
Token string `json:"token"`
}
if err := json.Unmarshal(cdnBody, &cdnResult); err == nil && cdnResult.Token != "" {
slog.Debug("MAX token from CDN", "token", cdnResult.Token, "fileId", cdnResult.FileID)
slog.Debug("MAX upload ok", "fileId", cdnResult.FileID)
return &maxschemes.UploadedInfo{Token: cdnResult.Token, FileID: cdnResult.FileID}, nil
}
if endpoint.Token != "" {
slog.Debug("MAX token from endpoint", "token", endpoint.Token)
slog.Debug("MAX upload ok (endpoint token)")
return &maxschemes.UploadedInfo{Token: endpoint.Token}, nil
}
return nil, fmt.Errorf("no token: endpoint and CDN both empty")
@ -84,9 +90,13 @@ func (b *Bridge) uploadTgMediaToMax(ctx context.Context, fileID string, uploadTy
if err != nil {
return nil, fmt.Errorf("tg getFileURL: %w", err)
}
slog.Debug("TG file URL", "url", fileURL)
resp, err := http.Get(fileURL)
dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
}
resp, err := b.httpClient.Do(dlReq)
if err != nil {
return nil, fmt.Errorf("download: %w", err)
}
@ -96,7 +106,7 @@ func (b *Bridge) uploadTgMediaToMax(ctx context.Context, fileID string, uploadTy
return nil, fmt.Errorf("tg download status: %d", resp.StatusCode)
}
slog.Debug("TG file downloaded", "status", resp.StatusCode, "size", resp.ContentLength, "contentType", resp.Header.Get("Content-Type"))
slog.Debug("TG file downloaded", "size", resp.ContentLength)
return b.customUploadToMax(ctx, uploadType, resp.Body, fileName)
}
@ -141,7 +151,11 @@ func (b *Bridge) sendMaxDirect(ctx context.Context, chatID int64, text string, a
for attempt := 0; attempt < 10; attempt++ {
if attempt > 0 {
delay := time.Duration(1+attempt) * time.Second
time.Sleep(delay)
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(delay):
}
slog.Warn("MAX retry", "attempt", attempt+1, "maxAttempts", 10)
}
@ -152,7 +166,7 @@ func (b *Bridge) sendMaxDirect(ctx context.Context, chatID int64, text string, a
req.Header.Set("Authorization", b.cfg.MaxToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
resp, err := b.httpClient.Do(req)
if err != nil {
return "", err
}