Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fd9994d4b | ||
|
|
e5d9f64d83 |
No files matched your search
@@ -26,11 +26,6 @@ make docker
|
|||||||
docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixa:latest
|
docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixa:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
The example config allowlists no host (`allowlist_hosts`), so pixa serves an
|
|
||||||
image only for a signed or encrypted URL. Every image on an allowlisted host is
|
|
||||||
served to anyone without a signature, at any size and in any format, so
|
|
||||||
allowlist only hosts you control, where nobody else can put images.
|
|
||||||
|
|
||||||
A container takes its settings from environment variables (see Configuration
|
A container takes its settings from environment variables (see Configuration
|
||||||
below for the list). Only `PIXA_SIGNING_KEY` is required; if it is unset the
|
below for the list). Only `PIXA_SIGNING_KEY` is required; if it is unset the
|
||||||
container exits at startup naming the variable. Everything else has a built-in
|
container exits at startup naming the variable. Everything else has a built-in
|
||||||
@@ -71,10 +66,9 @@ The state directory (`state_dir`, `/var/lib/pixa` in the container) holds the
|
|||||||
database and the disk cache:
|
database and the disk cache:
|
||||||
|
|
||||||
- It needs a persistent volume: without one, every restart starts with an empty
|
- 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
|
cache. In the container, the startup script gives the directory to the user
|
||||||
work again (see Encrypted URLs). In the container, the startup script gives
|
pixa runs as (uid 65532) and sets its mode to `750`; outside it, that user
|
||||||
the directory to the user pixa runs as (uid 65532) and sets its mode to `750`;
|
must be able to write the directory.
|
||||||
outside it, that user must be able to write the directory.
|
|
||||||
- `cache_max_bytes` limits the source and transformed images together. The
|
- `cache_max_bytes` limits the source and transformed images together. The
|
||||||
database, the metadata files, the `.meta` file beside each transformed image
|
database, the metadata files, the `.meta` file beside each transformed image
|
||||||
and files still being written come on top, and eviction runs in the
|
and files still being written come on top, and eviction runs in the
|
||||||
@@ -121,9 +115,7 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
|||||||
- `PIXA_SIGNING_KEY` (required): secret for signed and encrypted URLs and
|
- `PIXA_SIGNING_KEY` (required): secret for signed and encrypted URLs and
|
||||||
login, 32+ characters, for example from `openssl rand -base64 32`
|
login, 32+ characters, for example from `openssl rand -base64 32`
|
||||||
- `PIXA_ALLOWLIST_HOSTS`: upstream hosts served without a signature,
|
- `PIXA_ALLOWLIST_HOSTS`: upstream hosts served without a signature,
|
||||||
comma-separated; default none. Every image on a listed host is served to
|
comma-separated
|
||||||
anyone without a signature, so list only hosts you control, where nobody
|
|
||||||
else can put images.
|
|
||||||
- `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it;
|
- `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it;
|
||||||
default 75% of (free space + what the cache holds)
|
default 75% of (free space + what the cache holds)
|
||||||
- the rest are in the table under Configuration below
|
- the rest are in the table under Configuration below
|
||||||
@@ -360,7 +352,7 @@ nor change what it asks for.
|
|||||||
with the one signing key, so logging out ends every login session, in every
|
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
|
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
|
a login session made before it, a copy of the session cookie included, also
|
||||||
after a restart that keeps the state directory (see Deployment).
|
after a restart.
|
||||||
2. On the generator page, give the source image's URL, the width and height, the
|
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
|
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
|
(`POST /generate`). The format is JPEG XL unless another is chosen; a form
|
||||||
|
|||||||
@@ -35,21 +35,12 @@ P2: security: per-origin rate limiting
|
|||||||
browser and a copy of it kept working for its 30 days. Every login is made
|
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
|
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
|
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
|
that it outlasts a restart, and refuses a session made before it. A `/logout`
|
||||||
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 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
|
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
|
logout that cannot be saved answers 500. `session.NewManager`, which the tests
|
||||||
use, keeps logouts in memory only; pixad uses
|
use, keeps logouts in memory only; pixad uses
|
||||||
`session.NewManagerWithDatabase`.
|
`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
|
|
||||||
anyone without a signature, at any size and in any format.
|
|
||||||
`configs/config.example.yml` now leaves `allowlist_hosts` out, with one
|
|
||||||
commented entry under `example.com`. Getting Started and "Running under upaas"
|
|
||||||
in `README.md` say that every image on an allowlisted host is served without a
|
|
||||||
signature, so an allowlisted host must be one the operator controls.
|
|
||||||
- 2026-10-13 `fit=outside` no longer makes an output above 8192 on a side
|
- 2026-10-13 `fit=outside` no longer makes an output above 8192 on a side
|
||||||
(closes #257): it scales the image to cover the requested size, and the side
|
(closes #257): it scales the image to cover the requested size, and the side
|
||||||
scaled past that size was held to no maximum, so `1x8192?fit=outside` of a
|
scaled past that size was held to no maximum, so `1x8192?fit=outside` of a
|
||||||
|
|||||||
@@ -45,14 +45,15 @@ state_dir: ./data
|
|||||||
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
||||||
|
|
||||||
# Hosts that don't require signatures (default: none)
|
# Hosts that don't require signatures (default: none)
|
||||||
# Every image on an allowlisted host is served to anyone without a
|
|
||||||
# signature, at any size and in any format, so list only hosts you
|
|
||||||
# control, where nobody else can put images.
|
|
||||||
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
||||||
# An entry that is neither a host name nor an IP address (IPv6 without
|
# An entry that is neither a host name nor an IP address (IPv6 without
|
||||||
# brackets), such as one with a port or a "*." wildcard, aborts startup.
|
# brackets), such as one with a port or a "*." wildcard, aborts startup.
|
||||||
# allowlist_hosts:
|
allowlist_hosts:
|
||||||
# - images.example.com
|
- s3.sneak.cloud
|
||||||
|
- static.sneak.cloud
|
||||||
|
- sneak.berlin
|
||||||
|
- github.com
|
||||||
|
- user-images.githubusercontent.com
|
||||||
|
|
||||||
# Hosts whose pages may not show pixa's images, written as for
|
# Hosts whose pages may not show pixa's images, written as for
|
||||||
# allowlist_hosts. A request to /v1/image/ or /v1/e/ whose Referer header
|
# allowlist_hosts. A request to /v1/image/ or /v1/e/ whose Referer header
|
||||||
|
|||||||
@@ -92,32 +92,6 @@ func TestGetStringSlice_Empty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestExampleConfigAllowlistsNoHost loads configs/config.example.yml
|
|
||||||
// with a real signing key, as Getting Started in README.md has it copied
|
|
||||||
// and edited, and checks that it allowlists no host: every image on an
|
|
||||||
// allowlisted host is served to anyone without a signature.
|
|
||||||
func TestExampleConfigAllowlistsNoHost(t *testing.T) {
|
|
||||||
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
|
|
||||||
|
|
||||||
examplePath := filepath.Join("..", "..", "configs", "config.example.yml")
|
|
||||||
|
|
||||||
sc, err := loadTestConfig(examplePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load %s: %v", examplePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := newFromSmartConfig(sc)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s is not a valid config: %v", examplePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("allowlist_hosts in %s: %v", examplePath, c.AllowlistHosts)
|
|
||||||
|
|
||||||
if len(c.AllowlistHosts) != 0 {
|
|
||||||
t.Errorf("%s allowlists %v, want no host", examplePath, c.AllowlistHosts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadTestConfig is a helper to load a config file for testing.
|
// loadTestConfig is a helper to load a config file for testing.
|
||||||
func loadTestConfig(path string) (*smartconfig.Config, error) {
|
func loadTestConfig(path string) (*smartconfig.Config, error) {
|
||||||
return smartconfig.NewFromConfigPath(path)
|
return smartconfig.NewFromConfigPath(path)
|
||||||
|
|||||||
@@ -222,61 +222,6 @@ func TestHandleLogout_EndsCopiedSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleLogout_SaveFails_Answers500 verifies that when the logout cannot
|
|
||||||
// be saved in the database, GET /logout answers 500, leaves the session
|
|
||||||
// cookie in place and ends no session.
|
|
||||||
func TestHandleLogout_SaveFails_Answers500(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h, srv := newCSRFTestRouter(t)
|
|
||||||
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
sessMgr, err := session.NewManagerWithDatabase(
|
|
||||||
t.Context(), testSigningKey, db)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("session.NewManagerWithDatabase() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h.sessMgr = sessMgr
|
|
||||||
cookie := newSessionCookie(t, h)
|
|
||||||
|
|
||||||
// Saving the logout fails on a closed database.
|
|
||||||
err = db.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("closing the database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, "/logout", nil)
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.HandleLogout().ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
t.Logf("GET /logout: status %d, Set-Cookie %q",
|
|
||||||
rec.Code, rec.Header().Get("Set-Cookie"))
|
|
||||||
|
|
||||||
if rec.Code != http.StatusInternalServerError {
|
|
||||||
t.Errorf("GET /logout status = %d, want %d",
|
|
||||||
rec.Code, http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c := findSessionCookie(rec); c != nil {
|
|
||||||
t.Errorf("a logout that failed set the session cookie: %s", c)
|
|
||||||
}
|
|
||||||
|
|
||||||
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if !strings.Contains(rec.Body.String(), generatorForm) {
|
|
||||||
t.Error("a logout that failed ended the session")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGeneratePost_NoSession_RedirectsToLogin verifies that POST /generate
|
// TestGeneratePost_NoSession_RedirectsToLogin verifies that POST /generate
|
||||||
// with a valid CSRF token but no login session answers 303 to / and makes no
|
// with a valid CSRF token but no login session answers 303 to / and makes no
|
||||||
// URL.
|
// URL.
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go.uber.org/fx"
|
|
||||||
"go.uber.org/fx/fxtest"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
|
||||||
"sneak.berlin/go/pixa/internal/globals"
|
|
||||||
"sneak.berlin/go/pixa/internal/handlers"
|
|
||||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
|
||||||
"sneak.berlin/go/pixa/internal/middleware"
|
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// startPixa starts the components pixad's fx app builds, from cfg, and
|
|
||||||
// returns the server with its routes, as newTestServer does.
|
|
||||||
func startPixa(t *testing.T, cfg *config.Config) *Server {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
mw *middleware.Middleware
|
|
||||||
)
|
|
||||||
|
|
||||||
app := fxtest.New(t,
|
|
||||||
fx.Supply(cfg),
|
|
||||||
fx.Provide(
|
|
||||||
globals.New,
|
|
||||||
logger.New,
|
|
||||||
database.New,
|
|
||||||
healthcheck.New,
|
|
||||||
handlers.New,
|
|
||||||
middleware.New,
|
|
||||||
),
|
|
||||||
fx.Populate(&h, &mw),
|
|
||||||
)
|
|
||||||
app.RequireStart()
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
s := &Server{config: cfg, mw: mw, h: h}
|
|
||||||
s.SetupRoutes()
|
|
||||||
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// opensGeneratorPage reports whether GET / with cookie shows s's generator
|
|
||||||
// page, which only a valid login session opens.
|
|
||||||
func opensGeneratorPage(t *testing.T, s *Server, cookie *http.Cookie) bool {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
return rec.Code == http.StatusOK &&
|
|
||||||
strings.Contains(rec.Body.String(), `action="/generate"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLogoutOutlastsRestart verifies that a copy of the session cookie taken
|
|
||||||
// before /logout is still refused by a pixa started again on the same state
|
|
||||||
// directory, as pixad is after a restart.
|
|
||||||
func TestLogoutOutlastsRestart(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
|
|
||||||
login := postLogin(t, s, firstClient, "", testSigningKey)
|
|
||||||
|
|
||||||
var copied *http.Cookie
|
|
||||||
|
|
||||||
for _, c := range login.Result().Cookies() {
|
|
||||||
if c.Name == session.CookieName {
|
|
||||||
copied = c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if copied == nil || !opensGeneratorPage(t, s, copied) {
|
|
||||||
t.Fatal("logging in gave no session cookie that opens the generator page")
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, "/logout", nil)
|
|
||||||
req.AddCookie(copied)
|
|
||||||
|
|
||||||
logout := httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(logout, req)
|
|
||||||
|
|
||||||
if logout.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("GET /logout status = %d, want %d",
|
|
||||||
logout.Code, http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
restarted := startPixa(t, s.config)
|
|
||||||
|
|
||||||
opens := opensGeneratorPage(t, restarted, copied)
|
|
||||||
t.Logf("after the restart, the copied cookie opens the generator page: %v",
|
|
||||||
opens)
|
|
||||||
|
|
||||||
if opens {
|
|
||||||
t.Error("after a restart, a cookie copied before /logout " +
|
|
||||||
"still opens the generator page")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
// Package session provides encrypted session cookie management, and keeps the
|
// Package session provides encrypted session cookie management.
|
||||||
// time of the latest logout in the database so that a logout ends every
|
|
||||||
// session made before it.
|
|
||||||
package session
|
package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
Reference in new issue
Block a user