Compare commits

2 Commits
Author SHA1 Message Date
clawbot a4e9757db7 Test that Process stops once its deadline has passed
check / check (push) Canceled after 0s
Process looks at its context only while it waits for a processing
slot, so a request whose deadline has passed, before Process starts or
while it reads its input, still has its image decoded and encoded while
it holds the slot. The new test fails until Process returns
context.DeadlineExceeded in both cases and gives the slot back
(#243).

Model: opus-5-5
2026-10-15 05:48:53 +00:00
clawbot 88e1b1cdfa Allowlist no host in the example config (closes #249)
check / check (push) Canceled after 0s
The example config allowlisted github.com and user-images.githubusercontent.com, where anyone can put an image, so a deployment set up as Getting Started says served every image there to anyone without a signature. The example now allowlists nothing and shows one commented entry under example.com. README.md says in Getting Started and in "Running under upaas" that every image on an allowlisted host is served without a signature, so an allowlisted host must be one the operator controls. A new test loads the example config and fails if it allowlists any host.

Model: opus-5-5
2026-10-15 06:26:23 +02:00
5 changed files with 137 additions and 7 deletions

No files matched your search

+8 -1
View File
@@ -26,6 +26,11 @@ 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
@@ -115,7 +120,9 @@ 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 comma-separated; default none. Every image on a listed host is served to
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
+8
View File
@@ -30,6 +30,14 @@ P2: security: per-origin rate limiting
# Completed Steps # Completed Steps
- 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
+5 -6
View File
@@ -45,15 +45,14 @@ 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:
- s3.sneak.cloud # - images.example.com
- 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
+26
View File
@@ -92,6 +92,32 @@ 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)
@@ -0,0 +1,90 @@
package imageprocessor
import (
"bytes"
"context"
"errors"
"io"
"testing"
"time"
)
// readerAfterDeadline is a Process input whose first Read waits until done
// is closed, so the deadline passes after Process has taken its slot and
// before it encodes.
type readerAfterDeadline struct {
data *bytes.Reader
done <-chan struct{}
waited bool
}
func (r *readerAfterDeadline) Read(p []byte) (int, error) {
if !r.waited {
r.waited = true
<-r.done
}
return r.data.Read(p)
}
// TestProcessStopsWhenDeadlinePassed checks that Process returns
// context.DeadlineExceeded, and holds no slot afterwards, when its context's
// deadline has passed before it starts or while it reads its input. libvips
// cannot stop an encode once it has begun, so Process must not begin one for
// a request that has ended.
func TestProcessStopsWhenDeadlinePassed(t *testing.T) {
t.Parallel()
input := createTestJPEG(t, 50, 50)
t.Run("before Process starts", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithDeadline(t.Context(),
time.Now().Add(-time.Second))
defer cancel()
checkProcessStopsAtDeadline(ctx, t, bytes.NewReader(input))
})
t.Run("while Process reads its input", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
checkProcessStopsAtDeadline(ctx, t, &readerAfterDeadline{
data: bytes.NewReader(input), done: ctx.Done(),
})
})
}
// checkProcessStopsAtDeadline runs Process on input with ctx, whose deadline
// has passed by the time Process could encode, and fails the test unless
// Process returns context.DeadlineExceeded and gives its slot back.
func checkProcessStopsAtDeadline(
ctx context.Context, t *testing.T, input io.Reader,
) {
t.Helper()
proc := New(Params{MaxConcurrentProcessing: 1})
result, err := proc.Process(ctx, input, smallJPEGRequest())
if result != nil {
t.Logf("Process() made a %dx%d image after its deadline passed",
result.Width, result.Height)
_ = result.Content.Close()
}
t.Logf("Process() error = %v", err)
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("Process() error = %v, want context.DeadlineExceeded", err)
}
if held := len(proc.processingSemaphore); held != 0 {
t.Errorf("%d slots still held after Process() returned", held)
}
}