mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
fix: remove sensitive send scope from reply and forward shortcuts (#92)
* fix: remove sensitive send scope from reply and forward shortcuts Remove mail:user_mailbox.message:send from the required scopes of +reply, +reply-all, and +forward shortcuts. This scope is sensitive and may not be granted, while these shortcuts default to saving drafts and do not strictly require it. * fix: validate send scope dynamically when --confirm-send is set Add validateConfirmSendScope() to check mail:user_mailbox.message:send in the Validate phase when --confirm-send is used, preventing the "draft created but send failed" scenario. Add regression tests for +reply, +reply-all, and +forward.
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -1854,6 +1855,33 @@ func checkAttachmentSizeLimit(filePaths []string, extraBytes int64, extraCount .
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateConfirmSendScope checks that the user's token includes the
|
||||
// mail:user_mailbox.message:send scope when --confirm-send is set.
|
||||
// This scope is not declared in the shortcut's static Scopes (to keep the
|
||||
// default draft-only path accessible without the sensitive send permission),
|
||||
// so we validate it dynamically here.
|
||||
func validateConfirmSendScope(runtime *common.RuntimeContext) error {
|
||||
if !runtime.Bool("confirm-send") {
|
||||
return nil
|
||||
}
|
||||
appID := runtime.Config.AppID
|
||||
userOpenId := runtime.UserOpenId()
|
||||
if appID == "" || userOpenId == "" {
|
||||
return nil
|
||||
}
|
||||
stored := auth.GetStoredToken(appID, userOpenId)
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
required := []string{"mail:user_mailbox.message:send"}
|
||||
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("--confirm-send requires scope: %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant the send permission", strings.Join(missing, " ")))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error {
|
||||
if strings.TrimSpace(to) == "" && strings.TrimSpace(cc) == "" && strings.TrimSpace(bcc) == "" {
|
||||
return fmt.Errorf("at least one recipient (--to, --cc, or --bcc) is required")
|
||||
|
||||
52
shortcuts/mail/mail_confirm_send_scope_test.go
Normal file
52
shortcuts/mail/mail_confirm_send_scope_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestConfirmSendMissingScopeReply(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailReply, []string{
|
||||
"+reply", "--message-id", "msg_001", "--body", "hello", "--confirm-send",
|
||||
}, f, stdout)
|
||||
assertMissingSendScope(t, err)
|
||||
}
|
||||
|
||||
func TestConfirmSendMissingScopeReplyAll(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailReplyAll, []string{
|
||||
"+reply-all", "--message-id", "msg_001", "--body", "hello", "--confirm-send",
|
||||
}, f, stdout)
|
||||
assertMissingSendScope(t, err)
|
||||
}
|
||||
|
||||
func TestConfirmSendMissingScopeForward(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailForward, []string{
|
||||
"+forward", "--message-id", "msg_001", "--to", "alice@example.com", "--confirm-send",
|
||||
}, f, stdout)
|
||||
assertMissingSendScope(t, err)
|
||||
}
|
||||
|
||||
func assertMissingSendScope(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when token lacks send scope with --confirm-send, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Errorf("expected exit code %d (ExitAuth), got %d", output.ExitAuth, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_scope" {
|
||||
t.Errorf("expected detail type missing_scope, got %+v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ var MailForward = common.Shortcut{
|
||||
Command: "+forward",
|
||||
Description: "Forward a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Original message block included automatically.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "Required. Message ID to forward", Required: true},
|
||||
@@ -55,6 +55,9 @@ var MailForward = common.Shortcut{
|
||||
return api
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("confirm-send") {
|
||||
if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
|
||||
return err
|
||||
|
||||
@@ -18,7 +18,7 @@ var MailReply = common.Shortcut{
|
||||
Command: "+reply",
|
||||
Description: "Reply to a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Sets Re: subject, In-Reply-To, and References headers automatically.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "Required. Message ID to reply to", Required: true},
|
||||
@@ -52,6 +52,9 @@ var MailReply = common.Shortcut{
|
||||
return api
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -18,7 +18,7 @@ var MailReplyAll = common.Shortcut{
|
||||
Command: "+reply-all",
|
||||
Description: "Reply to all recipients and save as draft (default). Use --confirm-send to send immediately after user confirmation. Includes all original To and CC automatically.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "Required. Message ID to reply to all recipients", Required: true},
|
||||
@@ -53,6 +53,9 @@ var MailReplyAll = common.Shortcut{
|
||||
return api
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -42,7 +42,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
|
||||
RefreshToken: "test-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:send mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
|
||||
@@ -172,7 +172,7 @@ lark-cli mail +draft-edit --draft-id <draft_id> --patch-file /tmp/patch.json
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 需要已登录(`lark-cli auth login --scope "mail:user_mailbox.message:send mail:user_mailbox.message:readonly mail:user_mailbox:readonly"`)且具备写/读邮件权限
|
||||
- 需要已登录(`lark-cli auth login --scope "mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox:readonly"`)且具备写/读邮件权限
|
||||
- 邮件 ID 可从 `lark-cli mail user_mailbox.messages list` 获取
|
||||
- `--bcc` 仅在发送链路中生效,通常不会在收件方看到
|
||||
|
||||
|
||||
Reference in New Issue
Block a user