package cos import ( "errors" "fmt" "strings" ) // ============================================================================= // STS 临时凭证 policy 拼装与 STSScope 校验 // // 本文件是 IssueSTS 的纯函数依赖层:把业务侧描述的 STSScope 转换为腾讯云 STS // v2.0 policy 文档(policyDoc),同时完成最小权限原则下的入参强校验。 // // 设计要点: // - 不引入 qcloud-cos-sts-sdk 依赖,输出自定义中间结构 policyDoc, // 由 sts.go 在调用 SDK 前一次性映射到 *sts.CredentialPolicy; // - 全部为纯函数,无 goroutine、无 IO、无锁,可被多个 goroutine 并发调用; // - 校验失败立即返回哨兵错误,禁止做「宽容兜底」。 // ============================================================================= // 哨兵错误:STSScope 校验与 IssueSTS 上下文校验。 // // 集中在本文件维护,便于排障时按错误码一站式定位触发场景。 var ( // ErrSTSScopeActionEmpty 触发场景:STSScope.Action 为空切片或全部元素为空字符串。 ErrSTSScopeActionEmpty = errors.New("sts scope: action is empty") // ErrSTSScopeActionTooMany 触发场景:去重后 STSScope.Action 长度 > stsActionMaxCount(20)。 ErrSTSScopeActionTooMany = errors.New("sts scope: action exceeds 20 entries") // ErrSTSScopeActionInvalid 触发场景:单条 Action 不以 "name/cos:" 或 "name/sts:" 开头、 // 包含通配符 "*"、含空白字符(空格 / 制表符 / 换行符)或其它控制字符。 ErrSTSScopeActionInvalid = errors.New("sts scope: action invalid (must start with name/cos: or name/sts:, no wildcard, no whitespace)") // ErrSTSScopeKeyMissing 触发场景:STSScope.Key 与 STSScope.KeyPrefix 同时为空。 ErrSTSScopeKeyMissing = errors.New("sts scope: key and key_prefix are both empty") // ErrSTSScopeKeyInvalid 触发场景:Key / KeyPrefix 含 ".."、"*",或 KeyPrefix 等于 // 单一 "/"(等价于全桶授权,被禁用)。 ErrSTSScopeKeyInvalid = errors.New("sts scope: key/key_prefix invalid (forbid '..', '*', single '/')") // ErrSTSAppIDMissing 触发场景:IssueSTS 阶段 resolvedInstance.STSAppID 为空, // 且无法从 BucketURL 推断(桶名末段非纯数字,或未配置 BucketURL)。 ErrSTSAppIDMissing = errors.New("sts: app_id is empty (configure sts_app_id or use bucket_url containing -{appid})") // ErrSTSRegionMissing 触发场景:IssueSTS 阶段 resolvedInstance.STSRegion 为空, // 且无法从 BucketURL 推断(host 不符合 *.cos.{region}.myqcloud.com 模式)。 ErrSTSRegionMissing = errors.New("sts: region is empty (configure sts_region or use a bucket_url containing region)") // ErrSTSBucketMissing 触发场景:IssueSTS 阶段 resolvedInstance.Bucket 为空, // 且无法从 BucketURL 推断。多用于配置错误(BucketURL 缺失的 service-only 实例)。 ErrSTSBucketMissing = errors.New("sts: bucket is empty (configure bucket_url to enable sts)") ) // 内置常量。 const ( // stsActionPrefixCOS / stsActionPrefixSTS 是 CAM Action 字面量唯一允许的前缀。 stsActionPrefixCOS = "name/cos:" stsActionPrefixSTS = "name/sts:" // stsMinDurationSecs / stsMaxDurationSecs 限定 Scope.DurationSecs 的合法区间。 // 越界时由 validateScope 截断到本区间,并通过 durationClamped 标志告知调用方需要打 Warn。 stsMinDurationSecs = 60 stsMaxDurationSecs = 7200 // stsActionMaxCount 单次签发允许携带的 Action 上限(去重后)。 stsActionMaxCount = 20 // stsSafetyPaddingSecs IssueSTS 返回前从 ExpiredTime 中扣除的安全垫秒数, // 用于抵御本地时钟与腾讯云 STS 服务时钟之间的漂移。 stsSafetyPaddingSecs = 30 ) // policyDoc 与 policyStatement 是腾讯云 STS v2.0 policy 的本地中间表示。 // // 字段命名严格对齐腾讯云协议: // // { // "version": "2.0", // "statement": [{ "effect": "allow", "action": [...], "resource": [...], "condition": {...} }] // } // // 不导出本结构是为了避免成为 cos 包的对外契约:腾讯云协议演进时只需调整 policy.go // 与 sts.go 之间的内部映射,不影响 IssueSTS 等公开 API。 type policyDoc struct { Version string `json:"version"` Statement []policyStatement `json:"statement"` } // policyStatement 与 policyDoc 一同构成 v2.0 policy 文档;condition 仅在非空时输出。 type policyStatement struct { Effect string `json:"effect"` Action []string `json:"action"` Resource []string `json:"resource"` Condition map[string]map[string]interface{} `json:"condition,omitempty"` } // validateScope 校验 STSScope 并返回规整后的副本。 // // 行为: // - Action:去重保序、强校验前缀 / 通配符 / 空白字符; // - Key/KeyPrefix:互斥但至少一个非空,禁用 ".."、"*"、单 "/"、空字符串; // - DurationSecs:0 时使用 fallbackDuration;非 0 时越界则截断到 [60, 7200], // 并通过 durationClamped=true 告知调用方需要按你 ② 规约打 Warn 日志。 // // 返回值: // - normalized:可安全用于 buildPolicy 的规整副本(不修改入参); // - dedupedCount:去重前后的条数差,> 0 时调用方可选打 Debug 日志; // - durationClamped:DurationSecs 是否被截断(true 时调用方必须打 Warn)。 func validateScope(scope STSScope, fallbackDuration int) (normalized STSScope, dedupedCount int, durationClamped bool, err error) { // 1. Action 校验 + 去重 if len(scope.Action) == 0 { return STSScope{}, 0, false, ErrSTSScopeActionEmpty } seen := make(map[string]struct{}, len(scope.Action)) dedupActions := make([]string, 0, len(scope.Action)) for _, a := range scope.Action { if !isValidAction(a) { // 单独区分 "空字符串" 与 "格式错误":空字符串归类为 ActionEmpty, // 让上游能区分「忘了填」与「填错了」两种诊断路径。 if a == "" { return STSScope{}, 0, false, ErrSTSScopeActionEmpty } return STSScope{}, 0, false, fmt.Errorf("%w: %q", ErrSTSScopeActionInvalid, a) } if _, dup := seen[a]; dup { continue } seen[a] = struct{}{} dedupActions = append(dedupActions, a) } if len(dedupActions) == 0 { return STSScope{}, 0, false, ErrSTSScopeActionEmpty } if len(dedupActions) > stsActionMaxCount { return STSScope{}, 0, false, fmt.Errorf("%w: got %d", ErrSTSScopeActionTooMany, len(dedupActions)) } // 2. Key / KeyPrefix 校验 hasKey := scope.Key != "" hasPrefix := scope.KeyPrefix != "" if !hasKey && !hasPrefix { return STSScope{}, 0, false, ErrSTSScopeKeyMissing } if hasKey { if !isValidKeyOrPrefix(scope.Key, false) { return STSScope{}, 0, false, fmt.Errorf("%w: key=%q", ErrSTSScopeKeyInvalid, scope.Key) } } if hasPrefix { if !isValidKeyOrPrefix(scope.KeyPrefix, true) { return STSScope{}, 0, false, fmt.Errorf("%w: key_prefix=%q", ErrSTSScopeKeyInvalid, scope.KeyPrefix) } } // 3. DurationSecs 处理:0 → fallback;非 0 越界 → 截断 dur := scope.DurationSecs switch { case dur == 0: dur = fallbackDuration case dur < stsMinDurationSecs: dur = stsMinDurationSecs durationClamped = true case dur > stsMaxDurationSecs: dur = stsMaxDurationSecs durationClamped = true } // fallback 自身越界时再做一次保护性截断(极少数错误配置场景) if dur < stsMinDurationSecs { dur = stsMinDurationSecs } else if dur > stsMaxDurationSecs { dur = stsMaxDurationSecs } dedupedCount = len(scope.Action) - len(dedupActions) // 4. 复制副本,避免对入参做任何写操作(保持函数纯净) normalized = STSScope{ Action: dedupActions, Key: scope.Key, KeyPrefix: scope.KeyPrefix, DurationSecs: dur, MaxObjectSize: scope.MaxObjectSize, ContentType: scope.ContentType, SourceIP: append([]string(nil), scope.SourceIP...), } return normalized, dedupedCount, durationClamped, nil } // isValidAction 判断单条 Action 字面量是否合法。 // // 规则: // - 必须以 "name/cos:" 或 "name/sts:" 开头; // - 不允许包含 "*"(即使 "name/cos:Get*" 这种带前缀的通配也禁止); // - 不允许包含空白字符(空格 / 制表符 / 换行 / 回车)与控制字符。 func isValidAction(a string) bool { if a == "" { return false } if !strings.HasPrefix(a, stsActionPrefixCOS) && !strings.HasPrefix(a, stsActionPrefixSTS) { return false } if strings.ContainsRune(a, '*') { return false } for i := 0; i < len(a); i++ { // 拒绝所有 ASCII 控制字符(含 \t \n \r)以及裸空格 if a[i] <= 0x20 || a[i] == 0x7F { return false } } return true } // isValidKeyOrPrefix 判断 Key / KeyPrefix 是否合法。 // // 共同规则: // - 不允许空字符串、不允许包含 ".."(防止路径穿越)、不允许包含 "*"(避免提权)。 // // 仅 KeyPrefix 额外规则: // - 不允许等于单一 "/"(等价于全桶授权,违反最小权限原则)。 // // 参数 isPrefix=true 时按 KeyPrefix 语义校验。 func isValidKeyOrPrefix(s string, isPrefix bool) bool { if s == "" { return false } if strings.Contains(s, "..") { return false } if strings.Contains(s, "*") { return false } if isPrefix && s == "/" { return false } return true } // buildResources 根据 scope 与上下文(region/appid/bucket)拼接 COS 资源 ARN 列表。 // // 资源模板:qcs::cos:{region}:uid/{appid}:{bucket}/{key|prefix*} // - Key 模式:原样拼接(精确匹配单对象); // - KeyPrefix 模式:自动追加 "*"(COS 资源 ARN 通配语法),实现前缀授权。 // // region / appID / bucket 任一为空时返回对应哨兵错误:调用方据此可立即判定是 // 「配置缺失」还是「scope 自身错误」,方便分级排障。 func buildResources(scope STSScope, region, appID, bucket string) ([]string, error) { if region == "" { return nil, ErrSTSRegionMissing } if appID == "" { return nil, ErrSTSAppIDMissing } if bucket == "" { return nil, ErrSTSBucketMissing } prefix := fmt.Sprintf("qcs::cos:%s:uid/%s:%s", region, appID, bucket) resources := make([]string, 0, 1) switch { case scope.Key != "": // COS ARN 路径分隔符固定使用 "/",与对象 key 自身的前导斜杠归一处理: // 若 key 以 "/" 开头则去掉,避免出现 "bucket//foo" 双斜杠 key := strings.TrimPrefix(scope.Key, "/") resources = append(resources, prefix+"/"+key) case scope.KeyPrefix != "": p := strings.TrimPrefix(scope.KeyPrefix, "/") // 末尾必须 "*":腾讯云 ARN 协议要求显式通配 if !strings.HasSuffix(p, "*") { p += "*" } resources = append(resources, prefix+"/"+p) } return resources, nil } // buildPolicy 把规整后的 STSScope 转换成 v2.0 policy 文档。 // // 调用方典型流程: // // normalized, _, _, err := validateScope(scope, defaultDur) // if err != nil { ... } // doc, err := buildPolicy(normalized, region, appID, bucket) // // 注意 condition 仅在对应字段非零时输出(buildConditions),避免向 STS 服务发送 // 空 condition 影响调试。 func buildPolicy(scope STSScope, region, appID, bucket string) (*policyDoc, error) { resources, err := buildResources(scope, region, appID, bucket) if err != nil { return nil, err } stmt := policyStatement{ Effect: "allow", Action: append([]string(nil), scope.Action...), Resource: resources, } if cond := buildConditions(scope); len(cond) > 0 { stmt.Condition = cond } return &policyDoc{ Version: "2.0", Statement: []policyStatement{stmt}, }, nil } // buildConditions 仅在对应字段非零时输出 condition;全为零则返回 nil。 // // 字段映射(与腾讯云 v2.0 协议字面量保持一致,禁止改写大小写): // - MaxObjectSize → numeric_less_than_equal["cos:content-length"] // - ContentType → string_equal_if_exist["cos:content-type"] // - SourceIP → ip_equal["qcs:ip"](数组形式,对应 CIDR 列表) func buildConditions(scope STSScope) map[string]map[string]interface{} { cond := make(map[string]map[string]interface{}, 3) if scope.MaxObjectSize > 0 { cond["numeric_less_than_equal"] = map[string]interface{}{ "cos:content-length": scope.MaxObjectSize, } } if scope.ContentType != "" { cond["string_equal_if_exist"] = map[string]interface{}{ "cos:content-type": scope.ContentType, } } if len(scope.SourceIP) > 0 { cond["ip_equal"] = map[string]interface{}{ "qcs:ip": append([]string(nil), scope.SourceIP...), } } if len(cond) == 0 { return nil } return cond } // scopeLogKind 返回 "key" / "prefix",用于 IssueSTS 的结构化日志 scope_key_kind 字段。 // // 仅当 Key / KeyPrefix 至少一个非空时调用本函数;两者都空属于校验阶段就会被拦截 // 的情形(ErrSTSScopeKeyMissing),不会到达本函数。 func scopeLogKind(scope STSScope) string { if scope.Key != "" { return "key" } return "prefix" }