多租户的Kubernetes集群里跑着OpenFaaS,本来以为网关认证加上namespace隔离就能挡住所有人,结果生产环境一上线,就被安全团队打回来说有越权漏洞。这个问题其实很常见,很多人把“认证”和“授权”混为一谈,觉得只要登录了、只要Pod分开了,就万事大吉。但实际上,OpenFaaS的网关认证只能证明“你是谁”,namespace隔离只能保证“你的函数跑在哪个房间”,至于“你能不能调用别人的函数”,这两个机制根本管不着。今天咱们就用大白话,把这个漏洞讲清楚,然后给出一个生产环境必须补上的安全方案:OAuth代理加上RBAC策略。

一、先搞懂问题出在哪:认证不等于授权

1.1 网关认证做了什么

OpenFaaS网关默认有个Basic Auth,或者你可以接入OIDC之类的身份认证。这个认证的作用是:当你请求网关API时,它先确认你有合法的用户名和密码或Token。如果认证通过,网关就认为你是“合法用户”。但网关本身并不知道这个用户能访问哪些函数,它只负责把请求转发给对应的函数Pod。

举个例子,假设有两个租户,租户A和租户B。租户A部署了函数func-a,租户B部署了函数func-b。两个函数分别放在不同的namespace里,比如tenant-atenant-b。OpenFaaS网关在转发请求时,会根据URL路径里的函数名去找到对应的服务。比如/function/func-a就会路由到tenant-a命名空间下的服务。

1.2 namespace隔离的局限性

namespace是Kubernetes用来做资源隔离的,它能把Pod、Service、ConfigMap等资源分开。但在网络层面,默认情况下,不同namespace的Pod是可以互相访问的,除非你定义了NetworkPolicy。也就是说,如果租户A的某个Pod被攻破了,它完全可以请求租户B的Service,只要它知道对方服务的DNS名称。这就是一个典型的横向越权。

更关键的是,OpenFaaS网关本身通常运行在openfaas这个namespace下,它需要访问所有租户的命名空间,才能把请求转发给对应的函数。所以网关事实上成了一个“超级代理”,只要通过了网关认证,就能调用所有函数。

1.3 越权的具体场景

场景一:租户A的用户甲,登录OpenFaaS后,直接构造请求调用租户B的函数。因为网关只验证了甲的登录身份,并没有验证甲是否有权限访问func-b。所以甲能成功拿到租户B的数据。

场景二:租户B的某个函数内部调用了OpenFaaS的REST API,使用了B用户的密钥。如果B用户泄露了密钥,攻击者就能用这个密钥调用A的函数。

场景三:某些函数需要管理权限,比如重启Pod、修改配置,这些操作如果通过OpenFaaS的API暴露出去,而网关认证又比较宽松,就会引发更大的风险。

所以结论很明确:网关认证只解决了“谁能进大门”的问题,namespace隔离只解决了“资源放在不同房间”的问题,但“谁能进哪个房间”这个问题,必须由额外的授权机制来控制。

二、生产环境的解法:OAuth代理 + RBAC策略

OAuth代理,比如oauth2-proxy,可以放在OpenFaaS网关前面,负责统一入口认证和会话管理。它本身支持很多身份提供商,比如GitHub、Okta、Azure AD等。RBAC(基于角色的访问控制)则是定义“什么角色能对什么资源做什么操作”。两者结合,就能实现“认证在前,授权在后”的完整链路。

整体架构大概是:用户请求 -> OAuth代理(验证身份,把用户信息放入Header)-> OpenFaaS网关(识别Header里的用户信息)-> 网关通过RBAC判断用户是否有权限访问特定函数 -> 有则转发,无则拒绝。

下面咱们用一个完整示例来说明,技术栈统一使用Go语言和Kubernetes相关工具。假设你已经有一个Kubernetes集群,并且安装了OpenFaaS。

2.1 部署OAuth代理

我们使用oauth2-proxy作为OAuth代理。它支持OIDC,可以对接很多身份提供商。这里以Google OAuth为例,但你只需要换成自己的Provider即可。

先创建一个Kubernetes Deployment和Service,用于运行oauth2-proxy。

