fix: CSP policy missing front components like icons, nextui color mode, wasm
This commit is contained in:
1 parent
1e5f324579
commit
508a8ab77e
9 files changed
+124
-7
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const scriptSrcSelf = "script-src 'self'"
|
||||
|
||||
var inlineScriptPattern = regexp.MustCompile(`(?is)<script(\s[^>]*)?>(.*?)</script>`)
|
||||
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)
|
||||
}
|
||||
@@ -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:; " +
|
||||
|
||||
@@ -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(`<html><head><script>var a=1</script><script type="module" src="/a.js"></script><script> </script></head></html>`)
|
||||
|
||||
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(`<script type="module" src="/a.js"></script>`)
|
||||
|
||||
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)
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module 'virtual:lucide-subset' {
|
||||
import type { IconifyJSON } from '@iconify/types'
|
||||
const subset: IconifyJSON
|
||||
export default subset
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vite/**/*.ts",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>) {
|
||||
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<string>()
|
||||
for (const root of roots) walk(root, found)
|
||||
const subset = getIcons(icons, [...found].sort())
|
||||
return `export default ${JSON.stringify(subset)}`
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user