Files
800d61c382 fix(security): uniform limit-clamp, log-injection sanitization, SPA path validation (#1540)
Follow-up to v3.8.3 security train. Found by non-XSS input-validation
audit.

Three findings closed in one PR — all defense-in-depth: medium is
genuinely DoS-only (no data exposure), lows tighten log hygiene and SPA
path handling so future router changes can't silently expose the
filesystem.

## Findings addressed

### MEDIUM — unbounded `limit` on list endpoints
- **What:** four list endpoints accepted `limit=999999999` and passed
the value straight to SQL `LIMIT ?` and Go `make(..., 0, limit)`.
- **Where:** `cmd/server/routes.go` — handlePackets (incl. multi-node
branch), handleNodes, handleChannelMessages, handleAnalyticsSubpaths,
handleAnalyticsSubpathsBulk per-group lim, handleDroppedPackets.
- **Fix:** new `clampLimit(raw, def, max)` helper in
`cmd/server/clamp_limit.go` plus `queryLimit(r, def, max)` HTTP wrapper.
Caps: packets/nodes/channels/dropped = 500, analytics buckets /
bulk-health = 200. Already-clamped endpoints (handleBulkHealth) migrated
to the helper for uniformity. Silent clamp — no response-shape change.
Negative / zero / non-numeric → default.

### LOW — log injection via newline in advert name
- **What:** advert `name` field allows `\n` / `\t` (sanitizeName
intentionally preserves them for display). Logged at two MQTT-ingest
sites, an attacker with publish ACL could forge log lines.
- **Where:** `cmd/ingestor/main.go:659,690`.
- **Fix:** new `sanitizeLogString` in `cmd/ingestor/sanitize_log.go`
strips control bytes < 0x20 and DEL with `?`. Wrapped at the two log
call sites that interpolate `name=` and `observer=`. Stored display
values untouched.

### LOW — SPA static handler depends on default mux path-cleaning
- **What:** `cmd/server/main.go:469` joins `r.URL.Path` to root; safe
today only because gorilla/mux runs `path.Clean` and `http.FileServer`
rejects `..`. A future `SkipClean(true)` or router swap would silently
expose the filesystem.
- **Where:** `cmd/server/main.go` (spaHandler).
- **Fix:** new `isSafeStaticPath` rejects requests whose decoded or raw
path contains `..`, `%2e%2e`, `\\`, or `%5c` with a 400. Legit asset
names with dots (`/app.js`, `/customize-v2.js`, `/themes/dark.css`) are
unaffected.

## TDD

- Commit 1 (red): adds `TestClampLimit`, `TestSpaHandlerPathTraversal`,
`TestSanitizeLogString` with stub helpers — tests fail on assertions
(not build errors), proving they gate the change.
- Commit 2 (green): production fix. Revert the green commit and the red
commit's assertions fail.

## Audit reference

Source: non-XSS input-validation audit dated 2026-06-03 (workspace).
Sibling PR `fix/xss-r2-trace-obs-anl` owns the XSS findings — not
included here.

---------

Co-authored-by: clawbot <clawbot@users.noreply.github.com>
2026-06-03 13:58:04 -07:00

91 lines
3.0 KiB
Go

package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// TestSpaHandlerPathTraversal asserts that the SPA static handler does not
// serve files outside its served root, even when the URL contains traversal
// sequences. The default gorilla/mux + http.FileServer chain already cleans
// most of these, but we want defense-in-depth so a future SkipClean(true)
// (or a different router) cannot accidentally expose the filesystem.
//
// Audit ref: audit-input-vulns-20260603 (LOW — SPA static handler depends on
// default mux path-cleaning).
func TestSpaHandlerPathTraversal(t *testing.T) {
root := t.TempDir()
parent := filepath.Dir(root)
// Place a sentinel file OUTSIDE the served root. If traversal works the
// response body will contain the sentinel.
secretPath := filepath.Join(parent, "secret.txt")
if err := os.WriteFile(secretPath, []byte("CORESCOPE_SECRET_SENTINEL"), 0644); err != nil {
t.Fatalf("setup: %v", err)
}
t.Cleanup(func() { os.Remove(secretPath) })
// Minimal SPA root: index.html + an asset with a dot in the filename to
// prove legit names aren't false-positived.
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<html>SPA</html>"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "app.js"), []byte("console.log('ok')"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "customize-v2.js"), []byte("// v2"), 0644); err != nil {
t.Fatal(err)
}
themes := filepath.Join(root, "themes")
os.Mkdir(themes, 0755)
if err := os.WriteFile(filepath.Join(themes, "dark.css"), []byte("body{}"), 0644); err != nil {
t.Fatal(err)
}
fs := http.FileServer(http.Dir(root))
handler := spaHandler(root, fs)
traversal := []string{
"/../secret.txt",
"/..%2fsecret.txt",
"/%2e%2e/secret.txt",
"/foo/../../secret.txt",
"/..\\secret.txt",
"/static/..%5csecret.txt",
}
for _, p := range traversal {
t.Run("blocks "+p, func(t *testing.T) {
req := httptest.NewRequest("GET", p, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
body := w.Body.String()
if strings.Contains(body, "CORESCOPE_SECRET_SENTINEL") {
t.Fatalf("traversal succeeded — secret leaked for path %q: %s", p, body)
}
// Defense-in-depth: explicit traversal sequences must be
// rejected with a 4xx, not silently routed through the FS
// layer's own cleaning. This is what the green commit adds.
if w.Code < 400 || w.Code >= 500 {
t.Fatalf("path %q: expected explicit 4xx rejection, got %d (body=%q)", p, w.Code, body)
}
})
}
// Legit asset names with dots must still work.
legit := []string{"/app.js", "/customize-v2.js", "/themes/dark.css"}
for _, p := range legit {
t.Run("serves "+p, func(t *testing.T) {
req := httptest.NewRequest("GET", p, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("legit asset %q returned %d, expected 200", p, w.Code)
}
})
}
}