# oauth2-proxy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: oauth2-proxy
  namespace: openfaas
spec:
  replicas: 1
  selector:
    matchLabels:
      app: oauth2-proxy
  template:
    metadata:
      labels:
        app: oauth2-proxy
    spec:
      containers:
        - name: oauth2-proxy
          image: bitnami/oauth2-proxy:7.4.0
          args:
            - --provider=oidc
            - --oidc-issuer-url=https://accounts.google.com
            - --client-id=YOUR_CLIENT_ID
            - --client-secret=YOUR_CLIENT_SECRET
            - --cookie-secret=YOUR_COOKIE_SECRET
            - --email-domain=yourdomain.com
            - --http-address=0.0.0.0:4180
            - --whitelist-domain=.yourdomain.com
            # 关键:把用户邮箱和分组信息作为HTTP头传递给后端
            - --set-xauthrequest=true
            - --pass-user-headers=true
            - --pass-access-token=true
            - --upstream=http://gateway.openfaas.svc.cluster.local:8080
          env:
            - name: OAUTH2_PROXY_CLIENT_ID
              value: "YOUR_CLIENT_ID"
            - name: OAUTH2_PROXY_CLIENT_SECRET
              value: "YOUR_CLIENT_SECRET"
          ports:
            - containerPort: 4180
          readinessProbe:
            httpGet:
              path: /ping
              port: 4180
---
apiVersion: v1
kind: Service
metadata:
  name: oauth2-proxy
  namespace: openfaas
spec:
  selector:
    app: oauth2-proxy
  ports:
    - port: 4180
      targetPort: 4180

注意上面的--upstream=http://gateway.openfaas.svc.cluster.local:8080,意思是oauth2-proxy把经过认证的请求转发给OpenFaaS网关服务。同时通过--set-xauthrequest=true,oauth2-proxy会把用户的邮箱(X-Auth-Request-Email)等信息作为请求头发送给网关。

2.2 修改OpenFaaS网关,让它识别用户Header

OpenFaaS网关支持通过环境变量开启“用户上下文”传递。我们可以设置basic_auth为false,改为从Header中读取用户信息。这样网关在收到请求时,可以从X-Auth-Request-EmailX-Auth-Request-Groups这两个Header中获取用户身份和组信息。

用Helm安装OpenFaaS时,我们覆盖values.yaml:

# gateway-config.yaml
gateway:
  basicAuth: false
  # 允许从Header中读取用户身份
  writeDebug: true
  auth:
    # 使用我们自定义的认证插件
    image: ghcr.io/openfaas/openfaas-oidc-plugin:latest
    args:
      - "-uri=http://oauth2-proxy.openfaas.svc.cluster.local:4180/oauth2/auth"
      # 告诉插件从哪个Header读取用户
    env:
      OIDC_AUTH_URI: "http://oauth2-proxy.openfaas.svc.cluster.local:4180/oauth2/auth"

实际上,OpenFaaS有一个官方插件叫openfaas-oidc-plugin,它可以验证OAuth代理传来的令牌。但更简单的做法是,我们直接在网关的配置里启用X-Auth-Request-*头的信任。如果你不想使用插件,咱们可以通过修改网关的env来启用http_headers作为用户输入。

为了简化,咱们直接利用oauth2-proxy的--set-xauthrequest=true,它会自动在请求头中加入X-Auth-Request-UserX-Auth-Request-Email。然后在OpenFaaS里,我们可以在函数调用请求中,把这些Header传给函数,但网关本身并不会对它们进行授权判断。这就需要我们自己做RBAC。

2.3 实现RBAC控制的网关插件

接下来是核心:我们需要一个授权中间件,它检查每个调用函数的请求,看用户是否有权限调用这个函数。我们用Go语言写一个简单的HTTP处理器,它作为OpenFaaS的网关代理,拦截所有请求,并在转发前执行RBAC检查。

下面是一个完整的Go示例,展示了如何实现这个中间件:

package main

import (
    "fmt"
    "net/http"
    "net/http/httputil"
    "net/url"
    "os"
    "strings"
)

