Compare commits

...
5 Commits
Author SHA1 Message Date
qing e007990594 feat(dnfs,static): 新增管理员特殊节点(链接/网页代码/图片/文件)
- 信封格式:content 为 {"_sp":...} JSON,普通 Markdown 节点不受影响
- dnfs:add_note/update_note 按 is_admin 强制特殊节点发布权(非管理员 403),
  字段与大小校验(url≤2048、code≤512KB、base64≤3.5M 字符),AI 审核剔除 base64 负载
- dnfs:修复存量 bug —— 模块级 _sys.path.insert 未定义导致 import 即 NameError
- static:新增 GET /api/favicon 服务端代理(SSRF 防护:内网地址拦截 + 重定向校验 + 512KB 上限)
- 前端:管理员可见节点类型选择器;链接 favicon 卡片 / 网页可审计可运行 / 图片直显 / 文件下载
- theme.css 补充 select 与特殊节点样式;两个 README 同步更新
2026-08-01 04:34:23 +08:00
qing b79044d631 feat(aibox): add DeepSeek reasoning mode support with tool calling on deepseek-v4-flash
- Add api.reasoning_enabled config (default false) to enable thinking mode
- Uses deepseek-v4-flash native thinking capability (thinking param), supports tool calls while reasoning
- Real-time reasoning content streaming via reasoning_start/reasoning/reasoning_end WS messages
- Frontend shows reasoning in gray italic area, auto-folds long reasoning (>2000 chars)
- Dynamic reasoning detection: shows reasoning even if model returns it without explicit config
- Increase default API timeout to 120s for longer reasoning responses
- Remove duplicate _add_assistant_message_with_reasoning method
- Update docs: config example, message types table, v2.5 changelog
2026-08-01 04:33:55 +08:00
qing 81ae84851d feat(waf): add WebSocket proxy support and fix HTTP/2 proto version
- Implement http.Hijacker on responseRecorder to enable WebSocket upgrade
  (delegates Hijack() to underlying ResponseWriter, marks hijacked state)
- After WebSocket upgrade (101 Switching Protocols), skip HTTP response-phase
  WAF checks since connection becomes a binary frame stream
- Log WebSocket upgrades at Info level for observability
- Fix P2-3: use r.Proto instead of hardcoded "HTTP/1.1" in ProcessResponseHeaders
  for correct HTTP/2 protocol version reporting
- Add Write() method to responseRecorder to ensure WriteHeader is called
  before first Write (complies with http.ResponseWriter contract)
- Add bufio/net imports required by Hijacker interface
- Update README with WebSocket support section and v1.2 changelog
- Update SECURITY_AUDIT to mark P2-3 and P2-4 as fixed
2026-08-01 04:18:30 +08:00
qing 4e190953c0 feat(confs): integrate dnfs service with centralized config management
- Add dnfs.ini to confs/configs/ with server, database, ai_review, confs sections
- Refactor dnfs.py to use three-layer fail-safe config loading (defaults → confs → env vars)
- Unify timeouts for DB RPC and AI review calls via configuration
- Add _merge_ini() whitelist merging to prevent unexpected config injection
- Environment variables have highest priority for Docker networking (db/ai_review hostnames)
- Document config loading mechanism in both confs/README.md and dnfs/README.md

Follows the same pattern as static service integration; confs failure falls back
gracefully to built-in defaults + environment variables.
2026-08-01 04:13:39 +08:00
qing b4436b192a fix(waf): harden against OOM and Slowloris DoS attacks
- P0: add 10MB hard request body size limit with io.LimitReader,
  return 413 Request Entity Too Large when exceeded, prevents OOM
- P0: configure http.Server timeouts: ReadHeaderTimeout=10s (Slowloris),
  ReadTimeout=15s, WriteTimeout=30s, IdleTimeout=120s, MaxHeaderBytes=1MB
- P1: properly handle body read errors, log and return on client disconnect
- P2: drain and close r.Body in defer to ensure TCP connection reuse
- Log errors from ReadRequestBodyFrom and ProcessRequestBody
- Inject security headers on 413 responses
- Add security audit report (docs/SECURITY_AUDIT_20260801.md)
- Update README with timeout/body-limit docs and changelog
2026-08-01 04:13:39 +08:00
18 changed files with 1276 additions and 103 deletions

No files matched your search

