diff --git a/core/http/auth/public_routes.go b/core/http/auth/public_routes.go index c2509e9a9..ab753181b 100644 --- a/core/http/auth/public_routes.go +++ b/core/http/auth/public_routes.go @@ -59,6 +59,10 @@ var publicRouteRegistry = []publicRouteRule{ {Method: http.MethodGet, Path: "/locales/", Prefix: true}, {Method: http.MethodGet, Path: "/static/", Prefix: true}, + // Canvas preview frames. The random id is the capability; a frame + // navigation cannot send API key headers. + {Method: http.MethodGet, Path: "/api/canvas/preview/", Prefix: true}, + // Branding. {Method: http.MethodGet, Path: "/api/branding"}, {Method: http.MethodGet, Path: "/branding/asset/", Prefix: true}, diff --git a/core/http/endpoints/localai/canvas_preview.go b/core/http/endpoints/localai/canvas_preview.go new file mode 100644 index 000000000..ee7b3e4ef --- /dev/null +++ b/core/http/endpoints/localai/canvas_preview.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT + +package localai + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "sync" + "time" + + "github.com/labstack/echo/v4" +) + +// canvasPreviewCSP is the Content-Security-Policy of the canvas preview +// route. +// +// Canvas previews run HTML written by a model, and that HTML often loads +// libraries from a public CDN. The global policy (see +// middleware.SecurityHeaders) only allows scripts from 'self', so it blocks +// them. A frame loaded from srcdoc or a blob: URL inherits the policy of the +// parent document, and a CSP can only tighten it, so the global policy +// cannot be relaxed from inside such a frame. The preview is therefore served +// from its own URL, where the response carries its own header. The header is +// set in the handler, before the global middleware runs its "set if empty" +// check, so the global policy stays unchanged for the rest of the app. +// +// The UI embeds the route in an iframe with sandbox="allow-scripts" and +// without allow-same-origin. The frame gets an opaque origin: the page cannot +// read cookies, storage or tokens of the app, and cannot call the API with +// the user's session. Only script hosts are widened. Everything else stays +// restricted; frame-ancestors keeps other sites from embedding the route. +const canvasPreviewCSP = "default-src 'none'; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: " + + "https://unpkg.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com " + + "https://cdn.tailwindcss.com https://code.jquery.com; " + + "style-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net " + + "https://cdnjs.cloudflare.com https://fonts.googleapis.com; " + + "img-src data: blob: https:; " + + "media-src data: blob: https:; " + + "font-src data: https:; " + + "connect-src https: blob: data:; " + + "worker-src blob:; " + + "object-src 'none'; " + + "base-uri 'none'; " + + "form-action 'none'; " + + "frame-ancestors 'self'" + +const ( + canvasPreviewTTL = 10 * time.Minute + canvasPreviewMaxEntries = 128 + canvasPreviewMaxBytes = 4 << 20 +) + +type canvasPreviewEntry struct { + html []byte + expires time.Time +} + +// CanvasPreviewStore keeps canvas preview HTML in memory for a short time. +// Nothing is written to disk. The id is a random token; the content is not +// logged. +type CanvasPreviewStore struct { + mu sync.Mutex + entries map[string]canvasPreviewEntry + now func() time.Time +} + +func NewCanvasPreviewStore() *CanvasPreviewStore { + return &CanvasPreviewStore{entries: map[string]canvasPreviewEntry{}, now: time.Now} +} + +// pruneLocked drops expired entries and, if the store is still full, the +// entry that expires first. +func (s *CanvasPreviewStore) pruneLocked() { + now := s.now() + for id, e := range s.entries { + if now.After(e.expires) { + delete(s.entries, id) + } + } + for len(s.entries) >= canvasPreviewMaxEntries { + var oldest string + var oldestExp time.Time + for id, e := range s.entries { + if oldest == "" || e.expires.Before(oldestExp) { + oldest, oldestExp = id, e.expires + } + } + delete(s.entries, oldest) + } +} + +func (s *CanvasPreviewStore) put(html []byte) (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", err + } + id := hex.EncodeToString(buf) + s.mu.Lock() + defer s.mu.Unlock() + s.pruneLocked() + s.entries[id] = canvasPreviewEntry{html: html, expires: s.now().Add(canvasPreviewTTL)} + return id, nil +} + +func (s *CanvasPreviewStore) get(id string) ([]byte, bool) { + s.mu.Lock() + defer s.mu.Unlock() + e, ok := s.entries[id] + if !ok || s.now().After(e.expires) { + delete(s.entries, id) + return nil, false + } + return e.html, true +} + +// CreateCanvasPreviewEndpoint stores the HTML of a canvas preview and returns +// a short-lived id. POST /api/canvas/preview with {"code": ""}. +func CreateCanvasPreviewEndpoint(s *CanvasPreviewStore) echo.HandlerFunc { + return func(c echo.Context) error { + c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, canvasPreviewMaxBytes+1024) + var req struct { + Code string `json:"code"` + } + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + if len(req.Code) > canvasPreviewMaxBytes { + return c.JSON(http.StatusRequestEntityTooLarge, map[string]string{"error": "preview too large"}) + } + id, err := s.put([]byte(req.Code)) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "could not create preview"}) + } + return c.JSON(http.StatusOK, map[string]string{"id": id}) + } +} + +// GetCanvasPreviewEndpoint serves a stored preview with its own CSP. +// GET /api/canvas/preview/:id. The id is an unguessable capability, and the +// route is public because a frame navigation does not carry API key headers. +func GetCanvasPreviewEndpoint(s *CanvasPreviewStore) echo.HandlerFunc { + return func(c echo.Context) error { + html, ok := s.get(c.Param("id")) + if !ok { + return c.NoContent(http.StatusNotFound) + } + h := c.Response().Header() + h.Set("Content-Security-Policy", canvasPreviewCSP) + h.Set("Cache-Control", "no-store") + return c.Blob(http.StatusOK, "text/html; charset=utf-8", html) + } +} diff --git a/core/http/endpoints/localai/canvas_preview_test.go b/core/http/endpoints/localai/canvas_preview_test.go new file mode 100644 index 000000000..c919413b4 --- /dev/null +++ b/core/http/endpoints/localai/canvas_preview_test.go @@ -0,0 +1,70 @@ +package localai_test + +import ( + "net/http" + "net/http/httptest" + "strings" + + "github.com/labstack/echo/v4" + . "github.com/mudler/LocalAI/core/http/endpoints/localai" + "github.com/mudler/LocalAI/core/http/middleware" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Canvas preview", func() { + var e *echo.Echo + const page = `` + + BeforeEach(func() { + store := NewCanvasPreviewStore() + e = echo.New() + e.Use(middleware.SecurityHeaders()) + e.POST("/api/canvas/preview", CreateCanvasPreviewEndpoint(store)) + e.GET("/api/canvas/preview/:id", GetCanvasPreviewEndpoint(store)) + e.GET("/other", func(c echo.Context) error { return c.String(http.StatusOK, "ok") }) + }) + + create := func(body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/canvas/preview", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec + } + + It("serves the stored HTML with a CDN-friendly CSP", func() { + rec := create(`{"code":""}`) + Expect(rec.Code).To(Equal(http.StatusOK)) + id := strings.Trim(strings.Split(rec.Body.String(), `"id":"`)[1], "\"}\n") + + req := httptest.NewRequest(http.MethodGet, "/api/canvas/preview/"+id, nil) + out := httptest.NewRecorder() + e.ServeHTTP(out, req) + Expect(out.Code).To(Equal(http.StatusOK)) + Expect(out.Body.String()).To(Equal(page)) + csp := out.Header().Get("Content-Security-Policy") + for _, host := range []string{"https://unpkg.com", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://cdn.tailwindcss.com", "https://code.jquery.com"} { + Expect(csp).To(ContainSubstring(host)) + } + Expect(csp).To(ContainSubstring("default-src 'none'")) + Expect(csp).To(ContainSubstring("frame-ancestors 'self'")) + Expect(out.Header().Values("Content-Security-Policy")).To(HaveLen(1)) + }) + + It("returns 404 for an unknown id", func() { + req := httptest.NewRequest(http.MethodGet, "/api/canvas/preview/nope", nil) + out := httptest.NewRecorder() + e.ServeHTTP(out, req) + Expect(out.Code).To(Equal(http.StatusNotFound)) + }) + + It("keeps the global CSP on other routes", func() { + req := httptest.NewRequest(http.MethodGet, "/other", nil) + out := httptest.NewRecorder() + e.ServeHTTP(out, req) + csp := out.Header().Get("Content-Security-Policy") + Expect(csp).To(ContainSubstring("script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:;")) + Expect(csp).ToNot(ContainSubstring("unpkg.com")) + }) +}) diff --git a/core/http/react-ui/src/components/CanvasPanel.jsx b/core/http/react-ui/src/components/CanvasPanel.jsx index 9d465d404..e10c1b2ee 100644 --- a/core/http/react-ui/src/components/CanvasPanel.jsx +++ b/core/http/react-ui/src/components/CanvasPanel.jsx @@ -7,10 +7,33 @@ import { copyToClipboard } from '../utils/clipboard' import DOMPurify from 'dompurify' import hljs from '../utils/hljs' import Icon from './Icon' +import { apiUrl } from '../utils/basePath' const WIDTH_KEY = 'localai_canvas_width' const MIME_BY_EXT = { html: 'text/html', svg: 'image/svg+xml', json: 'application/json', css: 'text/css' } +// Renders HTML in a sandboxed iframe loaded from a same-origin URL. srcDoc +// would inherit the app's CSP and block CDN scripts, so the code is posted to +// the server, which serves it back with a preview-specific CSP. +function HtmlPreviewFrame({ code, title }) { + const [src, setSrc] = useState(null) + useEffect(() => { + let cancelled = false + setSrc(null) + fetch(apiUrl('/api/canvas/preview'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }) + .then(r => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) + .then(d => { if (!cancelled) setSrc(apiUrl('/api/canvas/preview/' + d.id)) }) + .catch(() => {}) + return () => { cancelled = true } + }, [code]) + if (!src) return
+ return