// 角色-权限映射表,模拟RBAC策略
// 这里为了方便演示,使用硬编码。在生产环境应该从配置中心或数据库读取。
var rolePolicies = map[string]map[string][]string{
    "tenant-a-admin": {
        "functions": {"func-a", "func-b"}, // 管理员可以访问两个函数
    },
    "tenant-a-user": {
        "functions": {"func-a"}, // 普通用户只能访问自己的函数
    },
    "tenant-b-user": {
        "functions": {"func-b"},
    },
}

// 模拟用户-角色映射,实际应从OAuth代理传来的Header中获取用户组信息
func getUserRoles(headers http.Header) []string {
    // oauth2-proxy会把用户所属组放到 X-Auth-Request-Groups 中
    groupsHeader := headers.Get("X-Auth-Request-Groups")
    if groupsHeader == "" {
        return nil
    }
    // 假设组名是 "tenant-a-user" 这种格式
    return strings.Split(groupsHeader, ",")
}

// 检查用户角色是否有权限访问某个函数
func hasPermission(roles []string, functionName string) bool {
    for _, role := range roles {
        if perms, ok := rolePolicies[role]; ok {
            for _, allowed := range perms["functions"] {
                if allowed == functionName || allowed == "*" {
                    return true
                }
            }
        }
    }
    return false
}

func main() {
    // 目标网关地址
    target := os.Getenv("UPSTREAM_GATEWAY")
    if target == "" {
        target = "http://gateway.openfaas.svc.cluster.local:8080"
    }

    proxy := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "http", Host: target})

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        // 只对函数调用路径做权限检查
        if strings.HasPrefix(r.URL.Path, "/function/") {
            // 提取函数名
            functionPath := strings.TrimPrefix(r.URL.Path, "/function/")
            functionName := strings.Split(functionPath, "/")[0]

            // 从请求头中获取用户角色(oauth2-proxy已经注入)
            roles := getUserRoles(r.Header)
            if len(roles) == 0 {
                // 没有角色信息,说明认证失败
                http.Error(w, "Unauthorized: missing roles", http.StatusUnauthorized)
                return
            }

            // 检查是否有权限
            if !hasPermission(roles, functionName) {
                http.Error(w, "Forbidden: you don't have access to this function", http.StatusForbidden)
                return
            }
        }

        // 如果通过检查,则转发请求到OpenFaaS网关
        proxy.ServeHTTP(w, r)
    })

    fmt.Println("RBAC gateway proxy listening on :8081")
    http.ListenAndServe(":8081", nil)
}

这个Go程序需要编译成镜像,然后部署到Kubernetes中,作为OpenFaaS网关前面的“授权中间件”。我们得把它放在OAuth代理和OpenFaaS网关之间,也就是让oauth2-proxy把请求先送到这个中间件,然后中间件再转发给真正的网关。

你可以用下面的Dockerfile构建这个中间件:

# Dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod ./
COPY go.sum ./
COPY main.go ./
RUN go build -o rbac-proxy main.go

FROM alpine:3.18
COPY --from=builder /app/rbac-proxy /usr/local/bin/rbac-proxy
EXPOSE 8081
ENTRYPOINT ["rbac-proxy"]

然后用Kubernetes部署它:

# rbac-proxy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: rbac-proxy
  namespace: openfaas
spec:
  replicas: 1
  selector:
    matchLabels:
      app: rbac-proxy
  template:
    metadata:
      labels:
        app: rbac-proxy
    spec:
      containers:
        - name: rbac-proxy
          image: your-registry/rbac-proxy:latest
          env:
            - name: UPSTREAM_GATEWAY
              value: "http://gateway.openfaas.svc.cluster.local:8080"
          ports:
            - containerPort: 8081
---
apiVersion: v1
kind: Service
metadata:
  name: rbac-proxy
  namespace: openfaas
spec:
  selector:
    matchLabels:
      app: rbac-proxy
  ports:
    - port: 8081
      targetPort: 8081

现在把oauth2-proxy的--upstream参数改成指向rbac-proxy服务:

--upstream=http://rbac-proxy.openfaas.svc.cluster.local:8081

这样,请求链路变为:用户 -> oauth2-proxy(认证)-> rbac-proxy(授权)-> OpenFaaS网关(转发函数)-> 函数Pod。