+4 -2
View File
@@ -60,7 +60,9 @@ BotOps/
- 发送:`{"type":"message","content":...}` / `{"type":"command","command":"/..."}` / `{"type":"interrupt",...}`
- 接收:
- `chunk`(**增量片段**):服务端 `chunk_buffer` 发送后即清零,前端直接 `append_ai` 拼接,为内容权威来源
- `reasoning_start`(**思考开始**):创建灰色斜体思考区域,实时展示 AI 思考过程
- `reasoning`(**思考流**):思考内容增量片段,追加到思考区域(超长自动折叠显示最后 2000 字符)
- `reasoning_end`(**思考结束**):思考结束,切换到正式回答
- `complete`(**收尾信号**):chunk 流式已完整渲染,complete 仅用于收尾;仅在完全丢帧(未收到任何 chunk)时用 full_text 补建消息,**不再做全文替换对齐**(避免服务端递归调用工具后 complete 只含最后一轮内容导致重复渲染)
- `line`(过程行):🔧 工具调用提示、工具输出、💬 助手前缀,路由到任务侧栏;助手前缀/思考提示在聊天区已由 AI 消息框承载,不重复展示
- `reasoning`(思考流):仅服务端 debug_level≥3 发送,记入任务侧栏
- `line`(过程行):🔧 工具调用提示、工具输出,路由到任务侧栏;助手前缀/思考提示在聊天区已由对应区域承载,不重复展示
- `dialogue_ended` / `dialogue_reset` / `task_added` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `info` / `command_result` / `connection_ack` / `notification` / `pong`
+20 -7
View File
@@ -58,6 +58,13 @@ class BotOpsApp(App):
color: yellow;
padding: 0 1;
}
.msg-reasoning {
color: $text-muted;
background: $surface;
padding: 0 1;
margin: 0 1;
text-style: italic;
}
#status {
height: 1;
}
@@ -163,21 +170,27 @@ class BotOpsApp(App):
if t == "chunk":
# 服务端 chunk 为增量片段(发送后即清零),直接拼接
await self.chat.append_ai(content)
elif t == "reasoning_start":
# 开始深度思考,创建思考区域
await self.chat.start_reasoning(content or "🤔 深度思考中...")
elif t == "reasoning":
# 思考内容增量
stripped = content
if stripped:
await self.chat.append_reasoning(stripped)
elif t == "reasoning_end":
# 思考结束
await self.chat.end_reasoning()
elif t == "line":
# 过程行(服务端 output_callback 原样透传):工具调用提示 / 工具输出 / 前缀
stripped = content.strip()
if not stripped:
pass
elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking")):
pass # 助手前缀 / 思考提示:聊天区已有 AI 消息框,无需展示
elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking", "🤔 深度思考中")):
pass # 助手前缀 / 思考提示:聊天区已有对应区域,无需展示
else:
# 🔧 调用工具 / 工具输出 / 🤔 分析工具结果 等 → 任务侧栏
self.tasks.write(stripped)
elif t == "reasoning":
# 思考流(服务端 debug_level>=3 才发送),记入任务侧栏
stripped = content.strip()
if stripped:
self.tasks.write(f"💭 {stripped[-200:]}")
elif t == "complete":
# complete 携带完整全文,用于对齐已显示内容
await self.chat.finish_ai(content)
+45 -3
View File
@@ -1,9 +1,12 @@
"""聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。
服务端协议(aibox/aibox.py output_callback 原样透传 msg_type):
- chunk 增量片段(服务端 chunk_buffer 发送后即清零),直接 append
- complete 收尾信号:chunk 流式是内容权威来源,complete 仅用于收尾;
仅当流式完全丢帧(未建 widget 且无内容)时,用 full_text 补建
- chunk 增量片段(正式回答内容),直接 append 到 AI 消息
- reasoning_start 思考开始信号,创建思考区域
- reasoning 思考内容增量片段,追加到思考区域
- reasoning_end 思考结束信号
- complete 收尾信号:chunk 流式是内容权威来源,complete 仅用于收尾;
仅当流式完全丢帧(未建 widget 且无内容)时,用 full_text 补建
"""
from textual.containers import VerticalScroll
from textual.widgets import Markdown, Static
@@ -20,7 +23,9 @@ class ChatView(VerticalScroll):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._ai_widget: Markdown | None = None
self._reasoning_widget: Static | None = None
self._ai_text = ""
self._reasoning_text = ""
self._ai_finalized = True # 初始无活跃 AI 消息
@property
@@ -49,6 +54,39 @@ class ChatView(VerticalScroll):
await self.mount(self._ai_widget)
self._anchor_bottom()
async def start_reasoning(self, hint: str = "🤔 思考中..."):
"""开始展示思考过程区域。"""
if self._reasoning_widget is not None:
return
self._reasoning_text = ""
self._reasoning_widget = Static(hint + "\n", classes="msg-reasoning", markup=False)
await self.mount(self._reasoning_widget)
self._anchor_bottom()
async def append_reasoning(self, delta: str):
"""追加思考内容增量。"""
if not delta:
return
if self._reasoning_widget is None:
await self.start_reasoning()
self._reasoning_text += delta
# 限制思考内容显示长度(最多显示最后 2000 字符,避免思考过长占满屏幕)
display = self._reasoning_text
if len(display) > 2000:
display = "...(前略)\n" + display[-2000:]
self._reasoning_widget.update(f"💭 思考中...\n{display}")
self.call_after_refresh(self.scroll_end, animate=False)
async def end_reasoning(self):
"""结束思考区域,更新提示文字。"""
if self._reasoning_widget is not None and self._reasoning_text:
# 思考结束后保持展示,但更新标签
display = self._reasoning_text
if len(display) > 2000:
display = "...(前略)\n" + display[-2000:]
self._reasoning_widget.update(f"💭 思考过程({len(self._reasoning_text)}字符)\n{display}\n{'─' * 30}")
# 思考区域保留显示,不删除
async def append_ai(self, delta: str):
"""追加增量片段(服务端 chunk 为增量语义,直接拼接)。"""
if not delta:
@@ -97,12 +135,16 @@ class ChatView(VerticalScroll):
self._anchor_bottom()
self._ai_text = ""
self._reasoning_widget = None
self._reasoning_text = ""
self._ai_finalized = True
self.call_after_refresh(self.scroll_end, animate=False)
async def reset(self):
"""清空聊天区(/clear)。"""
self._ai_widget = None
self._reasoning_widget = None
self._ai_text = ""
self._reasoning_text = ""
self._ai_finalized = True
await self.remove_children()
+19 -4
View File
@@ -215,9 +215,11 @@ const ws = new WebSocket('ws://localhost:8765');
| 类型 | 说明 |
|-----|------|
| `chunk` | AI 响应的**增量片段**(流式输出,chunk_buffer 发送后即清零,为内容权威来源) |
| `reasoning_start` | AI 开始思考信号(思考模式,创建思考区域展示) |
| `reasoning` | AI 思考过程增量片段(流式输出,灰色/斜体样式) |
| `reasoning_end` | AI 思考结束信号(切换到正式回答) |
| `complete` | AI 响应**收尾信号**(chunk 流式是内容权威来源,complete 仅用于收尾;完全丢帧时可用 full_text 补建) |
| `line` | 过程行(🔧 工具调用提示、工具输出、💬 助手前缀、思考提示等,供任务侧栏/终端显示) |
| `reasoning` | AI 思考过程(思考模式,仅 debug_level≥3 发送) |
| `line` | 过程行(🔧 工具调用提示、工具输出、💬 助手前缀等,供任务侧栏/终端显示) |
| `error` | 错误信息(API 请求失败、解析失败等;服务端自动回滚本轮消息防止对话历史被污染) |
| `command_result` | 命令执行结果 |
| `task_status` | 任务状态更新(executing/complete/failed) |
@@ -427,8 +429,9 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
"api": {
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
"deepseek_model": "deepseek-v4-flash",
"reasoning_enabled": false,
"api_key_env": "DEEPSEEK_API_KEY",
"timeout": 30,
"timeout": 120,
"temperature": 0.7
},
"context": {
@@ -492,7 +495,9 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
}
```
> 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。
> 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。设置 `api.reasoning_enabled: true` 可启用思考模式,此时会在请求中添加 `thinking: {type: "enabled"}` 参数,模型会先进行思考再回答,实时展示思考过程(灰色斜体区域),回答质量更高且**支持工具调用**(文件操作、命令执行、记忆搜索等工具均可在思考模式下正常使用)。
>
> API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。
## 🛠️ 命令行参数
@@ -701,6 +706,16 @@ elif command == "/mycommand":
## 📄 更新日志
### v2.5
- ✅ **新增 DeepSeek 思考模式支持**(Reasoning Mode):
- 新增 `api.reasoning_enabled` 配置项(默认 `false`),启用后在请求中添加 `thinking: {type: "enabled"}` 参数
- 使用 `deepseek-v4-flash` 模型原生思考能力,**思考模式下完整支持工具调用**(文件操作、命令执行、记忆搜索等均可正常使用)
- 实时展示 AI 思考过程:新增 `reasoning_start`/`reasoning`/`reasoning_end` WebSocket 消息类型
- 思考内容在前端以灰色斜体区域独立展示,超长思考自动折叠显示最后 2000 字符
- 即使未启用思考模式,若模型返回 `reasoning_content` 也会自动实时展示
- API 超时默认从 30s 提高到 120s,适配思考模式更长的响应时间
- ✅ 删除重复定义的 `_add_assistant_message_with_reasoning` 方法
### v2.4
- ✅ **新增专用工作目录规范**:
- AI 主动创建的临时文件、脚本、数据文件默认写入 `~/.aibox/workspace/`,避免污染系统其他目录
+43 -23
View File
@@ -1168,8 +1168,9 @@ class ConfigManager:
"api": {
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
"deepseek_model": "deepseek-v4-flash",
"reasoning_enabled": false,
"api_key_env": "DEEPSEEK_API_KEY",
"timeout": 30,
"timeout": 120,
"temperature": 0.7
},
"context": {
@@ -1356,7 +1357,11 @@ class Constants:
@property
def DEEPSEEK_MODEL(cls):
return cls._get_config().get("api.deepseek_model")
return cls._get_config().get("api.deepseek_model", "deepseek-v4-flash")
@property
def REASONING_ENABLED(cls):
return cls._get_config().get("api.reasoning_enabled", False)
@property
def DEFAULT_TIMEOUT(cls):
@@ -7169,11 +7174,13 @@ class DeepSeekChat:
"stream": True
}
# 可选:添加 thinking 模式配置(如果有)
payload["thinking"] = {
"type": "enabled"
}
# 思考模式:启用 thinking 参数
if constants.REASONING_ENABLED:
payload["thinking"] = {
"type": "enabled"
}
# 工具调用(v4-flash 支持思考+工具调用)
tools = self.tool_registry.get_tools_schemas()
if tools:
payload["tools"] = tools
@@ -7182,10 +7189,16 @@ class DeepSeekChat:
current_time = datetime.now().strftime("%H:%M:%S")
if output_callback:
output_callback("line", f"\n{i18n.get('thinking', current_time)}\n")
output_callback("line", i18n.get('assistant_prefix'))
if constants.REASONING_ENABLED:
output_callback("reasoning_start", "🤔 思考中...")
else:
# 普通模式先显示助手前缀
output_callback("line", i18n.get('assistant_prefix'))
full_response = ""
full_reasoning = "" # 存储思考内容
reasoning_started = False
reasoning_ended = False
tool_calls_buffer = []
current_tool_calls = {}
@@ -7236,10 +7249,25 @@ class DeepSeekChat:
if 'reasoning_content' in delta and delta['reasoning_content']:
reasoning = delta['reasoning_content']
full_reasoning += reasoning
# 可选:将思考内容也发送给前端(可以单独处理)
if output_callback and self.debug_level >= 3:
# 发送思考开始信号(仅第一次,且未提前发送过)
if not reasoning_started and output_callback:
if not constants.REASONING_ENABLED:
# 非显式思考模式但模型返回了思考内容,动态发送开始信号
output_callback("reasoning_start", "🤔 思考中...")
reasoning_started = True
# 实时发送思考内容给前端(无需debug_level限制)
if output_callback:
output_callback("reasoning", reasoning)
# 思考结束:当开始收到content且之前有reasoning时,发送结束信号
if 'content' in delta and delta['content'] and reasoning_started and not reasoning_ended:
reasoning_ended = True
if output_callback:
output_callback("reasoning_end", "")
output_callback("line", i18n.get('assistant_prefix'))
# 处理正常内容
if 'content' in delta and delta['content']:
content = delta['content']
@@ -7290,7 +7318,12 @@ class DeepSeekChat:
# 处理工具调用
if current_tool_calls:
tool_calls_buffer = list(current_tool_calls.values())
if output_callback:
# 如果之前在思考,先结束思考状态
if reasoning_started and not reasoning_ended and output_callback:
output_callback("reasoning_end", "")
output_callback("line", i18n.get('calling_tools') + "\n")
elif output_callback:
output_callback("line", i18n.get('calling_tools') + "\n")
# 【关键修复1】先添加助手消息,包含 tool_calls
@@ -7388,19 +7421,6 @@ class DeepSeekChat:
except Exception as e:
self.logger.error(f"消息回滚失败: {e}")
def _add_assistant_message_with_reasoning(self, content: str, reasoning_content: str = None, tool_calls: List = None):
"""添加助手消息,包含 reasoning_content"""
msg = {"role": "assistant"}
if content:
msg["content"] = content
if reasoning_content:
msg["reasoning_content"] = reasoning_content
if tool_calls:
msg["tool_calls"] = tool_calls
msg["content"] = None
self.messages.append(msg)
def reset_conversation(self, reload_prompts: bool = True):
"""
重置对话状态 - 清除所有对话记忆,可选择是否重新加载系统提示词
+9 -2
View File
@@ -236,6 +236,7 @@ except RpcError as e:
| 服务 | 配置文件 | 接入方式 |
|------|---------|---------|
| [static](../static/) | [static.ini](configs/static.ini) | 三层 fail-safe:内置默认值 → confs(优先)→ 本地 `config.ini` 回退 |
| [dnfs](../dnfs/) | [dnfs.ini](configs/dnfs.ini) | 三层 fail-safe:内置默认值 → confs(优先)→ 环境变量覆盖 |
static 的接入实现见 [static/fsys.py](../static/fsys.py):
- `_fetch_from_confs()` 通过 `RpcClient` 调 `get` 路由取 `static.ini`,3 秒超时
@@ -243,7 +244,12 @@ static 的接入实现见 [static/fsys.py](../static/fsys.py):
- `_merge_ini()` 仅覆盖已知的 section/key(白名单合并,不引入未预期字段)
- 配置来源会记录到日志(`logs.info/warning`),便于排查实际用了哪一层
其他服务(gateway / waf / ai_review / smtp / dnfs / db)尚未接入,仍读各自本地配置。
dnfs 的接入实现见 [dnfs/dnfs.py](../dnfs/dnfs.py):
- 三层加载顺序:内置默认值 → confs(dnfs.ini)→ 环境变量覆盖(优先级最高)
- 支持的环境变量:`LISTEN_ADDR`/`LISTEN_PORT`/`DB_HOST`/`DB_PORT`/`REVIEW_URL`/`CONFS_HOST`/`CONFS_PORT`
- Docker 环境通过环境变量传入服务名(db/ai_review),覆盖 confs 中的 127.0.0.1 默认值
其他服务(gateway / waf / ai_review / smtp / db)尚未接入,仍读各自本地配置。
## 项目结构
@@ -253,7 +259,8 @@ confs/
├── log_client.py # 统一日志客户端 SDK(与其他服务一致的副本)
├── configs/ # 配置文件存放目录(被分发对象)
│ ├── example.ini # 示例配置
│ └── static.ini # static 服务配置(已接入分发)
│ ├── static.ini # static 服务配置(已接入分发)
│ └── dnfs.ini # dnfs 服务配置(已接入分发)
├── docs/
│ └── SECURITY_AUDIT_20260801.md # 安全审计报告
├── _set.sh # 初始化脚本(建 venv + 安装依赖)
+16
View File
@@ -0,0 +1,16 @@
[server]
host = 127.0.0.1
port = 8006
[database]
host = 127.0.0.1
port = 8005
timeout = 10
[ai_review]
url = http://127.0.0.1:8007/review
timeout = 10
[confs]
host = 127.0.0.1
port = 8010
+54 -9
View File
@@ -4,6 +4,8 @@ Dendrachy Network File-System,基于父子关系(pid)组织的树状节点
监听 `127.0.0.1:8006`,提供节点的增删改查,支持以 Base64 编码形式在 `content` 中存储文件数据。所有写操作需携带由 gateway 签发的 Token。
支持**特殊节点**(管理员专属):`content` 为信封 JSON 时,前端按类型渲染为链接卡片 / 可运行网页 / 图片 / 文件下载,详见[特殊节点](#特殊节点管理员专属)。
## 架构
```
@@ -44,9 +46,26 @@ Authorization: Bearer <base64_payload>_<rsa_sign>
- payload 中取 `user` 字段作为操作者,写入 `owner`。
- `delete_note` 与 `update_note` 校验所有权:
- 节点 `owner` 必须等于当前 `user`,否则 `403`;
- **例外**:管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)可删除/更新任意节点。
- **例外**:管理员(Token payload `is_admin=true`,由 gateway 按 `[Admin] users` 白名单签发)可删除/更新任意节点。
- `get_notes` 不需要鉴权。
## 特殊节点(管理员专属)
`content` 为以下信封 JSON 时即为特殊节点,前端 `dnfs.js` 按 `_sp` 类型差异化渲染;普通 Markdown 内容不受影响:
| `_sp` | 信封字段 | 前端表现 |
|-------|---------|---------|
| `link` | `url`(http/https,≤2048)、`note?` | favicon 图标(经 static `/api/favicon` 代理拉取)+ 可点击链接 |
| `html` | `code`(≤512KB)、`note?` | "审计源码"(纯文本展示)+ "运行"(新窗口执行)按钮 |
| `image` | `name`、`data`(`data:image/` DataURL,≤3.5M 字符)、`note?` | 图片直接显示 |
| `file` | `name`、`mime`、`data`(Base64,≤3.5M 字符)、`note?` | 下载按钮(Base64→Blob→下载) |
规则:
- **仅管理员可发布/更新**(`add_note` / `update_note` 中按 `is_admin` 强制,非管理员返回 403);前端发布面板也仅对管理员显示。
- AI 内容审核剔除 Base64 负载(体量大且无审核意义):`link` 审 `title+url+note`,`html` 审 `title+code 前 4000 字符+note`,`image`/`file` 审 `title+name+note`。
- `{"_sp"` 开头但 JSON 非法的内容会被拒绝发布(400),防止半截信封流入。
## 接口文档
所有接口均为 `POST`,`Content-Type: application/json`。
@@ -154,7 +173,7 @@ curl -X POST http://localhost:8006/delete_note \
更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。
仅节点 owner 可更新;管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)豁免,可更新任意节点。
仅节点 owner 可更新;管理员(Token payload `is_admin`)豁免,可更新任意节点。
**Request**
@@ -196,15 +215,41 @@ curl -X POST http://localhost:8006/update_note \
## 配置
服务地址与上游依赖硬编码在 [dnfs.py](dnfs.py):
dnfs 已接入 confs 统一配置服务,采用**三层 fail-safe** 加载模式(与 static 服务一致):
| 常量 | 值 | 说明 |
|------|----|------|
| `ADDR` / `PORT` | `127.0.0.1` / `8006` | 监听地址 |
| `DB_SERVER_URL` | `http://127.0.0.1:8005/execute` | db 服务 |
| `REVIEW_SERVER_URL` | `http://127.0.0.1:8007/review` | ai_review 服务 |
**加载顺序**:内置默认值 → confs 统一配置服务(dnfs.ini,优先)→ 环境变量覆盖(最高优先级)
调用超时均为 10 秒。
### 配置文件
集中托管在 [confs/configs/dnfs.ini](../confs/configs/dnfs.ini),由 confs 服务分发:
| Section | Key | 默认值 | 说明 |
|---------|-----|--------|------|
| `[server]` | `host` | `127.0.0.1` | 监听地址 |
| `[server]` | `port` | `8006` | 监听端口 |
| `[database]` | `host` | `127.0.0.1` | db 服务地址 |
| `[database]` | `port` | `8005` | db 服务端口 |
| `[database]` | `timeout` | `10` | RPC 调用超时(秒) |
| `[ai_review]` | `url` | `http://127.0.0.1:8007/review` | AI 审核接口地址 |
| `[ai_review]` | `timeout` | `10` | AI 审核请求超时(秒) |
| `[confs]` | `host` | `127.0.0.1` | confs 服务地址 |
| `[confs]` | `port` | `8010` | confs 服务端口 |
### 环境变量(Docker 环境必需,优先级最高)
环境变量覆盖配置文件值,容器化部署时通过 docker-compose.yml 传入服务名:
| 变量 | 容器内值 | 说明 |
|------|---------|------|
| `LISTEN_ADDR` | `0.0.0.0` | 监听地址(容器内需绑定所有网卡) |
| `LISTEN_PORT` | `8006` | 监听端口 |
| `DB_HOST` | `db` | db 服务主机名(docker-compose 服务名) |
| `DB_PORT` | `8005` | db 服务端口 |
| `REVIEW_URL` | `http://ai_review:8007/review` | ai_review 服务地址 |
| `CONFS_HOST` | - | 可选,confs 容器化后使用 |
| `CONFS_PORT` | - | 可选,confs 容器化后使用 |
配置来源会记录到日志(`logs.info/warning`),便于排查实际用了哪一层。本地裸机运行时 confs 不可用会自动回退到内置默认值 + 环境变量。
## 前端
+179 -25
View File
@@ -1,9 +1,5 @@
#!/usr/bin/env python3
'''
Dendrachy Network File-System (dnfs)
@@ -21,8 +17,10 @@ from flask_cors import CORS
#from flask_sqlalchemy import SQLAlchemy # 使用pymsg模块进行通信
from waitress import serve
import os
import sys
import json
import base64
import configparser
import urllib.request
import urllib.error
from typing import Dict, Any
@@ -32,14 +30,75 @@ from log_client import logs
# pymsg 导入
# 我们有在依赖列表中加入pymsg
# 这个可以省略也许
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import RpcClient, RpcError # noqa: E402
# 从环境变量中获取参数
# 随后我们专门做一个统一配置微服务
_DBV2_ADDR = os.environ.get('DB_HOST', '127.0.0.1')
_DBV2_PORT = int(os.environ.get('DB_PORT', '8005'))
# ==================== 配置加载(三层 fail-safe) ====================
# 加载顺序:内置默认值 → confs 统一配置服务(dnfs.ini) → 环境变量覆盖
# 环境变量优先级最高(Docker 环境使用服务名 db/ai_review 而非 127.0.0.1)
_dnfs_config = {
'server': {'host': '127.0.0.1', 'port': '8006'},
'database': {'host': '127.0.0.1', 'port': '8005', 'timeout': '10'},
'ai_review': {'url': 'http://127.0.0.1:8007/review', 'timeout': '10'},
'confs': {'host': '127.0.0.1', 'port': '8010'},
}
def _merge_ini(text):
"""把 ini 文本合并进 _dnfs_config(仅覆盖已知的 section/key,白名单合并)。"""
ini = configparser.ConfigParser(interpolation=None)
ini.read_string(text)
for section in _dnfs_config:
if ini.has_section(section):
for key, value in ini[section].items():
if key in _dnfs_config[section]:
_dnfs_config[section][key] = value
def _fetch_from_confs():
"""从 confs 统一配置服务拉取 dnfs.ini 全文,失败返回 None(fail-safe)。"""
try:
host = os.environ.get('CONFS_HOST', _dnfs_config['confs']['host'])
port = int(os.environ.get('CONFS_PORT', _dnfs_config['confs']['port']))
data = RpcClient(host, port, 'dnfs', timeout=3).call(
'get', {'name': 'dnfs.ini'})
return data.get('content')
except Exception:
return None
_confs_text = _fetch_from_confs()
if _confs_text is not None:
_merge_ini(_confs_text)
logs.info("dnfs", "配置来源: confs 统一配置服务 (dnfs.ini)")
else:
logs.warning("dnfs", "confs 不可用,使用内置默认值 + 环境变量")
# 环境变量覆盖(优先级最高,Docker 环境需要)
if os.environ.get('LISTEN_ADDR'):
_dnfs_config['server']['host'] = os.environ['LISTEN_ADDR']
if os.environ.get('LISTEN_PORT'):
_dnfs_config['server']['port'] = os.environ['LISTEN_PORT']
if os.environ.get('DB_HOST'):
_dnfs_config['database']['host'] = os.environ['DB_HOST']
if os.environ.get('DB_PORT'):
_dnfs_config['database']['port'] = os.environ['DB_PORT']
if os.environ.get('REVIEW_URL'):
_dnfs_config['ai_review']['url'] = os.environ['REVIEW_URL']
if os.environ.get('CONFS_HOST'):
_dnfs_config['confs']['host'] = os.environ['CONFS_HOST']
if os.environ.get('CONFS_PORT'):
_dnfs_config['confs']['port'] = os.environ['CONFS_PORT']
# 提取配置常量
_DBV2_ADDR = _dnfs_config['database']['host']
_DBV2_PORT = int(_dnfs_config['database']['port'])
_DB_TIMEOUT = int(_dnfs_config['database']['timeout'])
_REVIEW_SERVER_URL = _dnfs_config['ai_review']['url']
_REVIEW_TIMEOUT = int(_dnfs_config['ai_review']['timeout'])
_ADDR = _dnfs_config['server']['host']
_PORT = int(_dnfs_config['server']['port'])
# 管理员判定已上移至 gateway:登录时命中 [Admin] users 白名单即在 Token payload
# 写入 is_admin=true,本服务通过 info.get('is_admin') 读取,不再本地维护名单。
@@ -48,13 +107,15 @@ _DBV2_PORT = int(os.environ.get('DB_PORT', '8005'))
_rpc = RpcClient(_DBV2_ADDR, _DBV2_PORT, "dnfs")
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
def _call_dbv2(payload: dict, timeout: int = None) -> dict:
"""通过 pymsg RPC 调用 dbv2 数据库服务(自动注入 ``_route: "execute"``)。
返回完整响应 dict(``{"success": ..., "data"/"error": ...}``),
与历史行为一致,调用方无需改动。服务端业务错误返回 success=False;
网络层故障抛 ConnectionError。
"""
if timeout is None:
timeout = _DB_TIMEOUT
try:
data = _rpc.call("execute", payload, timeout=timeout)
return {"success": True, "data": data}
@@ -65,15 +126,14 @@ def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
f"dbv2 调用失败 ({_DBV2_ADDR}:{_DBV2_PORT}): {e}"
) from None
# AI审核服务地址,从环境变量获取
REVIEW_SERVER_URL = os.environ.get('REVIEW_URL', 'http://127.0.0.1:8007/review')
def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
# AI审核服务地址
def review_content(content: str, timeout: int = None) -> Dict[str, Any]:
"""
调用 AI 内容审核服务的 /review 接口,对给定文本进行审核。
Args:
content: 待审核的文本内容,不可为空。
timeout: 请求超时时间(秒),默认 10 秒。
timeout: 请求超时时间(秒),默认使用配置值。
Returns:
字典,包含接口返回的字段,例如:
@@ -83,7 +143,9 @@ def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
- 请求参数错误时:{"error": "content is required"} 等
- 网络或异常时:{"error": "request_failed", "detail": "..."}
"""
url = REVIEW_SERVER_URL
if timeout is None:
timeout = _REVIEW_TIMEOUT
url = _REVIEW_SERVER_URL
payload = {"content": content}
json_data = json.dumps(payload).encode("utf-8")
@@ -121,6 +183,82 @@ class DnfsExp(Exception):
def get_status(self) -> int:
return self.status
# ==================== 特殊节点(管理员专属) ====================
# 信封格式:content 为 JSON {"_sp": "link|html|image|file", ...},前端识别后差异化渲染;
# 普通 Markdown 内容不受影响。仅管理员可发布(Token payload is_admin)。
_SP_TYPES = ('link', 'html', 'image', 'file')
_SP_MAX_URL = 2048 # link 节点 url 长度上限
_SP_MAX_CODE = 512 * 1024 # html 节点代码长度上限(字符)
_SP_MAX_DATA = 3500 * 1024 # image/file 节点 base64 长度上限(字符,约 2.6MB 原始数据)
_SP_REVIEW_CODE_PREFIX = 4000 # html 节点送审的代码前缀长度
def _parse_sp(content):
"""识别特殊节点信封:合法信封返回 dict;普通内容返回 None;
以 {"_sp" 开头但 JSON 非法视为格式错误(拒绝发布,防半截信封流入)。"""
if not isinstance(content, str):
return None
s = content.lstrip()
if not s.startswith('{"_sp"'):
return None
try:
obj = json.loads(s)
except Exception:
raise DnfsExp('特殊节点格式非法(JSON 解析失败)', 400)
if isinstance(obj, dict) and obj.get('_sp') in _SP_TYPES:
return obj
return None
def _validate_sp(sp):
"""校验特殊节点信封字段与大小上限,非法抛 DnfsExp。"""
t = sp['_sp']
if t == 'link':
url = sp.get('url', '')
if not isinstance(url, str) or not url.startswith(('http://', 'https://')):
raise DnfsExp('链接节点必须是 http/https URL', 400)
if len(url) > _SP_MAX_URL:
raise DnfsExp('链接过长', 400)
elif t == 'html':
code = sp.get('code', '')
if not isinstance(code, str) or not code.strip():
raise DnfsExp('网页代码不能为空', 400)
if len(code) > _SP_MAX_CODE:
raise DnfsExp('网页代码过大(上限 512KB)', 400)
elif t == 'image':
data = sp.get('data', '')
if not isinstance(data, str) or not data.startswith('data:image/'):
raise DnfsExp('图片节点 data 必须是 data:image/ 开头的 DataURL', 400)
if len(data) > _SP_MAX_DATA:
raise DnfsExp('图片过大(上限约 2.6MB)', 400)
elif t == 'file':
name = sp.get('name', '')
data = sp.get('data', '')
if not isinstance(name, str) or not name.strip():
raise DnfsExp('文件节点缺少文件名', 400)
if len(name) > 255:
raise DnfsExp('文件名过长', 400)
if not isinstance(data, str) or not data:
raise DnfsExp('文件节点缺少数据', 400)
if len(data) > _SP_MAX_DATA:
raise DnfsExp('文件过大(上限约 2.6MB)', 400)
def _sp_review_text(title, sp):
"""特殊节点的 AI 审核文本:剔除 base64 负载(体量大且无审核意义),仅保留文本字段。"""
parts = [title or '']
note = sp.get('note')
if isinstance(note, str) and note.strip():
parts.append(note)
t = sp['_sp']
if t == 'link':
parts.append(str(sp.get('url', '')))
elif t == 'html':
parts.append(str(sp.get('code', ''))[:_SP_REVIEW_CODE_PREFIX])
else: # image / file
parts.append(str(sp.get('name', '')))
return '\n'.join(parts)
# 这个也许不用这么复杂 大不了每个异常点我们抛出DnfsExp类
#class InvalidRequest(DnfsExp):
# '''非法参数异常'''
@@ -191,8 +329,15 @@ def add_note():
raise DnfsExp('父节点不存在', 400)
# 内容审查
review = review_content(title + '\n' + content, 10)
# 特殊节点(管理员专属):非管理员拒绝,并做字段/大小校验
sp = _parse_sp(content)
if sp is not None:
if not info.get('is_admin'):
raise DnfsExp('特殊节点仅管理员可发布,用户'+info.get('user')+',从'+request.remote_addr, 403)
_validate_sp(sp)
# 内容审查(特殊节点剔除 base64 负载)
review = review_content(_sp_review_text(title, sp) if sp is not None else title + '\n' + content)
if True != review: raise DnfsExp('内容审核不通过,用户'+info.get('user')+',从'+request.remote_addr, 400)
# 执行插入
@@ -411,8 +556,19 @@ def update_note():
logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
return jsonify({"error": 'It\'s not your note'}), 403
# 内容审查
review = review_content(title + '\n' + content, 10)
# 特殊节点(管理员专属):非管理员拒绝,并做字段/大小校验
sp = _parse_sp(content)
if sp is not None:
if not info.get('is_admin'):
logs.warning('dnfs', f'update_note 特殊节点非管理员拒绝 id={id}', extra={'user': info['user']})
return jsonify({"error": '特殊节点仅管理员可发布'}), 403
try:
_validate_sp(sp)
except DnfsExp as e:
return jsonify({"error": str(e)}), e.get_status()
# 内容审查(特殊节点剔除 base64 负载)
review = review_content(_sp_review_text(title, sp) if sp is not None else title + '\n' + content)
if True != review:
#return jsonify({'error': 'review failed', 'data': review}), 400
logs.warning('dnfs', f'update_note 内容审核未通过 id={id} title={title}', extra={'user': info['user']})
@@ -502,9 +658,7 @@ def get_note():
# """返回应用主页"""
# return render_template('index.html')
ADDR = os.environ.get('LISTEN_ADDR', '0.0.0.0')
PORT = int(os.environ.get('LISTEN_PORT', '8006'))
if __name__ == "__main__":
logs.info('dnfs', f'服务启动,监听 http://{ADDR}:{PORT}')
print(f'Listen at http://{ADDR}:{PORT}')
serve(app, host=ADDR, port=PORT)
logs.info('dnfs', f'服务启动,监听 http://{_ADDR}:{_PORT}(db={_DBV2_ADDR}:{_DBV2_PORT}, ai_review={_REVIEW_SERVER_URL})')
print(f'Listen at http://{_ADDR}:{_PORT}')
serve(app, host=_ADDR, port=_PORT)
+2
View File
@@ -50,6 +50,7 @@ static :9200 (Flask + CORS)
| `POST /api/hash` | SHA256 密码哈希 |
| `POST /api/check_user` | 检查用户是否已注册(查询 gateway 数据库) |
| `POST /api/reset_password` | 重置用户密码(直接操作 gateway 数据库) |
| `GET /api/favicon?url=` | favicon 服务端代理(dnfs 链接卡片用;SSRF 防护:仅 http/https + 内网地址拦截 + 重定向校验,512KB 上限,缓存 1 天) |
### yhdet OAuth 路由
@@ -79,6 +80,7 @@ static :9200 (Flask + CORS)
- 流媒体:视频(MP4/WebM/OGG/AVI/MOV/MKV)与音频(MP3/WAV/OGG/FLAC/AAC/M4A)在线播放,支持进度拖动
- 深色模式:`css/theme.css` 统一主题,自动适配系统偏好
- 表单组件:注册页图形/邮箱验证码统一 `.code-box` 行内布局(文字标签 + 输入框 + 控件同排,窄屏自动换行),验证码图片样式复用 `theme.css` 的 `.captcha-img` 类
- 特殊节点(dnfs 管理员专属):信封 JSON `{"_sp":...}` 内容差异化渲染 —— 链接(favicon 卡片)/ 网页代码(可审计可运行)/ 图片(直显)/ 文件(下载);发布权限由 dnfs 后端按 `is_admin` 强制
- 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内
- Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定)
- 安全头注入:`security_middleware.py` 为所有响应注入 CSP、X-Frame-Options 等安全头
+69
View File
@@ -531,6 +531,75 @@ def proxy_send_code():
# ==================== favicon 代理(dnfs 特殊节点"链接"卡片用) ====================
# 服务端拉取目标站 /favicon.ico:前端 CSP img-src 'self' 禁止外域图片,且跨域无法直取。
# SSRF 防护:仅 http/https;解析目标 IP,禁止内网/环回/保留地址;重定向目标同样校验;512KB 上限。
import ipaddress
import socket
from urllib.parse import urlparse
_FAVICON_MAX_BYTES = 512 * 1024
_FAVICON_TIMEOUT = 5
def _is_private_host(hostname):
"""主机解析后任一 IP 命中内网/环回/链路本地/保留/组播地址即视为非法(fail-closed)。"""
try:
infos = socket.getaddrinfo(hostname, None)
except Exception:
return True # 解析失败一律拒绝
if not infos:
return True
for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except ValueError:
return True
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
return True
return False
class _FaviconRedirectHandler(_urlreq.HTTPRedirectHandler):
"""重定向时重新校验目标主机,防止 302 跳转绕过 SSRF 检查。"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
if _is_private_host(urlparse(newurl).hostname or ''):
return None
return super().redirect_request(req, fp, code, msg, headers, newurl)
@app.route('/api/favicon', methods=['GET'])
def api_favicon():
target = (request.args.get('url') or '').strip()
try:
parsed = urlparse(target)
except Exception:
return jsonify({'error': 'bad url'}), 400
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
return jsonify({'error': 'bad url'}), 400
if _is_private_host(parsed.hostname):
return jsonify({'error': 'forbidden host'}), 403
favicon_url = f"{parsed.scheme}://{parsed.netloc}/favicon.ico"
try:
opener = _urlreq.build_opener(_FaviconRedirectHandler())
req = _urlreq.Request(favicon_url, headers={'User-Agent': 'qsont-favicon-fetcher/1.0'})
with opener.open(req, timeout=_FAVICON_TIMEOUT) as resp:
data = resp.read(_FAVICON_MAX_BYTES + 1)
if len(data) > _FAVICON_MAX_BYTES:
return jsonify({'error': 'favicon too large'}), 502
ctype = (resp.headers.get('Content-Type') or '').split(';')[0].strip()
if not ctype.startswith('image/'):
ctype = 'image/x-icon' # 部分站点 favicon 返回 text/html 等,统一兜底
out = Response(data, 200, mimetype=ctype)
out.headers['Cache-Control'] = 'public, max-age=86400'
return out
except Exception as e:
logs.info("static", "favicon 拉取失败", extra={"url": target[:128], "error": str(e)})
return jsonify({'error': 'fetch failed'}), 502
# ==================== 主路由 ====================
@app.route('/')
def serve_view():
+39
View File
@@ -119,6 +119,21 @@ textarea:focus {
input::placeholder, textarea::placeholder { color: var(--muted); }
textarea { min-height: 80px; resize: vertical; }
select {
display: block;
width: 100%;
padding: 0.6rem 0.9rem;
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.9rem;
font-family: inherit;
background: var(--bg);
color: var(--fg);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
/* ===== Buttons ===== */
button, input[type="submit"], input[type="reset"], input[type="button"] {
padding: 0.55rem 1.3rem;
@@ -492,6 +507,30 @@ input[type="button"]:disabled {
border-color: var(--accent);
}
/* ===== Special Nodes (dnfs,管理员发布的特殊节点) ===== */
.sp-link { display: flex; align-items: center; gap: 0.5rem; }
.sp-favicon { width: 20px; height: 20px; flex: none; border-radius: 4px; }
.sp-anchor { color: var(--accent); word-break: break-all; }
.sp-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.4rem; }
.sp-code {
max-height: 320px;
overflow: auto;
margin-top: 0.5rem;
padding: 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.78rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
.sp-image { display: block; max-width: 100%; border-radius: var(--radius-sm); }
.sp-note { font-size: 0.82rem; color: var(--fg-secondary); margin-top: 0.4rem; }
#sp-panel { margin-top: 0.4rem; }
#sp-panel select { margin-bottom: 0.4rem; }
#sp-file-tip { font-size: 0.78rem; }
/* ===== Node Form (dnfs) ===== */
.node-form { margin-top: 1rem; }
.node-form .form-actions { margin-top: 0.75rem; }
+15 -2
View File
@@ -15,8 +15,21 @@
<div class="recommend-list" id="recommend-list"></div>
<div class="card node-form">
<h3>节点操作</h3>
<label>新增节点</label><input id="input-note-title">
<label>节点内容</label><textarea id="input-note-content"></textarea>
<label>新增节点</label><input id="input-note-title">
<!-- 特殊节点面板:仅管理员可见(dnfs.js 按 /whoami is_admin 显示) -->
<div id="sp-panel" style="display:none">
<label>节点类型</label>
<select id="sp-type">
<option value="text">普通文本</option>
<option value="link">链接(自动显示站点图标)</option>
<option value="html">网页代码(可审计/可运行)</option>
<option value="image">图片(自动显示)</option>
<option value="file">文件(可下载)</option>
</select>
<input type="file" id="sp-file" style="display:none">
<span id="sp-file-tip"></span>
</div>
<label>节点内容</label><textarea id="input-note-content"></textarea>
<div class="form-actions">
<button id="input-note-submit">提交</button>
<button id="input-note-clear">重置</button>
+252 -7
View File
@@ -418,6 +418,151 @@ function renderMarkdown(element, content) {
element.innerHTML = md.render(content, {html: false});
}
/* ===== by qing — 特殊节点(管理员专属)=====
* 信封:content 为 {"_sp":"link|html|image|file", ...} JSON,普通内容走 Markdown。
* 发布权限由后端 dnfs 强制(is_admin),前端仅做展示与表单切换。 */
/* 与后端 base64 上限(3.5M 字符)对齐:原始文件约 2.5MB */
var SP_MAX_FILE_BYTES = 2.5 * 1024 * 1024;
function parseSp(content) {
if (typeof content !== 'string') return null;
var s = content.trim();
if (s.indexOf('{"_sp"') !== 0) return null;
try {
var obj = JSON.parse(s);
} catch (e) {
return null; // 非法信封按普通文本展示(后端发布时已拦截,这里是兜底)
}
if (obj && typeof obj === 'object' && ['link', 'html', 'image', 'file'].indexOf(obj._sp) !== -1) return obj;
return null;
}
/* 内容渲染分发:特殊节点按类型渲染,否则 Markdown */
function renderContent(container, item) {
var sp = parseSp(item.content);
if (!sp) {
renderMarkdown(container, item.content);
return;
}
function appendNote() {
if (typeof sp.note === 'string' && sp.note.trim()) {
var note = document.createElement('div');
note.className = 'sp-note';
note.textContent = sp.note;
container.appendChild(note);
}
}
if (sp._sp === 'link') renderSpLink(container, sp, appendNote);
else if (sp._sp === 'html') renderSpHtml(container, sp, appendNote);
else if (sp._sp === 'image') renderSpImage(container, sp, appendNote);
else if (sp._sp === 'file') renderSpFile(container, sp, appendNote);
}
/* 链接节点:服务端代理拉取的 favicon + 可点击链接(卡片内交互需 stopPropagation) */
function renderSpLink(container, sp, appendNote) {
var box = document.createElement('div');
box.className = 'sp-link';
var icon = document.createElement('img');
icon.className = 'sp-favicon';
icon.alt = '';
icon.src = '/api/favicon?url=' + encodeURIComponent(sp.url);
icon.addEventListener('error', function() { icon.style.display = 'none'; });
var anchor = document.createElement('a');
anchor.className = 'sp-anchor';
anchor.href = sp.url;
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
anchor.textContent = sp.url;
anchor.addEventListener('click', function(e) { e.stopPropagation(); });
box.appendChild(icon);
box.appendChild(anchor);
container.appendChild(box);
appendNote();
}
/* 网页代码节点:审计(展开源码,textContent 防注入)+ 运行(新窗口 document.write) */
function renderSpHtml(container, sp, appendNote) {
var bar = document.createElement('div');
bar.className = 'sp-actions';
var audit_btn = document.createElement('button');
audit_btn.className = 'act-btn act-edit';
audit_btn.textContent = '审计源码';
var run_btn = document.createElement('button');
run_btn.className = 'act-btn act-move';
run_btn.textContent = '运行';
var pre = document.createElement('pre');
pre.className = 'sp-code';
pre.style.display = 'none';
pre.textContent = sp.code;
audit_btn.addEventListener('click', function(e) {
e.stopPropagation();
var hidden = (pre.style.display === 'none');
pre.style.display = hidden ? 'block' : 'none';
audit_btn.textContent = hidden ? '收起源码' : '审计源码';
});
run_btn.addEventListener('click', function(e) {
e.stopPropagation();
var win = window.open('', '_blank');
if (!win) { alert('浏览器拦截了弹窗,请允许后重试'); return; }
win.document.open();
win.document.write(sp.code);
win.document.close();
});
bar.appendChild(audit_btn);
bar.appendChild(run_btn);
container.appendChild(bar);
container.appendChild(pre);
appendNote();
}
/* 图片节点:DataURL 直接显示(CSP img-src 允许 data:) */
function renderSpImage(container, sp, appendNote) {
var img = document.createElement('img');
img.className = 'sp-image';
img.src = sp.data;
img.alt = sp.name || '图片';
container.appendChild(img);
appendNote();
}
/* 文件节点:base64 → Blob → 触发下载(blob: 下载不受 CSP 限制) */
function renderSpFile(container, sp, appendNote) {
var bar = document.createElement('div');
bar.className = 'sp-actions';
var btn = document.createElement('button');
btn.className = 'act-btn act-edit';
btn.textContent = '下载 ' + (sp.name || '文件') + '(' + formatBytes(Math.floor((sp.data || '').length * 3 / 4)) + ')';
btn.addEventListener('click', function(e) {
e.stopPropagation();
try {
var bin = atob(sp.data);
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
var blob = new Blob([bytes], { type: sp.mime || 'application/octet-stream' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = sp.name || 'download';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function() { URL.revokeObjectURL(a.href); }, 10000);
} catch (err) {
alert('文件解码失败:' + err.message);
}
});
bar.appendChild(btn);
container.appendChild(bar);
appendNote();
}
function formatBytes(n) {
var units = ['B', 'KB', 'MB', 'GB'];
var i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return n.toFixed(1) + units[i];
}
/* by qing — 互动统计图标:inline SVG,stroke=currentColor 自动跟随主题色 */
const ICON_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
const ICON_HEART = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>';
@@ -450,6 +595,12 @@ var recommend_list = document.getElementById('recommend-list');
var delete_note = document.getElementById('delete-note');
var minimap = document.getElementById("minimap");
var movebar = document.getElementById("movebar");
/* 特殊节点面板(管理员专属,loadUserPanel 判定后显示) */
var sp_panel = document.getElementById('sp-panel');
var sp_type = document.getElementById('sp-type');
var sp_file = document.getElementById('sp-file');
var sp_file_tip = document.getElementById('sp-file-tip');
var sp_edit_original = null; /* 编辑图片/文件节点时的原信封字符串:未选新文件则原样提交 */
/* 面板默认展开的最小视口宽度(px):窄屏下面板展开会遮挡内容,默认折叠;宽屏默认展开 */
var PANEL_EXPAND_MIN_WIDTH = 768;
var is_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH;
@@ -471,10 +622,69 @@ function resetTextareaHeight() {
}
autoResizeTextarea(input_note_content); /* 初始化高度 */
/* ===== 特殊节点表单:类型切换控制输入区形态 ===== */
sp_type.addEventListener('change', function() {
var t = sp_type.value;
var is_file = (t === 'image' || t === 'file');
sp_file.style.display = is_file ? '' : 'none';
input_note_content.style.display = is_file ? 'none' : '';
sp_file_tip.textContent = '';
sp_file_tip.className = is_file ? 'tip-yellow' : '';
if (!is_file) {
sp_file.value = '';
sp_edit_original = null;
}
if (t === 'link') input_note_content.placeholder = '请输入链接 URL(https://…)';
else if (t === 'html') input_note_content.placeholder = '请输入完整 HTML 代码';
else input_note_content.placeholder = '';
});
function readFileAsDataURL(f) {
return new Promise(function(resolve, reject) {
var r = new FileReader();
r.onload = function() { resolve(r.result); };
r.onerror = function() { reject(new Error('文件读取失败')); };
r.readAsDataURL(f);
});
}
/* by qing — 构造提交内容:普通节点返回原文本,特殊节点返回信封 JSON 字符串。
* 校验失败返回 null(已弹提示);非管理员永远按普通文本提交。 */
async function buildSubmitContent() {
var t = (current_user && current_user.is_admin) ? sp_type.value : 'text';
if (t === 'text') return input_note_content.value;
if (t === 'link') {
var url = input_note_content.value.trim();
if (!/^https?:\/\//i.test(url)) { alert('链接必须以 http:// 或 https:// 开头'); return null; }
return JSON.stringify({ _sp: 'link', url: url });
}
if (t === 'html') {
var code = input_note_content.value;
if (!code.trim()) { alert('请输入 HTML 代码'); return null; }
return JSON.stringify({ _sp: 'html', code: code });
}
/* image / file:优先使用新选择的文件;编辑模式下未选新文件则保留原信封 */
if (sp_file.files.length === 0) {
if (sp_edit_original) return sp_edit_original;
alert('请选择要上传的' + (t === 'image' ? '图片' : '文件'));
return null;
}
var f = sp_file.files[0];
if (f.size > SP_MAX_FILE_BYTES) {
alert('文件过大(上限约 2.5MB):' + f.name);
return null;
}
var dataUrl = await readFileAsDataURL(f);
if (t === 'image') {
if (dataUrl.indexOf('data:image/') !== 0) { alert('所选文件不是图片'); return null; }
return JSON.stringify({ _sp: 'image', name: f.name, data: dataUrl });
}
return JSON.stringify({ _sp: 'file', name: f.name, mime: f.type || 'application/octet-stream', data: dataUrl.split(',')[1] });
}
input_note_submit.addEventListener('click', function(event) {
if (input_note_submit.disabled) return; // 防重复点击
var title = input_note_title.value
var content = input_note_content.value
var pid = (stack.length == 0) ? 0 : stack[stack.length - 1];
//alert(stack.length)
//alert(pid)
@@ -482,9 +692,16 @@ input_note_submit.addEventListener('click', function(event) {
input_note_submit.disabled = true;
input_note_clear.disabled = true;
delete_note.disabled = true;
// 编辑模式下保存修改,否则新增节点
var req = (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content);
Promise.resolve(req)
// 编辑模式下保存修改,否则新增节点;特殊节点先异步构造信封(可能涉及读文件)
buildSubmitContent()
.then(function(content) {
if (content == null) return null; // 校验失败,已弹提示
return (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content);
})
.catch(function(err) {
alert(err.message || '内容处理失败');
console.error('构造提交内容异常:', err);
})
.finally(() => {
input_note_submit.disabled = false;
input_note_clear.disabled = false;
@@ -513,11 +730,28 @@ delete_note.addEventListener('click', (event) => {
}
})
/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式 */
/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式。
* 特殊节点:link/html 回填 url/code 到文本域;image/file 记住原信封(未选新文件则原样提交) */
function startEdit(item) {
editing_id = item.id;
input_note_title.value = item.title;
input_note_content.value = item.content;
var sp = parseSp(item.content);
sp_edit_original = null;
if (sp && (sp._sp === 'link' || sp._sp === 'html')) {
sp_type.value = sp._sp;
sp_type.dispatchEvent(new Event('change'));
input_note_content.value = (sp._sp === 'link') ? (sp.url || '') : (sp.code || '');
} else if (sp) {
sp_type.value = sp._sp;
sp_type.dispatchEvent(new Event('change'));
sp_edit_original = item.content;
sp_file_tip.textContent = '不选择新文件则保持原' + (sp._sp === 'image' ? '图片' : '文件');
input_note_content.value = '';
} else {
sp_type.value = 'text';
sp_type.dispatchEvent(new Event('change'));
input_note_content.value = item.content;
}
input_note_submit.textContent = "保存修改";
autoResizeTextarea(input_note_content); /* 根据载入内容调整高度 */
input_note_title.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -527,6 +761,13 @@ function startEdit(item) {
/* by qing — 退出编辑模式,表单恢复为新增状态 */
function cancelEdit() {
editing_id = null;
sp_edit_original = null;
sp_file.value = '';
sp_file_tip.textContent = '';
if (sp_type.value !== 'text') {
sp_type.value = 'text';
sp_type.dispatchEvent(new Event('change'));
}
input_note_submit.textContent = "提交";
resetTextareaHeight(); /* 恢复初始高度 */
}
@@ -597,7 +838,7 @@ function load_notes(main_node) {
ttl.appendChild(wrt_div);
ttl.appendChild(sts_div);
let ctt = document.createElement('p');
renderMarkdown(ctt, item.content);
renderContent(ctt, item);
card.appendChild(ctt)
if (item.can_operate === true) {
let act_div = document.createElement('div');
@@ -699,6 +940,10 @@ async function loadUserPanel() {
});
if (!res.ok) return;
current_user = await res.json();
/* 管理员显示特殊节点面板(普通用户始终普通文本) */
if (current_user.login && current_user.is_admin) {
sp_panel.style.display = '';
}
renderUserPanel();
} catch (e) {
console.error('获取用户信息失败:', e);
+72 -7
View File
@@ -248,29 +248,94 @@ kill -HUP $(pgrep waf)
## 请求处理流程
[proxy.go:81-138](internal/proxy/proxy.go#L81) `ServeHTTP`:
[proxy.go:81-172](internal/proxy/proxy.go#L81) `ServeHTTP`:
1. `ProcessConnection` + `ProcessURI` + 添加请求头(含 Host)
2. `ProcessRequestHeaders` → 命中即 `block` 返回 403
3. 读全量请求体 → `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
3. 读取请求体(**10MB 硬限制**,超限返回 413)→ `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway
5. 捕获响应头 → `ProcessResponseHeaders`
5. 捕获响应头 → `ProcessResponseHeaders`(仅审计日志,不拦截)
6. `ModifyResponse` 统一注入安全响应头(覆盖下游同名头)
7. defer 中确保 `r.Body` 耗尽并关闭,保证 TCP 连接复用
### 请求体大小限制
WAF 在代码层面对请求体实施 **10MB 硬限制**([proxy.go#L19-L22](internal/proxy/proxy.go#L19-L22) `maxRequestBodySize`),与 `waf.conf` 中的 `SecRequestBodyLimit 10485760` 保持一致:
- 使用 `io.LimitReader` + 多读 1 字节检测超限
- 超限直接返回 `413 Request Entity Too Large`,不经过 Coraza 规则引擎
- 读取错误(客户端断开)记录日志后直接返回,不继续处理
- 从根本上防止超大请求体导致 OOM 崩溃
### HTTP 服务器超时防护
[main.go#L111-L122](main.go#L111) 为 `http.Server` 配置了多层超时,防御 Slowloris 等慢连接 DoS 攻击:
| 参数 | 值 | 作用 |
|------|-----|------|
| `ReadHeaderTimeout` | 10s | 读取请求头超时,**直接防御 Slowloris** |
| `ReadTimeout` | 15s | 读取整个请求(含 body)超时,防慢 POST |
| `WriteTimeout` | 30s | 写入响应超时 |
| `IdleTimeout` | 120s | keep-alive 空闲连接超时 |
| `MaxHeaderBytes` | 1MB | 请求头大小限制 |
Director 改写([proxy.go:36-46](internal/proxy/proxy.go#L36)):剥离 `RemoteAddr` 端口,设置 `X-Forwarded-For` 与 `X-Forwarded-Proto: https`,供 gateway 的 RateLimiter 取真实客户端 IP。
ErrorHandler([proxy.go:48-53](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,同时注入安全响应头,不暴露内部堆栈,仅服务端 log。
## WebSocket 代理支持
WAF 支持透明代理 WebSocket 连接(如 aibox 的 WebSocket 接口)。实现要点:
- `responseRecorder` 实现了 `http.Hijacker` 接口,将 Hijack 调用委托给底层 `http.ResponseWriter`
- WebSocket 握手阶段(HTTP Upgrade 请求/响应)仍然经过 WAF 规则检测(请求头 phase:1)
- 握手成功(101 Switching Protocols)后,连接被 Hijack,由 `httputil.ReverseProxy` 自动进行双向字节流转发
- 升级后连接转为二进制帧协议,跳过 HTTP 响应阶段的 WAF 检测,记录 Info 日志
- WebSocket 帧内容不做 WAF 检测(帧是二进制双向流,Coraza 规则引擎基于 HTTP 请求/响应模型)
- `http.Server` 的超时设置不影响 Hijack 后的 WebSocket 长连接(Hijack 后连接脱离 Server 超时管理)
- 安全响应头(CSP/HSTS 等)不注入 WebSocket 升级响应(101 响应由协议协商决定,注入非标准头可能导致客户端兼容问题)
配置要求:
- 无特殊配置,WebSocket 连接(`Upgrade: websocket`)自动识别并代理
- 需要上游服务正确处理 WebSocket 升级(如 aibox 监听 ws:// 端口)
## 已知限制
- 请求体全量读入内存检测,10MB 上限(`SecRequestBodyLimit`)。大文件上传场景需调大或加白名单。
- **响应头检测已启用**(`ProcessResponseHeaders`),**响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
- 响应头检测(`ProcessResponseHeaders`)当前仅做审计日志,命中后不执行拦截(响应头已发送无法撤销状态码)。如需响应头拦截需引入 buffered response writer。
- **响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
- **WebSocket 帧内容不检测**:升级后二进制帧直接转发,不经过 WAF 规则引擎(适用于内部可信服务如 aibox)。
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
- `http.Server` 未设 `ReadTimeout` / `WriteTimeout` / `IdleTimeout`,存在慢连接攻击风险。
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。
- `X-Forwarded-For` 直接覆盖原值,未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。
- `X-Forwarded-For` 直接覆盖原值(`Header.Set`),未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。注意:IPv6 地址在 X-Forwarded-For 中可能带方括号(待修复,使用 `LastIndexByte` 无法正确处理 IPv6 格式)。
- CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。
- HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。
- logclient 日志推送每次新建 TCP 短连接,高并发拦截场景可能产生较多 TIME_WAIT;db 地址硬编码 `127.0.0.1:8005`,不支持环境变量配置。
- **默认规则全部注释**:`rules/local/custom.conf` 中自定义规则默认注释,需手动取消注释或拉取 OWASP CRS 后才有实际拦截效果。启动时无 warning 提示。
- 缺少 SIGINT/SIGTERM 优雅关闭,进程终止时进行中的请求被暴力中断,logclient 队列中未发送的日志可能丢失。
- 标准日志(`log.SetOutput`)被静默到 `io.Discard`,net/http 的 panic 堆栈和 TLS 错误不可见。
## 版本日志
### v1.2(2026-08-01)— WebSocket 支持 + 修复
- **feat**: WebSocket 透明代理支持,`responseRecorder` 实现 `http.Hijacker` 接口
- WebSocket 握手阶段仍经过 WAF 请求头检测,升级后自动双向转发
- WebSocket 升级成功记录 Info 日志,便于追踪
- **fix(P2-3)**: `ProcessResponseHeaders` 协议版本从硬编码 `"HTTP/1.1"` 改为 `r.Proto`,支持 HTTP/2
- **fix(P2-4)**: responseRecorder 添加 Write 方法确保 WriteHeader 在首次 Write 前调用
- 补充 `bufio`/`net` import 用于 Hijacker 实现
### v1.1(2026-08-01)— 安全加固
- **P0 修复:请求体 OOM 防护**:添加 10MB 硬限制,`io.LimitReader` + 多读1字节检测超限,超限返回 413
- **P0 修复:Slowloris DoS 防护**:`http.Server` 添加 ReadHeaderTimeout(10s)/ReadTimeout(15s)/WriteTimeout(30s)/IdleTimeout(120s)/MaxHeaderBytes(1MB)
- **P1 修复:请求体读取错误处理**:`io.ReadAll` 失败时记录日志并返回,不再传递出错 body 给 ReverseProxy;`ReadRequestBodyFrom`/`ProcessRequestBody` 错误均记录日志
- **P2 修复:r.Body 连接复用**:defer 中耗尽并关闭 r.Body,确保 TCP 连接可复用
- 413 响应同样注入安全响应头
### v1.0
- 初始版本:TLS 终止 + Coraza WAF 规则匹配 + 反向代理 + SIGHUP 热加载 + 异步日志推送 + 安全响应头注入
## 依赖
+331
View File
@@ -0,0 +1,331 @@
# WAF 模块安全审计报告
**审计日期**: 2026-08-01
**审计范围**: `/waf/` 全部代码
**代码版本**: 当前工作区版本(dev_qing 分支)
---
## 修复进度
| 优先级 | 总数量 | 已修复 | 待修复 |
|--------|--------|--------|--------|
| **P0 (Critical)** | 3 | 2 | 1 |
| **P1 (High)** | 5 | 1 | 4 |
| **P2 (Medium)** | 6 | 3 | 3 |
| **P3 (Low)** | 3 | 0 | 3 |
---
## 问题总览
| 优先级 | 数量 | 说明 |
|--------|------|------|
| **P0 (Critical)** | 3 | 可导致服务崩溃、OOM、DoS,必须立即修复 |
| **P1 (High)** | 5 | 安全功能缺失、IP 解析错误、功能与文档不符 |
| **P2 (Medium)** | 6 | 健壮性、可观测性、配置灵活性问题 |
| **P3 (Low)** | 3 | 代码风格、优化建议 |
---
## P0 - Critical 严重问题
### ✅ P0-1: 请求体无大小限制导致 OOM 崩溃(已修复)
**位置**: `internal/proxy/proxy.go`(原 L114-L125)
**问题描述**:
`waf.conf` 中配置了 `SecRequestBodyLimit 10485760`(10MB),但这只是 **Coraza 内部的解析限制**,并不能阻止 `io.ReadAll` 将整个请求体读入内存。攻击者可以发送超大请求体导致 OOM。
**修复方案**(2026-08-01):
1. 添加常量 `maxRequestBodySize = 10 * 1024 * 1024`(10MB,与 SecRequestBodyLimit 一致)
2. 使用 `io.LimitReader(r.Body, maxRequestBodySize+1)` 包装后再读取
3. 多读 1 字节用于判断是否超限,超限返回 `413 Request Entity Too Large`
4. 同时修复了 P1-3(请求体读取错误被静默忽略)和 P2-6(r.Body 未关闭耗尽)的问题
5. 在 defer 中确保 r.Body 被关闭和耗尽,保证 TCP 连接可复用
6. 对 ReadRequestBodyFrom 和 ProcessRequestBody 的返回错误添加了日志记录
---
### ✅ P0-2: HTTP 服务器无超时设置,存在 Slowloris 攻击风险(已修复)
**位置**: `main.go`(原 L110-L116)
**问题描述**:
所有超时均为零值(无限等待):
- **Slowloris 攻击**: 攻击者以极慢速度发送请求头(每隔几秒发一个字节),可以长时间占用连接,耗尽文件描述符导致服务不可用
- **慢 POST 攻击**: 声明大的 Content-Length 但缓慢发送 body,同样耗尽连接
- **空闲连接泄漏**: 无 IdleTimeout,已建立的 keep-alive 连接永不超时
- **大 Header 攻击**: MaxHeaderBytes 未限制
这与 pymsg 模块中修复的 recv_timeout 问题是同一类 DoS 漏洞。
**修复方案**(2026-08-01):
添加以下超时和大小限制:
- `ReadTimeout: 15s` - 读取整个请求(包括 body)的超时,防止慢 POST
- `ReadHeaderTimeout: 10s` - 读取请求头的超时,直接防御 Slowloris
- `WriteTimeout: 30s` - 写入响应的超时
- `IdleTimeout: 120s` - keep-alive 连接空闲超时
- `MaxHeaderBytes: 1<<20` - 请求头最大 1MB,防止大 Header 攻击
---
### P0-3: 规则默认全部注释,WAF 启动后无任何防护
**位置**: `rules/local/custom.conf`
**问题描述**:
所有本地自定义规则(SQL注入、XSS、路径遍历、命令注入、扫描器UA)**全部被注释掉**了。WAF 启动后:
- 引擎正常加载
- 流量正常转发
- **没有任何攻击会被拦截**
- 安全团队以为 WAF 在工作,实际上是"裸奔"状态
README 中没有明确警示用户需要手动启用规则。
**修复建议**:
1. 至少启用一组保守的默认规则(如扫描器UA拦截、明显的SQL注入特征)
2. 或者在启动日志中明确警告:"当前未启用任何拦截规则,WAF 处于检测-only模式"
3. 在 README 中以醒目标注提示用户需要取消规则注释
---
## P1 - High 高危问题
### P1-1: IPv6 地址解析错误,X-Forwarded-For 包含多余方括号
**位置**: `internal/proxy/proxy.go#L40-L43` 和 `internal/proxy/proxy.go#L92-L95`
**问题描述**:
```go
clientIP := req.RemoteAddr
if idx := strings.LastIndexByte(req.RemoteAddr, ':'); idx != -1 {
clientIP = req.RemoteAddr[:idx]
}
```
使用 `LastIndexByte(':')` 无法正确处理 IPv6 地址:
- 对于 IPv4: `1.2.3.4:54321` → 正确得到 `1.2.3.4`
- 对于 IPv6: `[::1]:54321` → 错误得到 `[::1]`(含方括号)
- 对于 IPv6: `2001:db8::1:54321` → 错误截断
Go 标准库提供了 `net.SplitHostPort` 专门处理这个问题。Gateway 的 RateLimiter 如果收到带方括号的 IP,会将 `[::1]`、`[::1]:0` 等当成不同的 IP,导致 IPv6 地址下的限流完全失效。
**修复建议**: 使用 `net.SplitHostPort(req.RemoteAddr)` 替代手动解析。
---
### P1-2: 响应头检测只检测不拦截,与文档描述不符
**位置**: `internal/proxy/proxy.go#L131-L138`
**问题描述**:
```go
// 响应阶段:把 upstream 的 header 喂给 WAF
for k, vs := range rw.headers {
for _, v := range vs {
tx.AddResponseHeader(k, v)
}
}
tx.ProcessResponseHeaders(rw.status, "HTTP/1.1") // ← 返回值被丢弃!
```
`ProcessResponseHeaders` 返回 `(MatchedRule, Intervention)`,命中规则时会返回非 nil 的 MatchedRule。但代码完全忽略了返回值,没有任何拦截动作。README 中声称"响应头检测已启用",但实际上只"检测"不"拦截"。
**修复建议**: 捕获返回值记录高优先级告警,或明确在文档中说明"响应头检测仅做日志审计,不执行拦截"。
---
### ✅ P1-3: 请求体读取错误被静默忽略,Body 状态不一致(已修复)
**位置**: `internal/proxy/proxy.go`(原 L116-L124)
**问题描述**:
原来的代码中 `io.ReadAll` 失败时错误被完全忽略,r.Body 状态不一致,可能导致代理转发挂起;`ReadRequestBodyFrom` 的返回错误也被忽略。
**修复方案**(2026-08-01,与 P0-1 一并修复):
1. `io.ReadAll` 返回错误时,记录 Info 级别日志并直接返回(客户端断开无需继续处理)
2. `ReadRequestBodyFrom` 和 `ProcessRequestBody` 返回错误时记录 Warning 日志
3. 不再对出错的 body 继续调用 ProcessRequestBody
---
### P1-4: 缺少优雅关闭,SIGINT/SIGTERM 时请求被暴力中断
**位置**: `main.go#L97-L125`
**问题描述**:
当前只处理了 SIGHUP(热加载),没有处理 SIGINT/SIGTERM。进程被终止时:
1. 正在处理的请求被强制中断,客户端收到连接重置错误
2. logclient 队列中的日志可能丢失(只有在代理初始化失败/TLS监听失败路径才调用 Flush)
3. 没有给正在进行的请求完成的宽限期
**修复建议**: 监听 SIGINT/SIGTERM,调用 `server.Shutdown(ctx)` 优雅关闭,并在退出前 `logclient.Flush()` 刷新剩余日志。
---
### P1-5: 标准日志被完全静默,panic 堆栈和运行时错误丢失
**位置**: `main.go#L88`
**问题描述**:
```go
log.SetOutput(io.Discard) // 静默标准 log
```
这一行把 Go 标准库 `log` 的所有输出都丢弃了,包括:
- net/http 服务器捕获的 panic 堆栈(请求处理中 panic 时,服务器用 log.Printf 输出堆栈然后返回 502)
- net/http 服务器的内部错误(如 TLS 握手错误、连接错误)
- Coraza 库内部可能的 log 输出
- 任何第三方库使用标准 log 输出的诊断信息
出现问题时无法排查根因。
**修复建议**: 将标准日志输出到 stderr(systemd/journalctl 可捕获),而非完全丢弃。
---
## P2 - Medium 中危问题
### P2-1: 规则文件读取无大小限制,超大规则文件导致 OOM
**位置**: `internal/engine/engine.go#L58-L80` 和 `internal/engine/engine.go#L157`
**问题描述**:
`WithDirectivesFromFile` 和 `os.ReadFile` 都直接读取整个规则文件到内存。与之前 confs 模块修复的问题一样,如果攻击者或错误配置放入 GB 级的 `.conf` 文件,Reload/ListRules 时会导致 OOM。
**修复建议**: 加载规则文件前用 `os.Stat` 检查大小,设置合理上限(如 1 MiB)。
---
### P2-2: logclient 硬编码 db 地址,不支持环境变量配置
**位置**: `internal/logclient/logclient.go#L24`
**问题描述**:
```go
const dbAddr = "127.0.0.1:8005"
```
db 地址硬编码为常量。项目约定其他服务(db、ai_review、dnfs)都支持通过环境变量配置监听地址/端口,但 WAF 的 logclient 不支持,在非标准部署场景下无法灵活配置。
**修复建议**: 通过环境变量读取,提供默认值(`LOG_DB_ADDR` 或 `DB_HOST`+`DB_PORT`)。
---
### ✅ P2-3: ProcessResponseHeaders 硬编码 HTTP/1.1,协议版本错误(已修复)
**位置**: `internal/proxy/proxy.go`(原 L137)
**问题描述**:
硬编码 `"HTTP/1.1"`,HTTP/2 请求时协议版本不正确,Coraza 规则中依赖协议版本的检测可能不准确。
**修复方案**(2026-08-01,与 WebSocket 支持一并修复):
将 `tx.ProcessResponseHeaders(rw.status, "HTTP/1.1")` 改为 `tx.ProcessResponseHeaders(rw.status, r.Proto)`,从请求中获取正确的协议版本。
---
### ✅ P2-4: responseRecorder 不支持 http.Hijacker,WebSocket 升级无法工作(已修复)
**位置**: `internal/proxy/proxy.go`(原 L157-L180)
**问题描述**:
`responseRecorder` 嵌入了 `http.ResponseWriter` 但只实现了 `WriteHeader` 和 `Flush`,没有实现 `http.Hijacker` 接口,WebSocket 升级(需要接管底层 TCP 连接)无法工作。
**修复方案**(2026-08-01):
1. 添加 `bufio` 和 `net` import
2. 给 `responseRecorder` 添加 `hijacked bool` 和 `statusSent bool` 字段
3. 实现 `Hijack()` 方法,将调用委托给底层 `ResponseWriter.(http.Hijacker)`,成功后标记 `hijacked=true` 并设置 status=101
4. 添加 `Write()` 方法确保首次 Write 前 WriteHeader 被调用(符合 http.ResponseWriter 约定)
5. 在 `ServeHTTP` 中检测 `rw.hijacked`,WebSocket 升级后跳过响应阶段 WAF 检测,记录 Info 日志
6. ReverseProxy(Go 1.22)内置 WebSocket 代理支持,Hijack 后自动进行双向字节流转发
---
### P2-5: logclient 在 db 不可用时错误输出到 stdout
**位置**: `internal/logclient/logclient.go#L223`
**问题描述**:
```go
if json.Unmarshal(resp, &r) != nil || !r.Success {
fmt.Printf("[logclient] INSERT failed: %s\n", r.Error)
}
```
db 插入失败时用 `fmt.Printf` 输出到 stdout。stdout 输出没有时间戳、没有级别,且在 systemd 等守护进程下可能被缓冲或重定向。建议输出到 stderr。
---
### ✅ P2-6: 错误处理时未确保 r.Body 耗尽/关闭,影响连接复用(已修复)
**位置**: `internal/proxy/proxy.go`(原 L81-L138)
**问题描述**:
ServeHTTP 中 defer 只关闭了 WAF 事务 `tx`,但没有显式关闭/耗尽 `r.Body`。在拦截提前返回路径上,TCP 连接可能无法正确复用。
**修复方案**(2026-08-01,与 P0-1 一并修复):
在 defer 中添加 r.Body 耗尽和关闭逻辑:先丢弃最多 1KB 剩余数据(避免大量未读数据),然后 Close。
---
## P3 - Low 优化建议
### P3-1: logclient 每次发送日志新建 TCP 连接,TIME_WAIT 堆积
**位置**: `internal/logclient/logclient.go#L228-L233`
每条日志都通过 `net.DialTimeout` 建立新的短连接。在短时间内大量拦截(如攻击风暴)时,会产生大量 TIME_WAIT 状态的 TCP 连接,占用临时端口。考虑复用连接池或保持长连接。
---
### P3-2: TLS Cipher Suites 未限定,允许弱加密套件
**位置**: `main.go#L113-L115`
当前只设置了 `MinVersion: tls.VersionTLS12`,未设置 `CipherSuites` 和 `CurvePreferences`,允许 Go 默认的所有套件,包括一些较弱的套件。建议显式指定安全的密码套件列表。
---
### P3-3: CheckInput 中的 URL 编码逻辑不完整
**位置**: `internal/engine/engine.go#L115-L129`
`CheckInput`(CLI `-check` 用)中的 query 字符串编码只处理了简单的 `key=value&key=value` 格式,对于无值的 key、重复 key、复杂嵌套结构编码可能不正确。但这是 CLI 调试工具,不影响线上安全,优先级低。
---
## 正面评价(值得肯定的设计)
审计中也发现了一些优秀的设计实践:
1. ✅ **热加载 fail-safe**: Reload 失败时保留旧规则实例,服务不中断
2. ✅ **规则加载路径确定性**: 不使用 coraza 的 Include 指令(cwd 相对路径易出错),自行扫描目录按固定顺序加载
3. ✅ **日志客户端 fail-safe**: db 不可用时静默丢弃日志,绝不阻塞 WAF 主流程
4. ✅ **X-Forwarded-For 可信链设计**: WAF 从 TCP 连接获取真实 IP 直接覆盖 XFF,Gateway 侧先剥离伪造头再设置可信值,防止 IP 欺骗
5. ✅ **安全头统一在边缘注入**: ModifyResponse 用 `Header.Set()` 覆盖下游同名头,防止下游服务意外覆盖 CSP 等安全头
6. ✅ **证书和密钥目录被 .gitignore 排除**: 不会意外提交私钥到仓库
7. ✅ **GOTOOLCHAIN=local**: 避免国内环境 Go 自动下载 toolchain 超时
8. ✅ **并发安全**: Engine 使用 sync.RWMutex 保护 WAF 实例热替换,读请求用 RLock 不阻塞
9. ✅ **upstream 502 错误也注入安全头**: ErrorHandler 中也调用 setSecurityHeaders,避免错误页面泄露信息
10. ✅ **静态编译**: CGO_ENABLED=0 编译单二进制,无系统库依赖,部署方便
---
## 修复优先级建议
建议按以下顺序修复:
**立即修复(P0)**:
1. P0-1: 请求体大小限制(OOM 防护)
2. P0-2: HTTP 超时设置(Slowloris 防护)
3. P0-3: 启用默认规则或明确警告
**短期修复(P1)**:
4. P1-1: IPv6 地址解析(`net.SplitHostPort`)
5. P1-4: 优雅关闭
6. P1-5: 恢复标准日志输出
7. P1-2/P1-3: 响应头处理和 body 读取错误处理
**中期改进(P2)**:
8. 规则文件大小限制、logclient 配置化等
+99 -10
View File
@@ -2,9 +2,11 @@
package proxy
import (
"bufio"
"bytes"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
@@ -16,6 +18,10 @@ import (
"github.com/micro_servers/waf/internal/logclient"
)
// maxRequestBodySize 请求体最大大小(10MB),与 rules/waf.conf 中 SecRequestBodyLimit 保持一致。
// 超过此限制的请求直接返回 413 Request Entity Too Large,防止 OOM。
const maxRequestBodySize = 10 * 1024 * 1024
// Proxy 持有 ReverseProxy 与 WAF 引擎引用。
type Proxy struct {
rp *httputil.ReverseProxy
@@ -84,6 +90,11 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer func() {
tx.ProcessLogging()
_ = tx.Close()
// 确保 r.Body 被关闭并耗尽,保证 TCP 连接可复用
if r.Body != nil {
_, _ = io.Copy(io.Discard, io.LimitReader(r.Body, 1024))
_ = r.Body.Close()
}
}()
// 解析 RemoteAddr 中的 IP 与端口
@@ -111,14 +122,47 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// 请求体:读全量喂给 coraza,再用 buffer 恢复 r.Body 给 proxy 转发
// 请求体:读取时限制大小(maxRequestBodySize + 1 字节用于判断超限),防止 OOM
if r.Body != nil && r.Body != http.NoBody {
bodyBytes, err := io.ReadAll(r.Body)
if err == nil {
_, _, _ = tx.ReadRequestBodyFrom(bytes.NewReader(bodyBytes))
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
// 限制读取大小:多读 1 字节,若能读到说明超限
limitReader := io.LimitReader(r.Body, maxRequestBodySize+1)
bodyBytes, err := io.ReadAll(limitReader)
if err != nil {
// 客户端中途断开或读取错误,直接返回无需继续处理
logclient.Info("waf", fmt.Sprintf("body_read_error path=%s err=%v", r.URL.Path, err),
map[string]any{"ip": r.RemoteAddr})
return
}
if it, _ := tx.ProcessRequestBody(); it != nil {
if len(bodyBytes) > maxRequestBodySize {
// 请求体超限,返回 413
logclient.Warning("waf", fmt.Sprintf("body_too_large %s %s size=%d", r.Method, r.URL.Path, len(bodyBytes)),
map[string]any{
"ip": r.RemoteAddr,
"method": r.Method,
"path": r.URL.Path,
"size": len(bodyBytes),
"limit": maxRequestBodySize,
"cost_ms": time.Since(start).Milliseconds(),
})
setSecurityHeaders(w.Header())
w.WriteHeader(http.StatusRequestEntityTooLarge)
_, _ = w.Write([]byte("request body too large\n"))
return
}
// 喂给 coraza 进行请求体检测
if _, _, err := tx.ReadRequestBodyFrom(bytes.NewReader(bodyBytes)); err != nil {
logclient.Warning("waf", fmt.Sprintf("body_parse_error path=%s err=%v", r.URL.Path, err),
map[string]any{"ip": r.RemoteAddr})
}
// 恢复 r.Body 供 ReverseProxy 转发
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
it, err := tx.ProcessRequestBody()
if err != nil {
logclient.Warning("waf", fmt.Sprintf("body_process_error path=%s err=%v", r.URL.Path, err),
map[string]any{"ip": r.RemoteAddr})
}
if it != nil {
p.block(w, r, it.RuleID, start)
return
}
@@ -128,13 +172,26 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rw := &responseRecorder{ResponseWriter: w, status: 200}
p.rp.ServeHTTP(rw, r)
// WebSocket 升级成功后连接已被 Hijack,转为二进制帧协议,无需执行 HTTP 响应阶段检测
if rw.hijacked {
logclient.Info("waf", fmt.Sprintf("websocket_upgraded %s %s cost_ms=%d",
r.Method, r.URL.Path, time.Since(start).Milliseconds()),
map[string]any{
"ip": r.RemoteAddr,
"method": r.Method,
"path": r.URL.Path,
"cost_ms": time.Since(start).Milliseconds(),
})
return
}
// 响应阶段:把 upstream 的 header 喂给 WAF
for k, vs := range rw.headers {
for _, v := range vs {
tx.AddResponseHeader(k, v)
}
}
tx.ProcessResponseHeaders(rw.status, "HTTP/1.1")
tx.ProcessResponseHeaders(rw.status, r.Proto)
}
// block 命中规则时统一返回 403,并将关键输入推送到日志库。
@@ -155,26 +212,58 @@ func (p *Proxy) block(w http.ResponseWriter, r *http.Request, ruleID int, start
}
// responseRecorder 记录状态码与响应头,供响应阶段 WAF 检测使用。
// 同时实现 http.Hijacker 以支持 WebSocket 升级(ReverseProxy 在 WebSocket 握手时需要劫持底层连接)。
type responseRecorder struct {
http.ResponseWriter
status int
headers http.Header
status int
statusSent bool
headers http.Header
hijacked bool
}
func (r *responseRecorder) WriteHeader(code int) {
if r.hijacked {
return
}
r.status = code
r.statusSent = true
if r.headers == nil {
r.headers = http.Header{}
}
// 拷贝上游响应头
// 拷贝上游响应头(在 WriteHeader 调用时响应头已最终确定)
for k, vs := range r.Header() {
r.headers[k] = vs
}
r.ResponseWriter.WriteHeader(code)
}
func (r *responseRecorder) Write(b []byte) (int, error) {
if !r.statusSent {
r.WriteHeader(http.StatusOK)
}
return r.ResponseWriter.Write(b)
}
// Flush 实现 http.Flusher,支持 SSE/流式响应。
func (r *responseRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Hijack 实现 http.Hijacker,支持 WebSocket 升级。
// 委托给底层 ResponseWriter 的 Hijack 方法,并标记连接已被劫持。
func (r *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := r.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("underlying ResponseWriter does not support Hijacker")
}
conn, rw, err := hijacker.Hijack()
if err == nil {
r.hijacked = true
// WebSocket 升级响应是 101 Switching Protocols
r.status = http.StatusSwitchingProtocols
r.statusSent = true
}
return conn, rw, err
}
+8 -2
View File
@@ -22,6 +22,7 @@ import (
"os"
"os/signal"
"syscall"
"time"
"gopkg.in/yaml.v3"
@@ -108,8 +109,13 @@ func main() {
}()
server := &http.Server{
Addr: cfg.Listen,
Handler: p,
Addr: cfg.Listen,
Handler: p,
ReadTimeout: 15 * time.Second,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20, // 1MB
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},