From 9e3450df3106572ee3feecca2a53fe4411633724 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 11 Jun 2026 13:33:34 +0300 Subject: [PATCH] (security): auto-generate webhook secret when unset, redact token in tgapi standalone logs (fix): webhookLogger nil panic, dead warning block (tests): update secret-path test for new auto-gen behaviour (doc): CHANGELOG v1.0.1 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 6 ++++++ bot_webhook.go | 17 +++++++++++------ bot_webhook_test.go | 16 ++++++++-------- tgapi/api.go | 1 + tgapi/uploader_api.go | 5 +++-- utils.go | 11 +++++++++++ utils/version.go | 4 ++-- 7 files changed, 42 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3769cae..c75a8d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v1.0.1 + +### Fixed +- Fixed webhook always accepting unauthenticated requests when `SecretToken` is not configured. A cryptographically random 32-byte token is now generated automatically when `SecretToken` is empty, so the webhook endpoint is always authenticated. The generated token is logged as a warning so the operator can record it. +- Fixed `tgapi.NewAPI` and `tgapi.NewUploader` not installing token redaction on their managed loggers. The bot token is now masked as `` in debug output even when the `tgapi` package is used standalone without the `laniakea.Bot` wrapper. + ## v1.0.0 ### Breaking Changes diff --git a/bot_webhook.go b/bot_webhook.go index 74231ec..7e6a5d8 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -137,6 +137,15 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp if len(bot.plugins) == 0 { return ErrNoPlugins } + autoSecret := "" + if opts.SecretToken == "" { + rndSecret, err := generateToken(32) + if err != nil { + return err + } + opts.SecretToken = rndSecret + autoSecret = rndSecret + } if opts.URL == "" { return ErrNoBotWebhookOptsURL } @@ -146,9 +155,6 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil { return err } - if opts.UseStatusPath && opts.SecretToken == "" { - return ErrStatusPathSecretRequired - } if err := validateWebhookTLSFiles(tlsFiles); err != nil { return err } @@ -158,10 +164,9 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp } return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error { - if opts.SecretToken == "" { - bot.webhookLogger.Warnln("Using webhook without secret is very dangerous. Anyone can simulate Telegram requests.") + if autoSecret != "" { + bot.webhookLogger.Warnln("Using webhook without secret is very dangerous. Using random 32 bytes token:", autoSecret) } - i, err := bot.api.GetWebhookInfoWithContext(runCtx) if err != nil { return err diff --git a/bot_webhook_test.go b/bot_webhook_test.go index 6e4ec52..1789a25 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -350,20 +350,20 @@ func TestRunWebhookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing } } -func TestRunWebhookWithContextRequiresSecretWhenStatusPathEnabled(t *testing.T) { +func TestRunWebhookWithContextAutoGeneratesSecretWhenEmpty(t *testing.T) { bot := &Bot[NoData]{ prefixes: []string{"/"}, plugins: []Plugin[NoData]{{name: "demo"}}, } - opts := NewBotWebhookOpts(). - SetURL("https://bot.example.com"). - SetUseStatusPath(true) + // No SecretToken, no URL — function should auto-generate the token + // and then fail with ErrNoBotWebhookOptsURL before any network call. + opts := NewBotWebhookOpts().SetUseStatusPath(true) err := bot.RunWebhookWithContext(context.Background(), opts) - if err == nil { - t.Fatal("expected status-path secret validation error, got nil") + if !errors.Is(err, ErrNoBotWebhookOptsURL) { + t.Fatalf("expected ErrNoBotWebhookOptsURL after auto-generation, got: %v", err) } - if !strings.Contains(err.Error(), "SecretToken required") { - t.Fatalf("unexpected error: %v", err) + if opts.SecretToken == "" { + t.Fatal("expected SecretToken to be auto-generated, got empty string") } } diff --git a/tgapi/api.go b/tgapi/api.go index 78a9e0c..ae4e8f6 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -122,6 +122,7 @@ func NewAPI(opts *APIOpts) *API { "API", utils.GetLoggerLevel(), opts.logFormat, opts.logFormatter, ) + logger.AddReplacer(opts.token, "") client := opts.client if client == nil { diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index cb93a41..0624094 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -79,6 +79,7 @@ func NewUploader(api *API) *Uploader { "UPLOADER", utils.GetLoggerLevel(), api.logFormat, api.logFormatter, ) + logger.AddReplacer(api.token, "") return &Uploader{api, logger} } @@ -143,7 +144,7 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) req.ContentLength = int64(buf.Len()) - up.logger.Debugln("UPLOADER REQ", r.method) + up.logger.Debugln("UPLOADER REQ", url) resp, err := up.api.client.Do(req) if err != nil { return zero, err @@ -154,7 +155,7 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, if err != nil { return zero, err } - up.logger.Debugln("UPLOADER RES", r.method, string(body)) + up.logger.Debugln("UPLOADER RES", url, string(body)) response, err := parseBody[R](body) if err != nil { diff --git a/utils.go b/utils.go index 3de6014..0257676 100644 --- a/utils.go +++ b/utils.go @@ -1,6 +1,9 @@ package laniakea import ( + "crypto/rand" + "encoding/base64" + "git.scuroneko.dev/scuroneko/laniakea/utils" ) @@ -27,3 +30,11 @@ const ( // VersionBeta re-exports the module prerelease counter. VersionBeta = utils.VersionBeta ) + +func generateToken(b int) (string, error) { + bytes := make([]byte, b) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(bytes), nil +} diff --git a/utils/version.go b/utils/version.go index aa4a98a..1b463bd 100644 --- a/utils/version.go +++ b/utils/version.go @@ -2,13 +2,13 @@ package utils const ( // VersionString is the module version string. - VersionString = "1.0.0" + VersionString = "1.0.1" // VersionMajor is the module major version. VersionMajor = 1 // VersionMinor is the module minor version. VersionMinor = 0 // VersionPatch is the module patch version. - VersionPatch = 0 + VersionPatch = 1 // VersionBeta is the prerelease counter for the current version. VersionBeta = 0 )