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