安全最佳实践
安全模型
NiceGUI 提供了安全的默认设置和内置保护机制,但开发者必须编写安全的代码。并非所有 UI 组件都能安全地处理不受信任的输入,因此了解哪些组件需要验证是至关重要的。
框架提供的:
- 尽可能的安全默认设置
- 内置保护机制
- 及时的漏洞修复
开发者的责任:
- 审查应用逻辑中的不安全模式
- 保持对不受信任内容的清理功能为启用状态
- 在必要时验证用户输入
- 保持 NiceGUI 为最新版本
安全的输入解析
NiceGUI 应用本质上是 Python 代码,许多安全问题源于不安全的 Python 模式。审查你的应用逻辑可以发现不应出现在生产环境中的漏洞。
例如,使用 ast.literal_eval() 来安全地将用户输入解析为 Python 数据结构:
import ast
from nicegui import ui
def evaluate_safely():
try:
value = ast.literal_eval(user_input.value)
ui.notify(f'结果: {value}')
except (ValueError, SyntaxError):
ui.notify('无效的 Python 字面量', type='negative')
user_input = ui.input('输入 Python 字面量', placeholder='[1, 2]')
ui.button('解析', on_click=evaluate_safely)
ui.run()警告
绝对不要这样做:
value = eval(user_input.value) # 可以执行任意 Python 代码!组件选择
选择正确的组件可以减少手动验证的需求。
默认安全(在 sanitize=True 的默认设置下):
ui.html()ui.markdown()ui.chat_message()ui.interactive_image()- 其他具有明确用途的元素,框架可以自动保护。
需要开发者验证(框架无法区分安全值和不安全值):
ui.navigate.to()ui.link()element.style()
绝对不要与不受信任的输入一起使用:
ui.add_head_html()ui.add_body_html()ui.add_css()ui.run_javascript()
from nicegui import ui
username = ui.input('输入姓名')
ui.label().bind_text_from(username, 'value')
ui.markdown().bind_content_from(username, 'value')
ui.run()警告
绝对不要这样做:
ui.add_body_html(f"<div>欢迎 {username}</div>") # XSS: "<img src=x onerror=alert(1)>"
ui.add_head_html(f"<script>alert('{username}')</script>") # XSS: "');alert(1);//"URL 验证
NiceGUI 不会验证 URL 方案(scheme),因为 javascript: URL 有合法的用途。当接受来自用户输入的 URL 时,请验证方案以防止 javascript: 注入:
from urllib.parse import urlparse
from nicegui import ui
def is_safe_url(url: str) -> bool:
return urlparse(url.strip()).scheme in ('', 'http', 'https')
def open_link(url: str) -> None:
if is_safe_url(url):
ui.navigate.to(url)
else:
ui.notify('无效或不安全的 URL', type='negative')
def show_link(url: str) -> None:
if is_safe_url(url):
ui.link(target=url)
else:
ui.notify('无效或不安全的 URL', type='negative')
user_url = ui.input('输入 URL', placeholder='javascript:alert(1)')
ui.button('导航', on_click=lambda: open_link(user_url.value))
ui.button('显示链接', on_click=lambda: show_link(user_url.value))
ui.run()警告
绝对不要这样做:
ui.navigate.to(user_url.value) # 允许 javascript: URL 注入!
ui.link(user_url.value) # 渲染 javascript: URL 而不进行验证!CSS 注入
元素样式属性不会被转义,因为框架无法区分合法的 CSS 和 CSS 数据窃取技术。在应用用户输入之前请验证样式值:
import re
from nicegui import ui
def is_safe_color(color: str) -> bool:
hex_pattern = r'^#[0-9a-fA-F]+$'
rgb_pattern = r'^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$'
return bool(re.match(hex_pattern, color) or re.match(rgb_pattern, color))
def apply_color():
if is_safe_color(user_color.value):
label.style['color'] = user_color.value
else:
ui.notify('无效的颜色', type='negative')
user_color = ui.input('输入颜色', placeholder='#0000ff')
label = ui.label('示例文本')
ui.button('应用颜色', on_click=apply_color)
ui.run()警告
绝对不要这样做:
label.style['color'] = user_color.value # 允许 CSS 注入和数据窃取客户端密钥
NiceGUI 为每个客户端会话分配一个唯一的 client_id(随机 UUID)。
client_id 从哪里来:页面是唯一的签发者。
渲染页面时会生成一个新的 client_id 并将其嵌入响应 HTML 中,而该 HTML 中的元素决定了这个 client_id 拥有哪些能力。
- 用身份验证(中间件重定向、登录检查等)保护重要页面,使未经身份验证的访问者永远拿不到能访问受保护功能的
client_id。 - 除身份验证外,还要防范已验证用户的 HTML 通过旁路泄露(XSS、浏览器缓存暴露)。
client_id 在哪里被使用:/_nicegui/ 下的 NiceGUI 内部路由。
- 按客户端划分的路由会携带
client_id(在 URL 中或在 Socket.IO 消息载荷中),并将其视为会话令牌。这包括 Socket.IO 传输通道和ui.upload()的 POST 端点,以及其他所有动态注册的按客户端划分的路由。其他/_nicegui/路由(库、组件、静态资源和自动挂载的资源)提供的是公开资源,不按客户端划分。 - 按客户端划分的路由没有单独的身份验证层。保护签发
client_id的页面,就是在保护这些路由。
因此,如果 client_id 或客户端 cookie 被暴露给攻击者,则该客户端会话被视为已遭入侵。
保护客户端会话的方法:
- 在生产环境中通过 HTTPS 提供页面,以防止流量嗅探。
- 绝不要让共享缓存存储页面响应。每个页面都嵌入了一个新的
client_id,因此 CDN、反向代理缓存或任何其他会缓存 HTML 的中间层都会从两方面破坏安全模型:它会把一个访问者的会话令牌交给所有命中该缓存条目的人,并且会把任何被反射的输入变成缓存投毒的途径——一个恶意请求就能存入攻击者控制的 HTML(XSS、钓鱼、页面篡改),随后提供给之后的每一个访问者。正因如此,NiceGUI 会在页面上发送Cache-Control: no-store——不要覆盖或剥离它(例如一条笼统的「全部缓存」规则)。这是上文提到的私有浏览器缓存泄露在共享缓存上的对应情形。 - 不要从相同来源提供不受信任的内容(例如,提供用户上传的 HTML 文件可能通过 JavaScript 泄露密钥)。
- 不要在日志、URL 或对其他用户可见的 API 响应中暴露
client_id。 - 将
client_id视为会话令牌:任何知道它的人都可以代表该客户端发送事件。 - 保护页面,而不是
/_nicegui/:在凭据签发的地方加以保护,而不是在它被使用的地方。
并非每个随机 ID 都是密钥。
NiceGUI 会为几种不同的用途生成随机 UUID,它们承载的权限并不相同:
- 按设计公开:事件监听器、Leaflet 图层和 3D 场景对象的 ID 会以明文形式发送到浏览器。它们只需要保证唯一,知道其中一个并不会带来任何权限。
- 持有者凭证:上文的
client_id,以及作为app.storage.tab键的标签页 ID(由浏览器生成并随连接发送,而非由服务器签发)。任何知道其中之一的人都可以冒充该客户端,或读写该标签页的存储,因此两者都需要上述保护措施。 - 存储键:
app.storage.user的 ID 保存在用你的storage_secret签名的会话 cookie 中。在这里,访问边界是签名而不是 ID,因此仅猜出 ID 并不能解锁其他用户的存储。
只有第二类需要作为密钥加以保护。实践中失效的不是随机性,而是泄露——上述做法防范的正是这一点。
示例只是起点
NiceGUI 提供了许多示例。它们以最小的可运行形式演示某一种特定机制(身份验证、文件上传、终端等)。它们不是生产模板,可能有意省略了你的部署所需的安全加固。
在把示例作为真实应用的基础之前:
- 阅读每一行代码,并理解它为什么存在。在本地演示中安全的模式,放到公开 URL 后面可能并不安全。
- 重新评估每一条威胁模型假设。例如,
xterm示例按设计将浏览器连接到 Bash PTY。它的目的是演示这种集成,而不是暴露在公共互联网上。 - 根据你的需求调整身份验证示例。它演示了页面级别基于会话的身份验证,这是推荐的模式。但生产应用通常还需要速率限制、针对你添加的任何状态变更端点的 CSRF 防护、密码哈希和审计日志——而该示例并未提供其中任何一项。
- 在服务端验证上传的内容。
ui.upload()元素只在浏览器中强制执行max_file_size、max_total_size和max_files限制。如果你的on_upload处理函数会写入磁盘或处理文件,也要在服务端验证大小和类型。 - 将重载模式(
reload=True)视为仅供开发使用。自动重载会监视工作目录并重新导入发生变化的文件。这在本地很方便,但不适合生产环境。
示例是起点,而不是成品。你发布的任何东西都由你自己负责维护。
其他资源
安全公告:
外部资源:
关键原则:
- 保持清理功能为启用状态(这是默认设置)
- 验证 URL 方案(框架不会限制它们)
- 验证 CSS 值(框架不会转义它们)
- 仅对你控制的受信任内容禁用清理功能
- 应用纵深防御(iframe 阻止头、输入验证)
- 保持 NiceGUI 为最新版本