Compare commits
2
Commits
9d3f5a72fa
...
6fad0cbe50
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fad0cbe50 | ||
|
|
0318ba81b4 |
@@ -153,3 +153,29 @@ Tomcat 的 skin 忘了 css(按钮白字无底色)、WP 登录页底部挂着
|
||||
- 性能:RenderFamily 3.7µs→4.18µs、2→3 allocs(多的一次是 hex 编码),可接受。
|
||||
|
||||
四处变异验证有效(固定 token / xmlrpc 分支失效 / Pingback 相对路径 / .php 兜底失效)。
|
||||
|
||||
### 测试环境验收:XML-RPC 端点(部署 9d3f5a7 后)
|
||||
|
||||
- `GET /xmlrpc.php` → **405 + text/xml**(与真实 WP 一致,XML-RPC 只接受 POST),
|
||||
用户误以为这是报错;405 的 body 是 `<fault>XML-RPC services are disabled…`。
|
||||
- `POST system.listMethods` → 200 + 方法列表 ✓。
|
||||
- `POST wp.getUsersBlogs`(伪造 `CF-Connecting-IP: 2001:db8:beef::7`)→ 凭据落文件:
|
||||
`username=xruser / password=xrpass`、`fields.xmlrpc.0/1`、`xmlrpc.method`、`banned:true` ✓。
|
||||
- 紧接的下一次请求 `403 Forbidden` = 已封禁(第一次 POST 就封了该 /64),属预期。
|
||||
- 动态 token 两次不同(`d85a8c…` / `76a404…`)✓。
|
||||
|
||||
**再次出现的 `"path":" /xmlrpc.php"` 已判定为粘贴/终端产物**:`IsXMLRPCPath` 不做 trim,
|
||||
若 `path` 真带前导空格根本进不了 XML-RPC 分支,而记录里有 `xmlrpc.*` 字段 ⇒ 落盘值必是
|
||||
`/xmlrpc.php`。这类"看不见的字符"结论一律靠 `od -c`,别靠终端回显。
|
||||
|
||||
### 403 响应体换成图片
|
||||
|
||||
用户给了 R2 预签名链接(`X-Amz-Expires=604800`,7 天后失效)——**不能直接热链**。
|
||||
下载后(256×256 webp,8.6 KB)用 `//go:embed` 编进二进制:
|
||||
`internal/route/assets/403.webp` + `internal/route/banned.go: writeBanned()`。
|
||||
- 状态码仍是 403(语义不变),只换 body;`Content-Type: image/webp` + 显式 `Content-Length`
|
||||
(8 KB 超过 Go 的 2 KB 缓冲,不设会退回 chunked)+ `no-store`;HEAD 不写 body。
|
||||
- 放在 `internal/route/assets/` 而不是 `static/`:static 会多出一个可公开访问的 URL。
|
||||
- 换图方式:覆盖同目录 403.webp → 重新编译,无配置项。
|
||||
- 体积代价:每封禁请求 8.6 KB(原 13 字节)。被封来源重试频繁时要留意带宽,
|
||||
已压到 256×256;别塞几百 KB 原图。
|
||||
|
||||
@@ -110,6 +110,22 @@ honeypot_cred_dir = data/honeypot-creds
|
||||
|
||||
纯探测也不会被刷爆:元数据落库按「每 IP 每分钟一条」采样,凭据则是「有就存」——那才是要保的东西。
|
||||
|
||||
### 被封之后会看到什么
|
||||
|
||||
状态码 `403`,body 是一张 256×256 的 webp(约 8.6 KB,画面写着 403 Forbidden),不再是那行 13 字节的明文:
|
||||
|
||||
```
|
||||
$ curl -si https://你的站点/anything | head -4
|
||||
HTTP/2 403
|
||||
content-type: image/webp
|
||||
content-length: 8608
|
||||
cache-control: no-store, no-cache, must-revalidate
|
||||
```
|
||||
|
||||
这样做有两个目的:爆破脚本常靠读响应体里的 `403 Forbidden` 确认自己已被拦,换掉 body 让这类判断失效;人肉访问(尤其是被共享出口连累的误封)时,看到一张明确的图比白屏好判断。
|
||||
|
||||
图片用 `go:embed` 编进二进制(`internal/route/assets/403.webp`),不占任何可公开访问的 URL。想换成自己的图:覆盖同目录文件后重新编译、重启即可。注意体积——被封的来源会反复重试,8.6 KB 是刻意压过的(原图别直接塞几百 KB 的)。
|
||||
|
||||
### 封的是一把「键」,不是一个人
|
||||
|
||||
`autoban` 的表就是内存里的 `map[键]到期时间`,键由 `shared.LimitKey` 生成:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
@@ -0,0 +1,20 @@
|
||||
// Package assets 存放「必须由服务端直接吐出」的二进制资源。
|
||||
//
|
||||
// 为什么不放 static/:static 下的东西由静态文件中间件按 URL 提供,
|
||||
// 而这里的图片只该出现在 403 响应体里 —— 不该有一个能直接访问的公开路径
|
||||
// (否则被 ban 的人可以先抓一张「解禁图」,也等于多一个指纹入口)。
|
||||
package assets
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// BannedImage 是被封禁来源访问任何路径时返回的图(256×256 webp,约 8.6 KB)。
|
||||
//
|
||||
// 换成图片的动机:被封之后每次请求都只拿到 13 字节的 "403 Forbidden",
|
||||
// 既没有信息量,也让人误以为是站点挂了。这张图明确写着 403 Forbidden,
|
||||
// 人一看就懂,而状态码仍然是 403 —— 语义没变,只换了 body。
|
||||
//
|
||||
// 想替换:直接覆盖同目录的 403.webp 后重新编译(go:embed 是编译期打包的,
|
||||
// 不需要重启之外的操作)。
|
||||
//
|
||||
//go:embed 403.webp
|
||||
var BannedImage []byte
|
||||
@@ -1,8 +1,6 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/flamego/flamego"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
@@ -34,8 +32,7 @@ func autobanMiddleware(b *autoban.Banner, hp *honeypot.Honeypot) flamego.Handler
|
||||
"ban_key": ip,
|
||||
"path": path,
|
||||
}).Debug("拒绝已封禁来源")
|
||||
ctx.ResponseWriter().WriteHeader(http.StatusForbidden)
|
||||
_, _ = ctx.ResponseWriter().Write([]byte("403 Forbidden"))
|
||||
writeBanned(ctx.ResponseWriter(), ctx.Request().Request.Method)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/route/assets"
|
||||
)
|
||||
|
||||
// writeBanned 写出「来源已被封禁」的响应。
|
||||
//
|
||||
// 状态码仍然是 403 —— 语义不变,只把 body 从 13 字节的 "403 Forbidden"
|
||||
// 换成一张 256×256 的 webp(约 8.6 KB):
|
||||
// - 顶掉明文:爆破/扫描脚本不少会读响应体里的 "403 Forbidden" 来确认自己
|
||||
// 已被拦,换掉 body 至少让这类判断失效(状态码本身还是照常返回);
|
||||
// - 给人看:误封(比如共享出口、代理)时打开浏览器看到一张写着 403 的图,
|
||||
// 比一个没有任何样式的白屏好判断得多。
|
||||
//
|
||||
// 图片是编译期 embed 的,不走磁盘 IO,也不占一个可公开访问的 URL。
|
||||
// 被封住的来源往往反复重试,所以这里显式给 Content-Length(否则 8 KB 会退回
|
||||
// chunked),并标 no-store:中间层缓存一张 403 图没有意义,还会让解封后
|
||||
// 的响应继续命中缓存。
|
||||
func writeBanned(w http.ResponseWriter, method string) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "image/webp")
|
||||
h.Set("Content-Length", strconv.Itoa(len(assets.BannedImage)))
|
||||
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
h.Set("X-Robots-Tag", "noindex, nofollow")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
// HEAD 不写 body(net/http 也会丢弃,这里省掉一次无意义的拷贝)。
|
||||
if method != http.MethodHead {
|
||||
_, _ = w.Write(assets.BannedImage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/route/assets"
|
||||
)
|
||||
|
||||
// TestWriteBanned 守住「被封禁后的响应是一张图」。
|
||||
func TestWriteBanned(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
writeBanned(rec, http.MethodGet)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403(换成图片不改变状态码语义)", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "image/webp" {
|
||||
t.Errorf("Content-Type = %q, want image/webp", ct)
|
||||
}
|
||||
if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(len(assets.BannedImage)) {
|
||||
t.Errorf("Content-Length = %q, want %d", cl, len(assets.BannedImage))
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
|
||||
t.Errorf("Cache-Control = %q, want 含 no-store(中间层缓存 403 无意义)", cc)
|
||||
}
|
||||
|
||||
body := rec.Body.Bytes()
|
||||
// webp 的容器头:RIFF....WEBP。写成字节判断,避免「图片被换成 HTML」这类回归。
|
||||
if len(body) < 12 || string(body[0:4]) != "RIFF" || string(body[8:12]) != "WEBP" {
|
||||
t.Errorf("body 不是 webp(前 12 字节 %q)", body[:min(12, len(body))])
|
||||
}
|
||||
if strings.Contains(string(body), "403 Forbidden") && len(body) < 512 {
|
||||
t.Error("body 仍是明文文案,没换成图片")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteBannedHead 覆盖 HEAD:头照给(含 Content-Length),body 不发。
|
||||
func TestWriteBannedHead(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
writeBanned(rec, http.MethodHead)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Errorf("HEAD 不应有 body,实际 %d 字节", rec.Body.Len())
|
||||
}
|
||||
if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(len(assets.BannedImage)) {
|
||||
t.Errorf("Content-Length = %q, want %d", cl, len(assets.BannedImage))
|
||||
}
|
||||
}
|
||||
@@ -297,8 +297,12 @@ func TestHoneypotBansEvenWhenAutoBanDisabled(t *testing.T) {
|
||||
if resp2.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403(蜜罐封禁不得被 enable_auto_ban 连坐)", resp2.StatusCode)
|
||||
}
|
||||
if !strings.Contains(string(body), "403 Forbidden") {
|
||||
t.Errorf("body = %q, want 含 \"403 Forbidden\"", truncate(string(body), 80))
|
||||
// body 是一张 403 图(见 writeBanned),不再是那行明文。
|
||||
if ct := resp2.Header.Get("Content-Type"); ct != "image/webp" {
|
||||
t.Errorf("Content-Type = %q, want image/webp", ct)
|
||||
}
|
||||
if len(body) < 1024 {
|
||||
t.Errorf("body 只有 %d 字节,want 一张图片", len(body))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user