这是权限设计里最容易被跳过的一句话:前端隐藏按钮只是体验,不是安全边界。
这篇用框架里的真实防线说明:如果只做前端隐藏,攻击者能怎么绕过;以及服务端应该在哪些地方补校验。
一、只隐藏按钮,攻击者怎么绕过
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_IsDropped | Excel 里伪造 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 的这四层都有现成实现和测试,可以直接对照。