fix: prevent user permission escalation

This commit is contained in:
loveuer
2026-09-09 21:00:41 -07:00
parent d3016b94db
commit fa4357a3f2
4 changed files with 103 additions and 8 deletions
+2
View File
@@ -26,6 +26,8 @@
首次使用空数据库启动时,终端会输出 `admin` 的随机密码。该账号第一次登录后必须立即修改密码。
拥有用户管理权限的账号只能授予自己已有的权限,也不能编辑、重置或删除权限高于自己的账号。
## 分享链接
- 下载:`/uf/<file_hash>`
+42
View File
@@ -138,6 +138,36 @@ func (a *App) manageUsers(c *gin.Context) {
c.Next()
}
func permissionsWithin(actor *User, canManageUsers, canUpload, canAdvancedUpload bool) bool {
if canManageUsers && !actor.CanManageUsers {
return false
}
if canAdvancedUpload && !actor.CanAdvancedUpload {
return false
}
return !canUpload || actor.CanUpload || actor.CanAdvancedUpload
}
func canManageTarget(actor, target *User) bool {
return permissionsWithin(actor, target.CanManageUsers, target.CanUpload, target.CanAdvancedUpload)
}
func requireAssignablePermissions(c *gin.Context, canManageUsers, canUpload, canAdvancedUpload bool) bool {
if permissionsWithin(currentUser(c), canManageUsers, canUpload, canAdvancedUpload) {
return true
}
c.JSON(http.StatusForbidden, gin.H{"error": "不能授予自己不具备的权限"})
return false
}
func requireManageableTarget(c *gin.Context, target *User) bool {
if canManageTarget(currentUser(c), target) {
return true
}
c.JSON(http.StatusForbidden, gin.H{"error": "不能管理权限高于自己的用户"})
return false
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -562,6 +592,9 @@ func (a *App) createUser(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名需为 3 到 32 位字母、数字、点、横线或下划线"})
return
}
if !requireAssignablePermissions(c, req.CanManageUsers, req.CanUpload, req.CanAdvancedUpload) {
return
}
password, err := randomToken(12)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"})
@@ -600,6 +633,9 @@ func (a *App) updateUser(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
if !requireManageableTarget(c, &user) || !requireAssignablePermissions(c, req.CanManageUsers, req.CanUpload, req.CanAdvancedUpload) {
return
}
err := a.db.Model(&user).Updates(map[string]any{"username": req.Username, "can_manage_users": req.CanManageUsers, "can_upload": req.CanUpload, "can_advanced_upload": req.CanAdvancedUpload}).Error
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"})
@@ -619,6 +655,9 @@ func (a *App) resetPassword(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
if !requireManageableTarget(c, &user) {
return
}
password, err := randomToken(12)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置密码失败"})
@@ -653,6 +692,9 @@ func (a *App) deleteUser(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
if !requireManageableTarget(c, &user) {
return
}
var files []SharedFile
if err := a.db.Where("owner_id = ?", id).Find(&files).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"})
+49
View File
@@ -305,6 +305,55 @@ func TestDeleteUserRemovesOwnedFilesAndProtectsIDOne(t *testing.T) {
}
}
func TestUserManagerCannotGrantOrTakeOverPermissionsTheyDoNotHave(t *testing.T) {
app, _ := testApp(t)
passwordHash, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.DefaultCost)
manager := User{Username: "manager", PasswordHash: string(passwordHash), CanManageUsers: true, CanUpload: true}
higherUser := User{Username: "higher", PasswordHash: string(passwordHash), CanAdvancedUpload: true}
basicUser := User{Username: "editable", PasswordHash: string(passwordHash), CanUpload: true}
for _, user := range []*User{&manager, &higherUser, &basicUser} {
if err := app.db.Create(user).Error; err != nil {
t.Fatal(err)
}
}
router := app.router()
cookie := loginAs(t, router, manager.Username, "password-123")
createBody, _ := json.Marshal(map[string]any{"username": "escalated", "canAdvancedUpload": true})
created := request(t, router, http.MethodPost, "/api/users", bytes.NewReader(createBody), "application/json", cookie)
if created.Code != http.StatusForbidden {
t.Fatalf("create advanced user status = %d, body = %s", created.Code, created.Body.String())
}
updateBody, _ := json.Marshal(map[string]any{"username": basicUser.Username, "canUpload": true, "canAdvancedUpload": true})
updated := request(t, router, http.MethodPut, "/api/users/"+jsonNumber(basicUser.ID), bytes.NewReader(updateBody), "application/json", cookie)
if updated.Code != http.StatusForbidden {
t.Fatalf("grant advanced permission status = %d, body = %s", updated.Code, updated.Body.String())
}
reset := request(t, router, http.MethodPost, "/api/users/"+jsonNumber(higherUser.ID)+"/reset-password", nil, "", cookie)
if reset.Code != http.StatusForbidden {
t.Fatalf("reset higher user status = %d, body = %s", reset.Code, reset.Body.String())
}
downgradeBody, _ := json.Marshal(map[string]any{"username": higherUser.Username, "canUpload": true})
downgraded := request(t, router, http.MethodPut, "/api/users/"+jsonNumber(higherUser.ID), bytes.NewReader(downgradeBody), "application/json", cookie)
if downgraded.Code != http.StatusForbidden {
t.Fatalf("edit higher user status = %d, body = %s", downgraded.Code, downgraded.Body.String())
}
deleted := request(t, router, http.MethodDelete, "/api/users/"+jsonNumber(higherUser.ID), nil, "", cookie)
if deleted.Code != http.StatusForbidden {
t.Fatalf("delete higher user status = %d, body = %s", deleted.Code, deleted.Body.String())
}
createBody, _ = json.Marshal(map[string]any{"username": "allowed", "canUpload": true})
created = request(t, router, http.MethodPost, "/api/users", bytes.NewReader(createBody), "application/json", cookie)
if created.Code != http.StatusCreated {
t.Fatalf("create user with owned permission status = %d, body = %s", created.Code, created.Body.String())
}
}
func jsonNumber(id uint) string {
data, _ := json.Marshal(id)
return string(data)
+10 -8
View File
@@ -150,21 +150,22 @@ function UploadResult({ file, onAgain }) {
</section>
}
const emptyUser = { username: '', canManageUsers: false, canUpload: true, canAdvancedUpload: false }
function UserModal({ value, onClose, onSaved }) {
const [form, setForm] = useState(value || emptyUser)
const actor = useAppStore((s) => s.user)
const canGrantUpload = actor.canUpload || actor.canAdvancedUpload
const [form, setForm] = useState(value || { username: '', canManageUsers: false, canUpload: canGrantUpload, canAdvancedUpload: false })
const [error, setError] = useState('')
const submit = async (e) => {
e.preventDefault(); setError('')
try { onSaved(await api(value ? `/api/users/${value.id}` : '/api/users', json(value ? 'PUT' : 'POST', form))) }
catch (err) { setError(err.message) }
}
const checkbox = (key, title, hint) => <label className="flex cursor-pointer gap-3 rounded-xl border border-line p-3.5"><input className="mt-1 size-4 accent-leaf" type="checkbox" checked={form[key]} onChange={(e) => setForm({ ...form, [key]: e.target.checked })} /><span><span className="block text-sm font-medium">{title}</span><span className="mt-0.5 block text-xs text-ink/45">{hint}</span></span></label>
return <div className="fixed inset-0 z-40 grid place-items-center bg-ink/30 p-5 backdrop-blur-sm"><form onSubmit={submit} className="card w-full max-w-md p-6"><div className="flex items-center justify-between"><h2 className="text-lg font-semibold">{value ? '编辑用户' : '创建用户'}</h2><button type="button" onClick={onClose}><X size={20} /></button></div><label className="label mt-5">用户名</label><input className="field" required pattern="[A-Za-z0-9_.-]{3,32}" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /><div className="mt-4 space-y-2">{checkbox('canManageUsers', '用户管理', '创建、编辑、重置或删除用户')}{checkbox('canUpload', '普通上传', '最多保留 10 个有效文件')}{checkbox('canAdvancedUpload', '高级上传', '涵盖普通上传,并允许永久、无限下载')}</div>{error && <p className="mt-3 text-sm text-red-600">{error}</p>}<div className="mt-6 flex justify-end gap-2"><button type="button" className="btn-secondary" onClick={onClose}>取消</button><button className="btn-primary">保存</button></div></form></div>
const checkbox = (key, title, hint, disabled = false) => <label className={`flex gap-3 rounded-xl border border-line p-3.5 ${disabled ? 'cursor-not-allowed opacity-45' : 'cursor-pointer'}`}><input className="mt-1 size-4 accent-leaf" type="checkbox" disabled={disabled} checked={form[key]} onChange={(e) => setForm({ ...form, [key]: e.target.checked })} /><span><span className="block text-sm font-medium">{title}</span><span className="mt-0.5 block text-xs text-ink/45">{hint}</span></span></label>
return <div className="fixed inset-0 z-40 grid place-items-center bg-ink/30 p-5 backdrop-blur-sm"><form onSubmit={submit} className="card w-full max-w-md p-6"><div className="flex items-center justify-between"><h2 className="text-lg font-semibold">{value ? '编辑用户' : '创建用户'}</h2><button type="button" onClick={onClose}><X size={20} /></button></div><label className="label mt-5">用户名</label><input className="field" required pattern="[A-Za-z0-9_.-]{3,32}" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /><p className="mt-4 text-xs text-ink/45">只能分配你当前拥有的权限。</p><div className="mt-2 space-y-2">{checkbox('canManageUsers', '用户管理', '创建、编辑、重置或删除用户')}{checkbox('canUpload', '普通上传', '最多保留 10 个有效文件', !canGrantUpload)}{checkbox('canAdvancedUpload', '高级上传', '涵盖普通上传,并允许永久、无限下载', !actor.canAdvancedUpload)}</div>{error && <p className="mt-3 text-sm text-red-600">{error}</p>}<div className="mt-6 flex justify-end gap-2"><button type="button" className="btn-secondary" onClick={onClose}>取消</button><button className="btn-primary">保存</button></div></form></div>
}
function UsersPanel({ notify }) {
const actor = useAppStore((s) => s.user)
const users = useAppStore((s) => s.users)
const loadUsers = useAppStore((s) => s.loadUsers)
const [editing, setEditing] = useState(undefined)
@@ -172,9 +173,10 @@ function UsersPanel({ notify }) {
const [secret, setSecret] = useState(null)
useEffect(() => { loadUsers() }, [loadUsers])
const saved = async (result) => { setModalOpen(false); setEditing(undefined); await loadUsers(); if (result.temporaryPassword) setSecret({ title: '用户已创建', password: result.temporaryPassword }); else notify('用户信息已保存') }
const reset = async (user) => { if (!confirm(`重置 ${user.username} 的密码并注销其所有会话?`)) return; const result = await api(`/api/users/${user.id}/reset-password`, { method: 'POST' }); setSecret({ title: `${user.username} 的临时密码`, password: result.temporaryPassword }) }
const remove = async (user) => { if (!confirm(`删除 ${user.username} 及其全部文件?此操作不可撤销。`)) return; await api(`/api/users/${user.id}`, { method: 'DELETE' }); await loadUsers(); notify('用户及其文件已删除') }
return <section className="card overflow-hidden"><div className="flex items-center justify-between border-b border-line px-5 py-4"><div><h2 className="font-semibold">用户管理</h2><p className="mt-0.5 text-xs text-ink/40">权限修改立即生效</p></div><button className="btn-primary !py-2 text-sm" onClick={() => { setEditing(undefined); setModalOpen(true) }}>创建用户</button></div><div className="divide-y divide-line">{users.map((user) => <div key={user.id} className="flex flex-col gap-3 p-5 sm:flex-row sm:items-center"><div className="grid size-10 shrink-0 place-items-center rounded-full bg-paper font-semibold text-leaf">{user.username.slice(0, 1).toUpperCase()}</div><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><p className="font-medium">{user.username}</p><span className="text-xs text-ink/35">#{user.id}</span>{user.mustChangePassword && <span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] text-amber-700">待改密</span>}</div><p className="mt-1 text-xs text-ink/45">{[user.canManageUsers && '用户管理', user.canUpload && '普通上传', user.canAdvancedUpload && '高级上传'].filter(Boolean).join(' · ') || '无权限'}</p></div><div className="flex gap-2"><button className="btn-secondary !px-3 !py-2 text-xs" onClick={() => { setEditing(user); setModalOpen(true) }}>编辑</button><button className="btn-secondary !px-3 !py-2 text-xs" onClick={() => reset(user)}>重置密码</button>{user.id !== 1 && <button className="rounded-xl px-3 py-2 text-xs font-medium text-red-600 hover:bg-red-50" onClick={() => remove(user)}>删除</button>}</div></div>)}</div>
const manageable = (user) => (!user.canAdvancedUpload || actor.canAdvancedUpload) && (!user.canUpload || actor.canUpload || actor.canAdvancedUpload)
const reset = async (user) => { if (!confirm(`重置 ${user.username} 的密码并注销其所有会话?`)) return; try { const result = await api(`/api/users/${user.id}/reset-password`, { method: 'POST' }); setSecret({ title: `${user.username} 的临时密码`, password: result.temporaryPassword }) } catch (error) { notify(error.message) } }
const remove = async (user) => { if (!confirm(`删除 ${user.username} 及其全部文件?此操作不可撤销。`)) return; try { await api(`/api/users/${user.id}`, { method: 'DELETE' }); await loadUsers(); notify('用户及其文件已删除') } catch (error) { notify(error.message) } }
return <section className="card overflow-hidden"><div className="flex items-center justify-between border-b border-line px-5 py-4"><div><h2 className="font-semibold">用户管理</h2><p className="mt-0.5 text-xs text-ink/40">只能管理权限不高于自己的用户</p></div><button className="btn-primary !py-2 text-sm" onClick={() => { setEditing(undefined); setModalOpen(true) }}>创建用户</button></div><div className="divide-y divide-line">{users.map((user) => <div key={user.id} className="flex flex-col gap-3 p-5 sm:flex-row sm:items-center"><div className="grid size-10 shrink-0 place-items-center rounded-full bg-paper font-semibold text-leaf">{user.username.slice(0, 1).toUpperCase()}</div><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><p className="font-medium">{user.username}</p><span className="text-xs text-ink/35">#{user.id}</span>{user.mustChangePassword && <span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] text-amber-700">待改密</span>}</div><p className="mt-1 text-xs text-ink/45">{[user.canManageUsers && '用户管理', user.canUpload && '普通上传', user.canAdvancedUpload && '高级上传'].filter(Boolean).join(' · ') || '无权限'}</p></div>{manageable(user) ? <div className="flex gap-2"><button className="btn-secondary !px-3 !py-2 text-xs" onClick={() => { setEditing(user); setModalOpen(true) }}>编辑</button><button className="btn-secondary !px-3 !py-2 text-xs" onClick={() => reset(user)}>重置密码</button>{user.id !== 1 && <button className="rounded-xl px-3 py-2 text-xs font-medium text-red-600 hover:bg-red-50" onClick={() => remove(user)}>删除</button>}</div> : <span className="text-xs text-ink/35">权限高于你</span>}</div>)}</div>
{modalOpen && <UserModal value={editing} onClose={() => setModalOpen(false)} onSaved={saved} />}
{secret && <div className="fixed inset-0 z-50 grid place-items-center bg-ink/30 p-5 backdrop-blur-sm"><div className="card w-full max-w-md p-7"><h2 className="text-lg font-semibold">{secret.title}</h2><p className="mt-2 text-sm text-ink/50">该密码只显示这一次,用户登录后必须修改。</p><div className="mt-5 flex items-center justify-between rounded-xl bg-paper p-4 font-mono text-sm"><span className="break-all">{secret.password}</span><button onClick={() => navigator.clipboard.writeText(secret.password)}><Copy size={17} /></button></div><button className="btn-primary mt-5 w-full" onClick={() => setSecret(null)}>我已保存</button></div></div>}
</section>