中继答 504 并说"可能已经执行了,重试前请先确认"——这句话诚实的前提是它保持为真。 批次是串行处理的,所以满邮箱里第八个信封可以在它被签名之后 8 个上游超时才轮到; 那时手机早已被告知失败、用户可能已经重试。再执行它,就把一次诚实的"未知"变成了 一条重复发出的短信。 现在 agent 在动任何东西之前先比对自己的截止时间(策略里的 requestTtlSeconds), 超龄一律以 expired 拒绝并落审计。判定放在新鲜度检查之后、策略判定之前:它比签名 更便宜,而且一个过期请求无论路径是否在白名单里都不该执行。 为了让这条规则**可达**,策略新增一条断言:TTL 不得大于时钟偏移窗口。否则新鲜度 检查会先触发,这条规则永远不会被执行——而"永不触发的规则"是出事时才会被发现坏了 的那种东西。 单个上游调用的超时也移进了策略(agentUpstreamTimeoutSeconds)。"中继期限必须长于 一次上游调用"这个比较,与二进制里的常量是比不了的;现在两侧从同一个文件读,策略 加载时就能拒绝一组自相矛盾的数值。原来那个 20s 常量保留为默认值的来源,不再硬编码。 测试两项:一个请求在期限内被转发、一个超龄请求被拒且**没有被转发**(计数按路径, 否则首次转发前的 /api/auth/status 探测会让断言以错误的理由通过);另一项断言策略 本身满足上述两个关系。变异验证:关掉判定,第一项失败。 Go 全包、Node 119 项、Swift 101 项通过。
251 lines
9.3 KiB
Go
251 lines
9.3 KiB
Go
// Package policy loads and enforces the agent authorisation surface.
|
|
//
|
|
// The policy is embedded rather than read from disk: a security boundary that lives in an editable
|
|
// file can be silently widened on one host, and "that box was configured differently" is the kind
|
|
// of finding that only surfaces after an incident. The build vendors contracts/agent-policy.json
|
|
// into this directory, and TestVendoredCopyMatchesContracts fails if the vendored copy drifts.
|
|
package policy
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"regexp"
|
|
"strings"
|
|
|
|
_ "embed"
|
|
)
|
|
|
|
// The directive must sit immediately above the variable; a blank line here silently detaches it.
|
|
// Vendoring is a make step so the copy under this directory is never hand-edited.
|
|
//
|
|
//go:embed agent-policy.json
|
|
var embedded []byte
|
|
|
|
type Egress struct {
|
|
Mode string `json:"mode"`
|
|
PortOnly bool `json:"portOnly"`
|
|
DialedHosts []string `json:"dialedHosts"`
|
|
}
|
|
|
|
type AuthPaths struct {
|
|
Pattern string `json:"pattern"`
|
|
Proxied bool `json:"proxied"`
|
|
}
|
|
|
|
type Limits struct {
|
|
DangerousWritesPerMinute int `json:"dangerousWritesPerMinute"`
|
|
// AgentUpstreamTimeoutSeconds is how long one loopback call to SimAdmin may take. It lives in the
|
|
// policy rather than only in the agent's code because the relay's deadline has to be compared
|
|
// against it, and a comparison between a policy file and a constant in a binary is not one
|
|
// anything can check.
|
|
AgentUpstreamTimeoutSeconds int `json:"agentUpstreamTimeoutSeconds"`
|
|
MaxRequestBytes int `json:"maxRequestBytes"`
|
|
MaxResponseBytes int `json:"maxResponseBytes"`
|
|
RequestTTLSeconds int `json:"requestTtlSeconds"`
|
|
ClockSkewSeconds int `json:"clockSkewSeconds"`
|
|
ReplayCacheSize int `json:"replayCacheSize"`
|
|
MailboxDepth int `json:"mailboxDepth"`
|
|
}
|
|
|
|
type Policy struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Name string `json:"name"`
|
|
Egress Egress `json:"egress"`
|
|
AuthPaths AuthPaths `json:"authPaths"`
|
|
Read []string `json:"read"`
|
|
Write map[string][]string `json:"write"`
|
|
Dangerous []string `json:"dangerous"`
|
|
Limits Limits `json:"limits"`
|
|
|
|
authRe *regexp.Regexp
|
|
read map[string]bool
|
|
write map[string]map[string]bool
|
|
dangerous map[string]bool
|
|
digest string
|
|
}
|
|
|
|
// Load returns the embedded policy. It never falls back to a permissive default: if the embedded
|
|
// bytes fail validation the agent must not start.
|
|
func Load() (*Policy, error) { return Parse(embedded) }
|
|
|
|
func Parse(raw []byte) (*Policy, error) {
|
|
var p Policy
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return nil, fmt.Errorf("policy: decode: %w", err)
|
|
}
|
|
if p.SchemaVersion != 1 {
|
|
return nil, fmt.Errorf("policy: unsupported schemaVersion %d", p.SchemaVersion)
|
|
}
|
|
if p.Egress.Mode != "loopback-only" || !p.Egress.PortOnly {
|
|
return nil, errors.New("policy: egress must be loopback-only and port-only")
|
|
}
|
|
if len(p.Egress.DialedHosts) == 0 {
|
|
return nil, errors.New("policy: egress.dialedHosts is empty")
|
|
}
|
|
for _, host := range p.Egress.DialedHosts {
|
|
ip := net.ParseIP(host)
|
|
if ip == nil || !ip.IsLoopback() {
|
|
return nil, fmt.Errorf("policy: egress.dialedHosts entry %q is not a literal loopback address", host)
|
|
}
|
|
}
|
|
if p.AuthPaths.Proxied {
|
|
return nil, errors.New("policy: authPaths must not be proxied")
|
|
}
|
|
re, err := regexp.Compile(p.AuthPaths.Pattern)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("policy: authPaths.pattern: %w", err)
|
|
}
|
|
p.authRe = re
|
|
|
|
p.read = make(map[string]bool, len(p.Read))
|
|
for _, path := range p.Read {
|
|
if err := p.checkPath(path); err != nil {
|
|
return nil, err
|
|
}
|
|
if re.MatchString(path) {
|
|
return nil, fmt.Errorf("policy: read path %q matches the auth pattern", path)
|
|
}
|
|
if p.read[path] {
|
|
return nil, fmt.Errorf("policy: duplicate read path %q", path)
|
|
}
|
|
p.read[path] = true
|
|
}
|
|
|
|
p.write = make(map[string]map[string]bool, len(p.Write))
|
|
p.dangerous = make(map[string]bool, len(p.Dangerous))
|
|
for _, path := range p.Dangerous {
|
|
p.dangerous[path] = true
|
|
}
|
|
for path, methods := range p.Write {
|
|
if err := p.checkPath(path); err != nil {
|
|
return nil, err
|
|
}
|
|
if re.MatchString(path) {
|
|
return nil, fmt.Errorf("policy: write path %q matches the auth pattern", path)
|
|
}
|
|
if !p.dangerous[path] {
|
|
return nil, fmt.Errorf("policy: write path %q is not declared dangerous, so it would run without a confirmation intent", path)
|
|
}
|
|
set := make(map[string]bool, len(methods))
|
|
for _, method := range methods {
|
|
if method == "GET" || method == "HEAD" {
|
|
return nil, fmt.Errorf("policy: write path %q must not allow %s", path, method)
|
|
}
|
|
if set[method] {
|
|
return nil, fmt.Errorf("policy: write path %q duplicates method %s", path, method)
|
|
}
|
|
set[method] = true
|
|
}
|
|
if len(set) == 0 {
|
|
return nil, fmt.Errorf("policy: write path %q has no methods", path)
|
|
}
|
|
p.write[path] = set
|
|
}
|
|
for path := range p.dangerous {
|
|
if _, ok := p.write[path]; !ok {
|
|
return nil, fmt.Errorf("policy: %q is declared dangerous but is not writable", path)
|
|
}
|
|
}
|
|
|
|
for name, value := range map[string]int{
|
|
"dangerousWritesPerMinute": p.Limits.DangerousWritesPerMinute,
|
|
"maxRequestBytes": p.Limits.MaxRequestBytes,
|
|
"maxResponseBytes": p.Limits.MaxResponseBytes,
|
|
"requestTtlSeconds": p.Limits.RequestTTLSeconds,
|
|
"agentUpstreamTimeoutSeconds": p.Limits.AgentUpstreamTimeoutSeconds,
|
|
"clockSkewSeconds": p.Limits.ClockSkewSeconds,
|
|
"replayCacheSize": p.Limits.ReplayCacheSize,
|
|
"mailboxDepth": p.Limits.MailboxDepth,
|
|
} {
|
|
if value <= 0 {
|
|
return nil, fmt.Errorf("policy: limits.%s must be positive, got %d", name, value)
|
|
}
|
|
}
|
|
if p.Limits.ClockSkewSeconds > 300 {
|
|
return nil, errors.New("policy: clockSkewSeconds is too wide to bound replay")
|
|
}
|
|
// The relay's deadline has to sit inside the clock-skew window, and the reason is not tidiness.
|
|
//
|
|
// The freshness check above rejects an envelope whose timestamp is further from now than
|
|
// clockSkewSeconds. The agent's own deadline check rejects one older than requestTtlSeconds. If the
|
|
// TTL were the larger of the two, the freshness check would fire first and the deadline check would
|
|
// be unreachable — a rule that can never fire, which is the kind of thing that gets discovered
|
|
// during an incident by someone who assumed it was working.
|
|
if p.Limits.RequestTTLSeconds > p.Limits.ClockSkewSeconds {
|
|
return nil, errors.New("policy: requestTtlSeconds exceeds clockSkewSeconds, so the agent's deadline check would never be reached")
|
|
}
|
|
// And the node must not be told to wait longer than the phone is willing to. The two are one
|
|
// decision — how long a request may take end to end — stated in the file that both sides read.
|
|
if p.Limits.RequestTTLSeconds*1000 <= p.Limits.AgentUpstreamTimeoutSeconds*1000 {
|
|
return nil, errors.New("policy: requestTtlSeconds must exceed the per-request upstream timeout, or the relay deadline is not the longer bound")
|
|
}
|
|
if p.Limits.ReplayCacheSize < p.Limits.MailboxDepth {
|
|
return nil, errors.New("policy: replayCacheSize is smaller than mailboxDepth, so entries can be evicted while still queued")
|
|
}
|
|
|
|
sum := sha256.Sum256(raw)
|
|
p.digest = hex.EncodeToString(sum[:])
|
|
return &p, nil
|
|
}
|
|
|
|
func (p *Policy) checkPath(path string) error {
|
|
if !strings.HasPrefix(path, "/api/") {
|
|
return fmt.Errorf("policy: path %q is not under /api/", path)
|
|
}
|
|
if strings.Contains(path, "..") || strings.ContainsAny(path, "?#\\") {
|
|
return fmt.Errorf("policy: path %q contains traversal, query, fragment or backslash", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SHA256 is the digest of the exact bytes the agent enforced. It is logged at startup so an operator
|
|
// can confirm a fleet is running the intended policy without diffing files.
|
|
func (p *Policy) SHA256() string { return p.digest }
|
|
|
|
// Decision reports whether a request may be executed, and whether it needs a confirmation intent.
|
|
func (p *Policy) Decision(method, path string) (allowed bool, dangerous bool, reason string) {
|
|
clean := path
|
|
if i := strings.IndexAny(clean, "?#"); i >= 0 {
|
|
clean = clean[:i]
|
|
}
|
|
if err := p.checkPath(clean); err != nil {
|
|
return false, false, "path rejected"
|
|
}
|
|
// Constant-time on purpose is not needed for a public allowlist, but the auth check must come
|
|
// first so a path that is both auth-shaped and allowlisted cannot win by ordering.
|
|
if p.authRe.MatchString(clean) {
|
|
return false, false, "authentication endpoints are not reachable through the relay"
|
|
}
|
|
if method == "GET" {
|
|
if p.read[clean] {
|
|
return true, false, ""
|
|
}
|
|
return false, false, "path is not on the read allowlist"
|
|
}
|
|
methods, ok := p.write[clean]
|
|
if !ok {
|
|
if p.read[clean] {
|
|
return false, false, "path is read-only"
|
|
}
|
|
return false, false, "path is not on the write allowlist"
|
|
}
|
|
if !methods[method] {
|
|
return false, false, "method is not allowed for this path"
|
|
}
|
|
return true, true, ""
|
|
}
|
|
|
|
// RequiresConfirm reports whether a dangerous write carries a matching intent. The comparison is
|
|
// constant time so the agent does not leak the expected digest byte by byte on a wrong guess.
|
|
func RequiresConfirm(got, want []byte) bool {
|
|
return subtle.ConstantTimeCompare(got, want) == 1
|
|
}
|
|
|
|
// Raw returns the embedded bytes for the drift test.
|
|
func Raw() []byte { return embedded }
|