17 changed files with 298 additions and 5 deletions

No files matched your search

+8 -4
View File
@@ -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))
}
+33
View File
@@ -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)
}
+33
View File
@@ -0,0 +1,33 @@
package middleware
import "net/http"
const contentSecurityPolicy = "default-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:; " +
"connect-src 'self'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
"frame-ancestors 'none'"
const strictTransportSecurity = "max-age=31536000"
func SecurityHeaders(hsts bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", contentSecurityPolicy)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
if hsts {
h.Set("Strict-Transport-Security", strictTransportSecurity)
}
next.ServeHTTP(w, r)
})
}
}
+1
View File
@@ -280,6 +280,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
frontend := InitFrontendRoutes(settings.Environment)
apiRouter := chi.NewRouter()
apiRouter.Use(middleware.SecurityHeaders(settings.IsProduction()))
enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins)
apiRouter.Use(chimiddleware.GetHead)
apiRouter.Get("/healthz", GetHealth)
+6
View File
@@ -270,6 +270,12 @@ If RenewCMS runs on the same host as the proxy, consider firewalling port 8080.
Either way, set `TRUSTED_PROXIES` (see [configuration.md](configuration.md)) to the proxy's own address as seen by RenewCMS, typically `127.0.0.1` when the proxy and RenewCMS share a host, so the login/password-reset rate limiter honors the forwarded client IP instead of the proxy's own address.
### Security headers
RenewCMS sends `Content-Security-Policy` (including `frame-ancestors 'none'`), `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` and `Cross-Origin-Opener-Policy` itself, so the proxy doesn't need to add them. With `ENVIRONMENT=production` it also sends `Strict-Transport-Security: max-age=31536000` (browsers ignore it over plain HTTP, so serve the site over HTTPS only). Don't add the same headers in the proxy, or they will be duplicated.
The CSP allows Google Fonts (`fonts.googleapis.com`, `fonts.gstatic.com`) and `https:` images, because the SPA loads its fonts from Google and articles may embed remote images. `style-src` keeps `'unsafe-inline'` for the article editor; `script-src` is `'self'` only.
## Backups
Everything worth backing up is under the data directory. Take a consistent SQLite copy while the service is running:
+2
View File
@@ -32,3 +32,5 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
| `v2.7.0-beta` | v2.7.0-beta - Markdown and block article bodies |
| `v2.8.0-beta` | v2.8.0-beta - API keys and API reference |
| `v2.8.1-beta` | v2.8.1-beta - Docker deployment |
| `v2.8.2-beta` | v2.8.2-beta - Security headers and clearer log in errors |
| `v2.8.3-beta` | v2.8.3-beta - Security policy fixes for the web app |
+18
View File
@@ -0,0 +1,18 @@
This release hardens the server with browser security headers on every response and makes the log in form clearer when the username is wrong.
## Improvements and fixes
- RenewCMS now sends security headers on every response, including the web app and uploaded images: a Content Security Policy that forbids framing, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` and `Cross-Origin-Opener-Policy`. With `ENVIRONMENT=production` it also sends `Strict-Transport-Security`.
- The log in form now tells you when you typed an email address instead of your username, and when the username is longer than 20 characters, instead of only asking for at least 3 characters. The messages are available in English and French.
## Under the hood
- Add a security headers middleware, mounted on the whole router, with tests.
- Document the headers, the Google Fonts and `https:` image allowances in the policy, and the advice not to duplicate them in the reverse proxy in the deployment guide.
## Upgrading
- Replace the binary, or pull the new image. No schema change and no new environment variable.
- If your reverse proxy already adds the same security headers, remove them there, or browsers will receive them twice.
- The policy allows scripts from the same origin only. A custom script injected by your proxy will be blocked.
- Serve the site over HTTPS only when `ENVIRONMENT=production`, because the server then asks browsers to remember HTTPS for a year.
+18
View File
@@ -0,0 +1,18 @@
This release fixes the security headers added in v2.8.2-beta, which blocked parts of the web app in the browser.
## Improvements and fixes
- The web app's icons now display again. They are bundled into the app instead of being fetched from an external icon service, which the security policy blocked.
- The light and dark mode script in the page is now allowed by the security policy, so the color mode no longer fails to apply on load.
- The security policy now allows WebAssembly, which parts of the web app need.
## Under the hood
- Add a Vite plugin that bundles only the Lucide icons the app uses.
- Compute a hash for each inline script in the served page and add it to the `script-src` directive, instead of allowing inline scripts in general.
- Update the security headers tests.
## Upgrading
- Replace the binary, or pull the new image. No schema change and no new environment variable.
- If you are on v2.8.2-beta, upgrade: that version's policy broke icons and color mode.
+86
View File
@@ -0,0 +1,86 @@
package test
import (
"RenewCMS/api/handlers/frontend"
"RenewCMS/api/middleware"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func serveWithSecurityHeaders(hsts bool) http.Header {
handler := middleware.SecurityHeaders(hsts)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
return rec.Result().Header
}
func TestSecurityHeadersAlwaysSetBaseline(t *testing.T) {
headers := serveWithSecurityHeaders(false)
expected := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Cross-Origin-Opener-Policy": "same-origin",
}
for name, want := range expected {
if got := headers.Get(name); got != want {
t.Errorf("expected %s %q, got %q", name, want, got)
}
}
}
func TestSecurityHeadersContentSecurityPolicy(t *testing.T) {
csp := serveWithSecurityHeaders(false).Get("Content-Security-Policy")
for _, directive := range []string{
"default-src 'self'",
"script-src 'self' 'wasm-unsafe-eval';",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
} {
if !strings.Contains(csp, directive) {
t.Errorf("expected CSP to contain %q, got %q", directive, csp)
}
}
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)
}
if got := serveWithSecurityHeaders(true).Get("Strict-Transport-Security"); got != "max-age=31536000" {
t.Errorf("expected HSTS max-age=31536000, got %q", got)
}
}
+3
View File
@@ -54,6 +54,9 @@ export const messages = {
'Logging in…': 'Connexion…',
'Try again in {0}': 'Réessayez dans {0}',
'Use at least 3 characters.': 'Utilisez au moins 3 caractères.',
'Use your username, not your email address.':
"Utilisez votre nom d'utilisateur, pas votre adresse e-mail.",
'Use 20 characters or fewer.': 'Utilisez 20 caractères au maximum.',
'Enter your password.': 'Saisissez votre mot de passe.',
'Hide password': 'Masquer le mot de passe',
'Show password': 'Afficher le mot de passe',
+5
View File
@@ -0,0 +1,5 @@
declare module 'virtual:lucide-subset' {
import type { IconifyJSON } from '@iconify/types'
const subset: IconifyJSON
export default subset
}
+4
View File
@@ -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())
+15 -1
View File
@@ -134,8 +134,22 @@ function focusFirstInvalid() {
}
}
function usernameProblem(value: string) {
const trimmed = value.trim()
if (trimmed.includes('@')) {
return 'Use your username, not your email address.'
}
if (trimmed.length < 3) {
return 'Use at least 3 characters.'
}
if (trimmed.length > 20) {
return 'Use 20 characters or fewer.'
}
return ''
}
function validate() {
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
usernameError.value = usernameProblem(username.value)
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
return !usernameError.value && !passwordError.value
}
+19
View File
@@ -133,6 +133,25 @@ describe('Validation errors — nothing is sent for a short username and an empt
})
})
describe('Validation errors — specific username messages', () => {
it.each([
['marta@example.com', 'Use your username, not your email address.'],
['a'.repeat(21), 'Use 20 characters or fewer.'],
])('explains why %s is rejected without calling the API', async (value, message) => {
const login = vi.fn(() => mockResponse(200))
const wrapper = await mountLogin('/login', {
'POST /v1/auth/login': login,
})
await wrapper.find('input[placeholder="Your username"]').setValue(value)
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(login).not.toHaveBeenCalled()
expect(wrapper.text()).toContain(message)
})
})
describe('Submitting — fields and button are disabled while the request is in flight', () => {
it('disables the fields and shows the loading label', async () => {
let resolveLogin: (() => void) | null = null
+1
View File
@@ -2,6 +2,7 @@
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vite/**/*.ts",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
+5
View File
@@ -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,
+41
View File
@@ -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)}`
},
}
}