3 Commits

Author SHA1 Message Date
clawbot
f067c13d67 fix: remove PruneOrphanedMessages to preserve history within MAX_HISTORY
Some checks failed
check / check (push) Has been cancelled
PruneOrphanedMessages deleted messages that lost their client_queues
references after PruneOldQueueEntries ran, even when those messages
were within the MAX_HISTORY limit. This made MAX_HISTORY meaningless
for low-traffic channels.

RotateChannelMessages already caps messages per target. Queue pruning
handles client_queues growth. Orphan cleanup is redundant.

Closes #40
2026-03-10 03:27:14 -07:00
user
28908db1c8 feat: implement queue pruning and message rotation
Enforce QUEUE_MAX_AGE and MAX_HISTORY config values that previously
existed but were not applied. The existing cleanup loop now also:

- Prunes client_queues entries older than QUEUE_MAX_AGE (default 48h)
- Rotates messages per target (channel/DM) beyond MAX_HISTORY (default 10000)
- Removes orphaned messages no longer referenced by any client queue

closes #40
2026-03-10 03:26:42 -07:00
a98e0ca349 feat: add Content-Security-Policy middleware (#64)
All checks were successful
check / check (push) Successful in 4s
Add CSP header to all HTTP responses for defense-in-depth against XSS.

The policy restricts all resource loading to same-origin and disables dangerous features (object embeds, framing, base tag injection). The embedded SPA requires no inline scripts or inline style attributes (Preact applies styles programmatically via DOM properties), so a strict policy without `unsafe-inline` works correctly.

**Directives:**
- `default-src 'self'` — baseline same-origin restriction
- `script-src 'self'` — same-origin scripts only
- `style-src 'self'` — same-origin stylesheets only
- `connect-src 'self'` — same-origin fetch/XHR only
- `img-src 'self'` — same-origin images only
- `font-src 'self'` — same-origin fonts only
- `object-src 'none'` — no plugin content
- `frame-ancestors 'none'` — prevent clickjacking
- `base-uri 'self'` — prevent base tag injection
- `form-action 'self'` — restrict form submissions

closes #41

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #64
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:20:15 +01:00
5 changed files with 38 additions and 36 deletions

View File

@@ -1624,6 +1624,10 @@ 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.
--- ---

View File

@@ -1118,28 +1118,6 @@ func (database *Database) PruneOldQueueEntries(
return deleted, nil return deleted, nil
} }
// PruneOrphanedMessages deletes messages that are no
// longer referenced by any client_queues row and returns
// the number of rows removed.
func (database *Database) PruneOrphanedMessages(
ctx context.Context,
) (int64, error) {
res, err := database.conn.ExecContext(ctx,
`DELETE FROM messages WHERE id NOT IN
(SELECT DISTINCT message_id
FROM client_queues)`,
)
if err != nil {
return 0, fmt.Errorf(
"prune orphaned messages: %w", err,
)
}
deleted, _ := res.RowsAffected()
return deleted, nil
}
// RotateChannelMessages enforces MAX_HISTORY per channel // RotateChannelMessages enforces MAX_HISTORY per channel
// by deleting the oldest messages beyond the limit for // by deleting the oldest messages beyond the limit for
// each msg_to target. Returns the total number of rows // each msg_to target. Returns the total number of rows

View File

@@ -245,18 +245,4 @@ func (hdlr *Handlers) pruneQueuesAndMessages(
) )
} }
} }
orphaned, err := hdlr.params.Database.
PruneOrphanedMessages(ctx)
if err != nil {
hdlr.log.Error(
"orphan message cleanup failed",
"error", err,
)
} else if orphaned > 0 {
hdlr.log.Info(
"pruned orphaned messages",
"deleted", orphaned,
)
}
} }

View File

@@ -180,3 +180,36 @@ 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

@@ -29,6 +29,7 @@ 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 {