1 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
6 changed files with 37 additions and 144 deletions

View File

@@ -249,8 +249,8 @@ Key properties:
- **Ordered**: Queue entries have monotonically increasing IDs. Messages are - **Ordered**: Queue entries have monotonically increasing IDs. Messages are
always delivered in order within a client's queue. always delivered in order within a client's queue.
- **No delivery/read receipts** for channel messages. DM receipts are planned. - **No delivery/read receipts** for channel messages. DM receipts are planned.
- **Queue depth**: Server-configurable via `QUEUE_MAX_AGE`. Default is 30 - **Queue depth**: Server-configurable via `QUEUE_MAX_AGE`. Default is 48
days. Entries older than this are pruned. hours. Entries older than this are pruned.
### Long-Polling ### Long-Polling
@@ -1624,10 +1624,6 @@ authenticity.
termination. termination.
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`). - **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
Restrict this in production via reverse proxy configuration if needed. Restrict this in production via reverse proxy configuration if needed.
- **Content-Security-Policy**: The server sets a strict CSP header on all
responses, restricting resource loading to same-origin and disabling
dangerous features (object embeds, framing, base tag injection). The
embedded SPA works without `'unsafe-inline'` for scripts or styles.
--- ---
@@ -1788,10 +1784,10 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
### Data Lifecycle ### Data Lifecycle
- **Messages**: Pruned automatically when older than `MESSAGE_MAX_AGE` - **Messages**: Stored indefinitely in the current implementation. Rotation
(default 30 days). per `MAX_HISTORY` is planned.
- **Queue entries**: Pruned automatically when older than `QUEUE_MAX_AGE` - **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is
(default 30 days). planned.
- **Channels**: Deleted when the last member leaves (ephemeral). - **Channels**: Deleted when the last member leaves (ephemeral).
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle - **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default
@@ -1812,9 +1808,9 @@ directory is also loaded automatically via
| `PORT` | int | `8080` | HTTP listen port | | `PORT` | int | `8080` | HTTP listen port |
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. | | `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. |
| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) | | `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) |
| `MESSAGE_MAX_AGE` | int | `2592000` | Maximum age of messages in seconds (30 days). Messages older than this are pruned. | | `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) |
| `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. | | `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. |
| `QUEUE_MAX_AGE` | int | `2592000` | Maximum age of client queue entries in seconds (30 days). Entries older than this are pruned. | | `QUEUE_MAX_AGE` | int | `172800` | Maximum age of client queue entries in seconds (48h). Entries older than this are pruned (planned). |
| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) | | `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) |
| `LONG_POLL_TIMEOUT`| int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) | | `LONG_POLL_TIMEOUT`| int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) |
| `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` | | `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` |
@@ -2228,8 +2224,8 @@ GET /api/v1/challenge
### Post-MVP (Planned) ### Post-MVP (Planned)
- [ ] **Hashcash proof-of-work** for session creation (abuse prevention) - [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
- [x] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE` - [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
- [x] **Message expiry** — delete messages older than `MESSAGE_MAX_AGE` - [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n` - [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
- [ ] **User channel modes** — `+o` (operator), `+v` (voice) - [ ] **User channel modes** — `+o` (operator), `+v` (voice)
- [x] **MODE command** — query channel and user modes (set not yet implemented) - [x] **MODE command** — query channel and user modes (set not yet implemented)

View File

@@ -38,9 +38,8 @@ type Config struct {
MetricsUsername string MetricsUsername string
Port int Port int
SentryDSN string SentryDSN string
MessageMaxAge int MaxHistory int
MaxMessageSize int MaxMessageSize int
QueueMaxAge int
MOTD string MOTD string
ServerName string ServerName string
FederationKey string FederationKey string
@@ -69,9 +68,8 @@ func New(
viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("MESSAGE_MAX_AGE", "2592000") viper.SetDefault("MAX_HISTORY", "10000")
viper.SetDefault("MAX_MESSAGE_SIZE", "4096") viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
viper.SetDefault("QUEUE_MAX_AGE", "2592000")
viper.SetDefault("MOTD", defaultMOTD) viper.SetDefault("MOTD", defaultMOTD)
viper.SetDefault("SERVER_NAME", "") viper.SetDefault("SERVER_NAME", "")
viper.SetDefault("FEDERATION_KEY", "") viper.SetDefault("FEDERATION_KEY", "")
@@ -94,9 +92,8 @@ func New(
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MessageMaxAge: viper.GetInt("MESSAGE_MAX_AGE"), MaxHistory: viper.GetInt("MAX_HISTORY"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
QueueMaxAge: viper.GetInt("QUEUE_MAX_AGE"),
MOTD: viper.GetString("MOTD"), MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"), ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"), FederationKey: viper.GetString("FEDERATION_KEY"),

View File

@@ -1096,45 +1096,3 @@ func (database *Database) GetSessionCreatedAt(
return createdAt, nil return createdAt, nil
} }
// PruneOldQueueEntries deletes client_queues rows older
// than cutoff and returns the number of rows removed.
func (database *Database) PruneOldQueueEntries(
ctx context.Context,
cutoff time.Time,
) (int64, error) {
res, err := database.conn.ExecContext(ctx,
"DELETE FROM client_queues WHERE created_at < ?",
cutoff,
)
if err != nil {
return 0, fmt.Errorf(
"prune old queue entries: %w", err,
)
}
deleted, _ := res.RowsAffected()
return deleted, nil
}
// PruneOldMessages deletes messages older than cutoff and
// returns the number of rows removed.
func (database *Database) PruneOldMessages(
ctx context.Context,
cutoff time.Time,
) (int64, error) {
res, err := database.conn.ExecContext(ctx,
"DELETE FROM messages WHERE created_at < ?",
cutoff,
)
if err != nil {
return 0, fmt.Errorf(
"prune old messages: %w", err,
)
}
deleted, _ := res.RowsAffected()
return deleted, nil
}

View File

@@ -200,52 +200,4 @@ func (hdlr *Handlers) runCleanup(
"deleted", deleted, "deleted", deleted,
) )
} }
hdlr.pruneQueuesAndMessages(ctx)
}
// pruneQueuesAndMessages removes old client_queues entries
// per QUEUE_MAX_AGE and old messages per MESSAGE_MAX_AGE.
func (hdlr *Handlers) pruneQueuesAndMessages(
ctx context.Context,
) {
queueMaxAge := hdlr.params.Config.QueueMaxAge
if queueMaxAge > 0 {
queueCutoff := time.Now().Add(
-time.Duration(queueMaxAge) * time.Second,
)
pruned, err := hdlr.params.Database.
PruneOldQueueEntries(ctx, queueCutoff)
if err != nil {
hdlr.log.Error(
"queue pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned old queue entries",
"deleted", pruned,
)
}
}
messageMaxAge := hdlr.params.Config.MessageMaxAge
if messageMaxAge > 0 {
msgCutoff := time.Now().Add(
-time.Duration(messageMaxAge) * time.Second,
)
pruned, err := hdlr.params.Database.
PruneOldMessages(ctx, msgCutoff)
if err != nil {
hdlr.log.Error(
"message pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned old messages",
"deleted", pruned,
)
}
}
} }

View File

@@ -142,6 +142,20 @@ func (mware *Middleware) CORS() func(http.Handler) http.Handler {
}) })
} }
// Auth returns middleware that performs authentication.
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(
writer http.ResponseWriter,
request *http.Request,
) {
mware.log.Info("AUTH: before request")
next.ServeHTTP(writer, request)
})
}
}
// Metrics returns middleware that records HTTP metrics. // Metrics returns middleware that records HTTP metrics.
func (mware *Middleware) Metrics() func(http.Handler) http.Handler { func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
@@ -166,36 +180,3 @@ func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
}, },
) )
} }
// cspPolicy is the Content-Security-Policy header value applied to all
// responses. The embedded SPA loads scripts and styles from same-origin
// files only (no inline scripts or inline style attributes), so a strict
// policy works without 'unsafe-inline'.
const cspPolicy = "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self'; " +
"connect-src 'self'; " +
"img-src 'self'; " +
"font-src 'self'; " +
"object-src 'none'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'"
// CSP returns middleware that sets the Content-Security-Policy header on
// every response for defense-in-depth against XSS.
func (mware *Middleware) CSP() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(
writer http.ResponseWriter,
request *http.Request,
) {
writer.Header().Set(
"Content-Security-Policy",
cspPolicy,
)
next.ServeHTTP(writer, request)
})
}
}

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()
@@ -29,7 +34,6 @@ func (srv *Server) SetupRoutes() {
} }
srv.router.Use(srv.mw.CORS()) srv.router.Use(srv.mw.CORS())
srv.router.Use(srv.mw.CSP())
srv.router.Use(middleware.Timeout(routeTimeout)) srv.router.Use(middleware.Timeout(routeTimeout))
if srv.sentryEnabled { if srv.sentryEnabled {
@@ -134,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)