From 508a8ab77ef29518376f54f3e227b1fa7ac21f89 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Mon, 5 Oct 2026 16:32:19 +0200 Subject: [PATCH] fix: CSP policy missing front components like icons, nextui color mode, wasm --- api/handlers/frontend/frontHandler.go | 12 +++++--- api/handlers/frontend/inlineScripts.go | 33 +++++++++++++++++++++ api/middleware/securityHeaders.go | 2 +- test/security_headers_test.go | 28 ++++++++++++++++-- web/src/lucideSubset.d.ts | 5 ++++ web/src/main.ts | 4 +++ web/tsconfig.node.json | 1 + web/vite.config.ts | 5 ++++ web/vite/lucideSubset.ts | 41 ++++++++++++++++++++++++++ 9 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 api/handlers/frontend/inlineScripts.go create mode 100644 web/src/lucideSubset.d.ts create mode 100644 web/vite/lucideSubset.ts diff --git a/api/handlers/frontend/frontHandler.go b/api/handlers/frontend/frontHandler.go index 94a52b3..155f9f4 100644 --- a/api/handlers/frontend/frontHandler.go +++ b/api/handlers/frontend/frontHandler.go @@ -2,6 +2,7 @@ package frontend import ( "RenewCMS/internal/infrastructure/assets" + "bytes" "io" "io/fs" "net/http" @@ -93,12 +94,15 @@ func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) { } defer f.Close() - content, ok := f.(io.ReadSeeker) - if !ok { - http.Error(w, "File not seekable", http.StatusInternalServerError) + html, err := io.ReadAll(f) + if err != nil { + http.Error(w, "Index not readable", http.StatusInternalServerError) return } w.Header().Set("Cache-Control", indexCacheControl) - http.ServeContent(w, r, "index.html", h.modTime, content) + if policy := w.Header().Get("Content-Security-Policy"); policy != "" { + w.Header().Set("Content-Security-Policy", AllowInlineScripts(policy, html)) + } + http.ServeContent(w, r, "index.html", h.modTime, bytes.NewReader(html)) } diff --git a/api/handlers/frontend/inlineScripts.go b/api/handlers/frontend/inlineScripts.go new file mode 100644 index 0000000..c01f06f --- /dev/null +++ b/api/handlers/frontend/inlineScripts.go @@ -0,0 +1,33 @@ +package frontend + +import ( + "crypto/sha256" + "encoding/base64" + "regexp" + "strings" +) + +const scriptSrcSelf = "script-src 'self'" + +var inlineScriptPattern = regexp.MustCompile(`(?is)]*)?>(.*?)`) +var srcAttributePattern = regexp.MustCompile(`(?i)\ssrc\s*=`) + +func inlineScriptSources(html []byte) []string { + var sources []string + for _, match := range inlineScriptPattern.FindAllSubmatch(html, -1) { + if srcAttributePattern.Match(match[1]) || strings.TrimSpace(string(match[2])) == "" { + continue + } + digest := sha256.Sum256(match[2]) + sources = append(sources, "'sha256-"+base64.StdEncoding.EncodeToString(digest[:])+"'") + } + return sources +} + +func AllowInlineScripts(policy string, html []byte) string { + sources := inlineScriptSources(html) + if len(sources) == 0 { + return policy + } + return strings.Replace(policy, scriptSrcSelf, scriptSrcSelf+" "+strings.Join(sources, " "), 1) +} diff --git a/api/middleware/securityHeaders.go b/api/middleware/securityHeaders.go index 6bc359b..288262b 100644 --- a/api/middleware/securityHeaders.go +++ b/api/middleware/securityHeaders.go @@ -3,7 +3,7 @@ package middleware import "net/http" const contentSecurityPolicy = "default-src 'self'; " + - "script-src 'self'; " + + "script-src 'self' 'wasm-unsafe-eval'; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com; " + "img-src 'self' data: blob: https:; " + diff --git a/test/security_headers_test.go b/test/security_headers_test.go index bb772a3..c51c1b0 100644 --- a/test/security_headers_test.go +++ b/test/security_headers_test.go @@ -1,6 +1,7 @@ package test import ( + "RenewCMS/api/handlers/frontend" "RenewCMS/api/middleware" "net/http" "net/http/httptest" @@ -38,7 +39,7 @@ func TestSecurityHeadersContentSecurityPolicy(t *testing.T) { for _, directive := range []string{ "default-src 'self'", - "script-src 'self';", + "script-src 'self' 'wasm-unsafe-eval';", "object-src 'none'", "base-uri 'self'", "frame-ancestors 'none'", @@ -47,11 +48,34 @@ func TestSecurityHeadersContentSecurityPolicy(t *testing.T) { t.Errorf("expected CSP to contain %q, got %q", directive, csp) } } - if strings.Contains(csp, "script-src 'self' 'unsafe") { + if strings.Contains(csp, "'unsafe-eval'") || strings.Contains(csp, "script-src 'self' 'unsafe-inline'") { t.Errorf("script-src must not allow unsafe sources, got %q", csp) } } +func TestAllowInlineScriptsHashesOnlyInlineScripts(t *testing.T) { + policy := "default-src 'self'; script-src 'self'; object-src 'none'" + html := []byte(``) + + got := frontend.AllowInlineScripts(policy, html) + + if !strings.Contains(got, "script-src 'self' 'sha256-") || strings.Count(got, "sha256-") != 1 { + t.Errorf("expected exactly one inline hash in script-src, got %q", got) + } + if !strings.Contains(got, "; object-src 'none'") { + t.Errorf("expected other directives untouched, got %q", got) + } +} + +func TestAllowInlineScriptsLeavesPolicyWithoutInlineScripts(t *testing.T) { + policy := "script-src 'self'" + html := []byte(``) + + if got := frontend.AllowInlineScripts(policy, html); got != policy { + t.Errorf("expected policy unchanged, got %q", got) + } +} + func TestSecurityHeadersHSTSOnlyWhenEnabled(t *testing.T) { if got := serveWithSecurityHeaders(false).Get("Strict-Transport-Security"); got != "" { t.Errorf("expected no HSTS header, got %q", got) diff --git a/web/src/lucideSubset.d.ts b/web/src/lucideSubset.d.ts new file mode 100644 index 0000000..ccdc1ba --- /dev/null +++ b/web/src/lucideSubset.d.ts @@ -0,0 +1,5 @@ +declare module 'virtual:lucide-subset' { + import type { IconifyJSON } from '@iconify/types' + const subset: IconifyJSON + export default subset +} diff --git a/web/src/main.ts b/web/src/main.ts index e264b2a..12173a1 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,12 +1,16 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import ui from '@nuxt/ui/vue-plugin' +import { addCollection } from '@iconify/vue' +import lucide from 'virtual:lucide-subset' import App from './App.vue' import router from './router' import '@/assets/main.css' +addCollection(lucide) + const app = createApp(App) app.use(createPinia()) diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json index 822562d..e912505 100644 --- a/web/tsconfig.node.json +++ b/web/tsconfig.node.json @@ -2,6 +2,7 @@ "extends": "@tsconfig/node24/tsconfig.json", "include": [ "vite.config.*", + "vite/**/*.ts", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", diff --git a/web/vite.config.ts b/web/vite.config.ts index 84c7ba9..35c89d5 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -5,6 +5,7 @@ import vue from '@vitejs/plugin-vue' import vueDevTools from 'vite-plugin-vue-devtools' import ui from '@nuxt/ui/vite' import { renewUi } from './src/theme/ui.config' +import { lucideSubset } from './vite/lucideSubset' export default defineConfig({ build: { @@ -21,6 +22,10 @@ export default defineConfig({ plugins: [ vue(), vueDevTools(), + lucideSubset([ + fileURLToPath(new URL('./src', import.meta.url)), + fileURLToPath(new URL('./node_modules/@nuxt/ui/dist', import.meta.url)), + ]), ui({ ui: renewUi, colorMode: true, diff --git a/web/vite/lucideSubset.ts b/web/vite/lucideSubset.ts new file mode 100644 index 0000000..a072913 --- /dev/null +++ b/web/vite/lucideSubset.ts @@ -0,0 +1,41 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { icons } from '@iconify-json/lucide' +import { getIcons } from '@iconify/utils' +import type { Plugin } from 'vite' + +const virtualId = 'virtual:lucide-subset' +const resolvedId = `\0${virtualId}` +const iconPattern = /i-lucide-([a-z0-9]+(?:-[a-z0-9]+)*)/g +const sourceExtensions = /\.(vue|ts|js|mjs)$/ + +function walk(dir: string, found: Set) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + const stat = statSync(full) + if (stat.isDirectory()) { + if (entry !== '__tests__') walk(full, found) + continue + } + if (!sourceExtensions.test(entry)) continue + for (const match of readFileSync(full, 'utf8').matchAll(iconPattern)) { + found.add(match[1]!) + } + } +} + +export function lucideSubset(roots: string[]): Plugin { + return { + name: 'lucide-subset', + resolveId(id) { + return id === virtualId ? resolvedId : null + }, + load(id) { + if (id !== resolvedId) return null + const found = new Set() + for (const root of roots) walk(root, found) + const subset = getIcons(icons, [...found].sort()) + return `export default ${JSON.stringify(subset)}` + }, + } +}