Skip to content

Commit 4de933e

Browse files
committed
fix: harden geoip parser, probe miss-detection and CSV export (review)
Adversarial review of the six-feature batch found no critical/high defects; these are the three low-severity robustness/correctness fixes it surfaced. - geo: reject an over-long protobuf length varint by unsigned comparison — a length ≥ 2^63 cast to int went negative and slipped past the bounds check, panicking the slice on a corrupt/truncated geoip.dat (now errors cleanly) - decoy: Miss() now mirrors serveMiss exactly — an extensionless miss on a single-page template is served as the index under 200, so it is NOT counted as a probe (only a 404-serving template or a missing asset is), preventing a future SPA-style decoy from flagging real visitors as scanners - audit CSV: neutralize spreadsheet formula injection (leading = + - @ tab CR) in the free-text columns - tests: malformed/truncated geoip.dat no-panic; Miss for SPA vs classic decoy
1 parent f5bb172 commit 4de933e

5 files changed

Lines changed: 78 additions & 17 deletions

File tree

internal/decoy/decoy.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -207,18 +207,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
207207
h.serve(w, r, a, http.StatusOK)
208208
}
209209

210-
// Miss reports whether urlPath resolves to no asset this template ships — i.e. the
211-
// request would be served as a "not found" rather than a real page. Read-only; it
212-
// mirrors ServeHTTP's name resolution so probe detection sees exactly what the decoy
213-
// treats as a miss. A scanner guessing the hidden panel path hits misses; a browser
214-
// loading the decoy's own pages and assets does not.
210+
// Miss reports whether urlPath would be served as a genuine "not found" (a 404) rather
211+
// than a real page — mirroring what serveMiss actually does, so probe detection counts
212+
// exactly the requests the decoy treats as misses. A scanner guessing the hidden panel
213+
// path hits misses; a browser loading the decoy's own pages and assets does not.
214+
//
215+
// The subtlety is the single-page templates: they ship no 404 page and answer an
216+
// EXTENSIONLESS miss with the index under 200 (the `try_files $uri /index.html` case),
217+
// so that is NOT a miss — only a template with its own 404 page, or a missing asset
218+
// (a path with an extension), is. Counting extensionless SPA fallbacks would flag a
219+
// visitor following an internal client-route link as a scanner.
215220
func (h *Handler) Miss(urlPath string) bool {
216221
name := strings.TrimPrefix(path.Clean("/"+urlPath), "/")
217222
if name == "" {
218223
name = "index.html"
219224
}
220-
_, ok := h.files[name]
221-
return !ok
225+
if _, ok := h.files[name]; ok {
226+
return false // a real page or asset the template ships
227+
}
228+
return h.notFound != nil || path.Ext(name) != ""
222229
}
223230

224231
// serveMiss answers a path the template doesn't have.

internal/decoy/decoy_test.go

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -156,19 +156,31 @@ func TestSPATemplateFallbackAndAssetMiss(t *testing.T) {
156156
}
157157
}
158158

