Skip to content

Commit 99258bc

Browse files
committed
fix(telegram): single per-request message, no web page preview
Defer ask approval from SOCKS5 CONNECT to per-request HTTP handler so only one Telegram message appears per request (not two). The message combines destination and request info: OpenClaw wants to connect to: HTTPS httpbin.org:443 GET https://httpbin.org/ip Allow this request? Also disable Telegram web page preview on approval messages to prevent link-preview artifacts.
1 parent 673ff7e commit 99258bc

5 files changed

Lines changed: 60 additions & 133 deletions

File tree

internal/proxy/request_policy_context_test.go

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,11 @@ default = "allow"
117117
}
118118
}
119119

120-
func TestAllowAttachesCheckerOnAskApproval(t *testing.T) {
120+
func TestAllowDefersAskToPerRequest(t *testing.T) {
121+
// When a broker is configured and the destination matches an ask rule,
122+
// Allow() auto-allows the SOCKS5 CONNECT without consulting the broker.
123+
// A checker with no seed credit is attached so every HTTP request
124+
// triggers its own per-request approval with method/path visible.
121125
fc := newFakeChannel(channel.ResponseAllowOnce)
122126
broker := channel.NewBroker([]channel.Channel{fc})
123127
fc.broker = broker
@@ -132,40 +136,34 @@ destination = "api.example.com"
132136

133137
ctx, ok := rules.Allow(context.Background(), mkConnectRequest("api.example.com"))
134138
if !ok {
135-
t.Fatal("Allow returned false after ask->allow-once approval")
139+
t.Fatal("Allow returned false for ask destination with broker")
136140
}
137141
if skip, _ := ctx.Value(ctxKeySkipPerRequest).(bool); skip {
138-
t.Fatal("ctxKeySkipPerRequest should NOT be set for ask-approved connection")
142+
t.Fatal("ctxKeySkipPerRequest should NOT be set for ask-deferred connection")
139143
}
140144
checker, _ := ctx.Value(ctxKeyPerRequestPolicy).(*RequestPolicyChecker)
141145
if checker == nil {
142-
t.Fatal("ask-approved connection must attach a RequestPolicyChecker so subsequent HTTP requests re-trigger the ask flow")
146+
t.Fatal("ask-deferred connection must attach a RequestPolicyChecker")
143147
}
144148

145-
// Sanity check: Allow() must have consulted the broker exactly once for
146-
// the CONNECT-level ask.
147-
if got := fc.requestCount(); got != 1 {
148-
t.Fatalf("broker count after Allow = %d, want 1 (CONNECT ask)", got)
149+
// Allow() must NOT have consulted the broker (deferred to per-request).
150+
if got := fc.requestCount(); got != 0 {
151+
t.Fatalf("broker count after Allow = %d, want 0 (deferred)", got)
149152
}
150153

151-
// The first HTTP request must NOT re-ask the broker (double-prompt
152-
// regression guard). The checker was seeded with one prepaid allow
153-
// credit from the CONNECT approval, so CheckAndConsume on the first
154-
// request is satisfied from the seed without contacting the broker.
154+
// First HTTP request asks the broker (no seed credit).
155155
verdict, err := checker.CheckAndConsume("api.example.com", 443)
156156
if err != nil {
157157
t.Fatalf("first CheckAndConsume: %v", err)
158158
}
159159
if verdict != policy.Allow {
160-
t.Fatalf("first HTTP request verdict = %v, want Allow (seeded credit)", verdict)
160+
t.Fatalf("first HTTP request verdict = %v, want Allow", verdict)
161161
}
162162
if got := fc.requestCount(); got != 1 {
163-
t.Fatalf("broker count after first HTTP request = %d, want 1 (seed must consume without re-asking broker)", got)
163+
t.Fatalf("broker count after first request = %d, want 1", got)
164164
}
165165

166-
// The second HTTP request must re-ask the broker (seed is exhausted).
167-
// Flip the response so the second verdict differs from the first,
168-
// proving the broker was actually consulted again.
166+
// Second HTTP request also asks the broker.
169167
fc.setResponse(channel.ResponseDeny)
170168
verdict, err = checker.CheckAndConsume("api.example.com", 443)
171169
if err != nil {
@@ -175,17 +173,15 @@ destination = "api.example.com"
175173
t.Fatalf("second HTTP request verdict = %v, want Deny", verdict)
176174
}
177175
if got := fc.requestCount(); got != 2 {
178-
t.Fatalf("broker count after second HTTP request = %d, want 2 (each subsequent request re-asks)", got)
176+
t.Fatalf("broker count after second request = %d, want 2", got)
179177
}
180178
}
181179

182-
func TestAllowSetsSkipAfterAlwaysAllow(t *testing.T) {
183-
// When a connection-level ask resolves to Always Allow and the rule
184-
// is persisted, the connection should take the fast path because the
185-
// new rule will match every subsequent HTTP request via the engine.
186-
// This mirrors the SNI path which sets ctxKeySkipPerRequest after
187-
// sniSaveRule succeeds. Uses a store-backed rule set because the
188-
// persist path now requires a store (there is no in-memory fallback).
180+
func TestAllowDefersAskWithBrokerDoesNotPrompt(t *testing.T) {
181+
// With a broker, ask destinations are auto-allowed at CONNECT time.
182+
// The broker is NOT consulted. The checker has no seed credit.
183+
// Verify that Always Allow responses are handled per-request (the
184+
// checker's persist callback handles rule persistence).
189185
fc := newFakeChannel(channel.ResponseAlwaysAllow)
190186
broker := channel.NewBroker([]channel.Channel{fc})
191187
fc.broker = broker
@@ -200,14 +196,16 @@ destination = "api.example.com"
200196

201197
ctx, ok := rules.Allow(context.Background(), mkConnectRequest("api.example.com"))
202198
if !ok {
203-
t.Fatal("Allow returned false after ask->always-allow approval")
199+
t.Fatal("Allow returned false for ask destination with broker")
204200
}
205-
skip, _ := ctx.Value(ctxKeySkipPerRequest).(bool)
206-
if !skip {
207-
t.Fatal("ctxKeySkipPerRequest should be true after always-allow so per-request checks are skipped")
201+
// Broker was NOT consulted at connection level.
202+
if got := fc.requestCount(); got != 0 {
203+
t.Fatalf("broker count = %d, want 0 (deferred)", got)
208204
}
209-
if _, present := ctx.Value(ctxKeyPerRequestPolicy).(*RequestPolicyChecker); present {
210-
t.Fatal("ctxKeyPerRequestPolicy should NOT be set when skip flag is true")
205+
// Checker is attached (not skip).
206+
checker, _ := ctx.Value(ctxKeyPerRequestPolicy).(*RequestPolicyChecker)
207+
if checker == nil {
208+
t.Fatal("checker should be attached for ask-deferred connection")
211209
}
212210
}
213211

internal/proxy/server.go

Lines changed: 20 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -309,11 +309,6 @@ func (r *policyRuleSet) Allow(ctx context.Context, req *socks5.Request) (context
309309
// partial persistence failure does not silently allow all subsequent
310310
// requests without re-triggering per-request policy.
311311
var alwaysAllowPersisted bool
312-
// askApprovedOnce is true when the connection-level ask resolved to
313-
// ResponseAllowOnce. Used to seed the per-request checker with a single
314-
// prepaid allow credit so the first HTTP request does not re-prompt the
315-
// user (double-prompt fix).
316-
var askApprovedOnce bool
317312
switch verdict {
318313
case policy.Allow:
319314
allowed = true
@@ -323,39 +318,15 @@ func (r *policyRuleSet) Allow(ctx context.Context, req *socks5.Request) (context
323318
reason = "ask treated as deny (no approval broker)"
324319
log.Printf("[ASK->DENY] %s:%d (no approval broker)", dest, port)
325320
} else {
326-
log.Printf("[ASK] %s:%d (waiting for Telegram approval)", dest, port)
327-
timeout := time.Duration(eng.TimeoutSec) * time.Second
328-
proto := DetectProtocol(port)
329-
resp, err := r.broker.Request(dest, port, proto.String(), timeout)
330-
if err != nil {
331-
effectiveVerdict = policy.Deny
332-
reason = fmt.Sprintf("approval timeout: %v", err)
333-
log.Printf("[ASK->DENY] %s:%d (timeout: %v)", dest, port, err)
334-
} else {
335-
switch resp {
336-
case channel.ResponseAllowOnce:
337-
allowed = true
338-
effectiveVerdict = policy.Allow
339-
reason = "user approved once"
340-
askApprovedOnce = true
341-
log.Printf("[ASK->ALLOW] %s:%d (user approved once)", dest, port)
342-
case channel.ResponseAlwaysAllow:
343-
allowed = true
344-
effectiveVerdict = policy.Allow
345-
reason = "user approved always"
346-
log.Printf("[ASK->ALLOW+SAVE] %s:%d (user approved always)", dest, port)
347-
alwaysAllowPersisted = r.persistAlwaysAllow(dest, port)
348-
case channel.ResponseAlwaysDeny:
349-
effectiveVerdict = policy.Deny
350-
reason = "user denied always"
351-
log.Printf("[ASK->DENY+SAVE] %s:%d (user denied always)", dest, port)
352-
r.persistAlwaysDeny(dest, port)
353-
default:
354-
effectiveVerdict = policy.Deny
355-
reason = "user denied"
356-
log.Printf("[ASK->DENY] %s:%d (user denied)", dest, port)
357-
}
358-
}
321+
// Auto-allow the SOCKS5 CONNECT without prompting the user.
322+
// Approval happens per-request inside the MITM handler where
323+
// the HTTP method and path are known, producing a single
324+
// combined Telegram message per request instead of two
325+
// separate messages (connection + request).
326+
allowed = true
327+
effectiveVerdict = policy.Allow
328+
reason = "ask deferred to per-request"
329+
log.Printf("[ASK->DEFER] %s:%d (approval deferred to per-request)", dest, port)
359330
}
360331
}
361332

@@ -444,13 +415,11 @@ func (r *policyRuleSet) Allow(ctx context.Context, req *socks5.Request) (context
444415
if skipPerRequest {
445416
ctx = context.WithValue(ctx, ctxKeySkipPerRequest, true)
446417
} else {
447-
seed := 0
448-
if askApprovedOnce {
449-
seed = 1
450-
}
418+
// No seed credit: every HTTP request triggers its own
419+
// per-request approval with method and path visible in
420+
// the Telegram message.
451421
checker := NewRequestPolicyChecker(r.engine, r.broker,
452422
WithPersist(r.buildPersistFunc()),
453-
WithSeedCredits(seed),
454423
)
455424
ctx = context.WithValue(ctx, ctxKeyPerRequestPolicy, checker)
456425
}
@@ -1447,56 +1416,14 @@ func (s *Server) sniPolicyCheckBeforeDial(ctx context.Context, request *socks5.R
14471416
log.Printf("[SNI->DENY] %s:%d (hostname %s: ask treated as deny, no broker)", ipStr, port, sni)
14481417
return nil, ctx, false
14491418
}
1450-
log.Printf("[SNI->ASK] %s:%d (hostname %s: waiting for approval)", ipStr, port, sni)
1451-
timeout := time.Duration(eng.TimeoutSec) * time.Second
1452-
proto := DetectProtocol(port)
1453-
resp, reqErr := s.rules.broker.Request(sni, port, proto.String(), timeout)
1454-
if reqErr != nil {
1455-
log.Printf("[SNI->DENY] %s:%d (hostname %s: approval timeout: %v)", ipStr, port, sni, reqErr)
1456-
return nil, ctx, false
1457-
}
1458-
switch resp {
1459-
case channel.ResponseAllowOnce:
1460-
log.Printf("[SNI->ALLOW] %s:%d (hostname %s: user approved once)", ipStr, port, sni)
1461-
// Ask-approved once: attach a checker seeded with one prepaid
1462-
// allow credit so the first HTTP request on the new tunnel does
1463-
// not re-prompt the user (the SNI-level approval is reused for
1464-
// the first request). Subsequent requests on the same keep-alive
1465-
// connection re-trigger the ask flow normally.
1466-
checker := NewRequestPolicyChecker(s.rules.engine, s.rules.broker,
1467-
WithPersist(s.rules.buildPersistFunc()),
1468-
WithSeedCredits(1),
1469-
)
1470-
ctx = context.WithValue(ctx, ctxKeyPerRequestPolicy, checker)
1471-
return reader, ctx, true
1472-
case channel.ResponseAlwaysAllow:
1473-
log.Printf("[SNI->ALLOW+SAVE] %s:%d (hostname %s: user approved always)", ipStr, port, sni)
1474-
// Persist the new allow rule. If the persist path fails we
1475-
// fall back to attaching a checker as a safety net so the
1476-
// current keep-alive connection is still policy-checked on
1477-
// every request (the failure was only logged inside
1478-
// sniSaveRule, so the in-memory engine may still evaluate
1479-
// sni:port as ask). The checker is seeded with one credit
1480-
// because the user already approved this connection.
1481-
if ok := s.sniSaveRule("allow", sni, port); ok {
1482-
ctx = context.WithValue(ctx, ctxKeySkipPerRequest, true)
1483-
} else {
1484-
log.Printf("[SNI->ALLOW+SAVE] %s:%d persistence failed; attaching per-request checker as safety net", sni, port)
1485-
checker := NewRequestPolicyChecker(s.rules.engine, s.rules.broker,
1486-
WithPersist(s.rules.buildPersistFunc()),
1487-
WithSeedCredits(1),
1488-
)
1489-
ctx = context.WithValue(ctx, ctxKeyPerRequestPolicy, checker)
1490-
}
1491-
return reader, ctx, true
1492-
case channel.ResponseAlwaysDeny:
1493-
log.Printf("[SNI->DENY+SAVE] %s:%d (hostname %s: user denied always)", ipStr, port, sni)
1494-
s.sniSaveRule("deny", sni, port)
1495-
return nil, ctx, false
1496-
default:
1497-
log.Printf("[SNI->DENY] %s:%d (hostname %s: user denied)", ipStr, port, sni)
1498-
return nil, ctx, false
1499-
}
1419+
// Auto-allow the connection and defer approval to per-request
1420+
// checks where the HTTP method and path are visible.
1421+
log.Printf("[SNI->DEFER] %s:%d (hostname %s: approval deferred to per-request)", ipStr, port, sni)
1422+
checker := NewRequestPolicyChecker(s.rules.engine, s.rules.broker,
1423+
WithPersist(s.rules.buildPersistFunc()),
1424+
)
1425+
ctx = context.WithValue(ctx, ctxKeyPerRequestPolicy, checker)
1426+
return reader, ctx, true
15001427
default:
15011428
log.Printf("[SNI->DENY] %s:%d (hostname %s: default deny)", ipStr, port, sni)
15021429
return nil, ctx, false

internal/telegram/approval.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ func (tc *TelegramChannel) sendApprovalMessage(req channel.ApprovalRequest) {
137137

138138
msg := tgbotapi.NewMessage(tc.chatID, FormatApprovalMessage(req))
139139
msg.ParseMode = tgbotapi.ModeHTML
140+
msg.DisableWebPagePreview = true
140141
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(
141142
tgbotapi.NewInlineKeyboardRow(
142143
tgbotapi.NewInlineKeyboardButtonData("Allow", req.ID+"|allow_once"),

internal/telegram/bot.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,17 @@ func FormatApprovalMessage(req channel.ApprovalRequest) string {
6565
msg += "\n\nAllow this tool call?"
6666
return msg
6767
}
68+
display := protoDisplayName[req.Protocol]
69+
if display == "" {
70+
display = req.Protocol
71+
}
6872
if req.Method != "" {
6973
return fmt.Sprintf(
70-
"OpenClaw wants to send request (per-request):\n\n%s %s\n\nAllow this request?",
74+
"OpenClaw wants to connect to:\n\n%s %s:%d\n%s %s\n\nAllow this request?",
75+
htmlEscape(display), htmlEscape(req.Destination), req.Port,
7176
htmlEscape(req.Method), htmlEscape(buildRequestURL(req)),
7277
)
7378
}
74-
display := protoDisplayName[req.Protocol]
75-
if display == "" {
76-
display = req.Protocol
77-
}
7879
return fmt.Sprintf(
7980
"OpenClaw wants to connect to:\n\n%s %s:%d\n\nAllow this connection?",
8081
htmlEscape(display), htmlEscape(req.Destination), req.Port,

internal/telegram/bot_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,11 @@ func TestFormatApprovalMessage(t *testing.T) {
167167
Path: "/users/me",
168168
}
169169
msg := FormatApprovalMessage(req)
170-
if !strings.Contains(msg, "GET https://api.example.com/users/me") {
171-
t.Errorf("expected 'GET https://api.example.com/users/me' in message, got: %s", msg)
170+
if !strings.Contains(msg, "HTTPS api.example.com:443") {
171+
t.Errorf("expected destination line in message, got: %s", msg)
172172
}
173-
if !strings.Contains(msg, "per-request") {
174-
t.Errorf("expected 'per-request' label in message, got: %s", msg)
173+
if !strings.Contains(msg, "GET https://api.example.com/users/me") {
174+
t.Errorf("expected request line in message, got: %s", msg)
175175
}
176176
if !strings.Contains(msg, "Allow this request?") {
177177
t.Errorf("expected 'Allow this request?' wording, got: %s", msg)

0 commit comments

Comments
 (0)