租户标识必须从请求上下文安全提取且全程校验,唯一安全载体是context.Context,提取优先级为r.Host>r.URL.Path>X-Tenant-ID,所有拼接点须用统一白名单正则校验,禁止全局变量或闭包捕获。

租户标识必须从请求上下文安全提取,不能依赖全局变量或闭包捕获
租户 ID 一旦被错误地存在全局 var tenantID string 或 handler 闭包里,多个并发请求会互相覆盖——比如租户 A 的请求刚写入,租户 B 的请求就把它冲掉,后续所有 DB 查询、日志、缓存都跑错租户。Go 的 HTTP handler 是无状态且并发执行的,唯一安全载体只有 context.Context。
提取来源优先级应为:r.Host(子域名) > r.URL.Path(路径前缀如 /t/{tenant}) > r.Header.Get("X-Tenant-ID")。注意:r.Host 必须用 net.SplitHostPort 拆分端口,否则 tenant.example.com:8080 会被误判为非法租户名;路径提取需配合正则校验,例如 ^[a-z0-9][a-z0-9-]{2,29}$,防 ../etc/passwd 类路径遍历。
- 禁止在
http.HandleFunc回调里重复解析租户 ID —— 统一收口到中间件 - 校验失败直接返回
http.StatusBadRequest或http.StatusForbidden,不 fallback 到默认租户 - key 类型必须是未导出 struct:
type tenantKey struct{},避免与其他库的context.WithValue冲突
动态路由不能靠 gorilla/mux Subrouter 或 ServeMux 多实例实现
gorilla/mux 的 Subrouter 是启动时静态注册的,不支持运行时按请求动态挂载;http.ServeMux 也不支持 Host 或 header 匹配。试图为每个租户 new 一个 http.ServeMux 并调用 ListenAndServe,结果只有最后一个生效;而用 Subrouter 做 /t/{tenant} 路由,若没加正则约束,{tenant} 可能匹配到恶意路径如 /t/..%2fetc/passwd。
正确做法是主路由只注册通配路径,例如 PathPrefix("/t/{tenant}").Handler(tenantDispatcher),再由 tenantDispatcher 从 vars 拿到租户名,查表或加载对应 handler。若用标准库,直接在中间件里完成识别 + 分发:
立即学习“go语言免费学习笔记(深入)”;
开箱即用的技能链路由引擎。13 条预定义链覆盖搜索、开发、审查、MLOps、法律、创意等场景,三层路由架构(触发词→SAD反馈→DAG编排),recall@10=96.97%。配置驱动(chains.yaml),零代码扩展。pip install skill-weave-chains 一键安装。
func tenantRouter(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenant := extractTenant(r)
if tenant == "" {
http.Error(w, "tenant not found", http.StatusBadRequest)
return
}
ctx := context.WithValue(r.Context(), tenantKey{}, tenant)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}- 不要把租户判断逻辑分散到每个 handler 里,维护成本高且易漏
- 若用 Gin,应在中间件中调用
c.Set("tenant_id", tenant),而非传参或全局变量 - Subrouter 适合固定分组(如
/v1/、/admin/),不适合租户这种数量不可预知的场景
租户字符串参与拼接时必须白名单校验,严禁直接注入 SQL 或 topic 名
把租户 ID 直接拼进 SQL 表名("SELECT * FROM " + tenantID + "_users")、Kafka topic("orders_" + tenantID)、etcd key("/config/" + tenantID)是典型高危操作。一旦租户 ID 来自请求且未过滤,tenant_abc; DROP TABLE users 就能执行成功。
所有动态拼接点必须强制白名单校验,推荐正则:^[a-z0-9][a-z0-9-]{2,29}$(小写字母开头,长度 3–30,仅含字母、数字、短横线)。PostgreSQL schema 名、MySQL 库名、RabbitMQ vhost、NSQ topic 前缀都适用该规则。
- PostgreSQL 中
SET search_path = $1的参数必须经此校验,不能跳过 - Kafka group ID 应构造为
"processor-" + tenantID,但必须固定(不能每次请求新生成),否则 offset 丢失 - RabbitMQ vhost 连接字符串里的
/必须 URL 编码,例如租户acme.co→amqp://u:p@h:5672/acme%2eco
租户字符串作为 cache key 或 config path 时需嵌入命名空间,禁用共享客户端
viper 是全局单例,etcd 客户端不感知租户,复用同一 clientv3.Client 订阅 /config/ 前缀会导致跨租户配置污染——租户 A 的变更会触发租户 B 的回调。同样,Redis 缓存若用 tenantID + ":user:123" 作 key 却共用一个 *redis.Client,虽 key 隔离,但连接池、超时、重试策略无法按租户差异化配置。
物理隔离才是根本解法:为每个租户构造独立 namespace-aware 客户端。etcd 示例:
type TenantConfigClient struct {
client *clientv3.Client
tenant string
}
func (t *TenantConfigClient) Get(ctx context.Context, key string) ([]byte, error) {
resp, err := t.client.Get(ctx, fmt.Sprintf("/tenant/%s/config/%s", t.tenant, key))
// ...
}- 每个租户应持有自己的
clientv3.WatchChan实例,goroutine 独立消费 - Redis 不必为每个租户建新连接,但应使用
redis.NewClusterClient+ 按租户分 slot,或用redis.UniversalClient配置不同Addr和DB - 缓存 key 必须带租户前缀,且 prefix 本身也要校验,防止
/tenant/..%2fetc/passwd/config/db类越界
租户标识字符串看着只是个普通字符串,但它实际是整个隔离体系的信任锚点——从提取、校验、透传到拼接、缓存、路由,每一步漏校验或绕过上下文,都会导致租户数据串行。最常被忽略的是:校验必须在首次提取时做,而不是等到 DAO 层才检查;以及所有拼接点(SQL、topic、key、vhost)必须用同一套白名单规则,不能一处宽松一处严格。


















