fix(mana-search): Go best practices hardening

- Fix response body leak in SearXNG HealthCheck (defer resp.Body.Close)
- Handle ignored errors in HTTP request creation
- Add panic recovery in BulkExtract goroutines
- Add request body size limit (1 MB) via http.MaxBytesReader
- Add MaxHeaderBytes to HTTP server
- Sort engine list for deterministic responses
- Fix variable shadowing (r → res in loop)
- Run as non-root user in Docker container
- Log shutdown errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 22:09:31 +01:00
parent 15deaf4e0a
commit 28bbd7bbb0
6 changed files with 42 additions and 20 deletions

View file

@ -9,10 +9,12 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-search ./cmd/ser
FROM alpine:3.21
RUN apk --no-cache add ca-certificates tzdata
RUN apk --no-cache add ca-certificates tzdata && \
addgroup -g 1000 mana && adduser -u 1000 -G mana -s /sbin/nologin -D mana
COPY --from=builder /mana-search /usr/local/bin/mana-search
USER mana
EXPOSE 3021
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \

View file

@ -55,7 +55,7 @@ func main() {
mux.HandleFunc("POST /api/v1/extract", extractHandler.Extract)
mux.HandleFunc("POST /api/v1/extract/bulk", extractHandler.BulkExtract)
c_handler := cors.New(cors.Options{
corsHandler := cors.New(cors.Options{
AllowedOrigins: cfg.CORSOrigins,
AllowedMethods: []string{"GET", "POST", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
@ -63,11 +63,12 @@ func main() {
}).Handler(mux)
server := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: c_handler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: corsHandler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20, // 1 MB
}
// Graceful shutdown
@ -87,7 +88,9 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server.Shutdown(ctx)
if err := server.Shutdown(ctx); err != nil {
slog.Error("shutdown error", "error", err)
}
slog.Info("server stopped")
}

View file

@ -121,7 +121,7 @@ func (e *Extractor) Extract(ctx context.Context, req *ExtractRequest) *ExtractRe
})
if err != nil {
slog.Warn("extraction failed", "url", req.URL, "error", err)
return errorResponse(req.URL, fmt.Sprintf("extraction failed: %v", err), start)
return errorResponse(req.URL, fmt.Sprintf("extraction failed: %s", err), start)
}
text := cleanText(article.TextContent)
@ -196,6 +196,12 @@ func (e *Extractor) BulkExtract(ctx context.Context, req *BulkExtractRequest) *B
ch := make(chan indexedResult, end-i)
for j := i; j < end; j++ {
go func(idx int, u string) {
defer func() {
if p := recover(); p != nil {
slog.Error("extract panic", "url", u, "panic", p)
ch <- indexedResult{index: idx, result: errorResponse(u, "extraction panicked", start)}
}
}()
r := e.Extract(ctx, &ExtractRequest{URL: u, Options: req.Options})
ch <- indexedResult{index: idx, result: r}
}(j, req.URLs[j])

View file

@ -33,7 +33,7 @@ func (h *ExtractHandler) Extract(w http.ResponseWriter, r *http.Request) {
start := time.Now()
var req extract.ExtractRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
@ -97,7 +97,7 @@ func (h *ExtractHandler) BulkExtract(w http.ResponseWriter, r *http.Request) {
start := time.Now()
var req extract.BulkExtractRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"log/slog"
"net/http"
"sort"
"time"
"github.com/manacore/mana-search/internal/cache"
@ -35,7 +36,7 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
defer h.metrics.ActiveSearches.Dec()
var req search.SearchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
@ -84,15 +85,16 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
return
}
// Collect unique engines
// Collect unique engines (sorted for deterministic cache keys)
engineSet := make(map[string]bool)
for _, r := range results {
engineSet[r.Engine] = true
for _, res := range results {
engineSet[res.Engine] = true
}
engines := make([]string, 0, len(engineSet))
for e := range engineSet {
engines = append(engines, e)
}
sort.Strings(engines)
resp := search.SearchResponse{
Results: results,

View file

@ -160,14 +160,20 @@ func (p *SearxngProvider) HealthCheck(ctx context.Context) (string, int64) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/healthz", nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/healthz", nil)
if err != nil {
return "error", time.Since(start).Milliseconds()
}
resp, err := p.client.Do(req)
latency := time.Since(start).Milliseconds()
if err != nil || resp.StatusCode != http.StatusOK {
if err != nil {
return "error", latency
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "error", latency
}
resp.Body.Close()
return "ok", latency
}
@ -176,7 +182,10 @@ func (p *SearxngProvider) GetEngines(ctx context.Context) []string {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/config", nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/config", nil)
if err != nil {
return nil
}
resp, err := p.client.Do(req)
if err != nil {
slog.Warn("failed to fetch searxng engines", "error", err)