云函数安全加固实战:Serverless 最小权限配置

适用场景

把 API 后端、定时任务、文件处理交给云函数后,运维工作量下降,但攻击面并没有消失,只是换了位置。云函数安全加固要解决的是 Serverless 特有的四类问题:事件参数注入、环境变量中的明文密钥、IAM 角色过度授权、以及 HTTP 触发器裸奔。本文以函数计算类平台(阿里云函数计算 FC、腾讯云 SCF、AWS Lambda 均通用)为例,给出最小权限与运行时加固的可复制配置。

前置条件

  • 已部署至少一个云函数,具备控制台与 CLI 访问权限(aliyun / tccli / aws)
  • 函数使用 Node.js 18+、Python 3.10+ 或 Java 11+ 运行时
  • 已开通密钥管理服务(KMS)与日志服务
  • 有测试账号可用于越权与拒权验证

原理说明

Serverless 的信任边界与传统服务器不同,主要风险来自三类机制:

  • 事件注入:函数的入参 event 是攻击者完全可控的 JSON。若函数把 event 中的字段直接拼进 SQL、Shell 命令、模板或下游 API 调用,就等价于把注入点直接暴露在公网。常见形态是 API 网关透传的 pathParameters、queryStringParameters 未做白名单校验。
  • 身份即权限:每个函数绑定一个执行角色,该角色通常被授予了平台默认策略(如 AliyunFCDefaultRolePolicy 或含 Resource: "*" 的自定义策略)。一旦函数被注入利用,攻击者拿到的是整个云账号的临时凭证,可以直接操作 OSS、RDS、甚至修改角色本身。
  • 密钥与依赖:环境变量在控制台与日志中默认可读,硬编码的 AccessKey 极易泄露;Layer 与公共依赖包则是供应链投毒的常见载体。

此外,HTTP 触发器若配置为匿名访问,等于把函数直接挂到公网,缺少 WAF 与签名校验时会成为扫描器的靶子;并发与超时未限制时,一次洪泛即可把费用打到四位数。

操作步骤

1. 收敛执行角色到最小权限

不要使用平台默认策略,改为显式枚举 Action 与 Resource:

{
  "Version": "1",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["oss:GetObject", "oss:PutObject"],
      "Resource": ["acs:oss:*:*:my-app-bucket/uploads/*"]
    },
    {
      "Effect": "Allow",
      "Action": ["log:PutLogs"],
      "Resource": ["acs:log:*:*:project/my-app-logs/*"]
    }
  ]
}

落地时用 CLI 绑定到函数:

aliyun fc UpdateFunction \
  --serviceName my-service \
  --functionName api-handler \
  --runtime python3.10 \
  --handler index.handler \
  --role acs:ram::1234567890:role/my-app-fc-role

加固前务必先用 aliyun ram ListPoliciesForRole --RoleName my-app-fc-role 核对是否残留 *:* 类策略并解绑。

2. 密钥全部走 KMS,禁止写入环境变量明文

# 写入加密配置
aliyun kms CreateSecret --SecretName fc-db-password \
  --SecretData "$(openssl rand -base64 24)" --VersionId v1

# 只把 SecretName 放进环境变量,不在变量里放真实口令
aliyun fc UpdateFunction --serviceName my-service --functionName api-handler \
  --environmentVariables "{\"DB_SECRET_NAME\":\"fc-db-password\",\"REGION\":\"cn-hangzhou\"}"

函数运行时按需要临时拉取:

# Python 侧:从 KMS 拉取一次并缓存在进程内
from aliyunsdkcore.client import AcsClient
from aliyunsdkkms.request.v20160120 import GetSecretValueRequest
import os, json, functools

@functools.lru_cache(maxsize=1)
def get_secret():
    c = AcsClient(os.environ["ALIBABA_CLOUD_ACCESS_KEY_ID"],
                  os.environ["ALIBABA_CLOUD_ACCESS_KEY_SECRET"], "cn-hangzhou")
    r = GetSecretValueRequest.GetSecretValueRequest()
    r.set_SecretName(os.environ["DB_SECRET_NAME"])
    return json.loads(c.do_action_with_exception(r))["SecretData"]

更好的做法是给函数角色只授予 KMS 的 Decrypt 权限,彻底取消函数的长期 AccessKey。

3. 事件参数白名单校验

import re, json

ALLOW_KEYS = {"id", "page", "size", "type"}
ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,32}$")

def handler(event, context):
    try:
        ev = json.loads(event) if isinstance(event, str) else event
    except Exception:
        return {"statusCode": 400, "body": "bad json"}

    # 1) 键白名单,拒绝任何未声明字段
    if set(ev.keys()) - ALLOW_KEYS:
        return {"statusCode": 400, "body": "unknown field"}

    # 2) 值格式校验,杜绝拼接注入
    if not ID_RE.match(str(ev.get("id", ""))):
        return {"statusCode": 400, "body": "bad id"}

    # 3) 数值范围限制,避免超大分页拖垮下游
    page = max(1, min(int(ev.get("page", 1)), 1000))
    size = max(1, min(int(ev.get("size", 20)), 100))
    return {"statusCode": 200, "body": json.dumps({"page": page, "size": size})}

禁止把 event 直接传给 os.system、subprocess(shell=True)、字符串拼接 SQL,或作为对象存储路径前缀使用。

