Skip to content

Commit e8bb6ce

Browse files
committed
refactor(core): harden Telegram SDK fetch with marker validation and failure cooldown
- Add `telegramSDKMarker` to reject transparent-proxy block pages and truncated bodies - Introduce failure cooldown to prevent retry loops when upstream is unreachable - Move publish/release into defer to avoid permanent latch on panic - Switch subscription page Cache-Control from public to private - Add tests for stale-failure loop prevention and non-SDK body rejection - Improve tg.js script-tag detection and guard assertions in page tests
1 parent 6453ea0 commit e8bb6ce

5 files changed

Lines changed: 160 additions & 38 deletions

File tree

internal/core/manager.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,8 @@ func New(st *store.Store, sup *xray.Supervisor, opts xray.Options, tls TLSPaths,
289289
m.startWebhookWorkers() // drain the outbound-webhook delivery queue
290290
go m.prewarmRoutingTemplates() // warm the routing-template cache so the first
291291
// Happ/INCY sub pull after a restart doesn't block
292-
go m.refreshTelegramSDK() // fetch telegram-web-app.js so the sub page serves the
293-
// real SDK (not the shim) from the first Mini App open
292+
go m.refreshTelegramSDK() // warm telegram-web-app.js so the first subscription-page
293+
// view doesn't pay for the fetch inline
294294
// NOTE: the initial proxy-pool load is done synchronously by main.go via
295295
// SeedProxies() before the first reconcile, so Xray starts once (with proxies)
296296
// rather than starting empty and restarting when a background fetch lands.

internal/core/manager_settings.go

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package core
22

33
import (
4+
"bytes"
45
"context"
56
"fmt"
67
"regexp"
@@ -631,6 +632,13 @@ const (
631632
telegramSDKMaxBytes = 1 << 20 // the wrapper is ~120 KB; 1 MiB is ample headroom
632633
)
633634

635+
// telegramSDKMarker must appear in a fetched body for it to be cached. netguard
636+
// already rejects non-200 and enforces https, but a transparent proxy answering 200
637+
// with an HTML block page would otherwise be cached as JS for a full TTL and served
638+
// to every user. It also catches a silent truncation at telegramSDKMaxBytes (which
639+
// returns no error). The real wrapper mentions Telegram.WebApp ~115 times.
640+
var telegramSDKMarker = []byte("Telegram.WebApp")
641+
634642
// TelegramWebAppSDK returns a server-side cached copy of telegram.org's
635643
// telegram-web-app.js so the subscription page can serve it from our own
636644
// (reachable) origin. A fresh copy is returned as-is; a stale one is served
@@ -690,11 +698,16 @@ var telegramSDKFetch = func(ctx context.Context) ([]byte, error) {
690698
}
691699

692700
// refreshTelegramSDK fetches in the background (startup warm-up and stale refresh).
693-
// It's a no-op while another fetch is in flight, so the per-request stale trigger
694-
// can't pile up goroutines.
701+
// It's a no-op while a fetch is in flight OR while the failure cooldown is armed.
702+
//
703+
// That cooldown is what stops a failing upstream from becoming a retry loop: a
704+
// failed fetch never advances tgSDKAt, so a stale copy stays stale and EVERY
705+
// subsequent request re-triggers this. Without the guard the panel would dial a
706+
// blocked telegram.org back-to-back for as long as the page saw traffic — a beacon
707+
// the decoy story does not cover.
695708
func (m *Manager) refreshTelegramSDK() {
696709
m.tgSDKMu.Lock()
697-
if m.tgSDKWait != nil {
710+
if m.tgSDKWait != nil || time.Since(m.tgSDKFailAt) < telegramSDKRetryGap {
698711
m.tgSDKMu.Unlock()
699712
return
700713
}
@@ -706,20 +719,32 @@ func (m *Manager) refreshTelegramSDK() {
706719
// fetchTelegramSDK performs the upstream GET and publishes the result, then releases
707720
// everyone waiting on it. The caller must have claimed the fetch by setting
708721
// tgSDKWait. A failed fetch keeps any previous cached copy and stamps tgSDKFailAt so
709-
// cold readers stop blocking for a while.
722+
// readers stop blocking for a while.
723+
//
724+
// Publish/release runs in a defer so a panic anywhere in the fetch stack can't latch
725+
// tgSDKWait non-nil forever — that would permanently disable refreshes AND make every
726+
// later reader wait out the full budget as a rider on a fetch that never completes.
710727
func (m *Manager) fetchTelegramSDK() {
728+
var (
729+
b []byte
730+
err error
731+
)
732+
defer func() {
733+
m.tgSDKMu.Lock()
734+
if err == nil && bytes.Contains(b, telegramSDKMarker) {
735+
m.tgSDKBody, m.tgSDKAt = b, time.Now()
736+
m.tgSDKFailAt = time.Time{}
737+
} else {
738+
m.tgSDKFailAt = time.Now()
739+
}
740+
if m.tgSDKWait != nil {
741+
close(m.tgSDKWait) // wake the riders; they re-read the cache
742+
m.tgSDKWait = nil
743+
}
744+
m.tgSDKMu.Unlock()
745+
}()
746+
711747
ctx, cancel := context.WithTimeout(context.Background(), telegramSDKBudget)
712748
defer cancel()
713-
b, err := telegramSDKFetch(ctx)
714-
715-
m.tgSDKMu.Lock()
716-
if err == nil && len(b) > 0 {
717-
m.tgSDKBody, m.tgSDKAt = b, time.Now()
718-
m.tgSDKFailAt = time.Time{}
719-
} else {
720-
m.tgSDKFailAt = time.Now()
721-
}
722-
close(m.tgSDKWait) // wake the riders; they re-read the cache
723-
m.tgSDKWait = nil
724-
m.tgSDKMu.Unlock()
749+
b, err = telegramSDKFetch(ctx)
725750
}

internal/core/manager_tgsdk_test.go

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ import (
99
"time"
1010
)
1111

12+
// Stub bodies must carry telegramSDKMarker — fetchTelegramSDK rejects anything that
13+
// doesn't look like the real wrapper, so a marker-less fixture would be discarded and
14+
// every test would read as a fetch failure.
15+
const (
16+
fakeSDK = "// WebView\nwindow.Telegram.WebApp = {platform:'test'};"
17+
fakeSDKFresh = "// WebView fresh\nwindow.Telegram.WebApp = {platform:'test2'};"
18+
)
19+
1220
// stubTelegramSDKFetch swaps the upstream GET for the duration of a test and
1321
// reports how many times it was called.
1422
func stubTelegramSDKFetch(t *testing.T, fn func(ctx context.Context) ([]byte, error)) *atomic.Int32 {
@@ -44,25 +52,41 @@ func waitTelegramSDKIdle(t *testing.T, m *Manager) {
4452
}
4553
}
4654

55+
// waitTelegramSDKCalls waits until the stub has been entered `want` times and the
56+
// resulting fetch has finished. Needed because a background refresh is spawned with
57+
// `go`: waiting only for "no fetch in flight" can't tell "not scheduled yet" from
58+
// "already done", so the test would race ahead and observe zero calls.
59+
func waitTelegramSDKCalls(t *testing.T, m *Manager, calls *atomic.Int32, want int32) {
60+
t.Helper()
61+
deadline := time.Now().Add(5 * time.Second)
62+
for calls.Load() < want {
63+
if time.Now().After(deadline) {
64+
t.Fatalf("upstream called %d times after 5s, want %d", calls.Load(), want)
65+
}
66+
time.Sleep(5 * time.Millisecond)
67+
}
68+
waitTelegramSDKIdle(t, m)
69+
}
70+
4771
// A cold cache must fetch INLINE and hand the real body to the very first caller —
4872
// that's the whole point of the cold path (an empty file would silently disable the
4973
// Mini App bridge for whoever loads the page first).
5074
func TestTelegramSDKColdFetchesInline(t *testing.T) {
5175
calls := stubTelegramSDKFetch(t, func(context.Context) ([]byte, error) {
52-
return []byte("// WebView"), nil
76+
return []byte(fakeSDK), nil
5377
})
5478
m := &Manager{}
5579

5680
body, ok := m.TelegramWebAppSDK()
57-
if !ok || string(body) != "// WebView" {
81+
if !ok || string(body) != fakeSDK {
5882
t.Fatalf("cold read: got (%q, %v), want the fetched body", body, ok)
5983
}
6084
if got := calls.Load(); got != 1 {
6185
t.Fatalf("upstream called %d times, want 1", got)
6286
}
6387

6488
// Second read is served from cache — no new fetch.
65-
if body, ok = m.TelegramWebAppSDK(); !ok || string(body) != "// WebView" {
89+
if body, ok = m.TelegramWebAppSDK(); !ok || string(body) != fakeSDK {
6690
t.Fatalf("warm read: got (%q, %v)", body, ok)
6791
}
6892
if got := calls.Load(); got != 1 {
@@ -115,7 +139,7 @@ func TestTelegramSDKConcurrentColdSingleflight(t *testing.T) {
115139
release := make(chan struct{})
116140
calls := stubTelegramSDKFetch(t, func(context.Context) ([]byte, error) {
117141
<-release // hold the fetch open so every goroutine piles up behind it
118-
return []byte("// WebView"), nil
142+
return []byte(fakeSDK), nil
119143
})
120144
m := &Manager{}
121145

@@ -127,7 +151,7 @@ func TestTelegramSDKConcurrentColdSingleflight(t *testing.T) {
127151
go func() {
128152
defer wg.Done()
129153
body, ok := m.TelegramWebAppSDK()
130-
got[i] = ok && string(body) == "// WebView"
154+
got[i] = ok && string(body) == fakeSDK
131155
}()
132156
}
133157
time.Sleep(50 * time.Millisecond) // let them all reach the cold path
@@ -144,6 +168,59 @@ func TestTelegramSDKConcurrentColdSingleflight(t *testing.T) {
144168
}
145169
}
146170

171+
// A stale copy plus a failing upstream must NOT become a retry loop. A failed fetch
172+
// never advances tgSDKAt, so the copy stays stale and every request re-triggers the
173+
// refresh — without the cooldown guard the panel dials a blocked telegram.org
174+
// back-to-back for as long as the page sees traffic.
175+
func TestTelegramSDKStaleFailureDoesNotLoop(t *testing.T) {
176+
calls := stubTelegramSDKFetch(t, func(context.Context) ([]byte, error) {
177+
return nil, errors.New("dial tcp: i/o timeout")
178+
})
179+
m := &Manager{}
180+
m.tgSDKBody = []byte("old")
181+
m.tgSDKAt = time.Now().Add(-telegramSDKTTL - time.Minute) // stale
182+
183+
// First read kicks off a refresh, which fails and arms the cooldown.
184+
if body, ok := m.TelegramWebAppSDK(); !ok || string(body) != "old" {
185+
t.Fatalf("stale read: got (%q, %v), want the old body", body, ok)
186+
}
187+
waitTelegramSDKCalls(t, m, calls, 1)
188+
189+
// Hammer it: still stale, still failing — the cooldown must absorb every one.
190+
for range 50 {
191+
if body, ok := m.TelegramWebAppSDK(); !ok || string(body) != "old" {
192+
t.Fatalf("stale read regressed: got (%q, %v)", body, ok)
193+
}
194+
}
195+
waitTelegramSDKIdle(t, m)
196+
if got := calls.Load(); got != 1 {
197+
t.Fatalf("stale+failing upstream looped: %d upstream calls after 51 reads, want 1", got)
198+
}
199+
}
200+
201+
// A 200 that isn't actually the SDK (a transparent-proxy block page, or a body
202+
// truncated at the size cap — which returns no error) must not be cached: it would
203+
// be served as JS to every user for a full TTL.
204+
func TestTelegramSDKRejectsNonSDKBody(t *testing.T) {
205+
stubTelegramSDKFetch(t, func(context.Context) ([]byte, error) {
206+
return []byte("<html><body>Access denied</body></html>"), nil
207+
})
208+
m := &Manager{}
209+
210+
if body, ok := m.TelegramWebAppSDK(); ok || body != nil {
211+
t.Fatalf("garbage body was accepted: got (%q, %v), want (nil, false)", body, ok)
212+
}
213+
m.tgSDKMu.Lock()
214+
cached, failed := m.tgSDKBody, !m.tgSDKFailAt.IsZero()
215+
m.tgSDKMu.Unlock()
216+
if cached != nil {
217+
t.Errorf("garbage body got cached: %q", cached)
218+
}
219+
if !failed {
220+
t.Error("a rejected body must arm the failure cooldown like any other failure")
221+
}
222+
}
223+
147224
// A stale copy is served immediately (never blocking the page) while the refresh
148225
// happens behind it.
149226
func TestTelegramSDKStaleServedImmediately(t *testing.T) {
@@ -152,7 +229,7 @@ func TestTelegramSDKStaleServedImmediately(t *testing.T) {
152229
stubTelegramSDKFetch(t, func(context.Context) ([]byte, error) {
153230
once.Do(func() { close(started) })
154231
<-block
155-
return []byte("fresh"), nil
232+
return []byte(fakeSDKFresh), nil
156233
})
157234
m := &Manager{}
158235
m.tgSDKBody = []byte("old")
@@ -180,7 +257,7 @@ func TestTelegramSDKStaleServedImmediately(t *testing.T) {
180257
waitTelegramSDKIdle(t, m) // let it land before cleanup restores the stub
181258

182259
// ...and the refresh it kicked off replaced the stale copy.
183-
if body, ok := m.TelegramWebAppSDK(); !ok || string(body) != "fresh" {
260+
if body, ok := m.TelegramWebAppSDK(); !ok || string(body) != fakeSDKFresh {
184261
t.Fatalf("after refresh: got (%q, %v), want the fresh body", body, ok)
185262
}
186263
}

internal/server/subscription.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,16 +122,20 @@ func handleSub(rt *Router, w http.ResponseWriter, r *http.Request, rest string)
122122
// the page never loads it straight from telegram.org (blocked in Russia — a
123123
// direct <script> there hangs the page until the connection times out).
124124
//
125-
// A cold/unreachable cache serves an EMPTY body rather than blocking on an
126-
// inline fetch — never hanging the page is the whole point of this route. The
127-
// page degrades cleanly: window.Telegram stays undefined, so it treats itself
128-
// as a plain browser (INTG=false) exactly as it does today when telegram.org
129-
// is blocked. no-store on that empty reply so the client picks up the real SDK
130-
// as soon as the background fetch lands.
125+
// A COLD cache fetches inline (bounded by the manager's budget) so the first
126+
// visitor still gets the real SDK; if telegram.org is unreachable this serves
127+
// an EMPTY body instead. The page degrades cleanly on empty: window.Telegram
128+
// stays undefined, so it treats itself as a plain browser (INTG=false) exactly
129+
// as it did when telegram.org was loaded directly and blocked.
130+
//
131+
// private, not public: the body is the same non-secret SDK for everyone, but
132+
// the URL embeds the user's sub token — no reason to invite shared proxies to
133+
// key a cache entry (and a log line) on it. no-store on the empty reply so a
134+
// client doesn't pin the miss.
131135
js, ok := rt.mgr.TelegramWebAppSDK()
132136
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
133137
if ok {
134-
w.Header().Set("Cache-Control", "public, max-age=3600")
138+
w.Header().Set("Cache-Control", "private, max-age=3600")
135139
} else {
136140
w.Header().Set("Cache-Control", "no-store")
137141
}

internal/sub/page_tgjs_test.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package sub
22

33
import (
4+
"regexp"
45
"strings"
56
"testing"
67

@@ -25,10 +26,17 @@ func TestPageServesTelegramSDKLocally(t *testing.T) {
2526
if !strings.Contains(s, `/tg.js"></script>`) {
2627
t.Error("page missing the same-origin <script src=.../tg.js>")
2728
}
28-
// It must stay a plain blocking script: the inline script at the bottom reads
29-
// window.Telegram.WebApp synchronously, so defer/async would break the ordering.
30-
if strings.Contains(s, "/tg.js\" defer") || strings.Contains(s, "/tg.js\" async") {
31-
t.Error("tg.js must load synchronously, not deferred/async")
29+
// It must stay a plain BLOCKING script: the inline script at the bottom reads
30+
// window.Telegram.WebApp synchronously, so defer/async would leave it undefined
31+
// and silently kill Mini App deep-link routing. Match the whole tag — checking
32+
// only for `tg.js" defer` misses `<script async src=...>`, where the attribute
33+
// comes first.
34+
tag := regexp.MustCompile(`<script[^>]*\btg\.js\b[^>]*>`).FindString(s)
35+
if tag == "" {
36+
t.Fatal("no <script> tag for tg.js found")
37+
}
38+
if strings.Contains(tag, "async") || strings.Contains(tag, "defer") {
39+
t.Errorf("tg.js must load synchronously, got %q", tag)
3240
}
3341
}
3442

@@ -46,10 +54,18 @@ func TestPageToleratesMissingSDK(t *testing.T) {
4654
s := string(html)
4755
// The guarded read + the INTG flag derived from it are what make an empty SDK
4856
// degrade to "plain browser" instead of a ReferenceError.
49-
if !strings.Contains(s, "window.Telegram && window.Telegram.WebApp") {
50-
t.Error("page must read window.Telegram defensively (empty /tg.js is a valid state)")
57+
const guard = "var TG = (window.Telegram && window.Telegram.WebApp) || null;"
58+
if !strings.Contains(s, guard) {
59+
t.Fatalf("page must read window.Telegram defensively (empty /tg.js is a valid state); want %q", guard)
5160
}
5261
if !strings.Contains(s, "var INTG") {
5362
t.Error("page missing the INTG in-Telegram flag")
5463
}
64+
// Every other touch of the SDK must go through TG/INTG. A direct
65+
// `Telegram.WebApp.…` anywhere else throws on an empty /tg.js and takes the whole
66+
// page down with it, so assert the guarded read is the ONLY one. (Static check:
67+
// it can't catch an unguarded `TG.foo()`, which the INTG branches cover.)
68+
if rest := strings.Replace(s, guard, "", 1); strings.Contains(rest, "Telegram.WebApp") {
69+
t.Error("unguarded Telegram.WebApp access outside the guarded read — would throw when /tg.js is empty")
70+
}
5571
}

0 commit comments

Comments
 (0)