check / check (push) Successful in 3m10s
The route ignored a q that was not a number or was outside 1-100 and used 85, so q=banana or q=500 was served as if q were absent and verified against a signature made for 85. It now reads q with the check the URL generator uses for its quality field (parseFormInt with minQuality and maxQuality, default encurl.DefaultQuality) and answers anything else with a 400 naming q and the value. An absent or empty q is still 85. README.md states the range. Model: opus-5-5
14 KiB
14 KiB
Workflow
- branch per issue from
next - do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (
TODO.mdchanges in the same commit as the work) - open a PR based on
next - an independent reviewer who did not write the change gates it
- the manager squash-merges the PR into
nextonce review passes nextstays green and mergeable tomainat any time; only the owner mergesnextintomain, via the single milestone PR- push
Status
pre-1.0. No git tags exist. The 1.0.0 milestone is in progress; work
lands on next, and main receives only the milestone PR that the
owner merges. next is at the canonical golangci-lint v2.12.2 config
and is green. Recent work extracted the internal/magic,
internal/allowlist, internal/httpfetcher, and internal/signature
packages. The gosec findings from the 2026-07-06 survey are resolved.
The disk cache is now size-bounded with LRU eviction
(cache_max_bytes), closing the unbounded disk growth DoS vector.
Next Step
P1: rate limit global concurrent upstream fetches to prevent resource exhaustion
Completed Steps
- 2026-09-28 refuse an invalid
qon/v1/image/(closes #134): aqthat is not a whole number from 1 to 100 is a 400 namingqand the value, instead of being served at the default 85; the route readsqwith the generator's quality check (parseFormIntwithminQualityandmaxQuality); an absent or emptyqis still 85;README.mdstates the range. - 2026-09-28 start on a fresh upaas volume (closes #129): the image
starts as root only to give
/var/lib/pixatopixadwhenpixaddoes not own it (deploy/docker-entrypoint.sh), then runs the server aspixadthroughsu-exec, so a root-owned host directory bind-mounted there no longer stops the container at startup;README.mdgains a "Running under upaas" section. - 2026-09-28 run all linting in Docker via
Dockerfile.lint+script/lint(closes #104):make lintcallsscript/lint, the only way the linter is run; inside a container (both Dockerfiles setcontainer=docker) it runsgolangci-lint, anywhere else it builds the hash-pinnedDockerfile.lint, whose last step runsscript/lintagain; theDockerfilelint stage runsmake lint; no host or nix-shellgolangci-lintpath remains (script/bootstrapinstalls no linter); a per-runCACHEBUSTbuild-arg keeps the lint step from being served from cache, and a tmpfs mount on that step keeps Go's and golangci-lint's caches out of its layer, so a run leaves no large build cache behind;golangci-lint config verifystays out, as it fetches its schema over an unpinned live HTTPS call - 2026-09-28 every setting as an environment variable (closes #128, also
covers #99): each config key can be set by
PIXA_plus the key in upper case (.written as_), and the port byPORT; a variable present in the environment, even empty, wins over the config file, which wins over the default; the typed getters read the variable first, so every existing check applies to it and a bad value aborts startup naming the variable; lists are comma-separated, and an empty variable (or""in the file) is an empty list; the Docker image no longer bakes inconfig.docker.ymlor passes--config, and itsHEALTHCHECKprobesPORT(default8080); the config file is looked for under/etc/pixaand~/.config/pixainstead of the daemon namepixad; documented inREADME.mdandconfig.example.yml. - 2026-09-28 quality and fit in the URL signature (closes #60): the signed
data is now
host:path:query:width:height:format:expiration:quality:fit, using85andcoverwhen the URL has noqorfit, so one signed URL can no longer be replayed across other quality and fit values to create unauthorized cache entries and transcodes; the known-answer vectors ininternal/signature/golden_test.goand the README signature specification describe the new format. - 2026-09-28 Docker image healthcheck (closes #111): a
HEALTHCHECKin the runtime stage probing/.well-known/healthcheck.jsonwith busyboxwget;script/docker-smoke(make docker-smoke) builds the image, starts it with a throwawayPIXA_SIGNING_KEY, and passes only once Docker reports it healthy within 30 seconds, removing the container on exit; the Gitea workflow runs it afterscript/cibuild. - 2026-09-21 trusted-proxy client IP resolution (closes #94): a
trusted_proxiesconfig key taking a list of CIDRs, parsed by the samenet/netiplist parser asblocked_networks(an invalid entry aborts startup naming the key and value; an omitted key defaults to the RFC 1918 private ranges, an explicitly empty list trusts no one, and an explicit list replaces the default); a newinternal/clientippackage resolves the client address by honoringX-Forwarded-Foronly when the direct peer is a trusted proxy, walking the chain right-to-left to the rightmost non-proxy entry, so a client connecting directly cannot spoof its address; the resolved address is stored in the request context by a new middleware and used by the request-logging middleware and the login-attempt logs in place of the raw peer address; documented inREADME.mdandconfig.example.yml. - 2026-09-21 blocked networks configuration extending SSRF protection: a
blocked_networksconfig key taking a list of CIDRs (parsed withnet/netip, an invalid entry aborts startup naming the key and value), added to the built-in blocklist rather than replacing it; the built-in ranges extended to CGNAT100.64.0.0/10, IETF protocol assignments192.0.0.0/24, benchmark198.18.0.0/15, and NAT6464:ff9b::/96(IPv4-mapped forms covered); enforcement stays in the dial-time re-resolution so the DNS-rebinding window remains closed; documented inREADME.mdandconfig.example.yml. - 2026-09-21 validate dimensions and fit mode on the encrypted-URL
route and the token generator (closes #62):
imgcache.ValidateDimensionalone holds theMaxDimensionbound and is used by the path parser, by the newValidateImageRequest(which also appliesValidateFitMode) and by the generator; both the/v1/image/and/v1/e/routes callValidateImageRequest, so an over-limit size or an unknown fit mode is a 400 rather than an out-of-memory or a 500 from the processor; the URL generator answers 400 naming the field for awidthorheightthat is not a number or fails the shared check, aqualitythat is not a number from 1 to 100, attlthat is not a number from 0 to the largest number of seconds the expiry calculation can hold, or an unknownfit; an emptyqualityis 85 and an emptyttlnever expires; the form's width and height inputs stop at 8192 - 2026-09-21 http.Server hardening (closes #92): added
HTTPReadHeaderTimeout(10s, bounds the slowloris header dribble) andHTTPIdleTimeout(120s, bounds keep-alive reuse) alongside the existing timeouts and wired them onto the server; added aLimitBodymiddleware capping the two form POST bodies (POST /,POST /generate) atMaxFormBytes(1 MiB) and returning 413, applied ahead of the CSRF middleware so an oversized body is refused as 413 rather than being read as a missing CSRF token (403); leftWriteTimeoutat 60s unchanged - 2026-08-07 update golangci-lint to v2.12.2 with the canonical
.golangci.yml(v2 schema,default: allminus six disabled linters,lll88, tests included): bumped the pinnedgolangci/golangci-lint:v2.12.2-alpineimage inDockerfileand the release-archive sha256 pins inscript/bootstrap; fixed the findings the stricter config surfaced (notablyparalleltest,wsl_v5,goconst,lll,noinlineerr,err113,errcheck,testpackage— white-box test files renamed to*_internal_test.go), including #55's code absorbed after it merged, iterating the pinned linter to0 issues.; no single finding total is substantiable, since golangci-lint'suniq-by-linereveals new findings on a line as others there are fixed — the documented re-measurements were 81 after the #53 merge and 149 after the #55 merge; three behavior changes, so not a pure no-op:Cache.StoreVariantnow takes acontext.Context(noctx), so a cancelled request skips its best-effort accounting row;MetadataStorage.Store's cleanup defer was dead onmainand leaked.tmp-*.jsonon failure, now fixed with explicit removals; and thesigning_keyvalidation error text gainedvalue too short:; the eviction loop's uncancellable context is deferred to #102 under a//nolint:contextcheck; three//nolint:tagliatelledirectives keep the snake_case JSON wire/disk formats unchanged;make checkgreen - 2026-08-07 implement cache size management and eviction (closes
#51): new
cache_max_bytesconfig key validated by the startup framework (explicit values used exactly with no floor,0disables the disk cache entirely, omitted defaults to max(75% of free space on the filesystem containing<state_dir>/cache/, 500 MiB), logged at startup); processed variants are now tracked in the database (a newvariant_contenttable and an LRU timestamp onsource_content) so total usage is two SUMs, never a directory scan on the hot path; a background goroutine evicts globally least-recently-used entries (variants and source blobs merged) to the limit, woken by a periodic ticker and by write-pressure notifications from stores; a source blob and ALL of itssource_metadatareferences are deleted in one transaction before the file is unlinked, so multi-referenced blobs are never removed while referenced and rows never point at deleted files; a startup and periodic reconciliation pass adopts untracked variant files, drops rows for missing files, removes unreachable source blobs, and sweeps stale temp files - 2026-08-07 validate configuration on startup, fail fast on bad
config (closes #52): a config value that is set but unparseable or
invalid aborts startup naming the key and value (defaults apply only
to omitted keys), unknown config keys abort startup, a malformed
config file aborts instead of being skipped, and
state_diris verified creatable and writable before the listener binds - 2026-08-07 manual test pass of the auth and encrypted URL flows
against a locally built and running
pixad(built frommainat6573b9d, port 18099, local throwaway config); all six checks passed, plus all nine tests inscripts/manual-test.sh(closes #49):- visit
/and see the login form: HTTP 200,Pixa - Loginpage withname="key"password form - wrong key shows an error: POST
/withkey=wrong-keyreturned HTTP 200 login page containing "Invalid signing key" - correct signing key shows the generator form: POST
/returned HTTP 303 to/withSet-Cookie: pixa_session=...; HttpOnly; Secure; SameSite=Strict; GET/with that cookie renderedPixa - URL Generatorwith the/generateform and logout link - a generated encrypted URL serves the image: POST
/generate(ttl=3600) produced a/v1/e/<token>/img.jpegURL that returned HTTP 200,Content-Type: image/jpeg, an 800x600 baseline JPEG of 61706 bytes - an expired URL (short TTL) returns 410: a ttl=1 URL fetched
after 3 s returned HTTP 410 Gone with
{"error":"URL has expired","status":410,...} - logout redirects back to login: GET
/logoutreturned HTTP 303 to/withSet-Cookie: pixa_session=; Max-Age=0; subsequent GET/rendered the login form again
- visit
- 2026-08-07 fix the two remaining gosec findings (G124 in
internal/session): session cookies now always carry
Secure/HttpOnly/SameSite=Strict on both the set and clear paths;
make checkgreen (closes #47) - 2026-07-07 Adopted scripts-to-rule-them-all:
script/entrypoints, Makefile shims, README Entrypoints section - 2026-04-07 extract magic byte detection into internal/magic (#42)
- 2026-03-25 extract allowlist package from internal/imgcache (#41)
- 2026-03-25 move schema_migrations table creation into 000.sql (#36)
- 2026-03-20 enforce and document exact-match-only signature verification (#40)
- 2026-03-20 bound imageprocessor.Process input read to prevent unbounded memory use (#37); consolidate appname into an internal/globals constant (#34)
- 2026-03-18 parse version prefix from migration filenames (#33)
- 2026-03-15 QA audit fixes for 1.0/MVP readiness (#25)
- 2026-03-02 split Dockerfile with pre-built golangci-lint stage for faster CI (#23)
- 2026-02-25 repo policy compliance: CI workflow, hash-pinned images, golangci-lint and gosec fixes of that date (#14); arm64 Docker build fix (#16)
- 2026-01-08 WebP and AVIF encoding support via govips (both former P0 image processing items, now done)
Future Steps
- P1: strip EXIF and other metadata from processed images (privacy)
- P2: security
- referer blacklist
- per-IP rate limiting
- per-origin rate limiting
- P2: HTTP response handling
- Last-Modified headers
- Vary header for content negotiation
- X-Request-ID propagation
- P2: auto format selection (format=auto based on Accept header)
- P2: configuration
- add all configuration options from README
- YAML config file support
- P2: operational
- optional Sentry error reporting
- comprehensive request logging
- Prometheus performance metrics
- integration tests for the image proxy flow
- load tests to verify the 1k to 5k req/s target
- P2: documentation
- configuration options
- API endpoints
- deployment guide
- example nginx or caddy reverse proxy config