fix: prevent user permission escalation
This commit is contained in:
+10
-8
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user