2.4 更细粒度的RBAC:用Kubernetes RBAC管理函数权限

上面的示例中,我们把RBAC策略硬编码在Go程序里。但生产环境需要动态管理,我们可以使用Kubernetes的RBAC机制,让OpenFaaS网关或中间件调用Kubernetes API来检查用户是否对某个CRD有权限。但这样会复杂很多。另一个更好的方式是,采用OpenFaaS官方的“函数访问控制”插件,或者使用OPA(Open Policy Agent)。

不过,为了保持示例简单,同时又贴近生产,我建议把RBAC策略存储在ConfigMap里,然后让Go中间件动态加载。这样运维人员修改ConfigMap,就可以调整权限,而不用重新构建镜像。

比如,定义一个ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: rbac-policies
  namespace: openfaas
data:
  policies.yaml: |
    roles:
      tenant-a-admin:
        functions: ["func-a", "func-b"]
      tenant-a-user:
        functions: ["func-a"]
      tenant-b-user:
        functions: ["func-b"]

然后在Go代码中读取这个ConfigMap,建议用监听文件变化的方式动态更新。

三、技术优缺点和注意事项

3.1 这种“OAuth代理 + RBAC中间件”方案的优点

  • 认证与授权分离:oauth2-proxy只负责认证,授权逻辑完全由自己控制,灵活性高。
  • 兼容性强:OAuth代理支持各种身份源,RBAC也能适配大多数场景。
  • 可审计:因为所有请求都会经过授权中间件,你可以在这里记录日志,跟踪谁在什么时间访问了哪个函数。
  • 无需修改OpenFaaS核心:我们只是在前面加了一层,对OpenFaaS的侵入性很小。

3.2 缺点

  • 性能损耗:多一次HTTP转发,多了授权检查,会有额外的延迟。当然,在内部集群中,这个延迟通常可以忽略。
  • 复杂度增加:需要维护OAuth代理和授权中间件,运维成本变高。
  • 策略管理还是略显原始:如果策略很多,用ConfigMap编辑效率低,也不容易做版本控制。生产环境建议用更专业的策略引擎如OPA。

3.3 注意事项

  • 必须保护oauth2-proxy本身,因为它持有Client Secret和Cookie Secret,要用Kubernetes Secret存储,并设置合适的权限。
  • 授权中间件必须放在受信任的网络中,不能被用户直接访问,否则用户就可以绕过授权。
  • 如果OpenFaaS网关自己也开启了Basic Auth,记得关闭,否则会双重认证,而且容易造成混乱。
  • 从Header中读取用户信息时,一定要信任来自oauth2-proxy的Header,防止用户伪造Header。通常oauth2-proxy会重写这些Header,并且在转发到上游之前清除所有客户端传递的同名Header。但为了安全,可以在授权中间件中再次确认请求来源IP是oauth2-proxy的Pod IP,或者使用网络策略锁死。
  • 当函数需要接收用户身份时,授权中间件要把用户Header透传给函数,但也要注意防止下流服务伪造身份,可以在函数内部验证签名的令牌,而不只是依赖明文Header。

四、文章总结

生产环境的安全问题不是单靠一个组件就能解决的。OpenFaaS的网关认证提供的是“登录能力”,namespace隔离提供的是“资源隔离”,但要真正做到多租户之间的访问控制,必须把认证和授权结合起来。OAuth代理解决了认证的标准化和统一入口问题,而RBAC中间件则解决了细粒度的“谁可以调用哪个函数”的问题。这种组合虽然增加了部署的复杂度,但对于合规性要求较高的生产环境,是必不可少的。

以上示例展示了用Go语言实现一个简单的RBAC中间件,生产环境你可以换成更成熟的方案,比如使用OpenFaaS的插件生态或OPA。但核心思想都一样:不要信任网关的“认证通过”就代表授权,要在每个关键动作前显式地问一句“你有权限吗?”。

最后,安全是一个持续的过程,建议定期审计策略,监控访问日志,并做好密钥轮换。希望这篇文章能帮你在构建多租户OpenFaaS平台时,堵上这个越权漏洞。