security(review): fix invite bypass via smuggled seed flag, login rate limit, server-side logout, attachment preview XSS hardening

- /auth/register: strip client-supplied seed flag (PoC confirmed it bypassed
  invite code, MAX_USERS quota AND first-user auto-admin, granting admin
  without any credential); demo-rebuild internal path unaffected
- /auth/login: per-IP+username of 5 consecutive failures gets a 10-minute
  cooldown against in-memory counter (brute-force guard)
- /auth/logout: new endpoint revokes the current Bearer token server-side;
  frontend calls it (best-effort) before local clearToken on sidebar logout
- attachment docx/xlsx preview: sanitize generated HTML (strip scripts,
  event handlers, dangerous protocols) before dangerouslySetInnerHTML
  render, preventing token theft via malicious previewed documents

PoC suite: seed smuggling rejected, 429 on 6th login attempt, token dead
after logout, per-user isolation intact (403/401/404 as before);
integration 61/61 green
This commit is contained in:
root
2026-10-17 21:18:55 +08:00
parent 3613b9f5e1
commit 17ab5f0390
20 changed files with 416 additions and 358 deletions
@@ -7,6 +7,30 @@ import type { Attachment } from '@/lib/types';
export type AttKind = 'image' | 'pdf' | 'text' | 'audio' | 'video' | 'docx' | 'xlsx' | 'other';
// 预览 HTML 加固:docx(mammoth)/xlsx(sheet_to_html) 的转换输出不可信,
// 剥离脚本/事件属性/危险协议,防止恶意文档在预览时执行脚本偷取本地 token
function sanitizePreviewHtml(html: string): string {
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
for (const el of Array.from(doc.querySelectorAll('script,style,iframe,frame,object,embed,link,meta,form,input,button,base'))) el.remove();
for (const el of Array.from(doc.querySelectorAll('*'))) {
for (const attr of Array.from(el.attributes)) {
const n = attr.name.toLowerCase();
const v = attr.value.trim().toLowerCase();
if (n.startsWith('on')) el.removeAttribute(attr.name);
else if ((n === 'href' || n === 'src' || n === 'xlink:href') &&
(v.startsWith('javascript:') || (v.startsWith('data:') && !v.startsWith('data:image/')))) {
el.removeAttribute(attr.name);
}
}
}
return doc.body.innerHTML;
} catch {
return html;
}
}
export function attKind(a: { file_name: string; mime?: string | null }): AttKind {
const mime = (a.mime || '').toLowerCase();
const ext = (a.file_name.split('.').pop() || '').toLowerCase();
@@ -68,13 +92,13 @@ export function AttachmentPreview({ att, onClose }: { att: Attachment | null; on
const mammoth = await import('mammoth/mammoth.browser') as any;
const html = (await mammoth.convertToHtml({ arrayBuffer: bytes.buffer })).value as string;
if (cancelled) return;
setContent({ kind, html });
setContent({ kind, html: sanitizePreviewHtml(html) });
} else if (kind === 'xlsx') {
const XLSX = await import('xlsx');
const wb = XLSX.read(bytes, { type: 'array' });
const html = XLSX.utils.sheet_to_html(wb.Sheets[wb.SheetNames[0]]);
if (cancelled) return;
setContent({ kind, html });
setContent({ kind, html: sanitizePreviewHtml(html) });
} else {
// 本地回退的图片/PDF/音视频
const url = URL.createObjectURL(new Blob([bytes], { type: mime || 'application/octet-stream' }));
+2 -2
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, LogIn, LogOut, ShieldCheck, Cloud, User, ScrollText, ClipboardCheck } from 'lucide-react';
import { serverMe, clearToken, getToken } from '@/lib/api';
import { serverLogout, serverMe, clearToken, getToken } from '@/lib/api';
import { LoginDialog } from '@/components/auth/LoginDialog';
import { cn } from '@/lib/utils';
@@ -123,7 +123,7 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
<button
className="shrink-0 rounded p-1 text-ink-3 transition-colors hover:bg-surface-3 hover:text-danger"
title="退出登录"
onClick={() => { clearToken(); qc.clear(); window.location.reload(); }}
onClick={() => { void serverLogout(); clearToken(); qc.clear(); window.location.reload(); }}
>
<LogOut size={13} />
</button>
+10
View File
@@ -55,6 +55,16 @@ const SERVER_KEY = 'fp-server-url';
export function getServerUrl() { return localStorage.getItem(SERVER_KEY) || 'https://footpr.linyux.cn'; }
export function setServerUrl(url: string) { localStorage.setItem(SERVER_KEY, url.replace(/\/+$/, '')); }
// 服务端登出:吊销当前 Bearer token(尽力而为,网络失败不影响本地清理)
export async function serverLogout(baseUrl?: string): Promise<void> {
try {
const t = getToken();
if (!t) return;
const base = baseUrl || getServerUrl();
await fetch(base + '/auth/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + t } });
} catch { /* 本地清理兜底 */ }
}
export class AuthError extends Error {
constructor(msg: string) { super(msg); this.name = 'AuthError'; }
}