Skip to content

Commit b8f41be

Browse files
committed
Harden webhook handling and deployment config
1 parent 533ae3b commit b8f41be

12 files changed

Lines changed: 181 additions & 64 deletions

File tree

.github/workflows/ci.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- master
8+
- main
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
test:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Check out repository
18+
uses: actions/checkout@v4
19+
20+
- name: Set up Go
21+
uses: actions/setup-go@v5
22+
with:
23+
go-version-file: go.mod
24+
cache: true
25+
26+
- name: Check formatting
27+
run: |
28+
unformatted="$(gofmt -l ./cmd ./internal)"
29+
test -z "$unformatted" || (echo "$unformatted" && exit 1)
30+
31+
- name: Vet
32+
run: go vet ./...
33+
34+
- name: Test
35+
run: go test ./... -count=1
36+
37+
- name: Vulnerability scan
38+
run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...

Dockerfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ COPY . .
1919
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o tg-githubbot cmd/bot/main.go
2020

2121
# Final stage
22-
FROM alpine:latest
22+
FROM alpine:3.22
2323

2424
WORKDIR /app
2525

26+
RUN addgroup -S app && adduser -S -G app app
27+
2628
# Copy the CA certificates from builder
2729
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
2830

@@ -32,5 +34,7 @@ COPY --from=builder /app/tg-githubbot .
3234
# Expose the webhook port (default 8080)
3335
EXPOSE 8080
3436

