diff --git a/internal/middleware/security_headers.go b/internal/middleware/security_headers.go index 48b38d89..9eac7095 100644 --- a/internal/middleware/security_headers.go +++ b/internal/middleware/security_headers.go @@ -4,6 +4,11 @@ import ( "github.com/labstack/echo/v5" ) +// ScriptHashThemeFlashGuard is the CSP script-src hash of the theme flash +// guard's inline script (the app's only inline script). Enforced against +// the rendered component by security_headers_test.go. +const ScriptHashThemeFlashGuard = "'sha256-JYIVpFyKdLUtWizMha0v69f6yHds+GbJRBWjvpuECBY='" + // SecurityHeadersMiddleware adds security headers to all responses func SecurityHeadersMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { @@ -14,12 +19,23 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc { c.Response().Header().Set("X-XSS-Protection", "1; mode=block") c.Response().Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") // script-src notes: 'unsafe-inline' is required by the pre-paint - // theme flash guard (inline as rendered by + // templates.ThemeFlashGuard()). Editing the guard requires + // recomputing the hash — enforced by + // internal/middleware/security_headers_test.go, which renders + // the component and asserts the hash matches. + // + // style-src keeps 'unsafe-inline' for htmx-injected styles and + // Alpine :style attribute writes. // font/img blob: — the reader renders EPUB-embedded fonts and // images from blob: URLs inside the foliate content frame. - c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data: blob:; frame-src 'self' blob:") + c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' "+ScriptHashThemeFlashGuard+"; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data: blob:; frame-src 'self' blob:") c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") diff --git a/internal/middleware/security_headers_test.go b/internal/middleware/security_headers_test.go new file mode 100644 index 00000000..e9bea9ac --- /dev/null +++ b/internal/middleware/security_headers_test.go @@ -0,0 +1,51 @@ +package middleware_test + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "regexp" + "strings" + "testing" + + "bookhoard/internal/middleware" + "bookhoard/templates" +) + +// TestThemeFlashGuardHashMatchesCSP pins the CSP script hash to the theme +// flash guard's rendered bytes: the guard is the app's ONLY inline script, +// and script-src allow-lists it by content hash. If the guard's script text +// is ever edited, this test fails and prints the replacement hash — copy it +// into middleware.ScriptHashThemeFlashGuard. +func TestThemeFlashGuardHashMatchesCSP(t *testing.T) { + var sb strings.Builder + if err := templates.ThemeFlashGuard().Render(context.Background(), &sb); err != nil { + t.Fatalf("render ThemeFlashGuard: %v", err) + } + rendered := sb.String() + + m := regexp.MustCompile(`(?s)`).FindStringSubmatch(rendered) + if m == nil { + t.Fatalf("rendered guard contains no of +templates/theme_flash_guard.templ, base64-encoded, then update +middleware.ScriptHashThemeFlashGuard and the CSP header string.`, + middleware.ScriptHashThemeFlashGuard, computed) + } + + // The strict allow-list only holds the guard: no 'unsafe-inline' may + // sneak back into script-src (style-src keeps it for htmx/Alpine). + if strings.Contains(middleware.ScriptHashThemeFlashGuard, "unsafe-inline") { + t.Error("script-src must not re-introduce 'unsafe-inline' alongside the hash") + } +} diff --git a/templates/admin_archived.templ b/templates/admin_archived.templ index c5dfc5ef..c71350fd 100644 --- a/templates/admin_archived.templ +++ b/templates/admin_archived.templ @@ -36,7 +36,7 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {

if len(items) > 0 { - diff --git a/templates/admin_archived_templ.go b/templates/admin_archived_templ.go index 7bd9c593..3d15bcff 100644 --- a/templates/admin_archived_templ.go +++ b/templates/admin_archived_templ.go @@ -100,7 +100,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com return templ_7745c5c3_Err } if len(items) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " @@ -69,7 +69,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou

Create Library

-
@@ -77,7 +77,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou hx-post="/admin/library/create" hx-target="#libraries-container" hx-swap="innerHTML" - onsubmit="document.getElementById('create-library-modal').classList.add('hidden')" + @submit="hideModal('create-library-modal')" >
@@ -94,7 +94,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
- +
-
+
@@ -122,7 +122,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
- +
@@ -148,7 +148,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou

Delete Library

