← 中文资源

简体中文版本

对象级授权

对象级授权校验请求者对“每一个具体对象”的所有权或访问权,而不是仅持有有效会话——这正是 IDOR 与 BOLA 的缺陷所在。

阅读英文原文

概念定义

访问控制分两层:能否使用这个功能,以及能否对“这个对象”使用它。第二层失败得很安静:端点对任何有效会话与任何 id 都返回 200,跨租户读取只需一次 curl。

工作机制

健壮模式是让查询按认证主体圈定范围:WHERE id = :id AND owner_id = :user,或绑定会话变量的数据库行级安全策略。框架中间件与 ORM 支持默认作用域,让处理器想漏查都难。GraphQL 的嵌套路径每个都需强制执行。

使用场景

每个触碰 id 的处理器,包括管理面——内部工具同样泄数据。自动化测试应让两个不同用户请求同一对象,断言第二个拿到 404/403 而非载荷。

常见误区

“认证即授权”不成立;UUID 换掉序列 id 也只防枚举不防越权——id 本就经列表或日志外泄。先取回再过滤会泄漏对象存在性与时序。批量端点里每个 id 都要圈定;客户端能传 owner_id 且模型照单绑定时,mass assignment 与 BOLA 双重叠加。

代码示例

export function createFlagSet(names) {
  if (!Array.isArray(names) || names.length === 0) {
    throw new TypeError("names must be a non-empty array");
  }
  if (names.length > 30) throw new RangeError("at most 30 flags are supported");
  const bits = new Map();
  const all = [];
  for (const name of names) {
    if (typeof name !== "string" || name.trim() === "") {
      throw new TypeError("flag names must be non-empty strings");
    }
    const normalized = name.trim();
    if (bits.has(normalized)) throw new Error(`duplicate flag "${normalized}"`);
    bits.set(normalized, 1 << bits.size);
    all.push(normalized);
  }
  const resolve = (name) => {
    const bit = bits.get(name);
    if (bit === undefined) throw new Error(`unknown flag "${name}"`);
    return bit;
  };
  return {
    names: () => [...all],
    mask(...selected) {
      return selected.reduce((mask, name) => mask | resolve(name), 0);
    },
    has(mask, name) {
      return (mask & resolve(name)) !== 0;
    },
    add(mask, name) {
      return mask | resolve(name);
    },
    remove(mask, name) {
      return mask & ~resolve(name);
    },
    toggle(mask, name) {
      return mask ^ resolve(name);
    },
    toNames(mask) {
      return all.filter((name) => (mask & resolve(name)) !== 0);
    },
  };
}

const permissions = createFlagSet(["read", "write", "admin"]);
let mask = permissions.mask("read", "write");
console.log(mask, permissions.toNames(mask));
mask = permissions.remove(mask, "write");
mask = permissions.toggle(mask, "admin");
console.log(permissions.has(mask, "write"), permissions.toNames(mask));
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);