37+
USER app
38+
3539
# Command to run the executable
3640
CMD ["./tg-githubbot"]

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,15 +139,22 @@ TELEGRAM_WEBHOOK_URL=https://your-domain.com
139139
# GitHub OAuth App credentials
140140
GITHUB_CLIENT_ID=Iv1...
141141
GITHUB_CLIENT_SECRET=...
142+
GITHUB_WEBHOOK_SECRET=...
142143
143144
# HTTP server port (Use 10000 for Render, 8080 for Standard/Docker)
144145
PORT=8080
145146
146147
# (Optional) Set to true for Polling (Best for Cloud/Local). Default: false (Webhooks)
147148
USE_POLLING=false
148149
150+
# MongoDB connection string
151+
MONGODB_URI=mongodb://localhost:27017
152+
149153
# MongoDB database name
150154
DATABASE_NAME=github_bot
155+
156+
# Stable 32-byte hex key for encrypting stored tokens
157+
ENCRYPTION_KEY=...
151158
```
152159

153160
> [!CAUTION]
@@ -224,7 +231,7 @@ Docker Compose is the recommended deployment path.
224231
4. Check logs:
225232

226233
```bash
227-
docker compose logs -f github-bot
234+
docker compose logs -f bot
228235
```
229236

230237
5. Confirm the health page:

cmd/bot/main.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,10 @@ func run() (runErr error) {
155155
}
156156

157157
oauthStateCache.Delete(state)
158-
token, err := oauth.ExchangeCode(context.Background(), code)
158+
requestCtx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
159+
defer cancel()
160+
161+
token, err := oauth.ExchangeCode(requestCtx, code)
159162
if err != nil {
160163
http.Error(w, "Failed to exchange code", http.StatusInternalServerError)
161164
return
@@ -167,8 +170,8 @@ func run() (runErr error) {
167170
return
168171
}
169172

170-
ghClient := clientFactory.GetUserClient(context.Background(), token.AccessToken)
171-
u, _, err := ghClient.Users.Get(context.Background(), "")
173+
ghClient := clientFactory.GetUserClient(requestCtx, token.AccessToken)
174+
u, _, err := ghClient.Users.Get(requestCtx, "")
172175
if err != nil {
173176
http.Error(w, "Failed to fetch user", http.StatusInternalServerError)
174177
return
@@ -180,7 +183,7 @@ func run() (runErr error) {
180183
GitHubUsername: u.GetLogin(),
181184
EncryptedOAuthToken: encToken,
182185
}
183-
if err := database.UpsertUser(context.Background(), user); err != nil {
186+
if err := database.UpsertUser(requestCtx, user); err != nil {
184187
http.Error(w, "DB Error", http.StatusInternalServerError)
185188
return
186189
}
@@ -211,7 +214,11 @@ func run() (runErr error) {
211214
}
212215

213216
server := &http.Server{
214-
Handler: mux,
217+
Handler: mux,
218+
ReadHeaderTimeout: 5 * time.Second,
219+
ReadTimeout: 15 * time.Second,
220+
WriteTimeout: 15 * time.Second,
221+
IdleTimeout: 60 * time.Second,
215222
}
216223

217224
serverErr := make(chan error, 1)
@@ -261,10 +268,9 @@ func run() (runErr error) {
261268
} else {
262269
webhookBase := strings.TrimRight(cfg.TelegramWebhookURL, "/")
263270
webhookPath := "/bot" + cfg.TelegramToken
264-
fullWebhookURL := webhookBase + webhookPath
265271

266272
mux.HandleFunc(webhookPath, updater.GetHandlerFunc(""))
267-
log.Printf("Registered local Telegram webhook handler for %s", webhookPath)
273+
log.Printf("Registered local Telegram webhook handler for /bot<redacted>")
268274

269275
err = updater.SetAllBotWebhooks(webhookBase, &gotgbot.SetWebhookOpts{
270276
MaxConnections: 100,
@@ -278,7 +284,7 @@ func run() (runErr error) {
278284
}
279285
}()
280286
} else {
281-
log.Printf("✅ Bot successfully registered Webhook at Telegram: %s", fullWebhookURL)
287+
log.Printf("✅ Bot successfully registered Webhook at Telegram: %s/bot<redacted>", webhookBase)
282288
}
283289
}
284290

docker-compose.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,20 @@ services:
99
- "8080:8080"
1010
environment:
1111
- TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
12+
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL}
1213
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
1314
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
15+
- GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET}
1416
- MONGODB_URI=mongodb://mongo:27017
15-
- MONGODB_DB=${MONGODB_DB:-github_bot}
17+
- DATABASE_NAME=${DATABASE_NAME:-github_bot}
1618
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
17-
- BASE_URL=${BASE_URL}
19+
- USE_POLLING=${USE_POLLING:-false}
1820
- PORT=8080
1921
depends_on:
2022
- mongo
2123

2224
mongo:
23-
image: mongo:latest
25+
image: mongo:7
2426
container_name: tg_githubbot_mongo
2527
restart: unless-stopped
2628
ports:

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ require (
2020
github.com/xdg-go/stringprep v1.0.4 // indirect
2121
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
2222
golang.org/x/crypto v0.51.0 // indirect
23-
golang.org/x/net v0.54.0 // indirect
23+
golang.org/x/net v0.55.0 // indirect
2424
golang.org/x/sync v0.20.0 // indirect
2525
golang.org/x/text v0.37.0 // indirect
2626
)

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
4444
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
4545
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
4646
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
47-
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
48-
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
47+
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
48+
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
4949
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
5050
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
5151
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

internal/bot/callbacks/callbacks.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -699,7 +699,7 @@ func (h *CallbackHandler) handleAddRepoByID(b *gotgbot.Bot, ctx *ext.Context, re
699699

700700
// Reuse showRepoMenu to let user choose notifications immediately
701701
kb := h.repoMenuButtons(&link)
702-
702+
703703
msg := fmt.Sprintf("✅ Repository <b>%s</b> linked successfully!\n\nChoose what events to notify:", repo.GetFullName())
704704
_, _, err = ctx.EffectiveMessage.EditText(b, msg, &gotgbot.EditMessageTextOpts{
705705
ReplyMarkup: gotgbot.InlineKeyboardMarkup{InlineKeyboard: kb},

internal/bot/commands/commands.go

Lines changed: 55 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -26,27 +26,40 @@ import (
2626
)
2727

2828
type CommandHandler struct {
29-
Config *config.Config
30-
DB *db.DB
31-
OAuth *gh.OAuth
32-
StateCache *cache.Cache[string, int64]
33-
ClientFactory *gh.ClientFactory
34-
EncryptionKey string
35-
ContextCache *cache.Cache[string, models.MessageContext]
29+
Config *config.Config
30+
DB *db.DB
31+
OAuth *gh.OAuth
32+
StateCache *cache.Cache[string, int64]
33+
ClientFactory *gh.ClientFactory
34+
EncryptionKey string
35+
ContextCache *cache.Cache[string, models.MessageContext]
3636
}
3737

3838
func NewCommandHandler(cfg *config.Config, database *db.DB, oauth *gh.OAuth, stateCache *cache.Cache[string, int64], factory *gh.ClientFactory, key string, ctxCache *cache.Cache[string, models.MessageContext]) *CommandHandler {
3939
return &CommandHandler{
40-
Config: cfg,
41-
DB: database,
42-
OAuth: oauth,
43-
StateCache: stateCache,
44-
ClientFactory: factory,
45-
EncryptionKey: key,
46-
ContextCache: ctxCache,
40+
Config: cfg,
41+
DB: database,
42+
OAuth: oauth,
43+
StateCache: stateCache,
44+
ClientFactory: factory,
45+
EncryptionKey: key,
46+
ContextCache: ctxCache,
4747
}
4848
}
4949

50+
func requireAdminOrPrivate(b *gotgbot.Bot, ctx *ext.Context, deniedMessage string) error {
51+
if ctx.EffectiveChat != nil && ctx.EffectiveChat.Type == gotgbot.ChatTypePrivate {
52+
return nil
53+
}
54+
55+
if ctx.EffectiveChat != nil && ctx.EffectiveUser != nil && utils.IsAdmin(b, ctx.EffectiveChat.Id, ctx.EffectiveUser.Id) {
56+
return nil
57+
}
58+
59+
_, err := ctx.EffectiveMessage.Reply(b, deniedMessage, nil)
60+
return err
61+
}
62+
5063
func (h *CommandHandler) Start(b *gotgbot.Bot, ctx *ext.Context) error {
5164
msg := `<b>Welcome to the GitHub Bot!</b> 🤖
5265
@@ -177,8 +190,7 @@ func repoPageKeyboardNav(page int, resp *github.Response) []gotgbot.InlineKeyboa
177190
}
178191

