Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e5f324579 | ||
|
|
1d4f4bd2cc | ||
|
|
d8f171b8cc |
No files matched your search
@@ -0,0 +1,33 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
const contentSecurityPolicy = "default-src 'self'; " +
|
||||
"script-src 'self'; " +
|
||||
"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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -32,3 +32,4 @@ 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 |
|
||||
@@ -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.
|
||||
@@ -0,0 +1,62 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"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';",
|
||||
"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, "script-src 'self' 'unsafe") {
|
||||
t.Errorf("script-src must not allow unsafe sources, got %q", csp)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user