Files
HomeDash/history-server/favicon_test.go
HomeDash Bot b99319b38b chore(sanitize): 备案号与域名一律可配置,仓库不再预置真实域名/备案号/内网地址
用户要求:备案号与域名都应当可配置,仓库内容要脱敏。

功能(不只是替换字面量):
- 被代理页面注入的备案号改为读后台「站点信息」的 icp/gongan,**未配置就不注入**;
  原先写死具体备案号,等于每个部署者都会把别人的备案信息印到自己页面上
- 注入内容做 nginx 安全处理(去引号/反斜杠/换行/美元符),并有测试覆盖
- auth.go:PORTAL_COOKIE_DOMAIN / PORTAL_HOME_URL 不再预置域名,
  cookie 默认 host-only、首页默认相对路径 "/"
- gateway.go:默认 loginUrl 不再预置域名(受保护路由缺配置会明确报错,不静默生成跳不出去的登录页)
- 首装脚本在给了 NAVI_DOMAIN 时自动注入 PORTAL_COOKIE_DOMAIN/PORTAL_HOME_URL,
  避免"去掉写死默认值"导致跨子域单点登录失效

脱敏:内网 IP(10.0.0.10)与真实备案号清零;真实域名仅剩门户页内容 8 处(待定);
测试夹具/示例/错误页/脚本/后台占位提示全部通用化。

README:新增「跨子域单点登录需要的两个环境变量」;环境变量表改为 NAVI_HOST 必填。
测试:新增备案号注入三条(用配置、未配置不注入、危险字符清理)。
2026-09-12 10:10:52 +08:00

