fix: prevent user permission escalation
This commit is contained in:
@@ -26,6 +26,8 @@
|
|||||||
|
|
||||||
首次使用空数据库启动时,终端会输出 `admin` 的随机密码。该账号第一次登录后必须立即修改密码。
|
首次使用空数据库启动时,终端会输出 `admin` 的随机密码。该账号第一次登录后必须立即修改密码。
|
||||||
|
|
||||||
|
拥有用户管理权限的账号只能授予自己已有的权限,也不能编辑、重置或删除权限高于自己的账号。
|
||||||
|
|
||||||
## 分享链接
|
## 分享链接
|
||||||
|
|
||||||
- 下载:`/uf/<file_hash>`
|
- 下载:`/uf/<file_hash>`
|
||||||
|
|||||||
@@ -138,6 +138,36 @@ func (a *App) manageUsers(c *gin.Context) {
|
|||||||
c.Next()
|
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 {
|
type loginRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -562,6 +592,9 @@ func (a *App) createUser(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名需为 3 到 32 位字母、数字、点、横线或下划线"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名需为 3 到 32 位字母、数字、点、横线或下划线"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireAssignablePermissions(c, req.CanManageUsers, req.CanUpload, req.CanAdvancedUpload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
password, err := randomToken(12)
|
password, err := randomToken(12)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"})
|
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": "用户不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||||
return
|
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
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"})
|
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": "用户不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireManageableTarget(c, &user) {
|
||||||
|
return
|
||||||
|
}
|
||||||
password, err := randomToken(12)
|
password, err := randomToken(12)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置密码失败"})
|
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": "用户不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireManageableTarget(c, &user) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var files []SharedFile
|
var files []SharedFile
|
||||||
if err := a.db.Where("owner_id = ?", id).Find(&files).Error; err != nil {
|
if err := a.db.Where("owner_id = ?", id).Find(&files).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"})
|
||||||
|
|||||||
+49
@@ -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 {
|
func jsonNumber(id uint) string {
|
||||||
data, _ := json.Marshal(id)
|
data, _ := json.Marshal(id)
|
||||||
return string(data)
|
return string(data)
|
||||||
|
|||||||
+10
-8
@@ -150,21 +150,22 @@ function UploadResult({ file, onAgain }) {
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyUser = { username: '', canManageUsers: false, canUpload: true, canAdvancedUpload: false }
|
|
||||||
|
|
||||||
function UserModal({ value, onClose, onSaved }) {
|
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 [error, setError] = useState('')
|
||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault(); setError('')
|
e.preventDefault(); setError('')
|
||||||
try { onSaved(await api(value ? `/api/users/${value.id}` : '/api/users', json(value ? 'PUT' : 'POST', form))) }
|
try { onSaved(await api(value ? `/api/users/${value.id}` : '/api/users', json(value ? 'PUT' : 'POST', form))) }
|
||||||
catch (err) { setError(err.message) }
|
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>
|
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 })} /><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>
|
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 }) {
|
function UsersPanel({ notify }) {
|
||||||
|
const actor = useAppStore((s) => s.user)
|
||||||
const users = useAppStore((s) => s.users)
|
const users = useAppStore((s) => s.users)
|
||||||
const loadUsers = useAppStore((s) => s.loadUsers)
|
const loadUsers = useAppStore((s) => s.loadUsers)
|
||||||
const [editing, setEditing] = useState(undefined)
|
const [editing, setEditing] = useState(undefined)
|
||||||
@@ -172,9 +173,10 @@ function UsersPanel({ notify }) {
|
|||||||
const [secret, setSecret] = useState(null)
|
const [secret, setSecret] = useState(null)
|
||||||
useEffect(() => { loadUsers() }, [loadUsers])
|
useEffect(() => { loadUsers() }, [loadUsers])
|
||||||
const saved = async (result) => { setModalOpen(false); setEditing(undefined); await loadUsers(); if (result.temporaryPassword) setSecret({ title: '用户已创建', password: result.temporaryPassword }); else notify('用户信息已保存') }
|
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 manageable = (user) => (!user.canAdvancedUpload || actor.canAdvancedUpload) && (!user.canUpload || actor.canUpload || actor.canAdvancedUpload)
|
||||||
const remove = async (user) => { if (!confirm(`删除 ${user.username} 及其全部文件?此操作不可撤销。`)) return; await api(`/api/users/${user.id}`, { method: 'DELETE' }); await loadUsers(); notify('用户及其文件已删除') }
|
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) } }
|
||||||
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 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} />}
|
{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>}
|
{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>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user