diff options
| -rw-r--r-- | AGENTS.md | 6 | ||||
| -rw-r--r-- | README.md | 30 |
2 files changed, 30 insertions, 6 deletions
@@ -154,7 +154,11 @@ internal/web/ server.go (host router, middleware, render helpers) as well as to `reservedPageSlugs`) also get `X-Frame-Options: DENY` and a CSP of `frame-ancestors`/`base-uri`/`form-action`/`object-src` only — no `script-src`, the dashboard's inline scripts are a product - constraint. Public blog pages carry no framing rule (owner content). Flood + constraint. Public blog pages carry no framing rule (owner content). + Audited and left as is (2026-09): SQL is all parameterised (`likeEscape` + for ILIKE, `pgx.Identifier` for database names); templates are + `html/template` and the only `{{html}}` sinks are owner-authored content; + `/media` never renders a type a browser would script (`filetype.go`). Flood control for everything else stays at the reverse proxy (`limit_req`). - **Templates**: each page file is parsed together with its layout (`layouts/dashboard.html` or `layouts/blog.html` for `blog/*`) and all @@ -112,19 +112,29 @@ with the original `Host` header. Keep `HTTPS=true` and `TRUST_PROXY=true` in nginx example: ```nginx +limit_req_zone $binary_remote_addr zone=blogspace:10m rate=10r/s; # per client; the app throttles login and search itself + server { listen 443 ssl; server_name example.com *.example.com; # wildcard certificate client_max_body_size 101m; # the Files page sends up to 10 files per request: >= 10 x the largest blog limit + 1 MB + limit_req zone=blogspace burst=20 nodelay; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; # exactly one address: TRUST_PROXY reads the last one proxy_set_header X-Forwarded-Proto $scheme; } } ``` +The app never sees TLS itself, so tell it: `HTTPS=true` (https links, `Secure` +cookie, HSTS for the whole domain) and `TRUST_PROXY=true` (client addresses from +`X-Forwarded-For`). Flood control for the site as a whole belongs to the proxy +(`limit_req` above); the app throttles the two anonymous endpoints worth +abusing on its own: login attempts (10, then 10 a minute, per address and per +account) and blog search (30, then 30 a minute, per address). + Caddy: `example.com, *.example.com { reverse_proxy 127.0.0.1:8080 }` (wildcard certificates need the DNS challenge). @@ -195,7 +205,17 @@ shadowed by management routes (`webadmin`, `admin`, `b`, …) are rejected for e ### Auth notes -Sessions are HS256 JWTs in an `HttpOnly` cookie. The token carries the user's -`token_version`; changing a password or disabling a user bumps it, which logs -out every existing session. Every POST carries a `_csrf` field derived from the -same secret, so old browsers without `SameSite` support are protected too. +Sessions are HS256 JWTs in an `HttpOnly`, `SameSite=Lax` cookie (`Secure` with +`HTTPS=true`). The token carries the user's `token_version`; changing a +password or disabling a user bumps it, which logs out every existing session. +Every POST carries a `_csrf` field derived from the same secret, so old browsers +without `SameSite` support are protected too, and a POST a modern browser marks +as coming from another origin (`Sec-Fetch-Site`) is refused outright — a blog +on a subdomain counts as another origin. Failed logins and throttled requests +are logged with the client address (fail2ban-friendly). Management pages send +`X-Frame-Options: DENY` and a CSP that keeps their forms on this origin; every +response says `X-Content-Type-Options: nosniff`. + +Content bloggers write in HTML mode and the custom HTML module are published +unsanitised on the blog's own origin, by design; the dashboard origin never +renders them (HTML previews run in a sandboxed frame). |