-
@@ -165,7 +165,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
- + +
} else {
@@ -411,7 +411,7 @@ templ FolderBrowserContent(currentPath string, parentPath string, entries []DirE type="button" data-target-input={ targetInput } data-current-path={ currentPath } - onclick="document.getElementById(this.dataset.targetInput).value = this.dataset.currentPath; document.getElementById('folder-browser-modal').classList.add('hidden')" + @click="pickBrowsePath($event)" class="btn btn-primary text-sm" > @Icon("check", "h-4 w-4") diff --git a/templates/admin_library_templ.go b/templates/admin_library_templ.go index 6975e415..df579d4e 100644 --- a/templates/admin_library_templ.go +++ b/templates/admin_library_templ.go @@ -69,7 +69,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

Libraries

Manage media libraries, folders, and scanning

Manage media libraries, folders, and scanning

Edit Library

Edit Library

Browse Folders

Browse Folders

Delete Library

Delete Library

Are you sure you want to delete ?

Are you sure you want to delete ?

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "

No Libraries Yet

Create your first library to get started

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1038,7 +1038,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" onclick=\"document.getElementById(this.dataset.targetInput).value = this.dataset.currentPath; document.getElementById('folder-browser-modal').classList.add('hidden')\" class=\"btn btn-primary text-sm\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" @click=\"pickBrowsePath($event)\" class=\"btn btn-primary text-sm\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/audio_reader.templ b/templates/audio_reader.templ index 7f1c3c43..09e349da 100644 --- a/templates/audio_reader.templ +++ b/templates/audio_reader.templ @@ -184,7 +184,7 @@ templ AudioReader(user User, metadata ReaderMetadata) { src={ metadata.CoverImagePath } alt={ metadata.Title } class="w-full h-full object-cover" - onerror="this.style.display='none'" + data-fallback="hide" /> } else { 🎧 diff --git a/templates/audio_reader_templ.go b/templates/audio_reader_templ.go index 1937e0e8..cd19e757 100644 --- a/templates/audio_reader_templ.go +++ b/templates/audio_reader_templ.go @@ -220,7 +220,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/book_detail.templ b/templates/book_detail.templ index 8055e169..8b3181d1 100644 --- a/templates/book_detail.templ +++ b/templates/book_detail.templ @@ -88,7 +88,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb alt={ book.Title } class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl" style="box-shadow: var(--shadow-pop);" - onerror="this.src='/static/placeholder-book.svg'" + /> } else { ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl\" style=\"box-shadow: var(--shadow-pop);\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/collections.templ b/templates/collections.templ index 177b7b1d..2b75af37 100644 --- a/templates/collections.templ +++ b/templates/collections.templ @@ -63,7 +63,7 @@ templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) src={ book.CoverImagePath } alt="Cover" class="w-full aspect-[3/4] object-cover rounded-lg shadow-md" - onerror="this.src='/static/placeholder-book.svg'" + /> } else {
if book.CoverImagePath != "" { - { + { } else { { } diff --git a/templates/collections_templ.go b/templates/collections_templ.go index b10c7128..c7bcf329 100644 --- a/templates/collections_templ.go +++ b/templates/collections_templ.go @@ -236,7 +236,7 @@ func CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) t if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\" onerror=\"this.src='/static/placeholder-book.svg'\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -970,7 +970,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/dashboard.templ b/templates/dashboard.templ index 3584dce4..dfdfc091 100644 --- a/templates/dashboard.templ +++ b/templates/dashboard.templ @@ -139,7 +139,7 @@ templ BookCard(item handlers.BookInfo) { alt={ item.Title } class="w-full h-full object-cover" loading="lazy" - onerror="this.src='/static/placeholder-book.svg'" + /> } else { 🔥 { fmt.Sprintf("%d", week.CurrentStreak) }-day streak } - Full stats + Full stats @Icon("chevron-down", "h-4 w-4 transition-transform group-open:rotate-180") diff --git a/templates/dashboard_templ.go b/templates/dashboard_templ.go index f9c8fa28..114df93d 100644 --- a/templates/dashboard_templ.go +++ b/templates/dashboard_templ.go @@ -456,7 +456,7 @@ func BookCard(item handlers.BookInfo) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" class=\"w-full h-full object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" class=\"w-full h-full object-cover\" loading=\"lazy\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1027,7 +1027,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "Full stats") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "Full stats") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/docs.templ b/templates/docs.templ index 96f75dbb..089366ef 100644 --- a/templates/docs.templ +++ b/templates/docs.templ @@ -26,7 +26,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) { @DocsTopbar(user) - +
@DocsSidebar(nav, currentPath)
@@ -243,7 +243,7 @@ templ DocsSearchResults(results []SearchResultItem) {