265 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/png"
"testing"
)
// ── 图标格式白名单 ──
func TestRenderableByDashy(t *testing.T) {
// Dashy 的 isImage() 白名单只有 svg/png/jpg,其余一律渲染不出来。
cases := map[string]string{
".png": ".png",
".PNG": "", // 调用方负责先转小写
".svg": ".svg",
".jpg": ".jpg",
".jpeg": ".jpg",
".ico": "",
".gif": "",
".webp": "",
"": "",
}
for in, want := range cases {
got, ok := renderableByDashy(in)
if want == "" {
if ok {
t.Errorf("renderableByDashy(%q) = %q,true; want not renderable", in, got)
}
continue
}
if !ok || got != want {
t.Errorf("renderableByDashy(%q) = %q,%v; want %q,true", in, got, ok, want)
}
}
}
// ── sniffExt:绝不能把 SPA 的 HTML 兜底页当图标 ──
func TestSniffExtRejectsHTMLFallback(t *testing.T) {
// 真实场景:SPA 对 /favicon.ico 回 200 + text/html,body 是 index.html,
// 而 index.html 里恰好引用了 image/svg+xml(webui4frpc 就是这样)。
body := []byte(`<!DOCTYPE html><html><head><link rel="icon" href="/favicon.svg" type="image/svg+xml"><title>x</title></head></html>`)
if got := sniffExt(body, "text/html; charset=utf-8", "http://h/favicon.ico"); got != "" {
t.Fatalf("HTML fallback body must be rejected, got %q", got)
}
// 即使服务端把 Content-Type 谎报成 svg,只要正文不是 SVG 根元素也要拒。
if got := sniffExt(body, "image/svg+xml", "http://h/favicon.ico"); got == ".svg" {
t.Fatalf("non-SVG body must not be stored as .svg")
}
}
func TestSniffExtByMagicAndType(t *testing.T) {
pngMagic := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 16)...)
if got := sniffExt(pngMagic, "", "http://h/favicon.ico"); got != ".png" {
t.Errorf("png magic: got %q want .png", got)
}
jpgMagic := append([]byte("\xff\xd8\xff"), make([]byte, 16)...)
if got := sniffExt(jpgMagic, "", "http://h/favicon.ico"); got != ".jpg" {
t.Errorf("jpeg magic: got %q want .jpg", got)
}
svg := []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"/>`)
if got := sniffExt(svg, "", "http://h/favicon.svg"); got != ".svg" {
t.Errorf("svg body: got %q want .svg", got)
}
// ico 要被识别出来交给后续转换,而不是直接落盘
icoMagic := []byte("\x00\x00\x01\x00\x01\x00")
if got := sniffExt(icoMagic, "", "http://h/favicon.ico"); got != ".ico" {
t.Errorf("ico magic: got %q want .ico", got)
}
// Dashy 渲染不了的格式直接拒绝
gif := append([]byte("GIF89a"), make([]byte, 16)...)
if got := sniffExt(gif, "", "http://h/favicon.gif"); got != "" {
t.Errorf("gif must be rejected (Dashy cannot render it), got %q", got)
}
webp := append([]byte("RIFF\x00\x00\x00\x00WEBP"), make([]byte, 8)...)
if got := sniffExt(webp, "", "http://h/favicon.webp"); got != "" {
t.Errorf("webp must be rejected, got %q", got)
}
}
// ── ICO 解析 ──
// buildICO 把若干条目打包成 ICO 容器。
func buildICO(entries ...[]byte) []byte {
var buf bytes.Buffer
buf.Write([]byte{0, 0, 1, 0})
_ = binary.Write(&buf, binary.LittleEndian, uint16(len(entries)))
offset := 6 + 16*len(entries)
for _, e := range entries {
buf.Write([]byte{16, 16, 0, 0}) // w,h,colors,reserved
_ = binary.Write(&buf, binary.LittleEndian, uint16(1))
_ = binary.Write(&buf, binary.LittleEndian, uint16(32))
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(e)))
_ = binary.Write(&buf, binary.LittleEndian, uint32(offset))
offset += len(e)
}
for _, e := range entries {
buf.Write(e)
}
return buf.Bytes()
}
func encodePNG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, w, h))
img.SetNRGBA(0, 0, color.NRGBA{R: 1, G: 2, B: 3, A: 255})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("png encode: %v", err)
}
return buf.Bytes()
}
func TestPngFromICO(t *testing.T) {
small := encodePNG(t, 1, 1)
big := encodePNG(t, 16, 16)
ico := buildICO(small, big)
got, ok := pngFromICO(ico)
if !ok {
t.Fatal("expected an embedded png")
}
if !bytes.Equal(got, big) {
t.Fatalf("should pick the largest embedded png: got %d bytes want %d", len(got), len(big))
}
if _, ok := pngFromICO([]byte("not an ico")); ok {
t.Fatal("garbage must not yield a png")
}
}
// buildDIB 构造 ICO 内常见的 DIB 位图(32bpp,自下而上,行 4 字节对齐)。
func buildDIB(w, h int, rows [][]color.NRGBA) []byte {
var buf bytes.Buffer
hdr := make([]byte, 40)
binary.LittleEndian.PutUint32(hdr[0:4], 40)
binary.LittleEndian.PutUint32(hdr[4:8], uint32(w))
binary.LittleEndian.PutUint32(hdr[8:12], uint32(2*h)) // ICO 高度是两倍
binary.LittleEndian.PutUint16(hdr[12:14], 1)
binary.LittleEndian.PutUint16(hdr[14:16], 32)
buf.Write(hdr)
for _, row := range rows {
for _, c := range row {
buf.Write([]byte{c.B, c.G, c.R, c.A})
}
}
return buf.Bytes()
}
func decodePNGSize(t *testing.T, data []byte) image.Image {
t.Helper()
img, err := png.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode produced png: %v", err)
}
return img
}
func TestDibToPNG(t *testing.T) {
// DIB 自下而上:rows[0] 是图像最底下那一行。
topLeft := color.NRGBA{R: 10, G: 20, B: 30, A: 255}
bottomLeft := color.NRGBA{R: 200, G: 100, B: 50, A: 255}
rows := [][]color.NRGBA{
{bottomLeft, {R: 1, G: 1, B: 1, A: 255}},
{topLeft, {R: 2, G: 2, B: 2, A: 255}},
}
out, err := dibToPNG(buildDIB(2, 2, rows))
if err != nil {
t.Fatalf("dibToPNG: %v", err)
}
img := decodePNGSize(t, out)
if b := img.Bounds(); b.Dx() != 2 || b.Dy() != 2 {
t.Fatalf("size = %dx%d, want 2x2", b.Dx(), b.Dy())
}
r, g, b, a := img.At(0, 0).RGBA()
// 注意:解出的 PNG 是 NRGBA,At() 返回预乘值;这里 alpha=255 故直读高位
if uint8(r>>8) != topLeft.R || uint8(g>>8) != topLeft.G || uint8(b>>8) != topLeft.B || uint8(a>>8) != 255 {
t.Fatalf("pixel(0,0) = %d,%d,%d,%d; want %d,%d,%d,255",
r>>8, g>>8, b>>8, a>>8, topLeft.R, topLeft.G, topLeft.B)
}
}
func TestDibToPNGTreatsAllZeroAlphaAsOpaque(t *testing.T) {
// 老图标常把 alpha 全写 0;若照实解释会得到整张全透明图。
rows := [][]color.NRGBA{{{R: 9, G: 8, B: 7, A: 0}, {R: 9, G: 8, B: 7, A: 0}}}
out, err := dibToPNG(buildDIB(2, 1, rows))
if err != nil {
t.Fatalf("dibToPNG: %v", err)
}
img := decodePNGSize(t, out)
if _, _, _, a := img.At(0, 0).RGBA(); a>>8 != 255 {
t.Fatalf("all-zero alpha should be treated as opaque, got alpha=%d", a>>8)
}
}
func TestDibToPNGRejectsUnsupported(t *testing.T) {
hdr := make([]byte, 40)
binary.LittleEndian.PutUint32(hdr[0:4], 40)
binary.LittleEndian.PutUint32(hdr[4:8], 16)
binary.LittleEndian.PutUint32(hdr[8:12], 32)
binary.LittleEndian.PutUint16(hdr[14:16], 8) // 8bpp 不支持
if _, err := dibToPNG(hdr); err == nil {
t.Fatal("8bpp dib should be rejected")
}
if _, err := dibToPNG([]byte("short")); err == nil {
t.Fatal("short data should be rejected")
}
}
func TestDibToPNGFromICOPrefersLargest(t *testing.T) {
rows := [][]color.NRGBA{{{R: 1, G: 1, B: 1, A: 255}}}
small := buildDIB(1, 1, rows)
tallRows := [][]color.NRGBA{
{{R: 3, G: 3, B: 3, A: 255}, {R: 3, G: 3, B: 3, A: 255}},
{{R: 3, G: 3, B: 3, A: 255}, {R: 3, G: 3, B: 3, A: 255}},
}
large := buildDIB(2, 2, tallRows)
out, err := dibToPNGFromICO(buildICO(small, large))
if err != nil {
t.Fatalf("dibToPNGFromICO: %v", err)
}
if b := decodePNGSize(t, out).Bounds(); b.Dx() != 2 || b.Dy() != 2 {
t.Fatalf("should pick the largest entry, got %dx%d", b.Dx(), b.Dy())
}
}
// ── 文件名与段落名 ──
func TestSanitizeHostKey(t *testing.T) {
cases := map[string]string{
"example.com": "example.com",
"10.0.0.9:3000": "10.0.0.9_3000",
"example.com:8443": "example.com_8443",
"[::1]:8080": "___1__8080",
}
for in, want := range cases {
if got := sanitizeHostKey(in); got != want {
t.Errorf("sanitizeHostKey(%q) = %q, want %q", in, got, want)
}
}
}
func TestSectionNameMatching(t *testing.T) {
// 公开示例 conf.yml 用英文段落名,生产用中文;两者都必须能匹配。
for _, n := range []string{"常用服务", "Favorites", "favorites", "FAVORITES"} {
if !isFavSection(n) {
t.Errorf("isFavSection(%q) = false, want true", n)
}
}
for _, n := range []string{"历史访问", "History", "history", "HISTORY"} {
if !isHistSection(n) {
t.Errorf("isHistSection(%q) = false, want true", n)
}
}
for _, n := range []string{"Tools", "工具", "", "Favourite"} {
if isFavSection(n) || isHistSection(n) {
t.Errorf("section %q should not match favorites/history", n)
}
}
}