← 中文资源

简体中文版本

授权测试

授权测试系统性验证每个“角色×资源”组合——不只是未授权请求失败,更要证明没有角色经嵌套与批量路径获得越界能力。

阅读英文原文

概念定义

访问控制缺陷不崩溃——它以成功码返回错误数据。测它要在规模上问否定问题:每个角色、哪些请求必须失败。

工作机制

把矩阵从策略中作为数据推导:角色 × 操作 × 资源范围。每格以该主体执行请求并断言期望状态与载荷缺失。性质测试变体生成随机角色-资源对;路由清单覆盖本身也要断言——每条路由都出现在矩阵里。

使用场景

策略、角色、路由的每次变更,以及作为定期审计——授权回归是静默且累积的。内部面也要覆盖:管理面板、内部工具、任务执行器。

常见误区

“admin 能用、匿名被拒就够了”远远不够:逃逸住在角色之间——编辑摸到审计员数据、租户 A 碰租户 B。授权若只在控制器执行,服务层、仓储层、后台任务、GraphQL resolver 都在重新暴露决策;用上帝凭证跑的队列消费者与 cron 是常被遗忘的面。从代码生成矩阵会让测试与 bug 达成一致——要从声明的策略生成并评审策略本身的 diff。

代码示例

import express from 'express';
import jwt from 'jsonwebtoken';

function requireAuthMiddleware(req, res, next) {
  const header = req.headers.authorization ?? '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: 'token required' });
  try {
    req.claims = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch {
    res.status(403).json({ error: 'invalid token' });
  }
}

function requireRoleMiddleware(...allowed) {
  return (req, res, next) => {
    const role = req.claims?.role;
    if (!role || !allowed.includes(role)) {
      return res.status(403).json({ error: 'role ' + role + ' not permitted' });
    }
    next();
  };
}

const app = express();

app.delete('/invoices/:id',
  requireAuthMiddleware,
  requireRoleMiddleware('admin', 'finance'),
  (req, res) => res.json({ deleted: req.params.id }),
);

app.get('/profile',
  requireAuthMiddleware,
  (req, res) => res.json({ userId: req.claims.sub }),
);

app.listen(3000);
type PathTree = Map<string, PathTree>;

const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);

export function omitDeep<Value>(value: Value, paths: readonly string[]): Value {
  if (!Array.isArray(paths)) throw new TypeError("paths must be an array");
  if (paths.length === 0) return value;
  const tree: PathTree = new Map();
  for (const path of paths) {
    if (typeof path !== "string" || path.trim() === "") {
      throw new TypeError("paths must be non-empty strings");
    }
    const segments = path.split(".").map((segment) => segment.trim());
    if (segments.some((segment) => segment === "")) throw new Error(`invalid path "${path}"`);
    for (const segment of segments) {
      if (FORBIDDEN_KEYS.has(segment)) throw new Error(`path "${path}" contains a forbidden key`);
    }
    insert(tree, segments);
  }
  return clone(value, tree) as Value;
}

function insert(tree: PathTree, segments: readonly string[]): void {
  let node = tree;
  for (const segment of segments) {
    let child = node.get(segment);
    if (child === undefined) {
      child = new Map();
      node.set(segment, child);
    }
    node = child;
  }
}

function clone(value: unknown, tree: PathTree): unknown {
  if (Array.isArray(value)) {
    return value.map((item, index) => {
      const child = tree.get(String(index));
      if (child !== undefined && child.size === 0) return undefined;
      return clone(item, child ?? tree);
    });
  }
  if (value !== null && typeof value === "object") {
    const prototype = Object.getPrototypeOf(value);
    if (prototype !== Object.prototype && prototype !== null) {
      throw new TypeError("omitDeep only supports plain objects and arrays");
    }
    const output: Record<string, unknown> = {};
    for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
      const child = tree.get(key);
      if (child !== undefined && child.size === 0) continue;
      output[key] = clone(item, child ?? new Map());
    }
    return output;
  }
  return value;
}

const source = {
  id: 1,
  password: "secret",
  profile: { email: "[email protected]", token: "abc", nested: { keep: true } },
  items: [{ id: 1, token: "a" }, { id: 2, token: "b" }],
};
const sanitized = omitDeep(source, ["password", "profile.token", "items.token"]);
console.log(JSON.stringify(sanitized));
console.log(JSON.stringify(source));