Files
LocalAI/core/http/access_log.go
T
Ettore Di Giacinto f5f4b7ea58 feat(http): add --trusted-proxies to resolve the real client address
Echo's RealIP trusted X-Forwarded-For and X-Real-Ip from any peer
when no IPExtractor is set. That fed the login and registration rate
limits and the API trace client address, so a client could dodge a
limit by rotating a forged header.

Add pkg/clientip, one resolver for the whole server. With
--trusted-proxies / LOCALAI_TRUSTED_PROXIES (IPs and CIDRs, empty by
default), the forwarded chain is read only when the TCP peer is
listed. The client is the right-most X-Forwarded-For entry that is
not a trusted proxy; a malformed entry stops the walk. With the flag
unset every request resolves to its TCP peer.

The resolver is set as Echo's IPExtractor and used by the access log
(--log-client), so all consumers agree.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt
2026-11-03 15:48:38 +00:00

107 lines
3.1 KiB
Go

package http
import (
"errors"
"net/http"
"strings"
"time"
"unicode"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/pkg/clientip"
"github.com/mudler/LocalAI/pkg/diagnostics"
"github.com/mudler/xlog"
)
// maxLoggedUserAgent caps the user agent in the access log so a client
// cannot flood the log with one header.
const maxLoggedUserAgent = 80
// maxLoggedRequestID caps the request id for the same reason.
const maxLoggedRequestID = 64
// accessLog logs one line per request. When logClient is true the line also
// carries the client address (the TCP peer, or the forwarded client when the
// peer is a trusted proxy) and the user agent, so an unauthorized caller can
// be identified.
func accessLog(logClient bool, clients *clientip.Resolver) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
start := time.Now()
req := c.Request()
res := c.Response()
err := next(c)
// Echo's central HTTPErrorHandler runs *after* this middleware
// returns, so res.Status still reads the default 200 here when a
// handler returned an error without writing a response. Mirror
// echo.DefaultHTTPErrorHandler's status derivation so the access
// log reflects the status the client actually receives — without
// this, every silent handler error logs as 200.
status := res.Status
if err != nil && !res.Committed {
status = http.StatusInternalServerError
var he *echo.HTTPError
if errors.As(err, &he) {
status = he.Code
}
}
// Fix for #7989: Reduce log verbosity of Web UI polling, resources API, and health checks
// These paths are logged at DEBUG level (hidden by default) instead of INFO.
isQuietPath := false
for _, path := range quietPaths {
if req.URL.Path == path {
isQuietPath = true
break
}
}
// URL.Path never holds the query string, so keys passed as
// ?api_key=... stay out of the log.
attrs := []any{"method", req.Method, "path", req.URL.Path, "status", status}
if logClient {
attrs = append(attrs, "duration", time.Since(start).String(), "remote", clients.Resolve(req), "user_agent", sanitizeLogValue(req.UserAgent(), maxLoggedUserAgent))
if id := requestID(req, res); id != "" {
attrs = append(attrs, "request_id", id)
}
}
if isQuietPath && status == 200 {
xlog.Debug("HTTP request", attrs...)
} else {
xlog.Info("HTTP request", attrs...)
}
return err
}
}
}
// requestID returns the diagnostic id the server set on the response, or the
// X-Request-Id the caller sent, or an empty string.
func requestID(req *http.Request, res *echo.Response) string {
id := res.Header().Get(diagnostics.HeaderDiagnosticID)
if id == "" {
id = req.Header.Get(echo.HeaderXRequestID)
}
return sanitizeLogValue(id, maxLoggedRequestID)
}
// sanitizeLogValue replaces control characters with a space and truncates
// the value to max characters.
func sanitizeLogValue(ua string, max int) string {
var b strings.Builder
n := 0
for _, r := range ua {
if n == max {
break
}
if unicode.IsControl(r) {
r = ' '
}
b.WriteRune(r)
n++
}
return b.String()
}