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: "/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={{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user