Blazor Admin 数据权限:为什么前端隐藏按钮根本不够?

7 阅读6分钟

这是权限设计里最容易被跳过的一句话:前端隐藏按钮只是体验,不是安全边界。

这篇用框架里的真实防线说明:如果只做前端隐藏,攻击者能怎么绕过;以及服务端应该在哪些地方补校验。


一、只隐藏按钮,攻击者怎么绕过

1. 伪造事件触发被隐藏的操作

按钮不渲染,但对应的方法还在。Blazor Server 的事件通过 SignalR 回传,只要知道组件与事件的标识,理论上可以构造调用。框架在这里的处理是:保存/删除方法自己再判一次权限。

private async Task<bool> OnSaveDataAsync(TItem item, ItemChangedType changedType)
{
    // 服务端写操作校验:与 UI 按钮显隐使用同一权限判定,管理员自动放行。
    // 防止绕过 UI 直接触发保存(如伪造事件),普通用户无权限时拒绝写入。
    var action = changedType == ItemChangedType.Add ? "add" : "edit";
    if (!await admin.AuthButton(action))
    {
        await MessageService.Error(string.Format(CommonLocalizer["没有权限执行该操作"]));
        return false;
    }
    ...
}

2. 有编辑权限,但改别人的数据

这是更隐蔽的一类:用户确实有"编辑"按钮权限,但他把请求里的 Id 换成别人的记录。按钮权限拦不住这种。

// 数据权限:更新时必须校验该记录是否属于当前用户可操作范围,
// 不能只依赖按钮权限(否则伪造 Id 即可修改他人数据)
if (changedType == ItemChangedType.Update)
{
    var authorized = await FilterAuthorizedAsync([item], CommonLocalizer["没有权限修改该数据"]);
    if (authorized.Count == 0) return false;
}

FilterAuthorizedAsync 会按主键回查"这条记录在当前用户的数据权限范围内吗",回查条件与查询路径用的是同一套 ApplyDataPermission(第 09 篇)。

3. 直接调 HTTP 接口

页面操作大多走 SignalR 电路,但文件下载、上传这类必须走 HTTP。如果接口只判断"是否登录",越权依然成立。框架的处理是逐项判定:

private async Task<bool> IsAuthorizedAsync(SysFile file)
{
    await _adminContext.InitRoles();

    if (_adminContext.Roles.Any(r => r.IsAdministrator)) return true;
    if (_adminContext.Roles.Any(r => r.DataPermission == DataPermissionType.AllData)) return true;

    return file.CreatedUserId == _adminContext.User?.Id;
}

注意注释里那句"上传人信息缺失时按拒绝处理(fail-closed)"。

4. 订阅别人的实时通知

如果 Hub 暴露 JoinGroup(userId),客户端传别人的 Id 就能收到别人的站内信。框架的做法是根本不给这个方法(第 20 篇):

// 客户端不得通过 JoinGroup / LeaveGroup 指定任意 userId 加入/退出用户组
public override async Task OnConnectedAsync()
{
    var userId = GetCurrentUserId();   // 只来自服务端身份
    ...
    await Groups.AddToGroupAsync(Context.ConnectionId, GetUserGroup(userId));
}

5. 标记别人的消息为已读

批量更新接口如果直接信任客户端传来的实体,就可能改到别人的数据。框架只取 Id 列表,再拼当前用户条件:

/// <summary>
/// 批量设置消息为已读。
/// 不信任外部传入的完整实体,只取 Id 列表并结合当前登录用户重新构造更新条件,
/// 确保只能标记"发给当前用户"的消息,防止越权修改他人消息状态。
/// </summary>
public async Task UpdateMessagesStatus(List<SysMessage> unreadMessagesForUser)

二、框架里的四个服务端校验点

校验点位置管什么
路由级AdminContext.AuthPath(MainLayout 调用)能不能进这个页面
操作级AdminContext.AuthButton(AdminTable 保存/删除)能不能做这个动作
方法级AdminButtonAttribute(Rougamo AOP)服务方法能不能执行
数据级FreeSqlExtensions.FilterAuthorizedAsync能操作哪些行

方法级校验:适合"页面内的自定义操作"

[AttributeUsage(AttributeTargets.Method)]
public class AdminButtonAttribute : Rougamo.AsyncMoAttribute
{
    public string Name { get; set; } = string.Empty;

    public override async ValueTask OnEntryAsync(MethodContext context)
    {
        var service = context.GetServiceProvider();
        var admin = service.GetService<AdminContext>();
        var swalService = service.GetService<SwalService>();

        if (!await admin.AuthButton(Name))
        {
            context.ReplaceReturnValue(this, CreateDeniedReturnValue(context.ReturnType));
            if (swalService is not null)
                await swalService.Warning("你没有权限执行该操作!");
        }
    }
}

用法:

[AdminButton("alloc_menus")]
[OperationLog("修改角色菜单权限")]
private async Task OnSaveMenu()
{
    ...
}

被拦截时方法体不会执行,Task<bool> 直接返回 false。

自定义回调也会被包一层

如果页面自己传了 OnSaveAsync / OnDeleteAsync,框架会先包权限校验再执行:

