mirror of
https://github.com/NotAShelf/watchdog.git
synced 2026-04-17 07:40:07 +00:00
watchdog: add log sanitization and request tracking
Stuff to prevent possible log injection attacks via weird characters, now sanitized with `strconv` stuff. - X-Request-ID is now traced in ingestion handler - ValidateWithMap renamed to Validate (xd) - Some new tests :D Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I286ec399a5c4a407f0cc117472c079446a6a6964
This commit is contained in:
parent
4189d14d65
commit
d1181d38f0
3 changed files with 163 additions and 9 deletions
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
|
@ -167,6 +168,23 @@ func basicAuth(next http.Handler, username, password string) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanitizes a path for logging to prevent log injection attacks. Uses strconv.Quote
|
||||||
|
// to properly escape control characters and special bytes.
|
||||||
|
func sanitizePathForLog(path string) string {
|
||||||
|
escaped := strconv.Quote(path)
|
||||||
|
if len(escaped) >= 2 && escaped[0] == '"' && escaped[len(escaped)-1] == '"' {
|
||||||
|
escaped = escaped[1 : len(escaped)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit path length to prevent log flooding
|
||||||
|
const maxLen = 200
|
||||||
|
if len(escaped) > maxLen {
|
||||||
|
return escaped[:maxLen] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
return escaped
|
||||||
|
}
|
||||||
|
|
||||||
// Creates a file server that only serves whitelisted files. Blocks dotfiles, .git, .env, etc.
|
// Creates a file server that only serves whitelisted files. Blocks dotfiles, .git, .env, etc.
|
||||||
// TODO: I need to hook this up to eris somehow so I can just forward the paths that are being
|
// TODO: I need to hook this up to eris somehow so I can just forward the paths that are being
|
||||||
// scanned despite not being on a whitelist. Would be a good way of detecting scrapers, maybe.
|
// scanned despite not being on a whitelist. Would be a good way of detecting scrapers, maybe.
|
||||||
|
|
@ -179,7 +197,7 @@ func safeFileServer(root string, blockedRequests *prometheus.CounterVec) http.Ha
|
||||||
// Block directory listings
|
// Block directory listings
|
||||||
if strings.HasSuffix(path, "/") {
|
if strings.HasSuffix(path, "/") {
|
||||||
blockedRequests.WithLabelValues("directory_listing").Inc()
|
blockedRequests.WithLabelValues("directory_listing").Inc()
|
||||||
log.Printf("Blocked directory listing attempt: %s from %s", path, r.RemoteAddr)
|
log.Printf("Blocked directory listing attempt: %s from %s", sanitizePathForLog(path), r.RemoteAddr)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +206,7 @@ func safeFileServer(root string, blockedRequests *prometheus.CounterVec) http.Ha
|
||||||
for segment := range strings.SplitSeq(path, "/") {
|
for segment := range strings.SplitSeq(path, "/") {
|
||||||
if strings.HasPrefix(segment, ".") {
|
if strings.HasPrefix(segment, ".") {
|
||||||
blockedRequests.WithLabelValues("dotfile").Inc()
|
blockedRequests.WithLabelValues("dotfile").Inc()
|
||||||
log.Printf("Blocked dotfile access: %s from %s", path, r.RemoteAddr)
|
log.Printf("Blocked dotfile access: %s from %s", sanitizePathForLog(path), r.RemoteAddr)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -199,7 +217,7 @@ func safeFileServer(root string, blockedRequests *prometheus.CounterVec) http.Ha
|
||||||
strings.HasSuffix(lower, ".bak") ||
|
strings.HasSuffix(lower, ".bak") ||
|
||||||
strings.HasSuffix(lower, "~") {
|
strings.HasSuffix(lower, "~") {
|
||||||
blockedRequests.WithLabelValues("sensitive_file").Inc()
|
blockedRequests.WithLabelValues("sensitive_file").Inc()
|
||||||
log.Printf("Blocked sensitive file access: %s from %s", path, r.RemoteAddr)
|
log.Printf("Blocked sensitive file access: %s from %s", sanitizePathForLog(path), r.RemoteAddr)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -209,7 +227,7 @@ func safeFileServer(root string, blockedRequests *prometheus.CounterVec) http.Ha
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
if ext != ".js" && ext != ".html" && ext != ".css" {
|
if ext != ".js" && ext != ".html" && ext != ".css" {
|
||||||
blockedRequests.WithLabelValues("invalid_extension").Inc()
|
blockedRequests.WithLabelValues("invalid_extension").Inc()
|
||||||
log.Printf("Blocked invalid extension: %s from %s", path, r.RemoteAddr)
|
log.Printf("Blocked invalid extension: %s from %s", sanitizePathForLog(path), r.RemoteAddr)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
116
cmd/watchdog/root_test.go
Normal file
116
cmd/watchdog/root_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package watchdog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizePathForLog(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
maxLen int // expected max length check
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normal path",
|
||||||
|
input: "/web/beacon.js",
|
||||||
|
want: "/web/beacon.js",
|
||||||
|
maxLen: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path with newlines",
|
||||||
|
input: "/test\nmalicious",
|
||||||
|
want: `/test\nmalicious`,
|
||||||
|
maxLen: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path with carriage return",
|
||||||
|
input: "/test\rmalicious",
|
||||||
|
want: `/test\rmalicious`,
|
||||||
|
maxLen: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path with tabs",
|
||||||
|
input: "/test\tmalicious",
|
||||||
|
want: `/test\tmalicious`,
|
||||||
|
maxLen: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path with null bytes",
|
||||||
|
input: "/test\x00malicious",
|
||||||
|
want: `/test\x00malicious`,
|
||||||
|
maxLen: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path with quotes",
|
||||||
|
input: `/test"malicious`,
|
||||||
|
want: `/test\"malicious`,
|
||||||
|
maxLen: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path with backslash",
|
||||||
|
input: `/test\malicious`,
|
||||||
|
want: `/test\\malicious`,
|
||||||
|
maxLen: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "control characters",
|
||||||
|
input: "/test\x01\x02\x1fmalicious",
|
||||||
|
want: `/test\x01\x02\x1fmalicious`,
|
||||||
|
maxLen: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "truncation at 200 chars",
|
||||||
|
input: "/" + strings.Repeat("a", 250),
|
||||||
|
want: "/" + strings.Repeat("a", 199) + "...",
|
||||||
|
maxLen: 203, // 200 chars + "..." = 203
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
want: "",
|
||||||
|
maxLen: 200,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := sanitizePathForLog(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("sanitizePathForLog(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
if len(got) > tt.maxLen {
|
||||||
|
t.Errorf("sanitizePathForLog(%q) length = %d, exceeds max %d", tt.input, len(got), tt.maxLen)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizePathForLog_LogInjectionPrevention(t *testing.T) {
|
||||||
|
// Log injection attempts should be neutralized
|
||||||
|
maliciousPaths := []string{
|
||||||
|
"/api\nINFO: fake log entry",
|
||||||
|
"/test\r\nERROR: fake error",
|
||||||
|
"/.git/config\x00", // null byte injection
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range maliciousPaths {
|
||||||
|
sanitized := sanitizePathForLog(path)
|
||||||
|
// Check that newlines are escaped, not literal
|
||||||
|
if strings.Contains(sanitized, "\n") || strings.Contains(sanitized, "\r") {
|
||||||
|
t.Errorf("sanitizePathForLog(%q) contains literal newlines: %q", path, sanitized)
|
||||||
|
}
|
||||||
|
// Check that null bytes are escaped
|
||||||
|
if strings.Contains(sanitized, "\x00") {
|
||||||
|
t.Errorf("sanitizePathForLog(%q) contains literal null byte: %q", path, sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkSanitizePathForLog(b *testing.B) {
|
||||||
|
path := "/test/path/with\nnewlines\rand\ttabs"
|
||||||
|
for b.Loop() {
|
||||||
|
_ = sanitizePathForLog(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math/rand"
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
mrand "math/rand"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -23,7 +26,7 @@ type IngestionHandler struct {
|
||||||
refRegistry *normalize.ReferrerRegistry
|
refRegistry *normalize.ReferrerRegistry
|
||||||
metricsAgg *aggregate.MetricsAggregator
|
metricsAgg *aggregate.MetricsAggregator
|
||||||
rateLimiter *ratelimit.TokenBucket
|
rateLimiter *ratelimit.TokenBucket
|
||||||
rng *rand.Rand
|
rng *mrand.Rand
|
||||||
trustedNetworks []*net.IPNet // pre-parsed CIDR networks
|
trustedNetworks []*net.IPNet // pre-parsed CIDR networks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,12 +85,19 @@ func NewIngestionHandler(
|
||||||
refRegistry: refRegistry,
|
refRegistry: refRegistry,
|
||||||
metricsAgg: metricsAgg,
|
metricsAgg: metricsAgg,
|
||||||
rateLimiter: limiter,
|
rateLimiter: limiter,
|
||||||
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
rng: mrand.New(mrand.NewSource(time.Now().UnixNano())),
|
||||||
trustedNetworks: trustedNetworks,
|
trustedNetworks: trustedNetworks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *IngestionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *IngestionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Generate or extract request ID for tracing
|
||||||
|
requestID := r.Header.Get("X-Request-ID")
|
||||||
|
if requestID == "" {
|
||||||
|
requestID = generateRequestID()
|
||||||
|
}
|
||||||
|
w.Header().Set("X-Request-ID", requestID)
|
||||||
|
|
||||||
// Handle CORS preflight
|
// Handle CORS preflight
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
h.handleCORS(w, r)
|
h.handleCORS(w, r)
|
||||||
|
|
@ -131,8 +141,8 @@ func (h *IngestionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate event via map lookup (also O(1))
|
// Validate event via map lookup
|
||||||
if err := event.ValidateWithMap(h.domainMap); err != nil {
|
if err := event.Validate(h.domainMap); err != nil {
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -322,3 +332,13 @@ func (h *IngestionHandler) classifyDevice(width int, userAgent string) string {
|
||||||
|
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateRequestID creates a unique request ID for tracing
|
||||||
|
func generateRequestID() string {
|
||||||
|
b := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
// Fallback to timestamp if crypto/rand fails
|
||||||
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue