5 Commits
Author SHA1 Message Date
clawbot ba79d6de02 Stop allowing credentialed cross-origin requests (closes #261)
check / check (push) Failing after 3s
CORS() allowed every origin with credentials, so a page on another origin of the same site could read what the signed-in user sees.

CORS() now uses the CONVENTIONS.md template's options (AllowedOrigins "*", AllowCredentials false) and covers only the two public, read-only routes, GET /api/v1/server and GET /.well-known/healthcheck.json. Every other route sends no Access-Control-Allow-* header. A preflight to the public routes gets 405 like any other path.

Deviation: CORS() is not in the global middleware chain CONVENTIONS.md lists, because REPO_POLICIES.md forbids * on authenticated routes.

Model: opus-5-5
2026-11-11 01:22:38 +01:00
clawbot ed5125606a Stop the slow test packages waiting (closes #263)
check / check (push) Failing after 1m9s
The slowest test packages spent their time waiting rather than testing.

db.Params gains an optional PasswordCost (zero means bcrypt.DefaultCost), and the db, ircserver and handlers tests hash at bcrypt.MinCost. A test IRC server's relay checks its queue on broker notification instead of polling. The service and handlers tests build config.Config directly and run in parallel; config exports DefaultTrustedProxies and ParseTrustedProxies for them. Fixed sleeps give way to waits for the event. Production defaults are unchanged.

What still takes time is CPU work under -race or a timeout a test is about.

Model: opus-5-5
2026-11-11 01:15:43 +01:00
clawbot 847f5c6fec Let ircserver and db tests pass under heavy host load (closes #265)
check / check (push) Failing after 4s
Tests in internal/ircserver and internal/db failed under heavy host load and passed on a rerun.

testTimeout, the failure limit on every read and wait in internal/ircserver, goes from 5 to 30 seconds; passing tests take no longer. TestQuietClientNotAnsweringPingIsClosed gets its own 4-second ping interval and measures its upper bound from the end of registration, a second before a two-interval read deadline would close the connection, leaving room for scheduling delay. endlessQueryTimeout in internal/db goes from 100 ms to 2 seconds, so the cancelled-query tests interrupt a running statement.

Judgement call: the closed test no longer checks PING timing; TestQuietClientAnsweringPingStaysConnected still does.

Model: opus-5-5
2026-11-11 01:07:47 +01:00
sneak 142f0a1c4b Merge branch 'main' into next
check / check (push) Failing after 3s
2026-11-11 00:49:06 +01:00
sneak 12a232ee1b next → main: utility commands, IRC listener and audit fixes, build fixes (#111)
check / check (push) Canceled after 0s
Reviewed-on: #111
2026-10-22 18:27:05 +02:00
6 changed files with 141 additions and 32 deletions

No files matched your search

+10 -8
View File
@@ -168,8 +168,10 @@ either.
- Request bodies for `POST /api/v1/session`, `POST /api/v1/login` and - Request bodies for `POST /api/v1/session`, `POST /api/v1/login` and
`POST /api/v1/messages` are limited to `MAX_MESSAGE_SIZE` bytes (default `POST /api/v1/messages` are limited to `MAX_MESSAGE_SIZE` bytes (default
`4096`). A larger body gets `400` `invalid request body`. `4096`). A larger body gets `400` `invalid request body`.
- The server allows cross-origin requests from any origin, with credentials. The - `GET /api/v1/server` and `GET /.well-known/healthcheck.json` answer a request
cookie is `SameSite=Strict`, so a browser sends it only from the same site. from any origin with `Access-Control-Allow-Origin: *`. Every other route sends
no `Access-Control-Allow-*` header, so a page on another origin cannot read
its responses.
### The auth cookie ### The auth cookie
@@ -1893,14 +1895,14 @@ The IRC listener has no TLS.
seconds. A request still running after 60 seconds is cancelled, and the seconds. A request still running after 60 seconds is cancelled, and the
connection closes without a response. Headers may be up to 1 MiB. connection closes without a response. Headers may be up to 1 MiB.
- A long poll waits at most 30 seconds. - A long poll waits at most 30 seconds.
- Every response to a well-formed request, except to a cross-origin preflight, - Every response to a well-formed request carries a `Content-Security-Policy`
carries a `Content-Security-Policy` header: header:
`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'`. `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'`.
The web client works within it. The web client works within it.
- Cross-origin requests are allowed from any origin, with credentials. The - `GET /api/v1/server` and `GET /.well-known/healthcheck.json` answer a request
allowed methods are `GET`, `POST`, `PUT`, `DELETE` and `OPTIONS`. The allowed from any origin with `Access-Control-Allow-Origin: *`. Every other route sends
request headers are `Accept`, `Content-Type` and `X-CSRF-Token`. The cookie's no `Access-Control-Allow-*` header, so a page on another origin cannot read
`SameSite=Strict` still keeps it to the same site. its responses.
### Logging ### Logging
+5 -1
View File
@@ -27,7 +27,11 @@ const (
(SELECT 1 UNION ALL SELECT x + 1 FROM n) (SELECT 1 UNION ALL SELECT x + 1 FROM n)
SELECT COUNT(*) FROM n` SELECT COUNT(*) FROM n`
endlessQueryTimeout = 100 * time.Millisecond // endlessQueryTimeout is how long the endless query runs
// before its context ends. A query whose context ends before
// it starts is never run, and the pool keeps its connection,
// so the timeout leaves a loaded host time to start it.
endlessQueryTimeout = 2 * time.Second
) )
// TestDefaultDBURLSetsWALAndBusyTimeout opens a file database // TestDefaultDBURLSetsWALAndBusyTimeout opens a file database
+35 -10
View File
@@ -31,7 +31,10 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
const testTimeout = 5 * time.Second // testTimeout is how long a test waits for something before it
// fails. A passing test never waits this long, so it is generous
// enough for a heavily loaded host.
const testTimeout = 30 * time.Second
// testPollInterval is how often the relay of a test server // testPollInterval is how often the relay of a test server
// checks the client output queue without a broker // checks the client output queue without a broker
@@ -565,27 +568,49 @@ func TestQuietClientAnsweringPingStaysConnected(t *testing.T) {
func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) { func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) {
t.Parallel() t.Parallel()
// latest leaves room for a loaded host, but is sooner than // A read deadline of two ping intervals would close the
// a read deadline of two ping intervals would close it. // connection a second after latest. The ping interval is
// longer than testPingInterval, so that the time between
// the expected close and latest leaves room for a loaded
// host.
const ( const (
earliest = testPingInterval + testPongDeadline interval = 4 * time.Second
latest = 2 * testPingInterval earliest = interval + testPongDeadline
latest = 2*interval - time.Second
) )
env := newTestEnvWithShortPings(t) env := newTestEnvWithPingTimes(
t,
&config.Config{ServerName: testServerName},
interval, testPongDeadline,
)
client := env.dial(t) client := env.dial(t)
lastSent := time.Now() lastSent := time.Now()
client.register("silent") client.register("silent")
client.readServerPing(lastSent)
// The server starts both waits once it has handled the
// registration, so the time registration took does not
// count against latest.
registered := time.Now()
client.readUntil(isServerPing)
client.readUntilClosed() client.readUntilClosed()
waited := time.Since(lastSent) waited := time.Since(lastSent)
if waited < earliest || waited >= latest { if waited < earliest {
t.Errorf( t.Errorf(
"closed %v after the client's last line, want %v to %v", "closed %v after the client's last line, want at least %v",
waited, earliest, latest, waited, earliest,
)
}
sinceRegistered := time.Since(registered)
if sinceRegistered >= latest {
t.Errorf(
"closed %v after registration, want less than %v",
sinceRegistered, latest,
) )
} }
} }
+5 -10
View File
@@ -200,24 +200,19 @@ func (mware *Middleware) Logging() func(http.Handler) http.Handler {
} }
} }
// CORS returns middleware that handles Cross-Origin Resource Sharing. // CORS returns middleware that lets a page on any origin read the
// AllowCredentials is true so browsers include cookies in // response. Use it only on public, read-only routes.
// cross-origin API requests.
func (mware *Middleware) CORS() func(http.Handler) http.Handler { func (mware *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
AllowOriginFunc: func( AllowedOrigins: []string{"*"},
_ *http.Request, _ string,
) bool {
return true
},
AllowedMethods: []string{ AllowedMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS", "GET", "POST", "PUT", "DELETE", "OPTIONS",
}, },
AllowedHeaders: []string{ AllowedHeaders: []string{
"Accept", "Content-Type", "X-CSRF-Token", "Accept", "Authorization", "Content-Type", "X-CSRF-Token",
}, },
ExposedHeaders: []string{"Link"}, ExposedHeaders: []string{"Link"},
AllowCredentials: true, AllowCredentials: false,
MaxAge: corsMaxAge, MaxAge: corsMaxAge,
}) })
} }
+2 -3
View File
@@ -30,7 +30,6 @@ func (srv *Server) SetupRoutes() {
srv.router.Use(srv.mw.Metrics()) srv.router.Use(srv.mw.Metrics())
} }
srv.router.Use(srv.mw.CORS())
srv.router.Use(srv.mw.CSP()) srv.router.Use(srv.mw.CSP())
srv.router.Use(middleware.Timeout(routeTimeout)) srv.router.Use(middleware.Timeout(routeTimeout))
@@ -45,7 +44,7 @@ func (srv *Server) SetupRoutes() {
} }
// Health check. // Health check.
srv.router.Get( srv.router.With(srv.mw.CORS()).Get(
"/.well-known/healthcheck.json", "/.well-known/healthcheck.json",
srv.handlers.HandleHealthCheck(), srv.handlers.HandleHealthCheck(),
) )
@@ -69,7 +68,7 @@ func (srv *Server) SetupRoutes() {
} }
func (srv *Server) setupAPIv1(router chi.Router) { func (srv *Server) setupAPIv1(router chi.Router) {
router.Get( router.With(srv.mw.CORS()).Get(
"/server", "/server",
srv.handlers.HandleServerInfo(), srv.handlers.HandleServerInfo(),
) )
+84
View File
@@ -6,8 +6,10 @@ package server_test
import ( import (
"fmt" "fmt"
"maps"
"net" "net"
"net/http" "net/http"
"net/http/httptest"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -188,6 +190,88 @@ func TestServeAfterStopDoesNotStopApp(t *testing.T) {
} }
} }
// TestCORSHeaders checks the Access-Control-Allow-* headers on the
// answers to requests from another origin. The public, read-only
// routes answer with Access-Control-Allow-Origin: * alone. Routes
// that need the cookie, and a preflight to one, answer with none.
func TestCORSHeaders(t *testing.T) {
var srv *server.Server
startApp(t, fx.Populate(&srv))
tests := []struct {
name string
method string
path string
requestMethod string
wantHeaders map[string]string
}{
{
name: "server information",
method: http.MethodGet,
path: "/api/v1/server",
wantHeaders: map[string]string{"Access-Control-Allow-Origin": "*"},
},
{
name: "health check",
method: http.MethodGet,
path: "/.well-known/healthcheck.json",
wantHeaders: map[string]string{"Access-Control-Allow-Origin": "*"},
},
{
name: "send",
method: http.MethodPost,
path: "/api/v1/messages",
},
{
name: "state",
method: http.MethodGet,
path: "/api/v1/state",
},
{
name: "preflight for send",
method: http.MethodOptions,
path: "/api/v1/messages",
requestMethod: http.MethodPost,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequestWithContext(
t.Context(), test.method,
"http://chat.example.com"+test.path, nil,
)
request.Header.Set("Origin", "http://blog.example.com")
if test.requestMethod != "" {
request.Header.Set(
"Access-Control-Request-Method", test.requestMethod,
)
}
recorder := httptest.NewRecorder()
srv.ServeHTTP(recorder, request)
gotHeaders := map[string]string{}
for name := range recorder.Header() {
if strings.HasPrefix(name, "Access-Control-Allow-") {
gotHeaders[name] = recorder.Header().Get(name)
}
}
if !maps.Equal(gotHeaders, test.wantHeaders) {
t.Errorf(
"Access-Control-Allow-* headers are %v, want %v",
gotHeaders, test.wantHeaders,
)
}
})
}
}
// startApp starts the app newApp builds, with the HTTP port // startApp starts the app newApp builds, with the HTTP port
// set to 0, so the system gives the server a free port. // set to 0, so the system gives the server a free port.
func startApp( func startApp(