159-
// Miss must agree with what ServeHTTP actually treats as a not-found: the site root
160-
// and the template's own assets are hits, guessed paths are misses. Probe detection
161-
// keys off this, so a disagreement would either miss scanners or flag real visitors.
162-
func TestMissAgreesWithServe(t *testing.T) {
159+
// Miss must agree with what serveMiss actually returns, since probe detection keys off
160+
// it. A single-page template (no 404 page) answers an extensionless miss with the
161+
// index under 200 — that is NOT a miss; only a missing asset (has an extension) is.
162+
func TestMissMatchesSPAServe(t *testing.T) {
163163
h := newTestHandler(t, "filecloud")
164164
if h.Miss("/") {
165165
t.Error("root reported as a miss; a visitor loading the site must not count")
166166
}
167-
if !h.Miss("/definitely-not-a-real-path") {
168-
t.Error("a guessed path reported as a hit; scanners would go unseen")
167+
if h.Miss("/dashboard/files") {
168+
t.Error("extensionless path is served the index under 200 — must not count as a miss")
169169
}
170170
if !h.Miss("/assets/nope.js") {
171-
t.Error("a missing asset reported as a hit")
171+
t.Error("a missing asset is a genuine 404 — must count as a miss")
172+
}
173+
}
174+
175+
// A classic template ships its own 404 page, so every miss IS a genuine 404 — a
176+
// guessed path there is the scan signal, extension or not.
177+
func TestMissMatchesClassicServe(t *testing.T) {
178+
h := newTestHandler(t, "coming-soon")
179+
if h.Miss("/") {
180+
t.Error("root reported as a miss")
181+
}
182+
if !h.Miss("/definitely-not-a-real-path") {
183+
t.Error("a guessed path on a 404-serving site must count as a miss")
172184
}
173185
}
174186

internal/geo/lookup.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ func LoadCountryLookup(dir string) (*CountryLookup, error) {
4747
}
4848
data = data[n:]
4949
msgLen, n := binary.Uvarint(data)
50-
if n <= 0 || int(msgLen) > len(data[n:]) {
50+
// Unsigned compare: a length varint ≥ 2^63 makes int(msgLen) negative, which
51+
// would slip past a signed `> len` check and then panic on the slice.
52+
if n <= 0 || msgLen > uint64(len(data[n:])) {
5153
break
5254
}
5355
data = data[n:]
@@ -208,7 +210,9 @@ func readTag(msg []byte) (field uint64, wire byte, rest []byte, ok bool) {
208210

209211
func readBytes(msg []byte) (b, rest []byte, ok bool) {
210212
l, n := binary.Uvarint(msg)
211-
if n <= 0 || int(l) > len(msg[n:]) {
213+
// Unsigned compare so a huge (or overflow-to-negative-when-cast) length can't slip
214+
// past the bound and panic the slice below.
215+
if n <= 0 || l > uint64(len(msg[n:])) {
212216
return nil, nil, false
213217
}
214218
return msg[n : n+int(l)], msg[n+int(l):], true

internal/geo/lookup_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,25 @@ func TestCountryLookup(t *testing.T) {
7676
}
7777
}
7878
}
79+
80+
// A corrupt or truncated geoip.dat must fail cleanly, never panic — in particular a
81+
// length prefix ≥ 2^63 (which casts to a negative int) must not slip past the bounds
82+
// check and slice out of range.
83+
func TestCountryLookupMalformed(t *testing.T) {
84+
dir := t.TempDir()
85+
// Top-level entry tag 0x0A followed by an absurd length varint.
86+
bad := binary.AppendUvarint([]byte{0x0A}, uint64(1)<<63)
87+
if err := os.WriteFile(filepath.Join(dir, "geoip.dat"), bad, 0o644); err != nil {
88+
t.Fatalf("write: %v", err)
89+
}
90+
if _, err := LoadCountryLookup(dir); err == nil {
91+
t.Error("expected an error for a malformed geoip.dat, got nil")
92+
}
93+
94+
// A truncated valid-looking entry must also not panic.
95+
trunc := geoipDat(geoEntry("US", cidrMsg([]byte{8, 8, 8, 0}, 24)))
96+
if err := os.WriteFile(filepath.Join(dir, "geoip.dat"), trunc[:len(trunc)-3], 0o644); err != nil {
97+
t.Fatalf("write trunc: %v", err)
98+
}
99+
_, _ = LoadCountryLookup(dir) // must not panic; result is don't-care
100+
}

internal/server/panel_admin_audit.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,21 @@ type adminAuditResponse struct {
2222
NextBefore int64 `json:"next_before"`
2323
}
2424

25+
// csvSafe neutralizes spreadsheet formula injection: a cell beginning with =, +, -, @,
26+
// or a leading tab/CR can execute as a formula when the export is opened in Excel or
27+
// Sheets. Prefixing a single quote makes the app treat it as text. Applied to the
28+
// fields that can carry free-form (potentially user-influenced) text.
29+
func csvSafe(s string) string {
30+
if s == "" {
31+
return s
32+
}
33+
switch s[0] {
34+
case '=', '+', '-', '@', '\t', '\r':
35+
return "'" + s
36+
}
37+
return s
38+
}
39+
2540
// adminAuditFilterFromQuery builds the store filter shared by the paged list and the
2641
// CSV export, so a search/date/actor/category filter can never mean one thing on
2742
// screen and another in the exported file. ok=false means the query named a category
@@ -104,7 +119,8 @@ func (rt *Router) exportAdminAudit(w http.ResponseWriter, r *http.Request) {
104119
}
105120
}
106121
ts := time.Unix(ev.CreatedAt, 0).UTC().Format(time.RFC3339)
107-
return cw.Write([]string{ts, ev.Action, ev.Target, ev.ActorKind, ev.ActorName, ev.IP, det})
122+
return cw.Write([]string{ts, ev.Action,
123+
csvSafe(ev.Target), ev.ActorKind, csvSafe(ev.ActorName), ev.IP, csvSafe(det)})
108124
})
109125
if err != nil {
110126
// The 200 and header row are already on the wire, so there is nothing to

0 commit comments

Comments
 (0)