diff --git a/internal/server/router.go b/internal/server/router.go index 07b5e15..b8a78b4 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "net/http" + "net/url" "os" "runtime" "strings" @@ -160,6 +161,16 @@ func (f *filteredLogFormatter) NewLogEntry(r *http.Request) middleware.LogEntry return noopLogEntry{} } } + if r != nil && r.URL != nil { + if redacted, changed := redactSensitiveQueryParams(r.URL); changed { + cloned := *r + clonedURL := *r.URL + clonedURL.RawQuery = redacted + cloned.URL = &clonedURL + cloned.RequestURI = clonedURL.RequestURI() + return f.base.NewLogEntry(&cloned) + } + } return f.base.NewLogEntry(r) } @@ -169,6 +180,35 @@ func (noopLogEntry) Write(_ int, _ int, _ http.Header, _ time.Duration, _ interf func (noopLogEntry) Panic(_ interface{}, _ []byte) {} +func redactSensitiveQueryParams(u *url.URL) (string, bool) { + if u == nil || u.RawQuery == "" { + return "", false + } + values, err := url.ParseQuery(u.RawQuery) + if err != nil && len(values) == 0 { + return "", false + } + changed := false + for name, vals := range values { + if !isSensitiveQueryParam(name) { + continue + } + for i := range vals { + vals[i] = "REDACTED" + } + values[name] = vals + changed = true + } + if !changed { + return "", false + } + return values.Encode(), true +} + +func isSensitiveQueryParam(name string) bool { + return strings.EqualFold(name, "key") || strings.EqualFold(name, "api_key") +} + var defaultCORSAllowHeaders = []string{ "Content-Type", "Authorization", diff --git a/internal/server/router_log_test.go b/internal/server/router_log_test.go new file mode 100644 index 0000000..e5f43df --- /dev/null +++ b/internal/server/router_log_test.go @@ -0,0 +1,47 @@ +package server + +import ( + "bytes" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5/middleware" +) + +func TestFilteredLogFormatterRedactsSensitiveQueryParams(t *testing.T) { + var buf bytes.Buffer + formatter := &filteredLogFormatter{ + base: &middleware.DefaultLogFormatter{ + Logger: log.New(&buf, "", 0), + NoColor: true, + }, + } + req := httptest.NewRequest( + http.MethodPost, + "/v1beta/models/gemini-2.5-pro:generateContent?key=caller-secret&api_key=second-secret&alt=sse", + nil, + ) + + entry := formatter.NewLogEntry(req) + entry.Write(http.StatusOK, 0, http.Header{}, time.Millisecond, nil) + + got := buf.String() + for _, secret := range []string{"caller-secret", "second-secret"} { + if strings.Contains(got, secret) { + t.Fatalf("log line contains sensitive query value %q: %s", secret, got) + } + } + if !strings.Contains(got, "key=REDACTED") || !strings.Contains(got, "api_key=REDACTED") { + t.Fatalf("log line did not include redacted sensitive params: %s", got) + } + if !strings.Contains(got, "alt=sse") { + t.Fatalf("log line did not preserve non-sensitive query param: %s", got) + } + if req.URL.RawQuery != "key=caller-secret&api_key=second-secret&alt=sse" { + t.Fatalf("request was mutated, RawQuery = %q", req.URL.RawQuery) + } +} diff --git a/internal/webui/handler.go b/internal/webui/handler.go index da9649d..5742bb4 100644 --- a/internal/webui/handler.go +++ b/internal/webui/handler.go @@ -95,11 +95,12 @@ func setStaticContentType(w http.ResponseWriter, fullPath string) { } func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDir string) { + root := filepath.Clean(staticDir) path := strings.TrimPrefix(r.URL.Path, "/admin") path = strings.TrimPrefix(path, "/") if path != "" && strings.Contains(path, ".") { - full := filepath.Join(staticDir, filepath.Clean(path)) - if !strings.HasPrefix(full, staticDir) { + full := filepath.Join(root, filepath.Clean(path)) + if full != root && !strings.HasPrefix(full, root+string(os.PathSeparator)) { http.NotFound(w, r) return } @@ -116,7 +117,7 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi http.NotFound(w, r) return } - index := filepath.Join(staticDir, "index.html") + index := filepath.Join(root, "index.html") if _, err := os.Stat(index); err != nil { http.Error(w, "index.html not found", http.StatusNotFound) return diff --git a/internal/webui/handler_test.go b/internal/webui/handler_test.go index 99832c5..a819d8c 100644 --- a/internal/webui/handler_test.go +++ b/internal/webui/handler_test.go @@ -78,6 +78,33 @@ func TestServeFromDiskPinsContentType(t *testing.T) { } } +func TestServeFromDiskRejectsSiblingDirectoryWithSharedPrefix(t *testing.T) { + parent := t.TempDir() + staticDir := filepath.Join(parent, "admin") + siblingDir := filepath.Join(parent, "admin-leak") + if err := os.MkdirAll(staticDir, 0o755); err != nil { + t.Fatalf("mkdir static dir: %v", err) + } + if err := os.MkdirAll(siblingDir, 0o755); err != nil { + t.Fatalf("mkdir sibling dir: %v", err) + } + if err := os.WriteFile(filepath.Join(siblingDir, "secret.txt"), []byte("secret"), 0o644); err != nil { + t.Fatalf("write sibling secret: %v", err) + } + + h := &Handler{StaticDir: staticDir} + req := httptest.NewRequest(http.MethodGet, "/admin/../admin-leak/secret.txt", nil) + rec := httptest.NewRecorder() + h.serveFromDisk(rec, req, staticDir) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + if body := rec.Body.String(); strings.Contains(body, "secret") { + t.Fatal("served content from sibling directory") + } +} + // TestSetStaticContentTypeUnknownExtensionFallsThrough verifies that unknown // extensions leave the Content-Type header unset, so http.ServeFile can apply // its own detection (sniffing or mime.TypeByExtension) for cases the pinned