next is mergeable into main at any time; everything on it is reviewed. The upaas readiness work (#17) is complete here.
On the branch:
Running under upaas: every setting as an environment variable (PIXA_<KEY>, the port as PORT) with no baked config file; a Docker HEALTHCHECK and make docker-smoke; the container starts on a fresh root-owned volume and runs the server as pixad, never root; README.md "Running under upaas".
Security: quality and fit are signed; /v1/e/ and the URL generator validate size, fit and every numeric field; /v1/image/ answers 400 to an invalid or empty q, fit or exp, a repeated parameter or an undecodable query string; cache write and stats errors that were discarded are logged; login attempts limited to 5 per minute per client (429 with Retry-After); processed images carry no EXIF, GPS, XMP, IPTC or ICC data and are turned upright, orig included; CSRF on the forms; HSTS, CSP and Permissions-Policy headers; server timeouts and a form body limit; blocked_networks plus more built-in blocked ranges; client address from X-Forwarded-For only via trusted_proxies.
Tooling: all linting through make lint in Docker, nothing installed on the host; quieter script/test; more httpfetcher tests; a corrected comment on probe timing in script/docker-smoke.
To know before deploying:
The container needs PIXA_SIGNING_KEY (32+ characters) and refuses the example placeholder. Without PIXA_ALLOWLIST_HOSTS every image URL must be signed.
Under upaas the host directory for /var/lib/pixa must exist before the first deploy.
A mounted config file is read from /etc/pixa/config.yml (no longer /etc/pixad/); a variable named in its env: section overrides the environment.
A PIXA_ variable pixa does not know (a typo, or PIXA_PORT instead of PORT) aborts startup naming it.
trusted_proxies defaults to the RFC 1918 ranges; set it to the proxy's own address, or the login limit can be dodged by a client with a private address. The proxy must pass Host and Referer through unchanged, or the CSRF check rejects login.
HSTS with includeSubDomains makes every subdomain https-only in a browser for a year.
Waiting on you: merging this; protecting prod (clawbot has no admin rights); then a main to prod PR deploys.
Model: opus-5-5
`next` is mergeable into `main` at any time; everything on it is reviewed. The upaas readiness work (https://git.eeqj.de/sneak/pixa/issues/17) is complete here.
On the branch:
- Running under upaas: every setting as an environment variable (`PIXA_<KEY>`, the port as `PORT`) with no baked config file; a Docker `HEALTHCHECK` and `make docker-smoke`; the container starts on a fresh root-owned volume and runs the server as `pixad`, never root; `README.md` "Running under upaas".
- Security: quality and fit are signed; `/v1/e/` and the URL generator validate size, fit and every numeric field; `/v1/image/` answers 400 to an invalid or empty `q`, `fit` or `exp`, a repeated parameter or an undecodable query string; cache write and stats errors that were discarded are logged; login attempts limited to 5 per minute per client (429 with `Retry-After`); processed images carry no EXIF, GPS, XMP, IPTC or ICC data and are turned upright, `orig` included; CSRF on the forms; HSTS, CSP and Permissions-Policy headers; server timeouts and a form body limit; `blocked_networks` plus more built-in blocked ranges; client address from `X-Forwarded-For` only via `trusted_proxies`.
- Tooling: all linting through `make lint` in Docker, nothing installed on the host; quieter `script/test`; more `httpfetcher` tests; a corrected comment on probe timing in `script/docker-smoke`.
To know before deploying:
- The container needs `PIXA_SIGNING_KEY` (32+ characters) and refuses the example placeholder. Without `PIXA_ALLOWLIST_HOSTS` every image URL must be signed.
- Under upaas the host directory for `/var/lib/pixa` must exist before the first deploy.
- A mounted config file is read from `/etc/pixa/config.yml` (no longer `/etc/pixad/`); a variable named in its `env:` section overrides the environment.
- A `PIXA_` variable pixa does not know (a typo, or `PIXA_PORT` instead of `PORT`) aborts startup naming it.
- `trusted_proxies` defaults to the RFC 1918 ranges; set it to the proxy's own address, or the login limit can be dodged by a client with a private address. The proxy must pass `Host` and `Referer` through unchanged, or the CSRF check rejects login.
- HSTS with `includeSubDomains` makes every subdomain https-only in a browser for a year.
- The CSP still allows `unsafe-inline` (https://git.eeqj.de/sneak/pixa/issues/125).
Waiting on you: merging this; protecting `prod` (clawbot has no admin rights); then a `main` to `prod` PR deploys.
Model: opus-5-5
clawbot
added this to the 1.0.0 milestone 2026-09-21 19:26:34 +02:00
clawbot
self-assigned this 2026-09-21 19:26:34 +02:00
Adds CSRF protection to the two cookie-authenticated form posts, POST / (login) and POST /generate, using github.com/gorilla/csrf, the recorded default for this job.
The token key is derived from signing_key with its own HKDF salt, so it needs no new config and survives restarts. The token cookie is separate from the session cookie, which also covers login CSRF, where no session exists yet. Both templates carry the hidden token field.
What a reader would trip over: outside debug mode the library enforces its https Referer origin check, so the TLS-terminating proxy must preserve the Host and Referer headers from the browser or form posts are rejected.
Disclosure: one nolint:gosec on a test constant holding the library field name (G101 false positive).
Model: opus-4-8 (implementation, review); fable-5-1 (landing message)
script/test now runs the suite quietly first (with -race and -cover, 30s timeout) and re-runs it with -v only when that run fails, then exits non-zero. This is the pattern REPO_POLICIES.md mandates; before, every green run printed full per-test output.
What a reader would trip over: the whole compound command is passed as one string to run_with_cgo_deps, so it behaves the same on the host path and under the nix-shell fallback. -cover is on the first run only; the verbose rerun exists for diagnostics.
Disclosure: no test was written for the wrapper script itself; the failure path was exercised by hand by author and reviewer.
Disclosure: the nix-shell fallback is kept; moving tests into Docker belongs to #101 and #104.
Model: opus-4-8 (implementation, review); fable-5-1 (landing message)
internal/httpfetcher had only helper-level tests. This adds tests of the full Fetch path, with no non-test code changed: a redirect to a private address is refused and never dialed while public redirects and a two-hop chain still work; the per-host semaphore is released on error, after a full read and after a partial read; an oversized body yields ErrResponseTooLarge; non-2xx and disallowed content types are rejected; the dialer blocks private, link-local and loopback targets.
What a reader would trip over: the upstream host in the tests is the TEST-NET-1 literal 192.0.2.10, which the private-IP check treats as public; a recording dialer routes it to the local test server and records every dial.
Disclosure: DNS rebinding is not simulated end to end (it would mean changing the global resolver under parallel race tests); the dial-time re-resolution is tested directly instead.
Model: opus-4-8 (implementation, review); fable-5-1 (landing message)
The Workflow section of TODO.md still told contributors to branch from main and merge there. It now describes the current model: one branch per issue cut from next, a PR based on next, an independent reviewer, a squash-merge into next by the manager, and only the owner merging next into main through the milestone PR. The Status paragraph no longer claims work is green on main.
Disclosure: only the wrong lines are touched; reflowing the whole file is left to #100.
Model: opus-4-8 (implementation, review); fable-5-1 (landing message)
SecurityHeaders() now also sets Strict-Transport-Security (one year, includeSubDomains), a Content-Security-Policy (default-src self, frame-ancestors none) and a Permissions-Policy denying the browser features pixa does not use. X-Frame-Options stays as the legacy fallback.
What a reader would trip over: HSTS is sent on every response even though pixa listens on plain HTTP behind a TLS-terminating proxy; browsers ignore the header over plaintext, and this avoids trusting a forwarded-proto header. The clipboard feature is left unlisted so the copy button on the generator page keeps working.
Disclosure: script-src and style-src carry unsafe-inline because the generator template has inline onclick handlers and the bundled Tailwind script injects a style element at runtime; removing it needs template changes and is tracked separately.
Model: opus-4-8 (implementation, review); fable-5-1 (landing message)
The http.Server now sets ReadHeaderTimeout (10s), which bounds the slow header dribble that ReadTimeout alone does not, and IdleTimeout (120s), which bounds keep-alive reuse. Server construction moved into a small helper so a test can assert the timeouts without binding a listener.
POST / and POST /generate bodies are capped at 1 MiB and an oversized body returns 413.
What a reader would trip over: the CSRF library reads its token from the form and swallows a parse error, so a cap applied only inside it would surface as 403. The body limit therefore parses the form under the cap before the CSRF check; the parsed form is reused afterwards. A test covers an oversized body that carries a valid token.
Judgement call: WriteTimeout stays at 60s; it also bounds how long a large image may take to send over a slow link.
Model: opus-4-8 (implementation, review); fable-5-1 (landing message)
The Docker image now ships config.docker.yml, which sets only signing_key (read from the PIXA_SIGNING_KEY environment variable), state_dir and port. The placeholder key and the five-host allowlist from config.example.yml are no longer in the image; anything else is configured by mounting a file over /etc/pixa/config.yml. A container started without PIXA_SIGNING_KEY exits naming it.
Startup now refuses the exact placeholder signing_key from config.example.yml. It is 45 characters long and used to pass the length check, so a deployment could sign URLs with a key that is public in this repository. README Getting Started is corrected to match.
What a reader would trip over: the unset-variable error comes from config interpolation, not from validate(); the signing key checks moved into validateSigningKey to stay under the complexity limit.
Disclosure: TODO.md is not updated by this change.
Model: opus-4-8 (implementation, review); fable-5-1 (landing message)
Adds the blocked_networks config key: a list of CIDRs, parsed with net/netip, that is added to the built-in list of address ranges the fetcher refuses to contact and can never remove an entry from it. An invalid CIDR aborts startup naming the key and the value.
The built-in list gains CGNAT 100.64.0.0/10, IETF protocol assignments 192.0.0.0/24, benchmark 198.18.0.0/15 and NAT64 64:ff9b::/96. Resolved addresses are unmapped before matching, so IPv4-mapped IPv6 forms are caught too. Enforcement stays in the dial-time re-resolution, which is what closes the DNS rebinding window.
What a reader would trip over: 192.0.0.0/24 is now blocked but TEST-NET-1 (192.0.2.0/24), which the Fetch tests use as a public upstream, is a different range and stays dialable. The package-level dialer enforces the built-in ranges only; operator entries are applied by the fetcher.
Disclosure: one nolint:gochecknoglobals on the immutable built-in prefix list.
Model: opus-4-8 (implementation, review); fable-5-1 (landing message)
RFC1918 ranges are the default trusted proxy set on an omitted key; an explicit list replaces the default; an explicit empty list trusts no one; unparseable values abort startup; forwarded headers honored only from trusted peers. Independent review passed: #127 (comment)
model: claude-opus-4-8 (implementation and review); merged by claude-fable-5
The runtime stage declares a HEALTHCHECK that probes
/.well-known/healthcheck.json with busybox wget. script/docker-smoke
(make docker-smoke) builds the image with script/docker, starts it with
a random PIXA_SIGNING_KEY, and passes only once Docker reports the
container healthy within 30 seconds; the container is removed on exit
and its log printed on failure. The Gitea workflow runs it after
script/cibuild; it is not part of make check.
It waits on Docker's health status instead of polling a published host
port because the Gitea job runs in its own container on its own
network, where such a port is not reachable at localhost.
Model: opus-5-5
The signed data is now
host:path:query:width:height:format:expiration:quality:fit. The route
turns a missing q into 85 and a missing fit into cover before checking
the signature, so those are the values signed for a URL without them;
imgcache fills both from the parsed request.
imgcache.Service.GenerateSignedURL now writes q and fit into the URL
next to sig and exp, first setting an unset quality or fit to 85 or
cover, so a generated URL verifies for the values it signed.
The known-answer vectors in golden_test.go, including one for quality
40 and fit contain, and the README signature section describe the new
format.
Model: opus-4-8 (implementation); opus-5-5 (rework)
Each config key can now be set by PIXA_ plus the key in upper case
("." written as "_"), and the port by PORT. A present variable, even
an empty one, is read before the config file through the existing
typed getters, so every existing check covers it; errors name the key
and the variable, never the signing key or metrics password. A
variable named in the file's env: section overrides both. An empty
string for blocked_networks or trusted_proxies is now an empty list.
The image no longer bakes in config.docker.yml or passes --config; its
HEALTHCHECK probes ${PORT:-8080}. The config file is looked for under
/etc/pixa rather than /etc/pixad. Also covers #99.
Model: opus-5-5
make lint calls script/lint, the only way golangci-lint is run. Inside a
container it runs the linter; anywhere else it builds Dockerfile.lint,
whose last step runs script/lint again. Both Dockerfiles set
container=docker to mark the container, since /.dockerenv is missing in
build steps and present on hosts that are themselves containers. The
Dockerfile lint stage runs make lint.
A new CACHEBUST build-arg on every run keeps the lint step from being
served from cache; a tmpfs mount keeps Go's and golangci-lint's caches
out of that step's layer, so runs do not pile up build cache.
script/bootstrap and the nix-shell package lists no longer carry
golangci-lint. golangci-lint config verify is not run: it fetches its
schema over an unpinned live HTTPS call.
Model: opus-4-8 (implementation); opus-5-5 (rework)
upaas bind-mounts an existing host directory and sets no container
user, so a directory made with mkdir as root left pixad unable to
write /var/lib/pixa, and the container exited at startup.
The image now starts as root: deploy/docker-entrypoint.sh gives
/var/lib/pixa to pixad when pixad does not own it, then runs the
server as pixad through su-exec (alpine's package), so the server
never runs as root. README.md gains a "Running under upaas" section:
port, volume, environment variables, health check, first-run step.
Model: opus-5-5
The encrypted /v1/e/ route used the decrypted payload unchecked, so a
token could request an over-limit size or an unknown fit mode; the
generator turned unparseable numbers into 0.
imgcache.ValidateDimension alone holds the MaxDimension bound and is
used by the path parser, by the new ValidateImageRequest (which adds
ValidateFitMode) and by the generator. Both image routes call
ValidateImageRequest, so each answers 400. The generator answers 400
naming the field for a width or height that is not a number or fails
that check, a quality that is not a number from 1 to 100, a ttl that is
not a number from 0 to the largest the expiry calculation can hold, or
an unknown fit. Empty quality is 85; empty ttl never expires. The
form's size inputs stop at 8192.
Model: opus-4-8 (implementation); opus-5-5 (rework)
clawbot
removed their assignment 2026-09-28 15:28:08 +02:00
sneak
was assigned by clawbot2026-09-28 15:28:08 +02:00
The old comment said the flag stops the image's 30-second interval from
delaying the first probe past the wait. From Docker 25 on, the first
probe runs 5 seconds after start either way; the flag makes probes
after the 10-second start period come every second instead of every
30. Only the comment changes; TODO.md is left alone because the issue
limits the change to this script.
Model: opus-5-5
A variable whose name starts with PIXA_ but is neither a setting's
variable, from the list pairing each config key with its variable, nor
PIXA_CONFIG_PATH now aborts startup naming it, as an unknown config key
does. PIXA_PORT is named with a pointer to PORT. The check runs after
the config file loads, so the variables the file's env section sets are
checked too. README.md says so under Configuration.
Model: opus-5-5
A q that was not a number or was outside 1-100 was dropped and 85 used,
so q=500 was served and verified against a signature made for 85. It is
now a 400 naming q and the value, read with the generator's quality
check; only a q missing from the URL is 85.
The route also refuses with 400 a query string that cannot be decoded
(r.URL.Query() drops such a pair, so q=80% arrived as no q) and any
parameter given more than once, which was read from its first value only
(q=80&q=500 was served at 80).
Model: opus-5-5
A fit in the URL with an empty value (fit=) was treated as missing, so
it was served as cover and verified against a signature made for cover.
It is now a 400 naming fit, the same rule the route applies to an empty
q. It is checked before the existing fit-mode check, which takes an
empty fit as missing; any other value still goes through that check
unchanged. Only a fit missing from the URL is cover.
Model: opus-5-5
An exp that was not a whole number, or empty, was ignored, so a URL for
a host that needs a signature got 401 as if it had no exp. It is now a
400 naming exp and the value, on every host; only an exp missing from
the URL is unchanged.
A failed variant .meta write, source metadata JSON write, Stats count
query, stats counter update, negative cache write or expired negative
cache delete was discarded without a trace. Each is now logged at warn
with the path or key and the error, and stays non-fatal, with tests for
those that can be made to fail. VariantStorage takes the cache's logger.
Model: opus-5-5
POST / had no limit, so the signing key could be guessed at no cost. It
is now limited to 5 attempts per minute per client by a new RateLimit
middleware on github.com/go-chi/httprate; an attempt over the limit gets
429 with Retry-After. It counts by the address the ClientIP middleware
resolved through trusted_proxies (an IPv4-mapped address as its IPv4
address, IPv6 by its /64) and runs after the body-size and CSRF checks,
so every attempt that reaches the key comparison is counted. README says
that with the default trusted_proxies a client with a private address
can choose its counted address, and how to close that.
Model: opus-5-5
Every output is exported with govips' StripMetadata, so it carries no
EXIF (GPS, serial numbers, embedded thumbnails), XMP, IPTC or ICC
profile, the orig format included: it is always re-encoded, and pixa
never serves the source bytes. The image is turned upright with
AutoRotate right after decoding, so dropping the orientation tag does
not leave it rotated, and a requested size applies to the upright
image. An image with an ICC profile is converted to sRGB before export.
No setting turns this off. README.md documents it.
Model: opus-5-5
sneak
merged commit 05678eaae5 into main2026-09-29 03:01:06 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
nextis mergeable intomainat any time; everything on it is reviewed. The upaas readiness work (#17) is complete here.On the branch:
PIXA_<KEY>, the port asPORT) with no baked config file; a DockerHEALTHCHECKandmake docker-smoke; the container starts on a fresh root-owned volume and runs the server aspixad, never root;README.md"Running under upaas"./v1/e/and the URL generator validate size, fit and every numeric field;/v1/image/answers 400 to an invalid or emptyq,fitorexp, a repeated parameter or an undecodable query string; cache write and stats errors that were discarded are logged; login attempts limited to 5 per minute per client (429 withRetry-After); processed images carry no EXIF, GPS, XMP, IPTC or ICC data and are turned upright,origincluded; CSRF on the forms; HSTS, CSP and Permissions-Policy headers; server timeouts and a form body limit;blocked_networksplus more built-in blocked ranges; client address fromX-Forwarded-Foronly viatrusted_proxies.make lintin Docker, nothing installed on the host; quieterscript/test; morehttpfetchertests; a corrected comment on probe timing inscript/docker-smoke.To know before deploying:
PIXA_SIGNING_KEY(32+ characters) and refuses the example placeholder. WithoutPIXA_ALLOWLIST_HOSTSevery image URL must be signed./var/lib/pixamust exist before the first deploy./etc/pixa/config.yml(no longer/etc/pixad/); a variable named in itsenv:section overrides the environment.PIXA_variable pixa does not know (a typo, orPIXA_PORTinstead ofPORT) aborts startup naming it.trusted_proxiesdefaults to the RFC 1918 ranges; set it to the proxy's own address, or the login limit can be dodged by a client with a private address. The proxy must passHostandRefererthrough unchanged, or the CSRF check rejects login.includeSubDomainsmakes every subdomain https-only in a browser for a year.unsafe-inline(#125).Waiting on you: merging this; protecting
prod(clawbot has no admin rights); then amaintoprodPR deploys.Model: opus-5-5
Each config key can now be set by PIXA_ plus the key in upper case ("." written as "_"), and the port by PORT. A present variable, even an empty one, is read before the config file through the existing typed getters, so every existing check covers it; errors name the key and the variable, never the signing key or metrics password. A variable named in the file's env: section overrides both. An empty string for blocked_networks or trusted_proxies is now an empty list. The image no longer bakes in config.docker.yml or passes --config; its HEALTHCHECK probes ${PORT:-8080}. The config file is looked for under /etc/pixa rather than /etc/pixad. Also covers #99. Model: opus-5-5clawbot referenced this pull request2026-09-29 03:10:56 +02:00