179192
func (h *CommandHandler) AddRepo(b *gotgbot.Bot, ctx *ext.Context) error {
180-
if ctx.EffectiveChat.Type != gotgbot.ChatTypePrivate && !utils.IsAdmin(b, ctx.EffectiveChat.Id, ctx.EffectiveUser.Id) {
181-
_, err := ctx.EffectiveMessage.Reply(b, "Only admins can add repositories.", nil)
193+
if err := requireAdminOrPrivate(b, ctx, "Only admins can add repositories."); err != nil {
182194
return err
183195
}
184196

@@ -352,8 +364,7 @@ func (h *CommandHandler) sendRepoList(b *gotgbot.Bot, ctx *ext.Context, page int
352364
}
353365

354366
func (h *CommandHandler) Settings(b *gotgbot.Bot, ctx *ext.Context) error {
355-
if ctx.EffectiveChat.Type != gotgbot.ChatTypePrivate && !utils.IsAdmin(b, ctx.EffectiveChat.Id, ctx.EffectiveUser.Id) {
356-
_, err := ctx.EffectiveMessage.Reply(b, "Only admins can modify settings.", nil)
367+
if err := requireAdminOrPrivate(b, ctx, "Only admins can modify settings."); err != nil {
357368
return err
358369
}
359370

@@ -384,8 +395,7 @@ func (h *CommandHandler) Settings(b *gotgbot.Bot, ctx *ext.Context) error {
384395
}
385396

386397
func (h *CommandHandler) RemoveRepo(b *gotgbot.Bot, ctx *ext.Context) error {
387-
if ctx.EffectiveChat.Type != gotgbot.ChatTypePrivate && !utils.IsAdmin(b, ctx.EffectiveChat.Id, ctx.EffectiveUser.Id) {
388-
_, err := ctx.EffectiveMessage.Reply(b, "Only admins can remove repositories.", nil)
398+
if err := requireAdminOrPrivate(b, ctx, "Only admins can remove repositories."); err != nil {
389399
return err
390400
}
391401

@@ -414,31 +424,31 @@ func (h *CommandHandler) RemoveRepo(b *gotgbot.Bot, ctx *ext.Context) error {
414424
}
415425
} else {
416426

417-
var owner, repo string
418-
for i := 0; i < len(repoFullName); i++ {
419-
if repoFullName[i] == '/' {
420-
owner = repoFullName[:i]
421-
repo = repoFullName[i+1:]
422-
break
423-
}
427+
var owner, repo string
428+
for i := 0; i < len(repoFullName); i++ {
429+
if repoFullName[i] == '/' {
430+
owner = repoFullName[:i]
431+
repo = repoFullName[i+1:]
432+
break
424433
}
434+
}
425435

426-
if owner != "" && repo != "" {
427-
_, err := client.Repositories.DeleteHook(context.Background(), owner, repo, link.WebhookID)
428-
if err != nil {
429-
if h.handleAuthError(b, ctx, err) {
430-
webhookStatusMsg = "\n\n⚠️ <b>Warning:</b> GitHub authentication failed. Webhook not removed."
436+
if owner != "" && repo != "" {
437+
_, err := client.Repositories.DeleteHook(context.Background(), owner, repo, link.WebhookID)
438+
if err != nil {
439+
if h.handleAuthError(b, ctx, err) {
440+
webhookStatusMsg = "\n\n⚠️ <b>Warning:</b> GitHub authentication failed. Webhook not removed."
441+
} else {
442+
var errResp *github.ErrorResponse
443+
if errors.As(err, &errResp) && errResp.Response.StatusCode == http.StatusNotFound {
431444
} else {
432-
var errResp *github.ErrorResponse
433-
if errors.As(err, &errResp) && errResp.Response.StatusCode == http.StatusNotFound {
434-
} else {
435-
webhookStatusMsg = fmt.Sprintf("\n\n⚠️ <b>Warning:</b> Failed to remove webhook from GitHub: %v", err)
436-
}
445+
webhookStatusMsg = fmt.Sprintf("\n\n⚠️ <b>Warning:</b> Failed to remove webhook from GitHub: %v", err)
437446
}
438447
}
439448
}
440449
}
441450
}
451+
}
442452

443453
err = h.DB.RemoveRepoLink(context.Background(), ctx.EffectiveChat.Id, repoFullName)
444454
if err != nil {
@@ -494,7 +504,6 @@ Visit the <a href="https://github.com/AshokShau/GithubBot">GitHub page</a> for m
494504
return err
495505
}
496506

497-
498507
func (h *CommandHandler) Privacy(b *gotgbot.Bot, ctx *ext.Context) error {
499508
msg := `<b>Privacy Policy</b>
500509
@@ -557,6 +566,10 @@ func (h *CommandHandler) Reopen(b *gotgbot.Bot, ctx *ext.Context) error {
557566

558567
func (h *CommandHandler) Approve(b *gotgbot.Bot, ctx *ext.Context) error {
559568
msg := ctx.EffectiveMessage
569+
if err := requireAdminOrPrivate(b, ctx, "Only admins can approve pull requests in this chat."); err != nil {
570+
return err
571+
}
572+
560573
if msg.ReplyToMessage == nil {
561574
_, err := msg.Reply(b, "Please use this command in reply to a notification.", nil)
562575
return err
@@ -598,6 +611,10 @@ func (h *CommandHandler) Approve(b *gotgbot.Bot, ctx *ext.Context) error {
598611

599612
func (h *CommandHandler) handleIssueAction(b *gotgbot.Bot, ctx *ext.Context, state string) error {
600613
msg := ctx.EffectiveMessage
614+
if err := requireAdminOrPrivate(b, ctx, "Only admins can update issues or pull requests in this chat."); err != nil {
615+
return err
616+
}
617+
601618
if msg.ReplyToMessage == nil {
602619
_, err := msg.Reply(b, "Please use this command in reply to a notification.", nil)
603620
return err

internal/bot/commands/reply_handler.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ func (h *ReplyHandler) HandleReply(b *gotgbot.Bot, ctx *ext.Context) error {
3535
if msg.ReplyToMessage == nil {
3636
return nil
3737
}
38+
if err := requireAdminOrPrivate(b, ctx, "Only admins can comment on GitHub items from this chat."); err != nil {
39+
return err
40+
}
3841

3942
key := fmt.Sprintf("%d:%d", ctx.EffectiveChat.Id, msg.ReplyToMessage.MessageId)
4043
mContext, found := h.ContextCache.Get(key)

0 commit comments

Comments
 (0)