@pippit-dev/cli 1.0.17 → 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -2
- package/checksums.txt +6 -6
- package/cmd/auth/auth.go +136 -142
- package/cmd/auth/auth_test.go +134 -0
- package/cmd/canvas/canvas.go +241 -0
- package/cmd/canvas/canvas_test.go +182 -0
- package/cmd/get_credit_balance.go +37 -0
- package/cmd/get_credit_balance_test.go +73 -0
- package/cmd/root.go +31 -5
- package/cmd/root_test.go +69 -0
- package/cmd/short_drama_test.go +5 -7
- package/cmd/update/update.go +34 -5
- package/cmd/update/update_test.go +84 -0
- package/dist/checksums.txt +6 -0
- package/dist/xyq-canvas-command-runtime.cjs +16 -0
- package/dist/xyq-canvas-command-runtime.cjs.LEGAL.txt +599 -0
- package/dist/xyq-canvas-command-runtime.cjs.sha256 +2 -0
- package/internal/auth/auth_test.go +813 -0
- package/internal/auth/browser_darwin.go +14 -0
- package/internal/auth/browser_env.go +35 -0
- package/internal/auth/browser_linux.go +14 -0
- package/internal/auth/browser_windows.go +14 -0
- package/internal/auth/identity.go +89 -0
- package/internal/auth/loopback.go +324 -0
- package/internal/auth/manager.go +338 -144
- package/internal/auth/store.go +303 -0
- package/internal/auth/store_file_unix.go +176 -0
- package/internal/auth/store_file_windows.go +11 -0
- package/internal/auth/types.go +72 -0
- package/internal/canvas/allocate.go +70 -0
- package/internal/canvas/apply.go +279 -0
- package/internal/canvas/canvas_test.go +559 -0
- package/internal/canvas/create.go +380 -0
- package/internal/canvas/get.go +156 -0
- package/internal/canvas/types.go +68 -0
- package/internal/canvas/upload.go +250 -0
- package/internal/common/access_key.go +42 -6
- package/internal/common/access_key_test.go +113 -0
- package/internal/common/client.go +119 -21
- package/internal/common/client_test.go +213 -0
- package/internal/common/get_credit_balance.go +61 -0
- package/internal/common/get_credit_balance_test.go +75 -0
- package/internal/common/runner.go +15 -0
- package/internal/config/config.go +11 -28
- package/internal/config/config_test.go +3 -18
- package/package.json +9 -2
- package/scripts/canvas-command.js +881 -0
- package/scripts/run.js +21 -4
- package/skills/short-drama/SKILL.md +4 -4
- package/skills/xyq-nest-skill/SKILL.md +11 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import "strings"
|
|
4
|
+
|
|
5
|
+
// SanitizedBrowserEnv removes Pippit/XYQ credential variables before spawning
|
|
6
|
+
// a browser helper. The login URL carries its own one-time binding and does not
|
|
7
|
+
// need any CLI credential from the child process environment.
|
|
8
|
+
func SanitizedBrowserEnv(environment []string) []string {
|
|
9
|
+
result := make([]string, 0, len(environment))
|
|
10
|
+
for _, entry := range environment {
|
|
11
|
+
name, _, found := strings.Cut(entry, "=")
|
|
12
|
+
if !found || isCredentialEnvName(name) {
|
|
13
|
+
continue
|
|
14
|
+
}
|
|
15
|
+
result = append(result, entry)
|
|
16
|
+
}
|
|
17
|
+
return result
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func isCredentialEnvName(name string) bool {
|
|
21
|
+
upper := strings.ToUpper(strings.TrimSpace(name))
|
|
22
|
+
if !strings.HasPrefix(upper, "PIPPIT_") && !strings.HasPrefix(upper, "XYQ_") {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
if strings.Contains(upper, "ACCESS_KEY") || strings.Contains(upper, "TOKEN") || strings.Contains(upper, "SECRET") {
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
parts := strings.Split(upper, "_")
|
|
29
|
+
for _, part := range parts {
|
|
30
|
+
if part == "AK" {
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//go:build windows
|
|
2
|
+
|
|
3
|
+
package auth
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"os"
|
|
7
|
+
"os/exec"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func OpenBrowser(rawURL string) error {
|
|
11
|
+
command := exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
|
|
12
|
+
command.Env = SanitizedBrowserEnv(os.Environ())
|
|
13
|
+
return command.Start()
|
|
14
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/rand"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"crypto/subtle"
|
|
7
|
+
"encoding/base64"
|
|
8
|
+
"errors"
|
|
9
|
+
"io"
|
|
10
|
+
"strings"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func randomEncoded(reader io.Reader, size int) (string, error) {
|
|
14
|
+
if reader == nil {
|
|
15
|
+
reader = rand.Reader
|
|
16
|
+
}
|
|
17
|
+
value := make([]byte, size)
|
|
18
|
+
if _, err := io.ReadFull(reader, value); err != nil {
|
|
19
|
+
return "", err
|
|
20
|
+
}
|
|
21
|
+
return base64.RawURLEncoding.EncodeToString(value), nil
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func randomTempName() (string, error) {
|
|
25
|
+
value, err := randomEncoded(rand.Reader, 18)
|
|
26
|
+
if err != nil {
|
|
27
|
+
return "", err
|
|
28
|
+
}
|
|
29
|
+
return ".credential-" + value + ".tmp", nil
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func validDeviceID(deviceID string) bool {
|
|
33
|
+
decoded, err := base64.RawURLEncoding.DecodeString(deviceID)
|
|
34
|
+
return err == nil && len(decoded) == deviceIDBytes &&
|
|
35
|
+
constantTimeEqual(deviceID, base64.RawURLEncoding.EncodeToString(decoded))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func credentialScope(uid, deviceID string) string {
|
|
39
|
+
return "pippit-tool-cli:user:" + accountBinding(uid) + ":device:" + deviceID
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func accountBinding(uid string) string {
|
|
43
|
+
digest := sha256.Sum256([]byte(strings.TrimSpace(uid)))
|
|
44
|
+
return base64.RawURLEncoding.EncodeToString(digest[:16])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func accountBindingFromCredentialScope(scope, deviceID string) (string, bool) {
|
|
48
|
+
prefix := "pippit-tool-cli:user:"
|
|
49
|
+
suffix := ":device:" + deviceID
|
|
50
|
+
if !strings.HasPrefix(scope, prefix) || !strings.HasSuffix(scope, suffix) {
|
|
51
|
+
return "", false
|
|
52
|
+
}
|
|
53
|
+
binding := strings.TrimSuffix(strings.TrimPrefix(scope, prefix), suffix)
|
|
54
|
+
return binding, validAccountBinding(binding)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func legacyCredentialScope(deviceID string) string {
|
|
58
|
+
return "pippit-tool-cli:device:" + deviceID
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func constantTimeEqual(left, right string) bool {
|
|
62
|
+
leftHash := sha256.Sum256([]byte(left))
|
|
63
|
+
rightHash := sha256.Sum256([]byte(right))
|
|
64
|
+
return subtle.ConstantTimeCompare(leftHash[:], rightHash[:]) == 1
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func newIdentity(reader io.Reader) (*Credential, error) {
|
|
68
|
+
deviceID, err := randomEncoded(reader, deviceIDBytes)
|
|
69
|
+
if err != nil {
|
|
70
|
+
return nil, errors.New("生成本机登录设备标识失败")
|
|
71
|
+
}
|
|
72
|
+
return &Credential{
|
|
73
|
+
Version: credentialVersion,
|
|
74
|
+
DeviceID: deviceID,
|
|
75
|
+
}, nil
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func identityOnly(credential *Credential) *Credential {
|
|
79
|
+
if credential == nil {
|
|
80
|
+
return nil
|
|
81
|
+
}
|
|
82
|
+
return &Credential{
|
|
83
|
+
Version: credential.Version,
|
|
84
|
+
DeviceID: credential.DeviceID,
|
|
85
|
+
// TokenID is a non-secret exact selector used by the Web page to reuse the
|
|
86
|
+
// same remote token after logout instead of consuming another AK slot.
|
|
87
|
+
TokenID: credential.TokenID,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/base64"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
10
|
+
"mime"
|
|
11
|
+
"net"
|
|
12
|
+
"net/http"
|
|
13
|
+
"net/url"
|
|
14
|
+
"strings"
|
|
15
|
+
"sync"
|
|
16
|
+
"time"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const maxCallbackBodyBytes = 64 << 10
|
|
20
|
+
|
|
21
|
+
const maxInjectedCallbackQueryValueBytes = 4 << 10
|
|
22
|
+
|
|
23
|
+
type accessKeyPayload struct {
|
|
24
|
+
Type string `json:"type"`
|
|
25
|
+
AccessKey string `json:"access_key"`
|
|
26
|
+
UID string `json:"uid"`
|
|
27
|
+
TokenID string `json:"token_id"`
|
|
28
|
+
ExpiredAt int64 `json:"expired_at"`
|
|
29
|
+
RandomSecretKey string `json:"random_secret_key"`
|
|
30
|
+
Source string `json:"source"`
|
|
31
|
+
CallbackURL string `json:"callback_url"`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type browserFlow struct {
|
|
35
|
+
loginURL string
|
|
36
|
+
callbackURL string
|
|
37
|
+
secret string
|
|
38
|
+
state string
|
|
39
|
+
source string
|
|
40
|
+
origin string
|
|
41
|
+
listener net.Listener
|
|
42
|
+
server *http.Server
|
|
43
|
+
payload chan accessKeyPayload
|
|
44
|
+
serveErr chan error
|
|
45
|
+
callbackOnce sync.Once
|
|
46
|
+
closeOnce sync.Once
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func startBrowserFlow(authBaseURL *url.URL, randomReader io.Reader, deviceID, tokenID, expectedAccount string, forceRefresh bool) (*browserFlow, error) {
|
|
50
|
+
if !validDeviceID(deviceID) {
|
|
51
|
+
return nil, errors.New("本机登录设备标识无效")
|
|
52
|
+
}
|
|
53
|
+
if tokenID != "" && !validTokenID(tokenID) {
|
|
54
|
+
return nil, errors.New("本机 CLI 凭证编号无效")
|
|
55
|
+
}
|
|
56
|
+
if forceRefresh && tokenID == "" {
|
|
57
|
+
return nil, errors.New("无法确认需要轮换的本机 CLI 凭证编号")
|
|
58
|
+
}
|
|
59
|
+
if expectedAccount != "" && !validAccountBinding(expectedAccount) {
|
|
60
|
+
return nil, errors.New("本机登录账号绑定无效")
|
|
61
|
+
}
|
|
62
|
+
if forceRefresh && expectedAccount == "" {
|
|
63
|
+
return nil, errors.New("无法确认需要轮换的本机 CLI 登录账号")
|
|
64
|
+
}
|
|
65
|
+
secret, err := randomEncoded(randomReader, randomBindingBytes)
|
|
66
|
+
if err != nil {
|
|
67
|
+
return nil, errors.New("生成网页授权绑定信息失败")
|
|
68
|
+
}
|
|
69
|
+
state, err := randomEncoded(randomReader, randomBindingBytes)
|
|
70
|
+
if err != nil {
|
|
71
|
+
return nil, errors.New("生成网页授权状态失败")
|
|
72
|
+
}
|
|
73
|
+
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
|
74
|
+
if err != nil {
|
|
75
|
+
return nil, errors.New("启动本机网页授权回调失败")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
callback := &url.URL{
|
|
79
|
+
Scheme: "http",
|
|
80
|
+
Host: listener.Addr().String(),
|
|
81
|
+
Path: callbackPath,
|
|
82
|
+
}
|
|
83
|
+
callbackQuery := callback.Query()
|
|
84
|
+
callbackQuery.Set("state", state)
|
|
85
|
+
callback.RawQuery = callbackQuery.Encode()
|
|
86
|
+
|
|
87
|
+
loginURL := *authBaseURL
|
|
88
|
+
loginURL.Path = loginPagePath
|
|
89
|
+
loginURL.RawPath = ""
|
|
90
|
+
loginURL.RawQuery = ""
|
|
91
|
+
loginURL.Fragment = ""
|
|
92
|
+
query := loginURL.Query()
|
|
93
|
+
query.Set("callback", callback.String())
|
|
94
|
+
query.Set("random_secret_key", secret)
|
|
95
|
+
query.Set("source", loginSource)
|
|
96
|
+
query.Set("device_id", deviceID)
|
|
97
|
+
if tokenID != "" {
|
|
98
|
+
query.Set("token_id", tokenID)
|
|
99
|
+
}
|
|
100
|
+
if expectedAccount != "" {
|
|
101
|
+
query.Set("expected_account", expectedAccount)
|
|
102
|
+
}
|
|
103
|
+
if forceRefresh {
|
|
104
|
+
query.Set("force", "1")
|
|
105
|
+
}
|
|
106
|
+
loginURL.RawQuery = query.Encode()
|
|
107
|
+
|
|
108
|
+
flow := &browserFlow{
|
|
109
|
+
loginURL: loginURL.String(),
|
|
110
|
+
callbackURL: callback.String(),
|
|
111
|
+
secret: secret,
|
|
112
|
+
state: state,
|
|
113
|
+
source: loginSource,
|
|
114
|
+
origin: originOf(authBaseURL),
|
|
115
|
+
listener: listener,
|
|
116
|
+
payload: make(chan accessKeyPayload, 1),
|
|
117
|
+
serveErr: make(chan error, 1),
|
|
118
|
+
}
|
|
119
|
+
mux := http.NewServeMux()
|
|
120
|
+
mux.HandleFunc(callbackPath, flow.handleCallback)
|
|
121
|
+
flow.server = &http.Server{
|
|
122
|
+
Handler: mux,
|
|
123
|
+
ReadHeaderTimeout: 5 * time.Second,
|
|
124
|
+
ReadTimeout: 10 * time.Second,
|
|
125
|
+
WriteTimeout: 10 * time.Second,
|
|
126
|
+
IdleTimeout: 15 * time.Second,
|
|
127
|
+
}
|
|
128
|
+
go func() {
|
|
129
|
+
err := flow.server.Serve(listener)
|
|
130
|
+
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
131
|
+
flow.serveErr <- err
|
|
132
|
+
}
|
|
133
|
+
close(flow.serveErr)
|
|
134
|
+
}()
|
|
135
|
+
return flow, nil
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func (f *browserFlow) wait(ctx context.Context) (accessKeyPayload, error) {
|
|
139
|
+
select {
|
|
140
|
+
case payload := <-f.payload:
|
|
141
|
+
return payload, nil
|
|
142
|
+
case err, open := <-f.serveErr:
|
|
143
|
+
if open && err != nil {
|
|
144
|
+
return accessKeyPayload{}, errors.New("本机网页授权回调异常退出")
|
|
145
|
+
}
|
|
146
|
+
return accessKeyPayload{}, errors.New("本机网页授权回调已关闭")
|
|
147
|
+
case <-ctx.Done():
|
|
148
|
+
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
|
149
|
+
return accessKeyPayload{}, errors.New("等待网页授权超时,请重新登录")
|
|
150
|
+
}
|
|
151
|
+
return accessKeyPayload{}, ctx.Err()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func (f *browserFlow) close() {
|
|
156
|
+
f.closeOnce.Do(func() {
|
|
157
|
+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
158
|
+
defer cancel()
|
|
159
|
+
_ = f.server.Shutdown(ctx)
|
|
160
|
+
_ = f.listener.Close()
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func (f *browserFlow) handleCallback(writer http.ResponseWriter, request *http.Request) {
|
|
165
|
+
if !f.validRequestTarget(request) {
|
|
166
|
+
http.Error(writer, "invalid callback target", http.StatusBadRequest)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
if !constantTimeEqual(request.Header.Get("Origin"), f.origin) {
|
|
170
|
+
http.Error(writer, "origin not allowed", http.StatusForbidden)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
f.setCORSHeaders(writer.Header())
|
|
174
|
+
|
|
175
|
+
if request.Method == http.MethodOptions {
|
|
176
|
+
if !strings.EqualFold(strings.TrimSpace(request.Header.Get("Access-Control-Request-Method")), http.MethodPost) ||
|
|
177
|
+
!allowsContentTypeHeader(request.Header.Get("Access-Control-Request-Headers")) {
|
|
178
|
+
http.Error(writer, "invalid preflight", http.StatusBadRequest)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
writer.WriteHeader(http.StatusNoContent)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
if request.Method != http.MethodPost {
|
|
185
|
+
writer.Header().Set("Allow", "OPTIONS, POST")
|
|
186
|
+
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
mediaType, _, err := mime.ParseMediaType(request.Header.Get("Content-Type"))
|
|
190
|
+
if err != nil || !strings.EqualFold(mediaType, "application/json") {
|
|
191
|
+
http.Error(writer, "content type must be application/json", http.StatusUnsupportedMediaType)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
reader := http.MaxBytesReader(writer, request.Body, maxCallbackBodyBytes)
|
|
196
|
+
decoder := json.NewDecoder(reader)
|
|
197
|
+
decoder.DisallowUnknownFields()
|
|
198
|
+
payload := accessKeyPayload{}
|
|
199
|
+
if err := decoder.Decode(&payload); err != nil {
|
|
200
|
+
http.Error(writer, "invalid callback payload", http.StatusBadRequest)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
if err := ensureJSONEOF(decoder); err != nil {
|
|
204
|
+
http.Error(writer, "invalid callback payload", http.StatusBadRequest)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
if payload.Type != "access_key" || !validCallbackValue(payload.AccessKey, 4096) ||
|
|
208
|
+
!validCallbackValue(payload.UID, 256) || !validTokenID(payload.TokenID) || payload.ExpiredAt <= 0 ||
|
|
209
|
+
!constantTimeEqual(payload.RandomSecretKey, f.secret) ||
|
|
210
|
+
!constantTimeEqual(payload.Source, f.source) ||
|
|
211
|
+
!constantTimeEqual(payload.CallbackURL, f.callbackURL) {
|
|
212
|
+
http.Error(writer, "callback binding mismatch", http.StatusBadRequest)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
accepted := false
|
|
216
|
+
f.callbackOnce.Do(func() {
|
|
217
|
+
f.payload <- payload
|
|
218
|
+
accepted = true
|
|
219
|
+
})
|
|
220
|
+
if accepted {
|
|
221
|
+
writer.Header().Set("Content-Type", "application/json")
|
|
222
|
+
writer.WriteHeader(http.StatusOK)
|
|
223
|
+
_, _ = writer.Write([]byte(`{"ok":true}`))
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
http.Error(writer, "callback already received", http.StatusConflict)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func validCallbackValue(value string, maxLength int) bool {
|
|
230
|
+
return value != "" && len(value) <= maxLength && strings.TrimSpace(value) == value
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
func validTokenID(value string) bool {
|
|
234
|
+
if len(value) == 0 || len(value) > 128 {
|
|
235
|
+
return false
|
|
236
|
+
}
|
|
237
|
+
for index := range len(value) {
|
|
238
|
+
character := value[index]
|
|
239
|
+
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
|
|
240
|
+
(character >= '0' && character <= '9') || strings.ContainsRune(":._-", rune(character)) {
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
return false
|
|
244
|
+
}
|
|
245
|
+
return true
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
func validAccountBinding(value string) bool {
|
|
249
|
+
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
|
250
|
+
return err == nil && len(decoded) == 16 && len(value) == 22 &&
|
|
251
|
+
constantTimeEqual(value, base64.RawURLEncoding.EncodeToString(decoded))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
func (f *browserFlow) validRequestTarget(request *http.Request) bool {
|
|
255
|
+
if request.URL.Path != callbackPath || !constantTimeEqual(request.Host, strings.TrimPrefix(f.callbackURLHost(), "//")) {
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
query := request.URL.Query()
|
|
259
|
+
states, ok := query["state"]
|
|
260
|
+
if !ok || len(states) != 1 || !constantTimeEqual(states[0], f.state) {
|
|
261
|
+
return false
|
|
262
|
+
}
|
|
263
|
+
for key, values := range query {
|
|
264
|
+
if key == "state" {
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
// The site's security runtime appends these transport-only query
|
|
268
|
+
// parameters to cross-origin requests. They are not part of the
|
|
269
|
+
// callback binding: state, Origin, callback_url, and the body secret
|
|
270
|
+
// remain exact and independently verified below.
|
|
271
|
+
if key != "a_bogus" && key != "msToken" || len(values) != 1 ||
|
|
272
|
+
!validInjectedCallbackQueryValue(values[0]) {
|
|
273
|
+
return false
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return true
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func validInjectedCallbackQueryValue(value string) bool {
|
|
280
|
+
return value != "" && len(value) <= maxInjectedCallbackQueryValueBytes && strings.TrimSpace(value) == value
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
func (f *browserFlow) callbackURLHost() string {
|
|
284
|
+
parsed, err := url.Parse(f.callbackURL)
|
|
285
|
+
if err != nil {
|
|
286
|
+
return ""
|
|
287
|
+
}
|
|
288
|
+
return parsed.Host
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
func (f *browserFlow) setCORSHeaders(header http.Header) {
|
|
292
|
+
header.Set("Access-Control-Allow-Origin", f.origin)
|
|
293
|
+
header.Set("Access-Control-Allow-Methods", "POST")
|
|
294
|
+
header.Set("Access-Control-Allow-Headers", "Content-Type")
|
|
295
|
+
header.Set("Access-Control-Allow-Private-Network", "true")
|
|
296
|
+
header.Add("Vary", "Origin")
|
|
297
|
+
header.Add("Vary", "Access-Control-Request-Method")
|
|
298
|
+
header.Add("Vary", "Access-Control-Request-Headers")
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
func allowsContentTypeHeader(value string) bool {
|
|
302
|
+
for _, part := range strings.Split(value, ",") {
|
|
303
|
+
if strings.EqualFold(strings.TrimSpace(part), "content-type") {
|
|
304
|
+
return true
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return false
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
func ensureJSONEOF(decoder *json.Decoder) error {
|
|
311
|
+
var trailing any
|
|
312
|
+
err := decoder.Decode(&trailing)
|
|
313
|
+
if errors.Is(err, io.EOF) {
|
|
314
|
+
return nil
|
|
315
|
+
}
|
|
316
|
+
if err == nil {
|
|
317
|
+
return errors.New("unexpected trailing JSON")
|
|
318
|
+
}
|
|
319
|
+
return err
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
func originOf(value *url.URL) string {
|
|
323
|
+
return fmt.Sprintf("%s://%s", strings.ToLower(value.Scheme), value.Host)
|
|
324
|
+
}
|