启动模板自检 + 站长导航放行 + 统计脚本关口收紧
排查:/admin/settings 渲染到一半就断
- 现象:页面在「统计脚本」那张卡片处截断,没有保存按钮、没有 </form></html>,而 HTTP 仍是 200。
- 根因:跑的是旧二进制(改动前编译),磁盘上的模板却是新的。debug=true 下 pageSet()
每次渲染都从磁盘重解析模板,于是新 settings.html 引用了旧 setting.Settings 里没有的
TrackEnabled —— 执行期报错,而 render 只把错误写进日志,客户端只看到半张页面。
- 判据:strings <二进制> | grep -c cleanTrackScript(旧 0 / 新 2)。别用 strings | grep
TrackScript —— config 里本来就有这个名字,旧二进制也命中,是个假信号。
启动自检(防复发)
- template_smoke.go:pageProbe 给 24 个页面各配一份最小可用数据,New() 里全部渲染一遍,
任一页失败就拒绝启动。pageProbe 与 pageFiles 双向校验,新加页面必须补探针,
漏配按失败处理(不是省事)。
- smokeOne 用 recover 兜住方法里的 panic,报「哪一页坏了」而不是崩栈。
- 实测:往 settings.html 插一个不存在的字段 → 启动即报
「模板自检未通过…admin/settings: can't evaluate field …」,退出码 1。
- 门禁 template_smoke_test.go:一条跑全量自检,一条变异验证(探针换成字段更少的类型
必须报错且点名该页)。
站长导航被页面开关误伤
- guardPage 只拦访客(站长始终能访问全部公开页),但导航写的是 {{if .Settings.HasPage "x"}},
没有 IsAdmin 分支 ⇒ 页面开关一关,站长自己也只剩「主页」一个入口 —— 能访问却点不到。
改成 {{if or .IsAdmin (.Settings.HasPage "x")}}(map/places/timeline/stats/report 五处)。
- 门禁 nav_visibility_test.go:三向不变量(站长仍有全部入口 / 访客一个都没有 /
全开放时访客也看得到),做过变异验证。
- 访客侧「只有主页」是按后台配置应有的状态,没有动数据。
统计脚本关口收紧(cleanTrackScript)
- src= 只在 <script …> 开标签内部找。原来对整段做 strings.Index,脚本体里一句
`img.src=u` 就被命中、整段被判危险而清空 —— 页面照样提示「设置已保存」,站长看不出没挂上。
- 开闭标签从「只拒 shut > open」改成严格配平:末尾未闭合的 <script> 会把页面收尾整段吞进脚本。
- 不再静默:签名改成 (script, warn),说明码经 ?warn= 回显成人话;判为危险时整段不采纳、
库里原脚本与开关都不动(把站长手上好用的脚本一并清掉更糟)。
- 门禁 TestCleanTrackScript 重写:逐条边界 + 定义性不变量(采纳了非空结果时,
里面开闭标签数必定相等)+ TestTrackWarnMessage(未识别码不得回显)。变异验证做过。
- 文案订正:settings.html 的「conf/app.ini 的 track_script 会作为这里的默认值」有歧义 ——
它不回填文本框,而是「这个框留空时、注入那一刻才回落到 ini」。
其余
- bin/ 目录删除(15 个 exe / 412MB),二进制只留仓库根 rond.exe;
技能 rond-web-verify 与 memory details 十八里 bin/rond.exe 的写法同步改掉。
- 验收:./scripts/verify.sh 全绿且 0 skip(PASS=346 FAIL=0 SKIP=0)。
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
# 2026-10-09
|
||||
|
||||
## 排查:/admin/settings 报错(旧二进制 + 新模板)
|
||||
|
||||
**现象**:「统计脚本做成后台开关」那次提交(4015ddf)之后,访问 /admin/settings 报错。
|
||||
|
||||
**根因(已实测复现)**:跑的是**旧二进制**(根目录 `rond.exe`,10-08 18:06 编译,早于 18:27 的提交),
|
||||
磁盘上的模板却是新的。`debug=true` 下 `pageSet()` 每次渲染都从磁盘重解析模板,
|
||||
于是新 `settings.html:148` 引用了旧 `setting.Settings` 结构体里没有的 `TrackEnabled` → 执行期报错:
|
||||
|
||||
```
|
||||
渲染 admin/settings 失败: template: settings.html:148:85: executing "content"
|
||||
at <.Settings.TrackEnabled>: can't evaluate field TrackEnabled in type setting.Settings
|
||||
```
|
||||
|
||||
`render` 是流式直写 ⇒ header 已发(HTTP 200)但表单在「统计脚本」卡片处截断,
|
||||
没保存按钮、没 `</form></html>`。这就是用户看到的「报错」。
|
||||
|
||||
**判据**:`strings <二进制> | grep -c cleanTrackScript` —— 旧根二进制 0,新编译的 2。
|
||||
(注意别用 `strings | grep TrackScript` 判定,config 里本来就有这个名字,旧二进制也命中。)
|
||||
|
||||
**修复**:`go build -o rond.exe .`(+ `bin/rond.exe`),重启。旧二进制备份到
|
||||
`bin/rond.exe.old-20261008-stale`。修后 /admin/settings 完整(19642B,含 `</html>`/保存按钮)。
|
||||
|
||||
**教训**:Go 结构与模板字段是成对改的,`debug=true` 的热加载只覆盖模板 —— 改 Go 必须重编译重启,
|
||||
否则就是「新模板 + 旧结构 = 执行期报错」。想根治可在启动时把所有页面各渲染一遍做冒烟(现在没有)。
|
||||
|
||||
## 其他发现(未改代码)
|
||||
|
||||
1. **P1 待确认**:`settings` 行 `pages=null`(写入时间 2026-10-08 18:14:05)⇒ 访客侧
|
||||
`/map /places /timeline /stats /report` 全部 404、导航只剩 1 个链接。代码语义是
|
||||
「全不选 = 访客只能看主页」,属合法状态,像是测 404 分档时留下的。**没动数据,等确认**。
|
||||
2. **P2**:`cleanTrackScript` 的 `src=` 检查是**全文 `strings.Index`**,会误伤脚本体内的
|
||||
`src=`。实测:`<script>var i=new Image();i.src=u;</script>` → 返回 `""`(整段被静默清空),
|
||||
而页面仍提示「设置已保存」⇒ 静默失效。建议只在标签属性里查 src,或把「被清空」回传前端显式提示。
|
||||
3. **P2**:开闭标签只拒 `shut > open`;末尾未闭合 `<script>`(`open > shut`)会吞掉后面内容。
|
||||
实测 `<script>x</script><script>y` 与 `<script>y` 都被放行。建议改成要求 `open == shut`。
|
||||
4. **运维**:根 `rond.exe` 与 `bin/rond.exe` 两份二进制容易走偏(这次根目录那份就是旧的)。
|
||||
考虑只保留 `bin/`,或在启动日志打印构建时间便于比对。
|
||||
|
||||
**回归**:`go vet` 干净、`gofmt -l` 空、JS 单测 34/34、`go test ./...` 13 包全 ok 0 FAIL。
|
||||
(注:`internal/web` 里 110 例是 DB 门控的,不开 `ROND_TEST_CONF`/`ROND_SRC` 会 skip,不算真跑过。)
|
||||
|
||||
---
|
||||
|
||||
## 用户确认后的收尾(四件)
|
||||
|
||||
用户反馈:「管理员访问也只剩主页,这好像不对」+ P2 按建议修 + 二进制只留根目录 + 防复发一并做。
|
||||
|
||||
### 1. P1 更正:不是配置问题,是导航缺 IsAdmin 分支
|
||||
|
||||
上面第 1 条判断得不完整。实测:站长访问 /map /places /timeline /stats /report **全是 200**
|
||||
(`guardPage` 只拦访客),但导航里只有「主页」⇒ `layout.html` 的页面显隐写的是
|
||||
`{{if .Settings.HasPage "x"}}`,**没有 IsAdmin 分支**,与 `guardPage` 口径不一致。
|
||||
改成 `{{if or .IsAdmin (.Settings.HasPage "x")}}` 五处。门禁 `nav_visibility_test.go`
|
||||
(三向不变量 + 变异验证)。**访客侧的 pages=null 没动**(仍是「只对访客关」的配置状态)。
|
||||
|
||||
### 2. P2 修复:`cleanTrackScript` 重写
|
||||
|
||||
- `src=` 只在 `<script …>` **开标签内部**找(原来对整段 `strings.Index`,`i.src=u` 被误伤清空)。
|
||||
- 开闭标签从「只拒 `shut > open`」改成**严格配平**(末尾未闭合的开标签原来会被放行)。
|
||||
- 签名改 `(script, warn string)`,说明码经 `?warn=` 回显成人话;判危险时**整段不采纳、
|
||||
库里原脚本与开关不动**。
|
||||
- 实测(测试实例 8081 / `rond_fog_test`,用完已还原):`i.src=u` 现在原样落库;
|
||||
不配平 → `?warn=unbalanced` 且库值不变;`javascript:` → `?warn=badsrc` 且库值不变;
|
||||
5000 字符 → `?warn=truncated` 且落库 4096。注入范围精确复核:只在公开页(/、/map),
|
||||
后台与登录页 0。
|
||||
|
||||
### 3. `bin/` 删除
|
||||
|
||||
15 个 exe、412MB 全删;只保留根 `rond.exe`。技能 `rond-web-verify` 与 details 十八的
|
||||
`bin/rond.exe` 写法已同步改掉。
|
||||
|
||||
### 4. 防复发:启动自检 `smokeRender`
|
||||
|
||||
`template_smoke.go`:`pageProbe`(24 个页面各一份最小可用数据,键与 `pageFiles` 双向校验)
|
||||
+ `New()` 里全量渲染一遍,失败即 `log.Fatalf` 拒启。实测:往 settings.html 插一个不存在的
|
||||
字段 → 启动报 `模板自检未通过…admin/settings: can't evaluate field …`,退出码 1。
|
||||
门禁 `template_smoke_test.go`(含变异验证:把探针换成字段更少的类型必须报错)。
|
||||
|
||||
### 5. 用户裁决 + 文案订正
|
||||
|
||||
- **访客侧 `pages=null` 不动**:用户明确说「访客那一侧是根据我在后台配置的显示范围公开的,
|
||||
倒不如说原来默认就是这样才对」⇒ 那是**预期配置**,不是故障。已把这个结论写进
|
||||
details 四十一,免得后续会话又去「修」数据。
|
||||
- **settings.html 的统计脚本文案订正**(P3):原文两处不准 ——
|
||||
①「多一个 `</script>` 会被判为粘贴错误」漏了「少一个也会拒」(判据已改成严格配平);
|
||||
②「`conf/app.ini` 的 `track_script` 会作为这里的默认值」有歧义:它**不会回填文本框**,
|
||||
而是「这个框留空时,注入时回落到 ini 的值」。已改成准确说法。
|
||||
实测热加载生效(debug=true,无需重编),页面 19996B、含 `</html>`,旧文案 0 命中。
|
||||
- 验收:官方 `./scripts/verify.sh` → **PASS=346 FAIL=0 SKIP=0**(改动前那次全量;
|
||||
之后只动了这一个模板文案,重跑了全部模板门禁与 `TestSmokeRender*`,全绿)。
|
||||
|
||||
@@ -110,7 +110,7 @@ zip 包,**条目内容本身是 zlib 流**;`Model/*/` = 头 32768B + 每块
|
||||
|
||||
## 十八、重启与操作纪律
|
||||
|
||||
- **重编重启**:`go build -o bin/rond.exe .` → 停掉占 8080 的进程 → `./bin/rond.exe -c conf/app.ini`。`taskkill //F` 的双斜杠参数会被 Git Bash 吃掉,改用 PowerShell 按端口找 PID + `Stop-Process`。
|
||||
- **重编重启**:`go build -o rond.exe .`(**只构建到仓库根,`bin/` 已废弃、2026-10-09 删除**)→ 停掉占 8080 的进程 → `./rond.exe -c conf/app.ini`。`taskkill //F` 的双斜杠参数会被 Git Bash 吃掉,改用 PowerShell 按端口找 PID + `Stop-Process`。
|
||||
- 生产 `life.shiroko.one` **只走 IPv4**(curl 必须 `-4`);会话 cookie `rond_session=uid|token`(HMAC);HTTP 303 **不能跟随**(Location 里带 `ok=`/`err=`)。`/tmpfog/` 不是运行必需,`tmpfog/_ck.txt` 是**死票**(实打接口被 302)⇒ 不必轮换 `secret_key`。
|
||||
- **`gofmt -l` 会报假警**:仓库存 LF、`core.autocrlf=true` 把工作区换成 CRLF(已加 `.gitattributes` 钉住);**用 python `open(p,'w')` 回写 Go/CSS 会把行尾变 CRLF**,要二进制模式读写。
|
||||
- **同一文件绝不并行改**:多条 Edit 一起发给同一文件时,各自「读整份-替换-写整份」会互相覆盖——工具**报 success 但磁盘上没落**(实测一次发 3 条给 `map.html`,`map-count` 那条丢了)。**一个文件串行改,改完 `grep` 复核关键标记,再编译。**
|
||||
@@ -879,14 +879,69 @@ Esc 关闭、焦点自动进输入框、其余部分自动 inert。旧浏览器
|
||||
`settingsSubmit()` 的表单读取。`Load()` 里布尔项一律用 `present[key]` 判:
|
||||
历史配置没有这个键时若直接套零值,语义会悄悄反转。
|
||||
|
||||
### `cleanTrackScript` 的判据(这里踩过一次)
|
||||
### `cleanTrackScript` 的判据(这里踩过两次,2026-10-09 重写)
|
||||
|
||||
⚠️ **不能用「出现 `</script` 就拒」当规则** —— 百度统计、Umami 贴进来的就是完整
|
||||
HTML,一行里必然带 `</script>`,一拒就把正常用法挡死了(我第一版就是这么写的,
|
||||
被自己的测试当场抓住)。真正的注入面只有一种:**未闭合的 `<script>` 里出现
|
||||
`</script>` 会提前闭合**,后面内容当正文渲染。判据改成**开闭标签数配平**
|
||||
(`shut > open` 才拒),配平则整段原样放行。
|
||||
另挡 `src=` 的 `javascript:` / `data:` 伪协议(只放行 http/https 与 `/` 开头),
|
||||
超 4096 截断。
|
||||
门禁 `TestCleanTrackScript` 除了逐条边界,还钉了**定义性不变量**:
|
||||
任何输入都不得产出「含 `</script` 的非空结果」。
|
||||
被自己的测试当场抓住)。
|
||||
|
||||
现行判据两条:
|
||||
1. **开闭 `<script>` 标签必须严格配平(`open != shut` 就拒)**。少一个 `</script>`
|
||||
会提前闭合、后面内容当正文渲染;多一个 `<script>` 会把页面剩下的收尾整段吞进脚本。
|
||||
⚠️ 只拒 `shut > open` 是不够的(初版就是),末尾未闭合的开标签会被放行 ——
|
||||
`<script>x</script><script>y` 实测通过,已改成严格配平。
|
||||
2. **`src=` 只在 `<script …>` 开标签内部找**,只放行 `http(s)://` 与 `/` 开头的相对路径。
|
||||
⚠️ 初版对**整段**做 `strings.Index(low, "src=")`,脚本体里一句 `img.src=u` 就被命中、
|
||||
整段被判危险而清空 —— 而页面照样提示「设置已保存」,站长完全看不出脚本没挂上。
|
||||
实测:`<script>var i=new Image();i.src=u;</script>` 旧版返回 `""`,现在原样放行。
|
||||
超 4096 截断。
|
||||
|
||||
**收尾要显式**:函数签名改成 `(script, warn string)`,说明码
|
||||
(`truncated` / `unbalanced` / `badsrc`)经 `?warn=` 跳到设置页、由 `trackWarnMessage`
|
||||
翻成人话显示。用**固定码**而不是回显查询串原文(避免反射内容)。
|
||||
判为危险时**整段不采纳、库里原脚本与开关都不动**(把站长手上好用的脚本一并清掉更糟)。
|
||||
|
||||
门禁 `TestCleanTrackScript` 逐条钉边界 + **定义性不变量**:
|
||||
采纳了非空结果时,里面的开闭标签数必定相等(不是「不得含 `</script`」——整段贴进来的
|
||||
本来就带闭合标签)。
|
||||
|
||||
## 四十一、2026-10-09 三处修复:导航口径 / 启动自检 / 二进制只留根目录
|
||||
|
||||
### 1. 导航的页面显隐**只管访客**(`layout.html`)
|
||||
|
||||
`guardPage`(`server.go`)拦的只是访客:`userFrom(ctx)==nil && !sets.HasPage(nav)` → 404;
|
||||
站长始终能访问全部公开页。但导航写的是 `{{if .Settings.HasPage "map"}}`,**没有 IsAdmin 分支**
|
||||
⇒ 页面开关一关,站长自己也只剩「主页」一个入口 —— 能访问却点不到。
|
||||
已改成 `{{if or .IsAdmin (.Settings.HasPage "x")}}`(map/places/timeline/stats/report 五处)。
|
||||
门禁 `nav_visibility_test.go`:直接执行 `nav` 模板,钉「关掉开关后站长仍有 5 个入口、
|
||||
访客一个都没有、全开放时访客也看得到」三向不变量(做过变异验证)。
|
||||
|
||||
⚠️ **访客侧「只有主页」是配置出来的预期状态,别去"修"数据**:库里 `settings.pages = null`
|
||||
(= 站长把公开页面全关了),访客 `/map /places /timeline /stats /report` 一律 404。
|
||||
2026-10-09 用户明确确认:**访客能看哪些页就是按后台配的范围公开,这样才对**。
|
||||
要改就改后台的「公开页面」开关,不要直接动 `settings` 行。
|
||||
|
||||
### 2. 启动自检 `smokeRender`(`template_smoke.go`)
|
||||
|
||||
⚠️ **「模板能解析」不等于「渲染得完」**:字段不存在、nil 解引用都只在**执行期**暴露,
|
||||
而 `render` 只把错误写进日志 ⇒ 线上是「HTTP 200 + 渲染到一半的半张页面」。
|
||||
2026-10-09 的 /admin/settings 截断就是这么坏的(提交里同时改了 `setting.Settings` 和
|
||||
`settings.html`,跑的是改动前编译的二进制,debug 下模板走磁盘热加载 → 新模板 + 旧结构体)。
|
||||
|
||||
做法:`pageProbe`(每个页面一份「最小可用数据」)+ `smokeRender` 在 `New()` 里把
|
||||
**所有页面各渲染一遍**,任一失败就 `New` 返回 error → main `log.Fatalf` **拒绝启动**。
|
||||
- `pageProbe` 的键必须与 `pageFiles` **双向一致**:缺项按失败处理(新加页面必须补探针)。
|
||||
- 探针值只求「类型对、不炸」——页面本来就得能渲染空数据。
|
||||
- `smokeOne` 里 `recover()` 兜住方法里的 panic,报出「哪一页坏了」而不是崩栈。
|
||||
- 门禁 `template_smoke_test.go`:一个跑全量自检;一个**变异验证**(把 `admin/settings`
|
||||
的探针换成字段更少的 `Page`,必须报错且点名该页)。
|
||||
- 实测:往 `settings.html` 插一个不存在的字段 → 启动即报
|
||||
`初始化服务失败: 模板自检未通过…admin/settings: …can't evaluate field …`,退出码 1。
|
||||
|
||||
### 3. 二进制只留在仓库根(`bin/` 已删,2026-10-09)
|
||||
|
||||
`go build -o rond.exe .` 一个位置。`bin/` 整套(15 个 exe、412MB)已删;
|
||||
`.gitignore` 的 `/bin` 保留作为兜底。`scripts/build-linux.sh` 照旧只产
|
||||
`dist/rond-linux-amd64`。相关技能(`rond-web-verify`)与 details 十八已同步改掉
|
||||
`bin/rond.exe` 的写法。
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
写模板时人站在站长视角下笔,「我的结论 / 你自己 / 我推荐的地点」露给访客就没人读得懂。
|
||||
照 `place.html` 投票区与 `rond-map.js` 气泡的写法。门禁 `TestVisitorPagesUseThirdPersonCopy`
|
||||
(以访客 ctx 渲染 `/`、`/stats`、`/report` 逐个断言禁用词,并确认站长那份仍说「我的」)。
|
||||
- 改 Go 必须重启;模板/CSS/JS 只在 `debug=true` 热加载(`AssetV` 启动哈希)。
|
||||
- 改 Go 必须重启;模板/CSS/JS 只在 `debug=true` 热加载(`AssetV` 启动哈希)。⚠️ **新模板+旧二进制**=「渲染到一半仍 200」;启动自检 `smokeRender` 会拒启(四十一)。
|
||||
- 拼查询串走返回 `template.URL` 的方法(`PlaceLink`/`EditPlaceLink`/`MapLink`/`EditLink`),勿手工转义。
|
||||
- 窄屏覆盖写 `app.css` **末尾那块**;宽表堆叠类名**成对** `class="tb tb-stack"`;触控 44px。`tb_stack_test.go`
|
||||
- 加表格列:`PlaceCols`/`scanPlace` **成对**、新列追加最后;CJK 挤换行只抬该表 `min-width`。
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -467,6 +468,7 @@ func (s *Server) settingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
d := SettingsData{Page: p, Modules: setting.AllModules, Pages: setting.AllPages,
|
||||
MapInfo: s.mapInfo(), MapLayers: layers, S3Info: s.s3Info()}
|
||||
d.Flash = r.URL.Query().Get("saved")
|
||||
d.Warn = trackWarnMessage(r.URL.Query().Get("warn"))
|
||||
s.render(w, "admin/settings", d)
|
||||
}
|
||||
|
||||
@@ -581,57 +583,105 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if r.PostFormValue("regions_mode") == setting.RegionsBlacklist {
|
||||
next.RegionsMode = setting.RegionsBlacklist
|
||||
}
|
||||
next.TrackScript = cleanTrackScript(r.PostFormValue("track_script"))
|
||||
// 开关只在「勾选 + 脚本非空」时才生效:勾了但没填内容等于没开,
|
||||
// 否则 layout 里会挂一个空 <script>,看着像开了其实什么也没统计。
|
||||
next.TrackEnabled = r.PostFormValue("track_enabled") == "on" && next.TrackScript != ""
|
||||
script, trackWarn := cleanTrackScript(r.PostFormValue("track_script"))
|
||||
if trackWarnRejected(trackWarn) {
|
||||
// 判为危险:这次改动整段不采纳,库里原有的脚本与开关都保持不动
|
||||
// (next := cur 已经带上了它们)。把站长手上本来好用的脚本一并清掉,
|
||||
// 比拒绝这次改动更糟。
|
||||
} else {
|
||||
next.TrackScript = script
|
||||
// 开关只在「勾选 + 脚本非空」时才生效:勾了但没填内容等于没开,
|
||||
// 否则 layout 里会挂一个空 <script>,看着像开了其实什么也没统计。
|
||||
next.TrackEnabled = r.PostFormValue("track_enabled") == "on" && next.TrackScript != ""
|
||||
}
|
||||
if err := s.sets.Save(ctx, next); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/settings?saved=1", http.StatusSeeOther)
|
||||
dest := "/admin/settings?saved=1"
|
||||
if trackWarn != "" {
|
||||
dest += "&warn=" + url.QueryEscape(trackWarn)
|
||||
}
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// cleanTrackScript 收拾管理员粘进来的统计脚本。
|
||||
// 统计脚本没被原样采纳时的三类说明码。它们是固定常量、不是用户输入,
|
||||
// 所以可以安全地放进跳转查询串,再由 trackWarnMessage 翻成人话。
|
||||
const (
|
||||
warnTrackTruncated = "truncated"
|
||||
warnTrackUnbalanced = "unbalanced"
|
||||
warnTrackBadSrc = "badsrc"
|
||||
)
|
||||
|
||||
// trackWarnMessage 把说明码翻成人话;未识别的码不提示(不回显查询串原文)。
|
||||
func trackWarnMessage(code string) string {
|
||||
switch code {
|
||||
case warnTrackTruncated:
|
||||
return "统计脚本超过 4096 字符,已截断保存"
|
||||
case warnTrackUnbalanced:
|
||||
return "统计脚本里 <script> 与 </script> 的数量不配对,本次改动已拒绝,库里原有脚本保持不变"
|
||||
case warnTrackBadSrc:
|
||||
return "统计脚本的 src 只接受 http://、https:// 或站内相对路径,本次改动已拒绝,库里原有脚本保持不变"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// trackWarnRejected 报告这个说明码是否代表「整段没被采纳」。
|
||||
func trackWarnRejected(code string) bool {
|
||||
return code == warnTrackUnbalanced || code == warnTrackBadSrc
|
||||
}
|
||||
|
||||
// cleanTrackScript 收拾管理员粘进来的统计脚本,返回采纳后的内容与一个说明码(空 = 原样通过)。
|
||||
//
|
||||
// 这段内容会被原样注入**每一个公开页面**,所以要挡住几类会伤到访客的输入。
|
||||
// 但**不能**简单地把「出现 </script 就拒」当规则:百度统计、Umami 这类服务
|
||||
// 贴进来的就是完整 HTML,一行里必然带 </script>,一拒就把正常用法挡死了。
|
||||
//
|
||||
// 真正的注入面只有一种:**未闭合的 <script> 里出现 </script> 会提前闭合**,
|
||||
// 后面的内容当正文渲染,页面布局散架。判据因此是「奇偶」:
|
||||
// 开标签数与闭合标签数不配平 ⇒ 有提前闭合 ⇒ 拒。配平则整段原样放行。
|
||||
// 两条判据:
|
||||
// - 开闭 <script> 标签必须**严格配平**:少一个 </script> 会提前闭合、后面的内容
|
||||
// 当正文渲染(页面布局散架),多一个 <script> 则会把页面剩下的收尾整段吞进脚本。
|
||||
// - 开标签上的 src 只放行 http(s):// 与站内相对路径,挡掉 javascript: / data: 伪协议。
|
||||
//
|
||||
// 另外挡住:javascript: / data: 这类伪协议的 src(点击即执行),
|
||||
// 以及超长内容(多半是误粘了整份 HTML 文档,截断)。
|
||||
func cleanTrackScript(v string) string {
|
||||
// src 只在**标签内部**找。以前是对整段做 strings.Index(low, "src="),于是脚本体里
|
||||
// 一句 `img.src=u` 就会被命中、整段被判危险而清空——页面却照样提示「设置已保存」,
|
||||
// 站长根本看不出脚本没挂上(实测复现过)。
|
||||
func cleanTrackScript(v string) (script, warn string) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return ""
|
||||
return "", ""
|
||||
}
|
||||
const maxLen = 4096
|
||||
if len(v) > maxLen {
|
||||
v = v[:maxLen]
|
||||
warn = warnTrackTruncated
|
||||
}
|
||||
low := strings.ToLower(v)
|
||||
// 开闭标签数必须配平,否则有人能靠 </script> 提前闭合、把后面的内容
|
||||
// 从「脚本」变成「正文」注入到页面里。
|
||||
open := strings.Count(low, "<script")
|
||||
// 去掉属性里的 <script 字样不算开标签(如 data-src="<script"),这里只做保守计数:
|
||||
// 宁可多拒一次,也别放过真的注入。
|
||||
shut := strings.Count(low, "</script")
|
||||
if shut > open {
|
||||
return ""
|
||||
}
|
||||
// src 只放行 http/https 与站内相对路径,挡掉 javascript: / data: 伪协议
|
||||
if idx := strings.Index(low, "src="); idx >= 0 {
|
||||
rest := strings.TrimLeft(v[idx+4:], "\"'")
|
||||
if rest != "" && !strings.HasPrefix(rest, "http://") &&
|
||||
!strings.HasPrefix(rest, "https://") && !strings.HasPrefix(rest, "/") {
|
||||
return ""
|
||||
open := 0
|
||||
for i := 0; ; {
|
||||
j := strings.Index(low[i:], "<script")
|
||||
if j < 0 {
|
||||
break
|
||||
}
|
||||
start := i + j
|
||||
open++
|
||||
end := strings.IndexByte(low[start:], '>')
|
||||
if end < 0 {
|
||||
break // 这个开标签没闭合,交给下面的配平判定处理
|
||||
}
|
||||
tag := low[start : start+end]
|
||||
if k := strings.Index(tag, "src="); k >= 0 {
|
||||
rest := strings.TrimLeft(tag[k+4:], "'\" \t")
|
||||
if rest != "" && !strings.HasPrefix(rest, "http://") &&
|
||||
!strings.HasPrefix(rest, "https://") && !strings.HasPrefix(rest, "/") {
|
||||
return "", warnTrackBadSrc
|
||||
}
|
||||
}
|
||||
i = start + end + 1
|
||||
}
|
||||
return v
|
||||
if shut := strings.Count(low, "</script"); open != shut {
|
||||
return "", warnTrackUnbalanced
|
||||
}
|
||||
return v, warn
|
||||
}
|
||||
|
||||
// ---------- 修改口令 ----------
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rond-with-you/internal/auth"
|
||||
"rond-with-you/internal/setting"
|
||||
)
|
||||
|
||||
// TestNavShowsAllPagesToAdmin 钉住导航的显隐口径:页面开关**只管访客**。
|
||||
//
|
||||
// 站长的公开页一直是能访问的(guardPage 只拦访客),导航却没跟着放行——
|
||||
// 关掉页面开关后站长自己也只剩「主页」一个入口,明明能访问却点不到。
|
||||
// 这条口径必须和 guardPage 保持一致,所以两边都写死在这里。
|
||||
func TestNavShowsAllPagesToAdmin(t *testing.T) {
|
||||
base, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(tmplFS, "templates/layout.html")
|
||||
if err != nil {
|
||||
t.Fatalf("解析 layout.html: %v", err)
|
||||
}
|
||||
renderNav := func(p Page) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := base.ExecuteTemplate(&buf, "nav", p); err != nil {
|
||||
t.Fatalf("渲染 nav: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
links := []string{`href="/map"`, `href="/places"`, `href="/timeline"`, `href="/stats"`, `href="/report"`}
|
||||
|
||||
// 页面开关全关:访客点不到,站长必须照样有入口
|
||||
closed := setting.Default()
|
||||
closed.Pages = nil
|
||||
|
||||
adminNav := renderNav(Page{Nav: "home", Settings: closed, User: &auth.User{}})
|
||||
for _, l := range links {
|
||||
if !strings.Contains(adminNav, l) {
|
||||
t.Errorf("站点关闭了页面开关,但站长导航少了 %s——开关只管访客", l)
|
||||
}
|
||||
}
|
||||
|
||||
visitorNav := renderNav(Page{Nav: "home", Settings: closed})
|
||||
for _, l := range links {
|
||||
if strings.Contains(visitorNav, l) {
|
||||
t.Errorf("页面已对访客关闭,访客导航里不该出现 %s", l)
|
||||
}
|
||||
}
|
||||
|
||||
// 反向:全开放时访客也要看得到(防止把判据改成「只有站长」)
|
||||
openNav := renderNav(Page{Nav: "home", Settings: setting.Default()})
|
||||
for _, l := range links {
|
||||
if !strings.Contains(openNav, l) {
|
||||
t.Errorf("页面已对访客开放,访客导航里应出现 %s", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,11 @@ func New(cfg *config.Config, db *sql.DB) (*Server, error) {
|
||||
media: media.New(cfg),
|
||||
}
|
||||
s.routes()
|
||||
// 启动自检:把每个页面各渲染一遍,任一页失败就拒绝启动。
|
||||
// 「模板能解析」不代表「渲染得完」——字段不存在、nil 解引用都只在执行期暴露。
|
||||
if err := s.smokeRender(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"rond-with-you/internal/setting"
|
||||
)
|
||||
|
||||
// 启动时的模板自检:把每个页面各用一份「最小可用数据」渲染一遍,任一页失败就拒绝启动。
|
||||
//
|
||||
// 防的是一类**只在执行期暴露**的错误:模板引用了 Go 结构体里不存在的字段
|
||||
// (典型是改了结构体却没重新编译,磁盘上的新模板被老二进制拿去渲染),
|
||||
// 或者模板里对着 nil 解引用。这类错误既不会让模板解析失败,render 又只把错误写进日志,
|
||||
// 于是线上表现成「页面渲染到一半就断了、HTTP 却仍是 200」——用户对着半张没有收尾的页面
|
||||
// 猜半天,服务端日志里只有孤零零一行。
|
||||
//
|
||||
// 2026-10-09 就是这么踩的:提交里同时改了 setting.Settings 和 settings.html,
|
||||
// 但跑的是改动前编译的二进制,debug 模式下模板走磁盘热加载 ⇒ 新模板 + 旧结构体,
|
||||
// /admin/settings 在「统计脚本」那张卡片处被截断。启动自检能把这种组合直接挡在门外。
|
||||
//
|
||||
// 探针值只求「类型对、不炸」:这些页面本来就得能渲染空数据(还没导入数据时的首页就是空的),
|
||||
// 所以空值探针是合理的下限,而不是凑数。
|
||||
var pageProbe = map[string]func() any{
|
||||
"home": func() any { return HomeData{Page: probePage("home")} },
|
||||
"map": func() any { return MapData{Page: probePage("map")} },
|
||||
"places": func() any { return PlacesData{Page: probePage("places")} },
|
||||
"place": func() any { return PlaceData{Page: probePage("places")} },
|
||||
"timeline": func() any { return TimelineData{Page: probePage("timeline")} },
|
||||
"stats": func() any { return StatsData{Page: probePage("stats")} },
|
||||
"report": func() any { return ReportData{Page: probePage("report")} },
|
||||
"topics": func() any { return TopicListData{Page: probePage("topics")} },
|
||||
"topic": func() any { return TopicData{Page: probePage("topics")} },
|
||||
"admin/index": func() any { return AdminData{Page: probePage("admin"), MaxSize: maxUploadBytes} },
|
||||
"admin/password": func() any { return PasswordData{Page: probePage("admin"), Username: "admin"} },
|
||||
"admin/twofa": func() any { return TwoFAData{Page: probePage("admin")} },
|
||||
"admin/fog-mask": func() any { return FogMaskData{Page: probePage("admin")} },
|
||||
"admin/fog-preview": func() any { return FogPreviewData{Page: probePage("admin")} },
|
||||
"admin/backup-preview": func() any { return BackupDiffData{Page: probePage("admin")} },
|
||||
"admin/baseline": func() any { return BaselineData{Page: probePage("admin"), MaxSize: maxUploadBytes} },
|
||||
"admin/fillup": func() any { return FillupData{Page: probePage("admin")} },
|
||||
"admin/notes": func() any { return NotesData{Page: probePage("admin")} },
|
||||
"admin/edit": func() any { return EditData{Page: probePage("admin")} },
|
||||
"admin/topics": func() any { return TopicAdminData{Page: probePage("admin")} },
|
||||
"admin/photos": func() any { return PhotoData{Page: probePage("admin")} },
|
||||
"admin/login": func() any { return LoginData{Settings: setting.Default()} },
|
||||
"admin/twofa-login": func() any { return TwoFALoginData{Settings: setting.Default()} },
|
||||
"admin/settings": func() any {
|
||||
return SettingsData{Page: probePage("admin"), Modules: setting.AllModules,
|
||||
Pages: setting.AllPages, MapLayers: []MapLayerOption{{Key: "k", Label: "l"}}}
|
||||
},
|
||||
}
|
||||
|
||||
// probePage 是所有探针共用的页面上下文:给足站点设置,免得模板在取值阶段就断。
|
||||
func probePage(nav string) Page {
|
||||
return Page{Nav: nav, Title: "模板自检", Settings: setting.Default()}
|
||||
}
|
||||
|
||||
// smokeRender 把所有页面各渲染一遍,专为触发执行期错误。
|
||||
// 任一页失败都返回错误,让 New 拒绝启动——宁可起不来,也不要起来后一半页面是残的。
|
||||
func (s *Server) smokeRender() error {
|
||||
var failed []string
|
||||
for key := range pageFiles {
|
||||
probe, ok := pageProbe[key]
|
||||
if !ok {
|
||||
// 没有探针的页面等于没被自检覆盖。这是漏,不是省事,所以按失败处理。
|
||||
failed = append(failed, key+": pageProbe 里没有配自检数据")
|
||||
continue
|
||||
}
|
||||
if err := s.smokeOne(key, probe()); err != nil {
|
||||
failed = append(failed, err.Error())
|
||||
}
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
return fmt.Errorf("模板自检未通过,多半是 Go 结构体与模板字段不同步(改完 Go 要重新编译):\n %s",
|
||||
strings.Join(failed, "\n "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) smokeOne(key string, data any) (err error) {
|
||||
// 模板里调到的方法可能对着空数据 panic,别让它把整个启动带走——
|
||||
// 这里要的是一个可读的「哪一页坏了」,不是一段崩栈。
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("%s: 渲染时 panic: %v", key, r)
|
||||
}
|
||||
}()
|
||||
set, err := s.pageSet(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := set.t.ExecuteTemplate(&buf, set.entry, data); err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rond-with-you/internal/config"
|
||||
)
|
||||
|
||||
// TestSmokeRenderAllPages 把启动自检搬进 go test:每个页面都要能用「最小可用数据」渲染到底。
|
||||
//
|
||||
// 比 TestPageTemplatesParse 更强的地方在于**执行**:模板能解析不代表渲染得完,
|
||||
// 「字段不存在 / 对着 nil 解引用」只在执行期暴露,而线上表现是「HTTP 200 + 半张页面」。
|
||||
// 2026-10-09 的 /admin/settings 截断就是这么来的(新模板引用了旧结构体里没有的字段)。
|
||||
func TestSmokeRenderAllPages(t *testing.T) {
|
||||
srv := &Server{cfg: &config.Config{}}
|
||||
pages := make(map[string]pageSet, len(pageFiles))
|
||||
for key, def := range pageFiles {
|
||||
tpl, err := template.New(filepath.Base(def.files[0])).Funcs(templateFuncs()).
|
||||
ParseFS(tmplFS, def.files...)
|
||||
if err != nil {
|
||||
t.Fatalf("解析模板 %s 失败: %v", key, err)
|
||||
}
|
||||
pages[key] = pageSet{t: tpl, entry: def.entry}
|
||||
}
|
||||
srv.pages = pages
|
||||
|
||||
if err := srv.smokeRender(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 定义性不变量:pageFiles 里每加一个页面,就必须在 pageProbe 里配一份自检数据,
|
||||
// 否则那一页会悄悄逃过自检(smokeRender 会把缺项报成失败,这里再独立钉一次)。
|
||||
for key := range pageFiles {
|
||||
if _, ok := pageProbe[key]; !ok {
|
||||
t.Errorf("页面 %s 没有自检数据(pageProbe 缺项)——新加页面时要一起补", key)
|
||||
}
|
||||
}
|
||||
for key := range pageProbe {
|
||||
if _, ok := pageFiles[key]; !ok {
|
||||
t.Errorf("pageProbe 里的 %s 不是已注册页面(改名后忘了同步?)", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSmokeRenderCatchesFieldDrift 变异验证:把页面数据换成**字段更少**的类型时必须报错。
|
||||
//
|
||||
// 光断言「当前代码自检通过」证明不了自检有效——它可能只是什么都没检查。
|
||||
// 这里用 Page 顶替 SettingsData(模板引用的 .Modules / .S3Info 都不在 Page 上),
|
||||
// 自检必须红,且错误信息里要能看到是哪一页。
|
||||
func TestSmokeRenderCatchesFieldDrift(t *testing.T) {
|
||||
srv := &Server{cfg: &config.Config{}}
|
||||
pages := make(map[string]pageSet, len(pageFiles))
|
||||
for key, def := range pageFiles {
|
||||
tpl, err := template.New(filepath.Base(def.files[0])).Funcs(templateFuncs()).
|
||||
ParseFS(tmplFS, def.files...)
|
||||
if err != nil {
|
||||
t.Fatalf("解析模板 %s 失败: %v", key, err)
|
||||
}
|
||||
pages[key] = pageSet{t: tpl, entry: def.entry}
|
||||
}
|
||||
srv.pages = pages
|
||||
|
||||
orig := pageProbe["admin/settings"]
|
||||
pageProbe["admin/settings"] = func() any { return probePage("admin") }
|
||||
t.Cleanup(func() { pageProbe["admin/settings"] = orig })
|
||||
|
||||
err := srv.smokeRender()
|
||||
if err == nil {
|
||||
t.Fatal("页面数据缺字段时自检必须报错,否则它什么也挡不住")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "admin/settings") {
|
||||
t.Errorf("自检报错要指名是哪个页面,实际为:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
</div>
|
||||
|
||||
{{if .Flash}}<div class="alert ok">设置已保存</div>{{end}}
|
||||
{{if .Warn}}<div class="alert err">{{.Warn}}</div>{{end}}
|
||||
|
||||
<form method="post" action="/admin/settings">
|
||||
|
||||
@@ -154,11 +155,12 @@
|
||||
<div class="muted" style="font-size:12.5px">
|
||||
<div>填一个 <span class="mono">https://…</span> 地址会包成 <span class="mono"><script src=…></span>;
|
||||
需要初始化代码的服务(Umami、百度统计等)可以把整段 <span class="mono"><script></span> 贴进来。</div>
|
||||
<div>整段 <span class="mono"><script></span> 需要闭合标签配对(多一个 <span class="mono"></script></span> 会被判为粘贴错误);
|
||||
<div>整段脚本的 <span class="mono"><script></span> 与 <span class="mono"></script></span> 必须数量相同:
|
||||
多一个 <span class="mono"></script></span> 会提前闭合、少一个会把页面收尾吞进脚本,两种情况都拒收;
|
||||
<span class="mono">src</span> 只接受 <span class="mono">https://</span>、<span class="mono">http://</span> 或以 <span class="mono">/</span> 开头的地址,
|
||||
超过 4096 字符会被截断。填了但没勾开关不会生效。</div>
|
||||
<div><span class="mono">conf/app.ini</span> 的 <span class="mono">track_script</span> 会作为这里的默认值——
|
||||
它也需要开关打开才注入。</div>
|
||||
超过 4096 字符会被截断(并给出提示)。填了但没勾开关不会生效。</div>
|
||||
<div>这个框留空时回落到 <span class="mono">conf/app.ini</span> 的 <span class="mono">track_script</span>
|
||||
——它不会回填到上面这个框(改 ini 要重启)。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
<div class="nav-inner">
|
||||
<a class="brand" href="/" title="{{.Settings.SiteTitle}}"><span class="avatar">{{.Settings.OwnerAvatar}}</span><span class="t">{{.Settings.SiteTitle}}</span></a>
|
||||
<div class="nav-links">
|
||||
{{/* 页面开关只管访客:站长登录后始终能访问全部页面(guardPage 也是这么放的),
|
||||
导航必须跟着一起放行,否则关掉页面开关后站长自己也只剩「主页」一个入口,
|
||||
明明能访问却点不到。 */}}
|
||||
<a href="/" class="{{if eq .Nav "home"}}on{{end}}">主页</a>
|
||||
{{if .Settings.HasPage "map"}}<a href="/map" class="{{if eq .Nav "map"}}on{{end}}">地图</a>{{end}}
|
||||
{{if .Settings.HasPage "places"}}<a href="/places" class="{{if eq .Nav "places"}}on{{end}}">地点</a>{{end}}
|
||||
{{if .Settings.HasPage "timeline"}}<a href="/timeline" class="{{if eq .Nav "timeline"}}on{{end}}">时间线</a>{{end}}
|
||||
{{if .Settings.HasPage "stats"}}<a href="/stats" class="{{if eq .Nav "stats"}}on{{end}}">统计</a>{{end}}
|
||||
{{if or .IsAdmin (.Settings.HasPage "map")}}<a href="/map" class="{{if eq .Nav "map"}}on{{end}}">地图</a>{{end}}
|
||||
{{if or .IsAdmin (.Settings.HasPage "places")}}<a href="/places" class="{{if eq .Nav "places"}}on{{end}}">地点</a>{{end}}
|
||||
{{if or .IsAdmin (.Settings.HasPage "timeline")}}<a href="/timeline" class="{{if eq .Nav "timeline"}}on{{end}}">时间线</a>{{end}}
|
||||
{{if or .IsAdmin (.Settings.HasPage "stats")}}<a href="/stats" class="{{if eq .Nav "stats"}}on{{end}}">统计</a>{{end}}
|
||||
{{if .HasTopics}}<a href="/topics" class="{{if eq .Nav "topics"}}on{{end}}">专题</a>{{end}}
|
||||
{{if .Settings.HasPage "report"}}<a href="/report" class="{{if eq .Nav "report"}}on{{end}}">报告</a>{{end}}
|
||||
{{if or .IsAdmin (.Settings.HasPage "report")}}<a href="/report" class="{{if eq .Nav "report"}}on{{end}}">报告</a>{{end}}
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
{{if .User}}
|
||||
|
||||
@@ -37,47 +37,91 @@ func TestTrackTag(t *testing.T) {
|
||||
//
|
||||
// 这段内容会被原样注入**每一个公开页面**,因此必须挡住三类输入:
|
||||
// 提前闭合 </script>(页面布局散架)、javascript: 伪协议、超长误粘。
|
||||
// 判据写的是「危险输入一律清空」,所以这里逐条钉住放行与拒绝的边界。
|
||||
// 判据写的是「危险输入一律不收」,所以这里逐条钉住放行与拒绝的边界。
|
||||
func TestCleanTrackScript(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
wantWarn string
|
||||
}{
|
||||
{"空串", "", ""},
|
||||
{"纯空白", " \n\t ", ""},
|
||||
{"普通 https URL 原样保留", "https://umami.example.com/script.js", "https://umami.example.com/script.js"},
|
||||
{"首尾空白被裁掉", " https://a/b.js ", "https://a/b.js"},
|
||||
{"http 也放行", "http://a/b.js", "http://a/b.js"},
|
||||
{"空串", "", "", ""},
|
||||
{"纯空白", " \n\t ", "", ""},
|
||||
{"普通 https URL 原样保留", "https://umami.example.com/script.js", "https://umami.example.com/script.js", ""},
|
||||
{"首尾空白被裁掉", " https://a/b.js ", "https://a/b.js", ""},
|
||||
{"http 也放行", "http://a/b.js", "http://a/b.js", ""},
|
||||
{"整段 script 原样保留(百度统计这类)",
|
||||
`<script>var _hmt=_hmt||[];</script><script src="https://hm.baidu.com/hm.js"></script>`,
|
||||
`<script>var _hmt=_hmt||[];</script><script src="https://hm.baidu.com/hm.js"></script>`},
|
||||
`<script>var _hmt=_hmt||[];</script><script src="https://hm.baidu.com/hm.js"></script>`, ""},
|
||||
{"src 指向相对路径(放行站内相对)", `<script src="/static/a.js"></script>`, `<script src="/static/a.js"></script>`, ""},
|
||||
|
||||
// 脚本体里的 src= **不是标签属性**:以前对整段做 strings.Index,这里会被误伤、
|
||||
// 整段静默清空(页面还照样提示「设置已保存」)。实测复现过,钉住。
|
||||
{"脚本体里的 src= 赋值必须放行", `<script>var i=new Image();i.src=u;</script>`,
|
||||
`<script>var i=new Image();i.src=u;</script>`, ""},
|
||||
{"拼出来的 src 也要放行", `<script>var s=document.createElement('script');s.src=base+"/a.js";</script>`,
|
||||
`<script>var s=document.createElement('script');s.src=base+"/a.js";</script>`, ""},
|
||||
|
||||
// ↓ 必须拒的
|
||||
{"提前闭合 script", `abc</script><img src=x onerror=alert(1)>`, ""},
|
||||
{"大写闭合标签也挡", `abc</SCRIPT>`, ""},
|
||||
{"大写 SRC 伪协议", `<script src="javascript:alert(1)"></script>`, ""},
|
||||
{"data: 伪协议", `<script src="data:text/html,<script>alert(1)</script>">`, ""},
|
||||
{"src 指向相对路径(放行站内相对)", `<script src="/static/a.js"></script>`, `<script src="/static/a.js"></script>`},
|
||||
{"超长内容被截断到 4096", strings.Repeat("a", 5000), strings.Repeat("a", 4096)},
|
||||
{"提前闭合 script", `abc</script><img src=x onerror=alert(1)>`, "", warnTrackUnbalanced},
|
||||
{"大写闭合标签也挡", `abc</SCRIPT>`, "", warnTrackUnbalanced},
|
||||
{"末尾未闭合的开标签也挡", `<script>x</script><script>y`, "", warnTrackUnbalanced},
|
||||
{"光一个开标签也挡", `<script>y`, "", warnTrackUnbalanced},
|
||||
{"大写 SRC 伪协议", `<script src="javascript:alert(1)"></script>`, "", warnTrackBadSrc},
|
||||
{"data: 伪协议", `<script src="data:text/html,<script>alert(1)</script>">`, "", warnTrackBadSrc},
|
||||
{"超长内容被截断到 4096", strings.Repeat("a", 5000), strings.Repeat("a", 4096), warnTrackTruncated},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := cleanTrackScript(c.in); got != c.want {
|
||||
got, warn := cleanTrackScript(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("%s:cleanTrackScript(%q) = %q,期望 %q", c.name, c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
// 定义性不变量:任何输入都不许产出「含 </script 的非空结果」。
|
||||
// 相对断言(比对某个具体长度)会被基准值一起膨胀而假绿,这里钉死语义。
|
||||
dangerous := []string{
|
||||
`abc</script>`, `ABC</ScRiPt>`, `<script src=javascript:alert(1)>`,
|
||||
`<script src=data:text/html,x>`, strings.Repeat("x", 9000) + `</script>`,
|
||||
}
|
||||
for _, in := range dangerous {
|
||||
if got := cleanTrackScript(in); strings.Contains(strings.ToLower(got), "</script") && got != "" {
|
||||
t.Errorf("cleanTrackScript 放过了危险内容 %q -> %q", in, got)
|
||||
if warn != c.wantWarn {
|
||||
t.Errorf("%s:说明码 = %q,期望 %q", c.name, warn, c.wantWarn)
|
||||
}
|
||||
}
|
||||
if len(cleanTrackScript(strings.Repeat("a", 9000))) > 4096 {
|
||||
t.Error("超长内容没有被截断到 4096")
|
||||
|
||||
// 定义性不变量:只要采纳了非空结果,里面的开闭 <script> 标签数必定**相等**。
|
||||
// 注意不是「不得含 </script」——百度统计那种整段贴进来的本来就带闭合标签,
|
||||
// 真正的保证是「闭合数既不会多、也不会少」(多了提前闭合、少了吞掉收尾)。
|
||||
inputs := []string{
|
||||
`abc</script>`, `ABC</ScRiPt>`, `<script>x</script><script>y`, `<script>y`,
|
||||
`<script src=javascript:alert(1)>`, `<script src=data:text/html,x>`,
|
||||
strings.Repeat("x", 9000) + `</script>`,
|
||||
`<script>a</script><script>b</script>`, `<script>var i=new Image();i.src=u;</script>`,
|
||||
}
|
||||
for _, in := range inputs {
|
||||
got, _ := cleanTrackScript(in)
|
||||
if got == "" {
|
||||
continue
|
||||
}
|
||||
low := strings.ToLower(got)
|
||||
if open, shut := strings.Count(low, "<script"), strings.Count(low, "</script"); open != shut {
|
||||
t.Errorf("采纳了标签不配平的内容:%q -> open=%d shut=%d", in, open, shut)
|
||||
}
|
||||
}
|
||||
if got, _ := cleanTrackScript(strings.Repeat("a", 9000)); len(got) > 4096 {
|
||||
t.Errorf("超长内容没有被截断到 4096:len=%d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrackWarnMessage 说明码必须是**固定文案**:它最终进跳转查询串,
|
||||
// 未识别的码一律不提示(避免把查询串原文回显到页面上)。
|
||||
func TestTrackWarnMessage(t *testing.T) {
|
||||
for _, code := range []string{warnTrackTruncated, warnTrackUnbalanced, warnTrackBadSrc} {
|
||||
if trackWarnMessage(code) == "" {
|
||||
t.Errorf("说明码 %q 应有对应提示文案", code)
|
||||
}
|
||||
}
|
||||
for _, code := range []string{"", "unknown", "<script>alert(1)</script>"} {
|
||||
if msg := trackWarnMessage(code); msg != "" {
|
||||
t.Errorf("未识别的说明码 %q 不该产出提示,得到 %q", code, msg)
|
||||
}
|
||||
}
|
||||
if !trackWarnRejected(warnTrackUnbalanced) || !trackWarnRejected(warnTrackBadSrc) {
|
||||
t.Error("被拒的两类说明码必须被 trackWarnRejected 认出来")
|
||||
}
|
||||
if trackWarnRejected(warnTrackTruncated) || trackWarnRejected("") {
|
||||
t.Error("「截断」是采纳(只是改了内容),不该算作整段被拒")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,6 +1102,8 @@ type SettingsData struct {
|
||||
MapLayers []MapLayerOption
|
||||
// S3Info 是对象存储配置状态的只读说明(配没配齐、桶名、公开域名)。
|
||||
S3Info string
|
||||
// Warn 是统计脚本这次没被原样采纳的提示(「已截断」/「已拒绝」)。
|
||||
Warn string
|
||||
}
|
||||
|
||||
// MapLayerOption 是后台「默认底图」下拉的一项。
|
||||
|
||||
Reference in New Issue
Block a user