2 Commits

Author SHA1 Message Date
clawbot
706f5f6dcc feat: add Content-Security-Policy header for embedded web SPA
All checks were successful
check / check (push) Successful in 4s
Set CSP header on all SPA-served responses to provide defense-in-depth
against XSS. The policy restricts scripts, styles, and all other
resource types to same-origin only, matching the SPA's actual behavior
(external CSS/JS files, same-origin fetch API calls, no WebSockets or
external resources).
2026-03-10 03:17:55 -07:00
f287fdf6d1 fix: replay channel state on SPA reconnect (#61)
All checks were successful
check / check (push) Successful in 4s
## Summary

When closing and reopening the SPA, channel tabs were not restored because the client relied on localStorage to remember joined channels and re-sent JOIN commands on reconnect. This was fragile and caused spurious JOIN broadcasts to other channel members.

## Changes

### Server (`internal/handlers/api.go`, `internal/handlers/auth.go`)

- **`replayChannelState()`** — new method that enqueues synthetic JOIN messages plus join-numerics (332 TOPIC, 353 NAMES, 366 ENDOFNAMES) for every channel the session belongs to, targeted only at the specified client (no broadcast to other users).
- **`HandleState`** — accepts `?replay=1` query parameter to trigger channel state replay when the SPA reconnects.
- **`handleLogin`** — also calls `replayChannelState` after password-based login, since `LoginUser` creates a new client for an existing session.

### SPA (`web/src/app.jsx`, `web/dist/app.js`)

- On resume, calls `/state?replay=1` instead of `/state` so the server enqueues channel state into the message queue.
- `processMessage` now creates channel tabs when receiving a JOIN where `msg.from` matches the current nick (handles both live joins and replayed joins on reconnect).
- `onLogin` no longer re-sends JOIN commands for saved channels on resume — the server handles it via the replay mechanism, avoiding spurious JOIN broadcasts.

## How It Works

1. SPA loads, finds saved token in localStorage
2. Calls `GET /api/v1/state?replay=1` — server validates token and enqueues synthetic JOIN + TOPIC + NAMES for all session channels into the client's queue
3. `onLogin(nick, true)` sets `loggedIn = true` and requests MOTD (no re-JOIN needed)
4. Poll loop starts, picks up replayed channel messages
5. `processMessage` handles the JOIN messages, creating tabs and refreshing members/topics naturally

closes #60

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: sneak/chat#61
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:08:13 +01:00

View File

@@ -16,6 +16,11 @@ import (
const routeTimeout = 60 * time.Second const routeTimeout = 60 * time.Second
// cspHeader is the Content-Security-Policy applied to the embedded web SPA.
// The SPA loads external scripts and stylesheets from the same origin only;
// all API communication uses same-origin fetch (no WebSockets).
const cspHeader = "default-src 'self'; script-src 'self'; style-src 'self'"
// SetupRoutes configures the HTTP routes and middleware. // SetupRoutes configures the HTTP routes and middleware.
func (srv *Server) SetupRoutes() { func (srv *Server) SetupRoutes() {
srv.router = chi.NewRouter() srv.router = chi.NewRouter()
@@ -133,6 +138,11 @@ func (srv *Server) setupSPA() {
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
writer.Header().Set(
"Content-Security-Policy",
cspHeader,
)
readFS, ok := distFS.(fs.ReadFileFS) readFS, ok := distFS.(fs.ReadFileFS)
if !ok { if !ok {
fileServer.ServeHTTP(writer, request) fileServer.ServeHTTP(writer, request)