private async Task<bool> SaveWithPermissionAsync(Func<TItem, ItemChangedType, Task<bool>> originalSave, TItem item, ItemChangedType changedType)
{
    var action = changedType == ItemChangedType.Add ? "add" : "edit";
    if (!await admin.AuthButton(action))
    {
        await MessageService.Error(string.Format(CommonLocalizer["没有权限执行该操作"]));
        return false;
    }
    return await originalSave(item, changedType);
}

但要注意:这层包装只保证"按钮权限",不包含"数据权限回查"。自定义回调里如果要按主键改数据,需要自己补 FilterAuthorizedAsync 等价逻辑。


三、测试怎么证明这些不是"写在文档里"

DataPermissionTests.cs 用真实数据库覆盖了伪造场景:

测试场景断言
Update_ForgedIdOutsideScope_IsRejected伪造 Id 指向其他组织的数据授权列表为空,且数据库状态未被修改
Update_WithinScope_IsAllowed权限范围内正常通过
Delete_OtherOrgRows_AreFilteredOut批量删除混入他人数据越权记录未被删除
ExcelImport_OtherOrgRow_IsDroppedExcel 里伪造 Id越权行被剔除
Query_CannotReadOtherUsersData查询读不到他人数据
Admin_RoleWithAllData_SeesEverything管理员不受限

NotificationHubTests.cs 锁定了"不许出现客户端可控的组方法":

[Theory]
[InlineData("JoinGroup")]
[InlineData("LeaveGroup")]
public void Hub_DoesNotExposeClientControllableGroupMethods(string methodName)
{
    var methods = HubType.GetMethods(BindingFlags.Public | BindingFlags.Instance)
        .Where(m => m.Name == methodName && m.DeclaringType == HubType)
        .ToList();

    methods.Should().BeEmpty($"客户端不得通过 {methodName} 指定任意 userId 加入/退出用户组");
}

AuthorizationTests.cs 则防止新接口"意外匿名":

[Fact]
public void FileUploadController_IsNotAnonymous()
{
    typeof(FileUploadController).GetCustomAttribute<AllowAnonymousAttribute>().Should().BeNull();
    typeof(FileUploadController).GetCustomAttribute<AuthorizeAttribute>().Should().NotBeNull();
}

同一个文件里还有一条很实用的约束:控制器不得把 ex.Message 直接返回给客户端(WeChatController_DoesNotLeakExceptionMessage),避免异常信息泄露内部结构。


四、匿名接口怎么办

有些接口必须匿名(例如微信小程序回调,客户端无法携带后台登录态)。这时候的防护策略完全不同:

/// <summary>
/// 微信接口按设计保留 AllowAnonymous(小程序端无法登录),
/// 但必须通过限流/参数校验/超时做防护。
/// </summary>
[Fact]
public void WeChatController_KeepsAllowAnonymousButHasRateLimit()
{
    var type = typeof(EasyAdminBlazor.WeChat.WeChatAppController);

    type.GetCustomAttribute<AllowAnonymousAttribute>().Should().NotBeNull();

    var source = ReadSource("Extensions", "EasyAdminBlazor.WeChat", "WeChatAppController.cs");
    source.Should().Contain("TryRateLimit", "匿名接口必须做限流");
    source.Should().Contain("TryValidateCode", "必须做参数格式/长度校验");
    source.Should().Contain("TryValidatePath", "尤其 WeiXinQr 必须校验 path");
    source.Should().Contain("TryValidateScene", "尤其 WeiXinQr 必须校验 scene");
}

匿名接口的三件套:限流 + 参数校验 + 不泄露异常。这三条缺一条,匿名接口就会变成入口。


五、自查清单

写后台功能时,逐条对照:

问题应该怎么做
这个页面的入口有没有校验?路由级 AuthPath(框架已做);自定义页面用 @page + 菜单路径注册
这个按钮隐藏了,方法还会不会被执行?方法内再判一次 AuthButton,或用 [AdminButton]
这次修改是按 Id 落库的吗?用 FilterAuthorizedAsync 回查权限范围
这次删除是批量吗?先过滤再执行,越权的剔除并提示
有导入/导出吗?导入要过滤越权行;导出要与查询同一套条件
有对外接口吗?单独设计授权策略,不要指望组件权限自动生效
有匿名接口吗?限流 + 参数校验 + 不泄露异常信息
有实时推送吗?订阅目标必须由服务端身份决定
返回给用户的信息会不会泄露内部细节?用通用提示 + TraceId,异常详情只进日志

六、小结

层次前端隐藏服务端校验
能不能看到入口菜单不渲染AuthPath 拦路由
能不能点按钮按钮不渲染AuthButton / AdminButton 拦方法
能不能改这一行无法表达FilterAuthorizedAsync 拦主键
能不能收别人的消息无法表达Hub 组由服务端身份决定

一句话:前端负责"不让用户误点",服务端负责"不让用户越权"。 两件事都要做,但不能互相替代。


如果你正在用 .NET 10 + Blazor 做后台,可以按这篇的四个校验点检查自己的项目:路由、操作、方法、数据。EasyAdminBlazor 的这四层都有现成实现和测试,可以直接对照。