package middleware
import (
"net/http/httptest"
"github.com/labstack/echo/v4"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("BaseURL", func() {
Context("without prefix", func() {
It("should return base URL without prefix", func() {
app := echo.New()
actualURL := ""
// Register route - use the actual request path so routing works
routePath := "/hello/world"
app.GET(routePath, func(c echo.Context) error {
actualURL = BaseURL(c)
return nil
})
req := httptest.NewRequest("GET", "/hello/world", nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(200), "response status code")
Expect(actualURL).To(Equal("http://example.com/"), "base URL")
})
})
Context("with prefix", func() {
It("should return base URL with prefix", func() {
app := echo.New()
actualURL := ""
// Register route with the stripped path (after middleware removes prefix)
routePath := "/hello/world"
app.GET(routePath, func(c echo.Context) error {
// Simulate what StripPathPrefix middleware does - store original path
c.Set("_original_path", "/myprefix/hello/world")
// Modify the request path to simulate prefix stripping
c.Request().URL.Path = "/hello/world"
actualURL = BaseURL(c)
return nil
})
// Make request with stripped path (middleware would have already processed it)
req := httptest.NewRequest("GET", "/hello/world", nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(200), "response status code")
Expect(actualURL).To(Equal("http://example.com/myprefix/"), "base URL")
})
})
// Caddy's handle_path (and similar reverse-proxy directives) strips the
// matched prefix before forwarding upstream, so LocalAI receives the
// already-stripped path together with X-Forwarded-Prefix. In that case
// StripPathPrefix never stores _original_path, but BaseURL must still
// honor the header so that and asset URLs include the prefix.
Context("with X-Forwarded-Prefix header but pre-stripped path", func() {
It("should return base URL with prefix from header", func() {
app := echo.New()
actualURL := ""
routePath := "/app"
app.GET(routePath, func(c echo.Context) error {
actualURL = BaseURL(c)
return nil
})
req := httptest.NewRequest("GET", "/app", nil)
req.Header.Set("X-Forwarded-Prefix", "/localai")
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(200), "response status code")
Expect(actualURL).To(Equal("http://example.com/localai/"), "base URL")
})
It("should normalize a prefix that already ends with a slash", func() {
app := echo.New()
actualURL := ""
routePath := "/app"
app.GET(routePath, func(c echo.Context) error {
actualURL = BaseURL(c)
return nil
})
req := httptest.NewRequest("GET", "/app", nil)
req.Header.Set("X-Forwarded-Prefix", "/localai/")
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(200), "response status code")
Expect(actualURL).To(Equal("http://example.com/localai/"), "base URL")
})
})
// X-Forwarded-Prefix is attacker controllable on misconfigured proxy
// chains, and the value flows into the SPA HTML response (
// and asset URLs). BasePathPrefix must gate the header through
// SafeForwardedPrefix so values that turn the prefix into an open
// redirect or a protocol-relative URL are ignored and the base falls
// back to "/".
Context("with unsafe X-Forwarded-Prefix header", func() {
DescribeTable("falls back to / when the header is unsafe",
func(header string) {
app := echo.New()
actualURL := ""
app.GET("/app", func(c echo.Context) error {
actualURL = BaseURL(c)
return nil
})
req := httptest.NewRequest("GET", "/app", nil)
req.Header.Set("X-Forwarded-Prefix", header)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(200), "response status code")
Expect(actualURL).To(Equal("http://example.com/"), "base URL")
},
Entry("protocol-relative URL", "//evil.com"),
Entry("protocol-relative URL with path", "//evil.com/assets"),
Entry("backslash path", `/foo\bar`),
Entry("embedded NUL", "/foo\x00bar"),
Entry("CR injection", "/foo\rbar"),
Entry("LF injection", "/foo\nbar"),
Entry("missing leading slash", "evil"),
)
})
})