diff --git a/README.md b/README.md index 5306624..a1f0199 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,10 @@ The state directory (`state_dir`, `/var/lib/pixa` in the container) holds the database and the disk cache: - It needs a persistent volume: without one, every restart starts with an empty - cache and forgets the latest logout, so the login sessions that logout ended - work again (see Encrypted URLs). In the container, the startup script gives - the directory to the user pixa runs as (uid 65532) and sets its mode to `750`; - outside it, that user must be able to write the directory. + cache and forgets the count of logouts, so login sessions that a logout ended + can work again (see Encrypted URLs). In the container, the startup script + gives the directory to the user pixa runs as (uid 65532) and sets its mode to + `750`; outside it, that user must be able to write the directory. - `cache_max_bytes` limits the source and transformed images together. The database, the metadata files, the `.meta` file beside each transformed image and files still being written come on top, and eviction runs in the @@ -185,10 +185,11 @@ path under `/v1/` answers 200, in maintenance mode too. session it answers 303 to `/`. Answers: 200 with the page showing the URL; 400 with the page naming a field that is not valid; 500 when the URL cannot be made. -- `GET /logout` — end every login session, in every browser (see Encrypted - URLs), and remove the session cookie. Needs: a login session; without one it - only removes the cookie. Answers: 303 to `/`; 500 when the logout cannot be - saved in the database. +- `POST /logout` — log out with the generator page's Logout button: end every + login session, in every browser (see Encrypted URLs), and remove the session + cookie. Needs: a login session and the generator page's form (below); without + a login session it only removes the cookie. Answers: 303 to `/`; 500 when the + logout cannot be saved in the database. - `GET` or `HEAD` `/v1/image///.`, or `/v1/image///` with no format — an image, fetched, resized and converted (below). Needs: a signature, unless the host is allowlisted (see @@ -236,7 +237,7 @@ the request carries the same ID as `request_id`, and so do the lines it logs when it fetches, converts and serves an image; the fetch sends it to the upstream host as `X-Request-ID`. -Both `POST` routes accept only a form that pixa's own page served: the page puts +The `POST` routes accept only a form that pixa's own page served: the page puts a token in the form and sets a cookie to match, and a request without both is refused with 403, so another site cannot submit the form from a visitor's browser. The login and generator pages are meant to be opened over HTTPS: while @@ -357,11 +358,14 @@ nor change what it asks for. 1. Open `/` in a browser over HTTPS (or over plain HTTP while `debug` is on, see Routes) and log in with the signing key (`signing_key`). The login session - lasts 30 days, or until someone logs out at `/logout`. Every login is made - with the one signing key, so logging out ends every login session, in every - browser: pixa saves the time of the latest logout in its database and refuses - a login session made before it, a copy of the session cookie included, also - after a restart that keeps the state directory (see Deployment). + lasts 30 days, or until someone logs out with the generator page's Logout + button. Every login is made with the one signing key, so logging out ends + every login session, in every browser, a copy of the session cookie included. + pixa keeps a count of logouts in its database: each logout adds one, a login + session carries the count at its login, and a login session whose count is + not the current one is refused, also after a restart that keeps the state + directory (see Deployment). No time is compared, so a login after a logout + works whatever the host's clock says. 2. On the generator page, give the source image's URL, the width and height, the format, quality and fit, and how long the URL lasts, then submit the form (`POST /generate`). The format is JPEG XL unless another is chosen; a form diff --git a/TODO.md b/TODO.md index 7b44291..d06080a 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,22 @@ P2: security: per-origin rate limiting # Completed Steps +- 2026-10-15 logging out ends every login session (closes #256): a login session + was a cookie with nothing kept on the server, so `/logout` removed it from one + browser and a copy of it kept working for its 30 days. Every login is made + with the one signing key, so logging out now ends them all: the session + manager counts logouts, saves the count in the new `logout` table so that it + outlasts a restart, puts the count at login in each session, and refuses a + session whose count is not the current one. No time is compared, so a login + after a logout works whatever the host's clock does. A restart without a + persistent volume for the state directory forgets the count. Logging out is + now `POST /logout` from a form on the generator page, behind the CSRF check + the other forms have, so a link or image elsewhere on the same site cannot end + the sessions. A logout without a valid login session ends none and only + removes the cookie, as anyone can take a CSRF token from the login page. A + logout that cannot be saved answers 500. `session.NewManager`, which the tests + use, keeps the count in memory only; pixad uses + `session.NewManagerWithDatabase`. - 2026-10-15 expired negative cache rows are removed on a schedule (closes #244): the row of a failed fetch, which makes pixa answer 502 for that source URL for 5 minutes, was deleted only when the same URL was requested again @@ -70,18 +86,6 @@ P2: security: per-origin rate limiting and the query now have each `%` written as `%25` and each `:` as `%3A`; a path and query holding neither are signed and keyed as before. Signature Specification in `README.md` says so. -- 2026-10-15 logging out ends every login session (closes #256): a login session - was a cookie with nothing kept on the server, so `/logout` removed it from one - browser and a copy of it kept working for its 30 days. Every login is made - with the one signing key, so `/logout` now ends them all: the session manager - keeps the time of the latest logout, saves it in the new `logout` table so - that it outlasts a restart, and refuses a session made before it. A restart - without a persistent volume for the state directory forgets it. A `/logout` - without a valid login session, such as one another site makes a browser send - without the `SameSite=Strict` cookie, ends none and only removes the cookie. A - logout that cannot be saved answers 500. `session.NewManager`, which the tests - use, keeps logouts in memory only; pixad uses - `session.NewManagerWithDatabase`. - 2026-10-15 the example config allowlists no host (closes #249): it allowlisted `github.com` and `user-images.githubusercontent.com`, where anyone can put an image, so a deployment set up as Getting Started says served those images to diff --git a/internal/db/migrations/001_schema.sql b/internal/db/migrations/001_schema.sql index d4788b9..2e018af 100644 --- a/internal/db/migrations/001_schema.sql +++ b/internal/db/migrations/001_schema.sql @@ -177,9 +177,11 @@ CREATE TABLE IF NOT EXISTS cache_stats ( ); INSERT OR IGNORE INTO cache_stats (id) VALUES (1); --- Time of the latest logout. A login session made before it is refused, --- so logging out ends every login session. No row: nobody has logged out. +-- Count of logouts. Each logout adds one; a login session carries the count +-- at its login and is refused once the count has changed, so logging out ends +-- every login session. CREATE TABLE IF NOT EXISTS logout ( id INTEGER PRIMARY KEY CHECK (id = 1), - logged_out_at DATETIME NOT NULL + logout_count INTEGER NOT NULL DEFAULT 0 ); +INSERT OR IGNORE INTO logout (id) VALUES (1); diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 6e191d1..c82b621 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -164,7 +164,8 @@ func (s *Handlers) initImageService() error { // Initialize session manager (signing key is validated at config load // time). Session cookies are always Secure/HttpOnly/SameSite=Strict. - // Logouts are saved in the database, so they outlast a restart. + // The count of logouts is saved in the database, so it outlasts a + // restart. sessMgr, err := session.NewManagerWithDatabase( context.Background(), s.config.SigningKey, s.db.DB()) if err != nil { diff --git a/internal/server/routes.go b/internal/server/routes.go index 104f2e6..b7ed1a2 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -69,10 +69,9 @@ func (s *Server) SetupRoutes() { r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute, nil)). Post("/", s.h.HandleRoot()) r.Post("/generate", s.h.HandleGenerateURL()) + r.Post("/logout", s.h.HandleLogout()) }) - s.router.Get("/logout", s.h.HandleLogout()) - // Image routes, the only ones that send CORS headers, as pages on other // sites read them. They are a subrouter rather than a group: a group's // middleware runs only for a request that matches one of its routes, diff --git a/internal/session/session.go b/internal/session/session.go index 0097dd4..3448b5b 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1,6 +1,6 @@ -// Package session provides encrypted session cookie management, and keeps the -// time of the latest logout in the database so that a logout ends every -// session made before it. +// Package session provides encrypted session cookie management, and keeps a +// count of logouts in the database so that a logout ends every session made +// before it. package session import ( @@ -38,28 +38,34 @@ type Data struct { Authenticated bool `json:"auth"` CreatedAt time.Time `json:"created"` ExpiresAt time.Time `json:"expires"` + + // LogoutCount is the count of logouts when the session was made. + LogoutCount int64 `json:"logout_count"` } // Manager handles session creation and validation using encrypted cookies. // // Every login is made with the one signing key, so a logout ends every -// session made before it, in every browser. The Manager keeps the time of -// the latest logout in memory, so checking a session reads no database; a +// session made before it, in every browser. The Manager counts logouts: each +// logout adds one, a session carries the count at its login, and a session +// whose count is not the current one is refused. No clock is compared, so a +// login after a logout works whatever the host's clock does. The Manager +// keeps the count in memory, so checking a session reads no database; a // Manager from NewManagerWithDatabase also saves it in the database, so that // it outlasts a restart. type Manager struct { sc *securecookie.SecureCookie db *sql.DB - // mu guards latestLogout, and is held while a logout is saved, so that - // the database and latestLogout agree. - mu sync.Mutex - latestLogout time.Time + // mu guards logoutCount, and is held while a logout is saved, so that + // the database and logoutCount agree. + mu sync.Mutex + logoutCount int64 } // NewManager creates a session manager with keys derived from the signing key. -// It keeps logouts in memory only, so a restart forgets them; -// NewManagerWithDatabase keeps them in the database. +// It keeps the count of logouts in memory only, so a restart forgets it; +// NewManagerWithDatabase keeps it in the database. // // Session cookies always carry the Secure, HttpOnly, and SameSite=Strict // attributes; this cannot be configured. Browsers treat http://localhost as a @@ -88,8 +94,8 @@ func NewManager(signingKey string) (*Manager, error) { } // NewManagerWithDatabase creates a session manager like NewManager that saves -// each logout in db, which must have pixa's schema, and starts from the -// latest logout saved there. +// the count of logouts in db, which must have pixa's schema, and starts from +// the count saved there. func NewManagerWithDatabase( ctx context.Context, signingKey string, db *sql.DB, ) (*Manager, error) { @@ -99,10 +105,10 @@ func NewManagerWithDatabase( } err = db.QueryRowContext(ctx, - "SELECT logged_out_at FROM logout WHERE id = 1", - ).Scan(&m.latestLogout) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("failed to read the latest logout: %w", err) + "SELECT logout_count FROM logout WHERE id = 1", + ).Scan(&m.logoutCount) + if err != nil { + return nil, fmt.Errorf("failed to read the count of logouts: %w", err) } m.db = db @@ -117,6 +123,7 @@ func (m *Manager) CreateSession(w http.ResponseWriter) error { Authenticated: true, CreatedAt: now, ExpiresAt: now.Add(SessionTTL), + LogoutCount: m.currentLogoutCount(), } encoded, err := m.sc.Encode(CookieName, data) @@ -165,8 +172,8 @@ func (m *Manager) ValidateSession(r *http.Request) (*Data, error) { return nil, ErrInvalidSession } - // A session made before the latest logout has ended. - if !data.CreatedAt.After(m.latestLogoutTime()) { + // A logout since the session was made has ended it. + if data.LogoutCount != m.currentLogoutCount() { return nil, ErrInvalidSession } @@ -174,9 +181,8 @@ func (m *Manager) ValidateSession(r *http.Request) (*Data, error) { } // Logout removes the session cookie and, when r has a valid session, ends -// every session made until now. A request without one ends no session: -// another site can make a browser request /logout, but the browser leaves -// the SameSite=Strict session cookie out of that request. +// every session made until now. A request without one ends no session, so +// someone who has not logged in cannot end the sessions of those who have. func (m *Manager) Logout(w http.ResponseWriter, r *http.Request) error { if m.IsAuthenticated(r) { err := m.saveLogout(r.Context()) @@ -211,33 +217,30 @@ func (m *Manager) IsAuthenticated(r *http.Request) bool { return err == nil && data != nil && data.Authenticated } -// saveLogout makes now the time of the latest logout, saving it in the +// saveLogout adds one to the count of logouts, saving the new count in the // database first when the Manager has one. func (m *Manager) saveLogout(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() - now := time.Now().UTC() - if m.db != nil { _, err := m.db.ExecContext(ctx, - "INSERT OR REPLACE INTO logout (id, logged_out_at) VALUES (1, ?)", - now) + "UPDATE logout SET logout_count = ? WHERE id = 1", + m.logoutCount+1) if err != nil { return fmt.Errorf("failed to save the logout: %w", err) } } - m.latestLogout = now + m.logoutCount++ return nil } -// latestLogoutTime returns the time of the latest logout, or the zero time -// when there has been none. -func (m *Manager) latestLogoutTime() time.Time { +// currentLogoutCount returns the count of logouts. +func (m *Manager) currentLogoutCount() int64 { m.mu.Lock() defer m.mu.Unlock() - return m.latestLogout + return m.logoutCount } diff --git a/internal/static/style.css b/internal/static/style.css index da22318..407c46e 100644 --- a/internal/static/style.css +++ b/internal/static/style.css @@ -122,12 +122,20 @@ header { margin-bottom: 2rem; } -header a { +/* The logout button, drawn as a link. */ + +header button { + width: auto; + margin: 0; + padding: 0; + background: none; font-size: 0.875rem; color: #4b5563; + text-decoration: underline; } -header a:hover { +header button:hover { + background: none; color: #1f2937; } diff --git a/internal/templates/generator.html b/internal/templates/generator.html index 2caaaac..6d679d2 100644 --- a/internal/templates/generator.html +++ b/internal/templates/generator.html @@ -10,9 +10,12 @@

Pixa URL Generator

- - Logout - +
+ {{ .CSRFField }} + +
{{if .GeneratedURL}}