147 lines
4.6 KiB
Go
147 lines
4.6 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package apiclient
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// AllowedHosts is the set of trusted DingTalk API hosts.
|
|
// Only these hosts may receive access tokens to prevent token leakage.
|
|
var AllowedHosts = map[string]bool{
|
|
"api.dingtalk.com": true,
|
|
"oapi.dingtalk.com": true,
|
|
}
|
|
|
|
// ValidateTargetHost checks that the resolved request URL targets a trusted
|
|
// DingTalk host. This prevents access-token leakage to arbitrary domains.
|
|
func ValidateTargetHost(fullURL string) error {
|
|
parsed, err := url.Parse(fullURL)
|
|
if err != nil {
|
|
return fmt.Errorf("无法解析请求 URL: %w", err)
|
|
}
|
|
host := strings.ToLower(parsed.Hostname())
|
|
if !AllowedHosts[host] {
|
|
return fmt.Errorf(
|
|
"安全限制: 目标域名 %q 不在允许列表中。\n"+
|
|
"dws api 仅允许向以下域名发起请求:\n"+
|
|
" - api.dingtalk.com (新版 API)\n"+
|
|
" - oapi.dingtalk.com (旧版 API)\n"+
|
|
"请检查 URL 或 --base-url 参数是否正确。",
|
|
host,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateMethod checks that the HTTP method is one of the five allowed methods.
|
|
func ValidateMethod(method string) (string, error) {
|
|
upper := strings.ToUpper(strings.TrimSpace(method))
|
|
if !AllowedMethods[upper] {
|
|
return "", fmt.Errorf("不支持的 HTTP 方法: %s (允许: GET, POST, PUT, PATCH, DELETE)", method)
|
|
}
|
|
return upper, nil
|
|
}
|
|
|
|
// ValidatePath checks the API path for injection attacks and dangerous characters.
|
|
func ValidatePath(path string) error {
|
|
if strings.TrimSpace(path) == "" {
|
|
return fmt.Errorf("API 路径不能为空")
|
|
}
|
|
if err := rejectDangerousChars(path, "path"); err != nil {
|
|
return err
|
|
}
|
|
// Reject path traversal
|
|
if strings.Contains(path, "..") {
|
|
return fmt.Errorf("API 路径不能包含 '..' (路径遍历)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateUserInput checks a user-provided string for control characters and
|
|
// dangerous Unicode codepoints that could enable injection attacks.
|
|
func ValidateUserInput(value, fieldName string) error {
|
|
return rejectDangerousChars(value, fieldName)
|
|
}
|
|
|
|
// rejectDangerousChars rejects C0 control characters (except \t and \n),
|
|
// DEL (0x7F), and dangerous Unicode codepoints in a string.
|
|
func rejectDangerousChars(s, fieldName string) error {
|
|
for i, r := range s {
|
|
// Allow tab and newline
|
|
if r == '\t' || r == '\n' {
|
|
continue
|
|
}
|
|
// Reject C0 control chars (0x00-0x1F) and DEL (0x7F)
|
|
if r < 0x20 || r == 0x7F {
|
|
return fmt.Errorf("%s 包含非法控制字符 (位置 %d, U+%04X)", fieldName, i, r)
|
|
}
|
|
// Reject dangerous Unicode
|
|
if isDangerousUnicode(r) {
|
|
return fmt.Errorf("%s 包含危险 Unicode 字符 (位置 %d, U+%04X)", fieldName, i, r)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isDangerousUnicode returns true for Unicode codepoints that can be used
|
|
// for visual spoofing or terminal injection attacks.
|
|
func isDangerousUnicode(r rune) bool {
|
|
switch {
|
|
// Zero-width characters
|
|
case r >= 0x200B && r <= 0x200D:
|
|
return true
|
|
// BOM
|
|
case r == 0xFEFF:
|
|
return true
|
|
// Bidi override characters
|
|
case r >= 0x202A && r <= 0x202E:
|
|
return true
|
|
// Line/paragraph separator
|
|
case r == 0x2028 || r == 0x2029:
|
|
return true
|
|
// Bidi isolate characters
|
|
case r >= 0x2066 && r <= 0x2069:
|
|
return true
|
|
// Additional Bidi controls
|
|
case r == 0x061C:
|
|
return true
|
|
// Non-characters
|
|
case r >= 0xFDD0 && r <= 0xFDEF:
|
|
return true
|
|
}
|
|
// Object replacement (U+FFFC) / replacement (U+FFFD) characters and
|
|
// other non-printable non-ASCII runes (e.g. CJK, symbols) are allowed
|
|
// through — only the explicit dangerous ranges above are blocked.
|
|
return false
|
|
}
|
|
|
|
// ValidateStdinExclusion checks that --params and --data don't both read from stdin.
|
|
func ValidateStdinExclusion(params, data string) error {
|
|
if strings.TrimSpace(params) == "-" && strings.TrimSpace(data) == "-" {
|
|
return fmt.Errorf("--params 和 --data 不能同时从 stdin 读取 (-)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateFlagExclusion checks mutual exclusion between flags.
|
|
func ValidateFlagExclusion(outputPath string, pageAll bool) error {
|
|
if strings.TrimSpace(outputPath) != "" && pageAll {
|
|
return fmt.Errorf("--output 和 --page-all 不能同时使用")
|
|
}
|
|
return nil
|
|
}
|