mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
[ui] Serve canvas HTML previews with their own CSP (#12715)
fix(ui): serve canvas HTML previews from a route with their own CSP External scripts in canvas previews (for example three.js from unpkg) were blocked. The preview used an iframe with srcdoc, which inherits the global Content-Security-Policy, and a meta tag can only tighten it. The UI now posts the HTML to POST /api/canvas/preview, gets a random id, and loads GET /api/canvas/preview/:id in the iframe. That response sets its own CSP that allows scripts from unpkg, jsdelivr, cdnjs, the Tailwind CDN and jQuery. The global CSP is unchanged. The iframe keeps sandbox="allow-scripts" without allow-same-origin. Previews are kept in memory for 10 minutes and are never written to disk. The GET route is public because the id is the capability and a frame navigation cannot send API key headers. Assisted-by: Claude:claude-haiku-5-5 [go test] [npm] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
06b2abe5ec
commit
9ad78381d8
5 files changed
+259
-1
No files matched your search
@@ -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},
|
||||
|
||||
@@ -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 <meta> 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": "<html>"}.
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 = `<script src="https://unpkg.com/three@0.160.0/build/three.module.js"></script>`
|
||||
|
||||
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":"<script src=\"https://unpkg.com/three@0.160.0/build/three.module.js\"></script>"}`)
|
||||
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"))
|
||||
})
|
||||
})
|
||||
@@ -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 <div className="canvas-preview-iframe" />
|
||||
return <iframe src={src} sandbox="allow-scripts" className="canvas-preview-iframe" title={title} />
|
||||
}
|
||||
|
||||
export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }) {
|
||||
const { t } = useTranslation('chat')
|
||||
const [showPreview, setShowPreview] = useState(true)
|
||||
@@ -134,7 +157,7 @@ export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }
|
||||
// Code artifacts
|
||||
if (showPreview && hasPreview) {
|
||||
if (current.language === 'html') {
|
||||
return <iframe srcDoc={current.code} sandbox="allow-scripts" className="canvas-preview-iframe" title={t('canvas.htmlPreview')} />
|
||||
return <HtmlPreviewFrame code={current.code} title={t('canvas.htmlPreview')} />
|
||||
}
|
||||
if (current.language === 'svg') {
|
||||
return <div className="canvas-preview-svg" dangerouslySetInnerHTML={{
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/endpoints/localai"
|
||||
"github.com/mudler/LocalAI/core/services/galleryop"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,12 @@ func RegisterUIRoutes(app *echo.Echo,
|
||||
// SPA routes are handled by the 404 fallback in app.go which serves
|
||||
// index.html for any unmatched HTML request, enabling client-side routing.
|
||||
|
||||
// Canvas HTML previews: served from their own route so the response can
|
||||
// carry a CSP that allows CDN scripts (see localai.canvasPreviewCSP).
|
||||
canvasPreviews := localai.NewCanvasPreviewStore()
|
||||
app.POST("/api/canvas/preview", localai.CreateCanvasPreviewEndpoint(canvasPreviews))
|
||||
app.GET("/api/canvas/preview/:id", localai.GetCanvasPreviewEndpoint(canvasPreviews))
|
||||
|
||||
// Pipeline models API (for the Talk page WebRTC interface).
|
||||
// A model qualifies when it either declares an explicit VAD+STT+LLM+TTS
|
||||
// pipeline (legacy/composed) or carries the realtime_audio usecase (a
|
||||
|
||||
Reference in new issue
Block a user