4. HTTP 触发器加签名与 WAF

# 关闭匿名访问,改为需要签名校验
aliyun fc UpdateTrigger --serviceName my-service --functionName api-handler \
  --triggerName http-trigger --triggerType http \
  --triggerConfig "{\"authType\":\"function\",\"methods\":[\"POST\"]}"

平台侧建议同步开启三道控制:把 API 网关或自定义域名接入 WAF 并启用 CC 防护;限制请求体大小(如 1MB)与查询串长度;对写操作接口强制 HMAC 签名 + 时间戳 + nonce,时间戳窗口不超过 300 秒,nonce 在缓存中做一次性校验。

5. 出网收敛与运行时限制

# 把函数放进 VPC,仅通过 NAT 网关出网,且 NAT 侧只放行必要目标
aliyun fc UpdateFunction --serviceName my-service --functionName api-handler \
  --vpcConfig "{\"vpcId\":\"vpc-bp1xxxx\",\"vSwitchIds\":[\"vsw-bp1xxxx\"],\"securityGroupId\":\"sg-bp1xxxx\"}"

# 限制单实例并发、超时与实例数,防止被当成免费算力
aliyun fc PutProvisionConfig --serviceName my-service --functionName api-handler \
  --qualifier LATEST --target 0
aliyun fc UpdateFunction --serviceName my-service --functionName api-handler \
  --timeout 10 --memorySize 256 --instanceConcurrency 10

同时在函数配置中设置 最大实例数上限(如 20),并给账号配置费用告警,避免并发洪泛直接转化为账单事故。

6. 依赖锁定与日志脱敏

# Node.js:使用锁文件 + 完整性校验
npm ci --omit=dev --ignore-scripts
npm audit --audit-level=high

# Python:固定版本哈希
pip install --require-hashes -r requirements.lock
# 日志脱敏:禁止打印 event 全文与凭证字段
SENSITIVE = {"password", "token", "secret", "authorization", "cookie"}

def safe_log(obj):
    if isinstance(obj, dict):
        return {k: ("***" if k.lower() in SENSITIVE else safe_log(v)) for k, v in obj.items()}
    if isinstance(obj, list):
        return [safe_log(i) for i in obj]
    return obj

--ignore-scripts 可阻断依赖安装期脚本执行,是防 Layer 与 npm 投毒的有效手段。

配置验证

  • 越权测试:用函数角色尝试访问未授权的存储桶,应返回 AccessDenied:
    aliyun oss ls oss://other-bucket --access-key-id $AK --access-key-secret $SK。
  • 注入测试:向 HTTP 触发器发送含额外字段的 JSON,例如 {"id":"1","cmd":"whoami"},应返回 unknown field。
  • 密钥泄露检查:执行 aliyun fc GetFunction --serviceName my-service --functionName api-handler,确认 output 的 environmentVariables 中无明文口令。
  • 日志脱敏检查:在日志服务中检索 password、token 关键字,命中记录应只显示 ***。
  • 并发上限检查:用 ab -n 500 -c 100 打一轮压测,控制台应显示实例数被限制在配置上限,且无异常费用增长。

常见问题

Q1:函数只需要读一个存储桶,为什么还要自定义策略,直接用默认角色不行吗?

默认角色通常包含对象存储全量读写、日志写入乃至其它平台服务的访问权限。函数一旦被注入利用,攻击者可以用函数身份遍历、下载甚至删除账号下所有存储桶的数据,还能借助日志服务的写入权限伪造审计记录。最小权限的意义在于把「一台函数失陷」的爆炸半径限制在单桶前缀之内,这与传统服务器上的权限收敛原则完全一致。

Q2:密钥放在 KMS 里,是不是就绝对安全了?

不是。KMS 解决的是「密钥不以明文形式存储与分发」,但函数运行时有权限读取密钥,所以仍要控制三点:一是函数角色只授予特定 Secret 的读权限,避免横向遍历;二是密钥缓存在进程内并使用 lru_cache,减少调用次数与日志暴露面;三是密钥定期轮换,并开启 KMS 的访问审计,一旦出现异常读取立即告警。真正的兜底是密钥可随时轮换且轮换成本极低,而不是假设它永远不会泄露。

Q3:函数并发限制设小了,会不会影响业务体验?

应先测量正常峰值的并发数(控制台可查看实例与并发指标),把上限设为峰值的一点五到两倍,再配合 API 网关侧的限流与排队。这样既能挡住突发洪泛,又不会在日常流量下产生 429。压测时观察冷启动比例,如果因为实例上限导致大量请求排队,应优先优化单次执行耗时,而不是无脑抬高上限。

总结

云函数安全加固的核心是把 Serverless 当成「随时可能失陷的边缘节点」来设计:权限最小化(自定义策略、单桶前缀、只读 KMS)、输入强校验(键白名单 + 值格式 + 范围限制)、入口加固(关闭匿名触发、强制签名、WAF 前置)、运行时收敛(VPC 出网、并发与超时上限、费用告警)、供应链与日志(锁文件 + 禁用安装脚本 + 日志脱敏)。五层叠加后,即使某个函数被成功利用,攻击者拿到的也只是一个权限受限、网络受限、日志留痕的临时身份。