[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:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-17 00:54:22 +02:00
1 parent 06b2abe5ec
commit 9ad78381d8
5 files changed
+259 -1

No files matched your search

+4
View File
@@ -59,6 +59,10 @@ var publicRouteRegistry = []publicRouteRule{
{Method: http.MethodGet, Path: "/locales/", Prefix: true}, {Method: http.MethodGet, Path: "/locales/", Prefix: true},
{Method: http.MethodGet, Path: "/static/", 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. // Branding.
{Method: http.MethodGet, Path: "/api/branding"}, {Method: http.MethodGet, Path: "/api/branding"},
{Method: http.MethodGet, Path: "/branding/asset/", Prefix: true}, {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 DOMPurify from 'dompurify'
import hljs from '../utils/hljs' import hljs from '../utils/hljs'
import Icon from './Icon' import Icon from './Icon'
import { apiUrl } from '../utils/basePath'
const WIDTH_KEY = 'localai_canvas_width' const WIDTH_KEY = 'localai_canvas_width'
const MIME_BY_EXT = { html: 'text/html', svg: 'image/svg+xml', json: 'application/json', css: 'text/css' } 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 }) { export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }) {
const { t } = useTranslation('chat') const { t } = useTranslation('chat')
const [showPreview, setShowPreview] = useState(true) const [showPreview, setShowPreview] = useState(true)
@@ -134,7 +157,7 @@ export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }
// Code artifacts // Code artifacts
if (showPreview && hasPreview) { if (showPreview && hasPreview) {
if (current.language === 'html') { 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') { if (current.language === 'svg') {
return <div className="canvas-preview-svg" dangerouslySetInnerHTML={{ return <div className="canvas-preview-svg" dangerouslySetInnerHTML={{
+7
View File
@@ -6,6 +6,7 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/endpoints/localai"
"github.com/mudler/LocalAI/core/services/galleryop" "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 // SPA routes are handled by the 404 fallback in app.go which serves
// index.html for any unmatched HTML request, enabling client-side routing. // 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). // Pipeline models API (for the Talk page WebRTC interface).
// A model qualifies when it either declares an explicit VAD+STT+LLM+TTS // A model qualifies when it either declares an explicit VAD+STT+LLM+TTS
// pipeline (legacy/composed) or carries the realtime_audio usecase (a // pipeline (legacy/composed) or carries the realtime_audio usecase (a