Skip to content

操作指南:通知收件箱 API

FT 参考:FT271(NENE2-FT/notificationlog)——通知收件箱:类型允许列表的通知创建,每用户 IDOR 保护(404 而非 403),管理员故障关闭模式,批量标记为已读,is_read 幂等性,带 PDO::PARAM_INT 绑定的分页钳制,31 个测试 / 98 个断言全部通过。

同样在 FT222(NENE2-FT/notificationlog)中验证——对相同模式的 VULN 评估。

本指南展示如何使用 NENE2 构建带类型允许列表推送通知、每用户 IDOR 保护和批量标记为已读的通知收件箱系统。

功能特性

  • 带类型允许列表的仅管理员通知创建
  • 每用户 IDOR 保护:用户只能看到自己的通知(未授权访问时返回 404)
  • 带所有权验证的单个和批量标记为已读
  • 每次列表查询都返回未读数
  • 可选的仅未读过滤器和分页
  • 管理员故障关闭

数据库结构

sql
CREATE TABLE IF NOT EXISTS notifications (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    type       TEXT    NOT NULL,
    title      TEXT    NOT NULL,
    body       TEXT    NOT NULL DEFAULT '',
    is_read    INTEGER NOT NULL DEFAULT 0,
    created_at TEXT    NOT NULL,
    read_at    TEXT
);

CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id, id DESC);

没有单独的 users 表——API 信任 X-User-Id 请求头(生产环境请替换为真实认证)。

端点

方法路径认证描述
POST/notifications管理员为用户创建通知
GET/users/{userId}/notifications本人 / 管理员列出通知
POST/notifications/{id}/read本人 / 管理员将单条通知标记为已读
POST/users/{userId}/notifications/read-all本人 / 管理员全部标记为已读

类型允许列表

自由格式的类型字符串被拒绝,以防止注入和枚举攻击:

php
public const array ALLOWED_TYPES = [
    'system',
    'promotion',
    'social',
    'account',
    'security',
    'reminder',
];

路由处理器在任何 DB 访问之前进行校验:

php
if (!in_array($type, NotificationRepository::ALLOWED_TYPES, true)) {
    $allowed = implode(', ', NotificationRepository::ALLOWED_TYPES);
    return $this->problem(422, 'validation-failed', "type must be one of: {$allowed}.");
}

IDOR 保护

用户只能读取自己的通知。未授权访问时返回 404(而非 403)以防止用户 ID 枚举:

php
private function isSelfOrAdmin(ServerRequestInterface $req, int $ownerId): bool
{
    if ($this->isAdmin($req)) {
        return true;
    }
    $uid = $this->requestUserId($req);
    return $uid !== null && $uid === $ownerId;
}

标记为已读也在操作前验证所有权:

php
// POST /notifications/{id}/read 处理器
$notification = $this->repo->findById($id);
if ($notification === null) {
    return $this->problem(404, 'not-found', 'Notification not found.');
}
// IDOR:只有所有者或管理员可以标记为已读
if (!$this->isSelfOrAdmin($req, (int) $notification['user_id'])) {
    return $this->problem(404, 'not-found', 'Notification not found.');
}

管理员故障关闭

php
private function isAdmin(ServerRequestInterface $req): bool
{
    if ($this->adminKey === '') {
        return false;   // 故障关闭:未配置密钥时不允许管理员
    }
    $key = $req->getHeaderLine('X-Admin-Key');
    return $key !== '' && hash_equals($this->adminKey, $key);
}

分页

limitoffset 在数据仓库中被钳制——绝不直接信任客户端提供的值:

php
private const int MAX_LIMIT = 100;

$limit  = max(1, min(self::MAX_LIMIT, $limit));
$offset = max(0, $offset);

PDO 整数绑定防止 LIMIT / OFFSET 的 SQL 注入:

php
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);

标记为已读的幂等性

php
/** @return 'ok'|'not_found'|'already_read' */
public function markAsRead(int $id): string
{
    $notification = $this->findById($id);
    if ($notification === null) return 'not_found';
    if ((bool) $notification['is_read']) return 'already_read';

    // ... UPDATE SET is_read = 1, read_at = :now ...
    return 'ok';
}

路由处理器对 okalready_read 都返回 200——使该端点可以多次调用而不产生副作用。

安全模式

模式实现
类型允许列表in_array($type, ALLOWED_TYPES, true) — 严格匹配
IDOR → 404返回 404(非 403)以隐藏用户/通知是否存在
所有权验证获取通知,在标记为已读前检查 user_id
管理员故障关闭if ($this->adminKey === '') return false;
ctype_digit()路径参数 ID 校验——ReDoS 安全
分页钳制max(1, min(100, $limit)) + PDO::PARAM_INT 绑定
is_int() + > 0严格 user_id 检查——拒绝浮点数、字符串、负数

反模式

反模式风险
接受自由格式 type 字符串未经校验的类型污染收件箱;无法按有意义的类别过滤
未授权通知访问返回 403暴露通知或用户是否存在——IDOR 信息泄露
在所有权检查之前从标记为已读返回 404攻击者知道通知存在并属于某人
允许空 adminKey 表示"允许管理员"故障开放;如果未配置密钥,任何请求都成为管理员
直接信任查询字符串中的原始 limitlimit=999999 的请求导致全表扫描
在 LIMIT/OFFSET 中使用字符串插值带未校验输入的 "LIMIT {$limit}" 允许 SQL 注入

基于 MIT 许可证发布。