33 Commits
Author SHA1 Message Date
clawbot fc87c2117d Test that a read during a store keeps the stored content type (closes #70)
check / check (push) Failing after 4m2s
A GetVariant that begins before StoreVariant finishes can find the
variant file but not yet its .meta file, and so reads
application/octet-stream. When it adds that to memory after the store
added the real type, the wrong type is served to every later hit. The
part of GetVariant that runs after its check of memory moves, unchanged,
into loadVariantWithMeta, so the test can run it after a store. The
test fails now.

Model: opus-5-5
2026-09-29 07:22:21 +00:00
clawbot 1a07e9e261 Keep variant content types in memory for cache hits (closes #70)
Cache.metaCache was declared and never used, so every hit read and
parsed the variant's .meta file. It is now an LRU of up to 10,000
content types (hashicorp/golang-lru/v2), filled by StoreVariant and by
GetVariant after it reads a .meta file. For a variant it holds, Lookup
skips the disk check and GetVariant skips the .meta read; the variant
file is still opened and its size taken from it. Eviction removes the
entry before deleting the files, and GetVariant removes it when the
file will not open, so a missing variant is never served. The cap is a
constant, not a setting. README.md describes it.

Model: opus-5-5
2026-09-29 07:15:11 +00:00
clawbot dd9c85d5ec Test that a cache hit takes the content type from memory (closes #70)
Tests for keeping each variant's content type in memory. A second hit
must still get the stored content type after the variant's .meta file
is deleted, whether the first came from storing the variant or from
reading it after a restart; this fails now. A variant removed by
EvictToLimit, or whose file was deleted from disk, must be a miss and
must not be served, and concurrent stores, reads and evictions run
under the race detector; these pass now and guard the change.

Model: opus-5-5
2026-09-29 07:14:16 +00:00
clawbot 0c99be939a Install Dockerfile build dependencies through script/bootstrap (closes #95)
check / check (push) Successful in 3m29s
The Dockerfile lint and build stages and Dockerfile.lint each carried
their own apk add list, a copy of what script/bootstrap installs. They
now copy script/, go.mod and go.sum and run script/bootstrap, so that
layer is reused until one of those changes. script/bootstrap gains a C
compiler check: the golang image has none, and cgo needs one.

The build adds -trimpath and -s -w; CGO_ENABLED=1 stays, as govips
links libvips. ARG VERSION moves to just above the build, so a new
version reruns neither script/bootstrap nor the tests.

Model: opus-5-5
2026-09-29 09:11:20 +02:00
clawbot 56217cbf4a Add the four settings the README documented but pixa lacked (closes #61)
check / check (push) Successful in 15s
README.md documented access_control_allow_origin,
upstream_fetch_timeout, upstream_max_response_size and
downstream_timeout, but pixa did not know them, so a config following
the README aborted startup. Each is now a setting with its PIXA_
variable, defaulting to the value that was fixed in the code: *, 30s,
50 MiB and 60s. Durations are Go duration strings and must be positive;
the size is whole bytes, at most 1 GiB. The origin is * or one http or
https origin written exactly as a browser sends it; anything else
aborts startup. downstream_timeout sets both the server's write timeout
and the per-request timeout. The owner approved the edits to existing
tests.

Model: opus-5-5
2026-09-29 08:49:18 +02:00
clawbot 46a55ec15d Move migrations to internal/db/migrations (closes #96)
check / check (push) Successful in 14s
REPO_POLICIES.md puts migrations in internal/db/migrations/ as
000_migration.sql and 001_schema.sql. The two files move there with
their contents unchanged. go:embed cannot reach outside its own
package, so internal/db/migrations has a small package that embeds
them, and internal/database reads them through its FS(). The database
package stays where CONVENTIONS.md puts it; moving it would change
existing test files in other packages.

The version still comes from the filename prefix, so a database that
has recorded versions 0 and 1 runs neither again. A new test applies
the migrations twice to one database file and checks that the second
run applies nothing.

Model: opus-5-5
2026-09-29 07:42:05 +02:00
clawbot 1b920fe000 Correct trusted_proxies advice and state signature padding (closes #150)
check / check (push) Successful in 14s
The README told operators to set trusted_proxies to the proxy's own
address. A proxy on the Docker host that connects over 127.0.0.1 reaches
pixa from the Docker network's gateway, so that advice made pixa count
every user as one client for the login limit. The login-limit paragraph,
the trusted_proxies entry and config.example.yml now say to use the
address pixa sees for requests through the proxy, that a proxy connecting
through another host address is seen with that address, and how to read
it from the request log.

The signature section now says sig is base64url with the = padding
kept, since pixa compares it exactly, and shows the example's sig for a
stated key, computed with pixa's signer.

Model: opus-5-5
2026-09-29 06:05:44 +02:00
clawbot e98b998cb6 Make the cache stats count what is cached, fetched and transcoded (closes #56)
check / check (push) Successful in 18s
Cache.Stats read request_cache and output_content, which nothing
writes, so TotalItems and TotalSizeBytes were always 0. They now count
source_content plus variant_content and use UsageBytes; a disabled disk
cache reports 0 for both. The upstream fetch count and bytes and the
transform count never moved: Get now passes the bytes it fetched
(including those read before a failed body read) and counts each
successful transcode. Hits, misses and these counters are written with
context.WithoutCancel, so a client disconnect or the request timeout no
longer loses them. The unused tables stay; metaCache is #70.

Model: opus-5-5
2026-09-29 05:25:10 +02:00
clawbot ed3f8770e6 Give pixad a fixed uid and gid 65532 (closes #151)
check / check (push) Successful in 20s
adduser took the first free uid, 1000, and the entrypoint gives a
bind-mounted /var/lib/pixa to pixad, so on the host a person's login
account ended up owning pixa's database and cache. The image now creates
the pixad group with gid 65532 and the pixad user with uid 65532, which
host login and system accounts do not use. The first-run step of
"Running under upaas" in README.md names the uid and gid.

Model: opus-5-5
2026-09-29 04:44:49 +02:00
clawbot 2afe61e301 Keep max-age within an expiring image URL's lifetime (closes #63)
check / check (push) Successful in 12s
Both image routes sent Cache-Control: public, max-age=31536000,
immutable unconditionally, so a browser or proxy could keep serving an
image for a year after its signed or encrypted URL had expired. max-age
is now the whole seconds left until the URL expires, never negative and
at most one year; a URL with no expiry keeps one year. The 304 answer
uses the same value. An encrypted URL's expiry now reaches
ImageRequest.Expires through ToImageRequest. immutable stays: freshness
now ends no later than the URL's expiry. README.md documents the header.

Model: opus-5-5
2026-09-29 03:51:58 +02:00
sneak 05678eaae5 next -> main (1.0.0 milestone) (#118)
check / check (push) Successful in 12s
Reviewed-on: #118
2026-09-29 03:01:06 +02:00
clawbot be060a8305 Strip metadata from processed images (closes #82)
check / check (push) Successful in 13s
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
2026-09-29 02:18:25 +02:00
clawbot e410146fb6 Rate limit login attempts per client address (closes #66)
check / check (push) Successful in 12s
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
2026-09-29 01:03:37 +02:00
clawbot 6010f5beb0 Refuse an unparseable exp with 400; log swallowed cache errors (closes #72)
check / check (push) Successful in 12s
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
2026-09-28 19:59:39 +02:00
clawbot f149813c7e Refuse an empty fit on /v1/image/ with 400 (closes #139)
check / check (push) Successful in 13s
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
2026-09-28 18:07:11 +02:00
clawbot 45869572ff Refuse a q outside 1-100 on /v1/image/ with 400 (closes #134)
check / check (push) Successful in 13s
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
2026-09-28 17:46:49 +02:00
clawbot 0f3700f7f5 Abort startup on an unknown PIXA_ environment variable (closes #133)
check / check (push) Successful in 11s
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
2026-09-28 16:12:57 +02:00
clawbot 582ff66ba6 Describe what --health-interval does in docker-smoke on current Docker (closes #132)
check / check (push) Successful in 14s
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
2026-09-28 15:48:53 +02:00
clawbot f8d40b89a7 Validate dimensions and fit mode on encrypted URLs (closes #62)
check / check (push) Successful in 3m6s
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)
2026-09-28 15:24:32 +02:00
clawbot 50123b2a6d Start on a fresh upaas volume and document running under upaas (closes #129)
check / check (push) Successful in 11s
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
2026-09-28 15:12:48 +02:00
clawbot 2f7365cc9b Run all linting in Docker through script/lint (closes #104)
check / check (push) Successful in 12s
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)
2026-09-28 14:27:35 +02:00
clawbot 0f5bd51b09 Every setting can be given as an environment variable (closes #128)
check / check (push) Successful in 13s
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
2026-09-28 13:46:56 +02:00
clawbot db784bf561 Include quality and fit in the URL signature (closes #60)
check / check (push) Successful in 12s
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)
2026-09-28 13:02:21 +02:00
clawbot b7c1226c38 Add a Docker HEALTHCHECK and make docker-smoke (closes #111)
check / check (push) Successful in 11s
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
2026-09-28 12:06:29 +02:00
clawbot 10eab440e7 Resolve real client IP behind trusted proxies (closes #94)
check / check (push) Successful in 2m31s
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
2026-09-22 10:25:41 +02:00
clawbot 3cfcda0730 feat: blocked_networks config and extended SSRF ranges (closes #67)
check / check (push) Failing after 1s
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)
2026-09-22 00:43:27 +02:00
clawbot 1798cba96c Take the image signing key from PIXA_SIGNING_KEY and refuse the example placeholder (closes #110)
check / check (push) Failing after 1s
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)
2026-09-21 21:59:24 +02:00
clawbot 37d49ade11 Harden http.Server: slowloris timeouts and form body limit (closes #92)
check / check (push) Failing after 1s
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)
2026-09-21 20:59:24 +02:00
clawbot b4e5300feb feat: add HSTS, CSP, and Permissions-Policy security headers (closes #91)
check / check (push) Failing after 0s
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)
2026-09-21 20:43:13 +02:00
clawbot 4f95cb6a37 docs: update TODO.md Workflow and Status for the next branching model (closes #106)
check / check (push) Failing after 0s
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)
2026-09-21 19:59:51 +02:00
clawbot 6f416eac31 test: cover redirect SSRF and semaphore release in httpfetcher (closes #78)
check / check (push) Failing after 0s
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)
2026-09-21 19:59:26 +02:00
clawbot b95ef1eb69 fix: script/test conditional-verbose-rerun with -cover (closes #59)
check / check (push) Failing after 2s
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)
2026-09-21 19:43:21 +02:00
clawbot a96eba8083 CSRF protection on the login and URL-generator forms (closes #93)
check / check (push) Failing after 1s
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)
2026-09-21 19:26:18 +02:00
73 changed files with 6708 additions and 474 deletions
+1
View File
@@ -7,3 +7,4 @@ jobs:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/cibuild - run: script/cibuild
- run: script/docker-smoke
+40 -25
View File
@@ -1,18 +1,23 @@
# Lint stage # Lint stage
# Same image as Dockerfile.lint: change both pins together.
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07 # golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
RUN apk add --no-cache make build-base vips-dev libheif-dev pkgconfig
WORKDIR /src WORKDIR /src
# Copy go mod files first for better layer caching # script/bootstrap installs the build dependencies and downloads the Go
# modules. Only script/, go.mod and go.sum are copied first, so this
# layer is reused until one of them changes.
COPY script/ ./script/
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN script/bootstrap
# Copy source code # Copy source code
COPY . . COPY . .
# Tells script/lint it is inside a container, so it runs the linter.
ENV container=docker
# Run formatting check and linter # Run formatting check and linter
RUN make fmt-check RUN make fmt-check
RUN make lint RUN make lint
@@ -24,20 +29,12 @@ FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66
# Depend on lint stage passing # Depend on lint stage passing
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
ARG VERSION=dev
# Install build dependencies for CGO image libraries
RUN apk add --no-cache \
build-base \
vips-dev \
libheif-dev \
pkgconfig
WORKDIR /src WORKDIR /src
# Copy go mod files first for better layer caching # Build dependencies and Go modules, as in the lint stage
COPY script/ ./script/
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN GOTOOLCHAIN=auto go mod download RUN script/bootstrap
# Copy source code # Copy source code
COPY . . COPY . .
@@ -45,8 +42,14 @@ COPY . .
# Run tests # Run tests
RUN make test RUN make test
# Build with CGO enabled # VERSION is declared here, not earlier: a new value reruns only the
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad # build, not script/bootstrap or the tests. CGO stays enabled for
# govips; -trimpath keeps build paths out of the binary, and -s -w
# leave out the symbol table and debug information.
ARG VERSION=dev
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
-ldflags "-s -w -X main.Version=${VERSION}" \
-o /pixad ./cmd/pixad
# Runtime stage # Runtime stage
# alpine:3.21, 2026-02-25 # alpine:3.21, 2026-02-25
@@ -57,22 +60,34 @@ RUN apk add --no-cache \
vips \ vips \
libheif \ libheif \
ca-certificates \ ca-certificates \
tzdata tzdata \
su-exec
# Copy binary from builder # Copy binary from builder
COPY --from=builder /pixad /usr/local/bin/pixad COPY --from=builder /pixad /usr/local/bin/pixad
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Create non-root user, config directory, and data directory # Create non-root user, config directory, and data directory. pixad
RUN adduser -D -H -s /sbin/nologin pixad && \ # gets uid and gid 65532, which host login and system accounts do not
# use: a bind-mounted /var/lib/pixa is given to pixad, and on the host
# it must not belong to a person's account.
RUN addgroup -g 65532 pixad && \
adduser -D -H -s /sbin/nologin -u 65532 -G pixad pixad && \
mkdir -p /var/lib/pixa /etc/pixa && \ mkdir -p /var/lib/pixa /etc/pixa && \
chown pixad:pixad /var/lib/pixa chown pixad:pixad /var/lib/pixa
# Copy default config (edit signing_key before use) # No USER: the entrypoint must start as root to give a bind-mounted
COPY config.example.yml /etc/pixa/config.yml # /var/lib/pixa to pixad; it then runs the server as pixad.
USER pixad
WORKDIR /var/lib/pixa WORKDIR /var/lib/pixa
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/pixad", "--config", "/etc/pixa/config.yml"] # Shell form so the probe follows PORT; a port set only in a mounted
# config file is not seen here.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --spider -q "http://localhost:${PORT:-8080}/.well-known/healthcheck.json" || exit 1
# Settings come from PORT and the PIXA_ environment variables; only
# PIXA_SIGNING_KEY is required. A config file mounted at
# /etc/pixa/config.yml is optional and is read when present.
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+34
View File
@@ -0,0 +1,34 @@
# Dockerfile.lint: the container script/lint builds to run golangci-lint,
# which is never installed on the host. Pinned to the same image as the
# Dockerfile lint stage: change both pins together, or the two run
# different linter versions.
#
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60
WORKDIR /src
# pixa is CGO/libvips: the type-aware linters compile every package, so
# this image needs the same C libraries the build does. script/bootstrap
# installs them and downloads the Go modules. Only script/, go.mod and
# go.sum are copied first; they settle this layer's result, so it may
# safely be reused between runs.
COPY script/ ./script/
COPY go.mod go.sum ./
RUN script/bootstrap
COPY . .
# Tells script/lint it is inside a container, so it runs the linter.
ENV container=docker
# script/lint passes a different CACHEBUST on every run, and BuildKit
# keys every RUN after this ARG on its value, so the lint step always
# runs instead of returning a cached success that linted nothing.
#
# Go's and golangci-lint's caches (/root/.cache, hundreds of MB) go on a
# tmpfs that is discarded after the step. Written into the layer, they
# would pile up as build cache on every run, since no later run, with
# its new CACHEBUST, can reuse that layer.
ARG CACHEBUST
RUN --mount=type=tmpfs,target=/root/.cache script/lint
+7 -2
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-versioned docker-test devserver devserver-stop hooks .PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-smoke docker-versioned docker-test devserver devserver-stop hooks
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -X main.Version=$(VERSION) LDFLAGS := -X main.Version=$(VERSION)
@@ -10,7 +10,7 @@ ifdef HAS_PKGCONFIG
NIX_RUN_PREFIX = NIX_RUN_PREFIX =
NIX_RUN_SUFFIX = NIX_RUN_SUFFIX =
else else
NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif golangci-lint git --run ' NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif git --run '
NIX_RUN_SUFFIX = ' NIX_RUN_SUFFIX = '
endif endif
@@ -54,6 +54,11 @@ clean:
docker: docker:
@script/docker @script/docker
# Build the image, start it, and wait for its healthcheck (needs Docker;
# not part of check)
docker-smoke:
@script/docker-smoke
# Build Docker image tagged pixad:$(VERSION) and pixad:latest # Build Docker image tagged pixad:$(VERSION) and pixad:latest
docker-versioned: docker-versioned:
docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest . docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest .
+201 -22
View File
@@ -15,14 +15,54 @@ git clone https://git.eeqj.de/sneak/pixa.git
cd pixa cd pixa
make build make build
# run with a config file # run with a config file: copy the example and set a real signing key
./bin/pixad --config config.example.yml # (the example placeholder is refused at startup), e.g. with
# openssl rand -base64 32
cp config.example.yml config.yml
$EDITOR config.yml # replace the signing_key placeholder
./bin/pixad --config config.yml
# or build and run via Docker # or build and run via Docker
make docker make docker
docker run -p 8080:8080 pixad:latest docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixa:latest
``` ```
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 container exits at startup naming the variable. Everything
else has a built-in default. A config file mounted at `/etc/pixa/config.yml`
is optional: it is read when present, and an environment variable wins over
the same setting in it.
## Running under upaas
What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
- **Port:** pixa listens on container port `8080`.
- **Volume:** container path `/var/lib/pixa`, where pixa keeps its
database and cache. upaas bind-mounts the host path it is given and
does not create it, so the host directory must exist before the first
deploy.
- **Environment variables:**
- `PIXA_SIGNING_KEY` (required): secret for signed and encrypted URLs
and login, 32+ characters, for example from
`openssl rand -base64 32`
- `PIXA_ALLOWLIST_HOSTS`: upstream hosts served without a signature,
comma-separated
- `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it;
default 75% of free space
- the rest are in the table under Configuration below
- **Health check:** the image's `HEALTHCHECK` requests
`/.well-known/healthcheck.json`. upaas reads the container's health 60
seconds after a deploy and marks the deploy failed unless it is
`healthy`. The probe uses the port from `PORT` (default `8080`), so a
port changed only in a mounted config file is not seen by it: change
the port with `PORT`.
- **First run:** create the host directory, owned by root or by uid
`65532` and gid `65532`. The server runs as the container's `pixad`
user, which has that uid and gid, and the container gives the
directory to `pixad` when it starts.
## Rationale ## Rationale
Image-heavy web applications need a fast, caching reverse proxy that Image-heavy web applications need a fast, caching reverse proxy that
@@ -46,7 +86,10 @@ prevent abuse, and allowlisted source hosts for open access.
Multiple source paths may reference the same content blob; the Multiple source paths may reference the same content blob; the
database tracks references rather than using filesystem refcounting. database tracks references rather than using filesystem refcounting.
In-process caching of request-to-output mappings targets 1-5k r/s. Toward a target of 1-5k r/s, pixa keeps in memory the content types of
the 10,000 transformed images most recently cached or served, so a
cache hit on one of them reads only the image file from disk and not
the metadata file stored beside it.
### Routes ### Routes
@@ -56,9 +99,51 @@ In-process caching of request-to-output mappings targets 1-5k r/s.
Images are only fetched from origins using TLS with valid certificates. Images are only fetched from origins using TLS with valid certificates.
A request whose query string cannot be decoded, or gives any parameter more
than once, is refused with 400.
- `<format>`: one of `orig`, `png`, `jpeg`, `webp` - `<format>`: one of `orig`, `png`, `jpeg`, `webp`
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`) - `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
An image is served with `Cache-Control: public, max-age=<seconds>, immutable`.
When the URL has an expiry (an `exp`, or the TTL of an encrypted URL),
`max-age` is the whole seconds left until then, at most one year, so no browser
or proxy cache keeps the image after pixa would refuse the URL. A URL with no
expiry gets one year. `immutable` only stops a client revalidating while its
copy is fresh.
The login form (`POST /`) is limited to 5 attempts per minute per client
address, counting an IPv6 client by its /64; an attempt over the limit is
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
address comes from `X-Forwarded-For` only when the address pixa sees for
requests that come through the proxy is in `trusted_proxies`; otherwise all
users behind the proxy are counted as one client. That address is not always
the proxy's own: a proxy on the Docker host that connects to pixa over
`127.0.0.1` is seen as the gateway of the container's Docker network, such as
`172.17.0.1` on the default bridge, and one that connects through another of the
host's addresses is seen with that address. To be sure, read it as `remoteIP` in
pixa's request log while it is not in `trusted_proxies` (see `trusted_proxies`
under Configuration). With the default `trusted_proxies` (the RFC 1918 ranges),
a client with a private address can choose the address it is counted by through
its own `X-Forwarded-For`, whether it connects directly or through the proxy,
because its own address is trusted too. Setting `trusted_proxies` to only the
address pixa sees for requests that come through the proxy closes this.
### Image Metadata
pixa decodes and re-encodes every image it serves, and removes all metadata from
the output: EXIF (GPS position, camera make, model and serial number, capture
time, embedded thumbnail), XMP, IPTC and the ICC colour profile. This cannot be
turned off.
- The `orig` format means the source's own format, not the source's bytes: an
`orig` image is re-encoded and stripped like any other.
- An image with an EXIF orientation is turned upright first, so it displays the
same without the tag; a requested size applies to the upright image.
- An image with an ICC profile is converted to sRGB first, since clients show an
image with no profile as sRGB. Colours outside sRGB, such as the most
saturated ones in a Display P3 photo, are clipped.
### Source Hosts ### Source Hosts
Source hosts may be allowlisted in the configuration. Non-allowlisted Source hosts may be allowlisted in the configuration. Non-allowlisted
@@ -68,14 +153,14 @@ hosts require an HMAC-SHA256 signature.
Signatures use HMAC-SHA256 and include an expiration timestamp to Signatures use HMAC-SHA256 and include an expiration timestamp to
prevent replay attacks. Signatures are **exact match only**: every prevent replay attacks. Signatures are **exact match only**: every
component (host, path, query, dimensions, format, expiration) must component (host, path, query, dimensions, format, expiration, quality,
match exactly what was signed. No suffix matching, wildcard matching, fit) must match exactly what was signed. No suffix matching, wildcard
or partial matching is supported. matching, or partial matching is supported.
**Signed data format** (colon-separated): **Signed data format** (colon-separated):
``` ```
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration") HMAC-SHA256(secret, "host:path:query:width:height:format:expiration:quality:fit")
``` ```
Where: Where:
@@ -86,18 +171,37 @@ Where:
- `width` — requested width in pixels, `0` for original - `width` — requested width in pixels, `0` for original
- `height` — requested height in pixels, `0` for original - `height` — requested height in pixels, `0` for original
- `format` — output format (jpeg, png, webp, avif, gif, orig) - `format` — output format (jpeg, png, webp, avif, gif, orig)
- `expiration` — Unix timestamp when signature expires - `expiration` — the URL's `exp` query parameter, the Unix timestamp when
the signature expires; a request whose `exp` is not a whole number, an
empty `exp=` included, is refused with 400
- `quality` — the URL's `q` query parameter, a whole number from 1 to 100,
or `85` when the URL has no `q`; a request whose `q` is anything else is
refused with 400
- `fit` — the URL's `fit` query parameter (cover, contain, fill, inside,
outside), or `cover` when the URL has no `fit`; a request whose `fit` is
anything else, an empty `fit=` included, is refused with 400
**Example:** resize The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet
`https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with of RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa
expiration 1704067200: compares it exactly, so a signature encoded without padding, as Node's
`base64url` and Go's `base64.RawURLEncoding` do, is refused with 401.
**Example:** with the signing key `example-signing-key-for-documentation`,
resize `https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
expiration 1704067200, default quality and fit:
1. Build input: 1. Build input:
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200` `cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover`
2. Compute HMAC-SHA256 with your secret key 2. Compute HMAC-SHA256 of it with the signing key
3. Base64URL-encode the result 3. Base64URL-encode the result, keeping the `=` padding:
`-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=`
4. URL: 4. URL:
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200` `/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=&exp=1704067200`
For the same image at quality 40 with fit `contain`, the input ends in
`:40:contain`, the signature is `5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=`,
and the URL is
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=&exp=1704067200&q=40&fit=contain`.
**Allowlist patterns:** **Allowlist patterns:**
@@ -107,13 +211,86 @@ expiration 1704067200:
### Configuration ### Configuration
Configured via YAML file (`--config`). Key settings: Every setting can be given as an environment variable, in a YAML config
file (`--config`), or both. A variable present in the environment wins over
the file, even when it is empty, and the file wins over the built-in
default. The one exception is a variable named in the file's `env:` section:
it is set while the file loads, so it overrides both the environment the
process was started with and the file's own key. A variable's value is
parsed as the same text in the file would be. The three lists take
comma-separated entries, with the spaces around each trimmed; an empty
variable is an empty list. A value that does not parse or is invalid aborts
startup, naming the variable. A variable whose name starts with `PIXA_` but
is not in the table below, such as a misspelled one or `PIXA_PORT`, aborts
startup naming it, as an unknown config key does. The one other accepted
name is `PIXA_CONFIG_PATH`, the config file's path (like `--config`). The
variables set by the file's `env:` section are checked the same way.
- `access_control_allow_origin` — CORS origin | Variable | Config key | Meaning |
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
| `PIXA_SIGNING_KEY` | `signing_key` | Required: secret for signed and encrypted URLs and login, 32+ characters |
| `PORT` | `port` | Port to listen on; default `8080` |
| `PIXA_STATE_DIR` | `state_dir` | Directory for the database and the disk cache; default `/var/lib/pixa` |
| `PIXA_DB_URL` | `db_url` | SQLite database URL; default `state.sqlite3` in the state directory |
| `PIXA_CACHE_MAX_BYTES` | `cache_max_bytes` | Disk cache limit in bytes; `0` disables it; default 75% of free space |
| `PIXA_ALLOWLIST_HOSTS` | `allowlist_hosts` | Upstream hosts served without a signature |
| `PIXA_BLOCKED_NETWORKS` | `blocked_networks` | CIDR ranges never fetched from, on top of the built-in ones |
| `PIXA_TRUSTED_PROXIES` | `trusted_proxies` | CIDR ranges of proxies whose `X-Forwarded-For` is believed; default RFC 1918 |
| `PIXA_ALLOW_HTTP` | `allow_http` | Allow plain-HTTP upstreams, for testing only; default `false` |
| `PIXA_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` |
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
| `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read responses: `*` or one origin; default `*` |
| `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set |
| `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
| `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
| `PIXA_DEBUG` | `debug` | Debug logging and plain-HTTP local development; default `false` |
| `PIXA_MAINTENANCE_MODE` | `maintenance_mode` | Maintenance flag reported by the health check; default `false` |
Key settings in more detail:
- `access_control_allow_origin` — the origin a browser lets read pixa's
responses, sent as the CORS `Access-Control-Allow-Origin` header: `*`, the
default, is any site; otherwise one `http` or `https` origin such as
`https://example.com`, whose host is a lowercase host name (letters,
digits, hyphens and dots, with a letter in its last part) or an IP address
(IPv6 in brackets, in its shortest form), with an optional port 1-65535
that has no leading zero and is not the scheme's default. Any other value,
including another scheme such as a browser extension's, aborts startup
- `allowlist_hosts` — list of allowed upstream hosts - `allowlist_hosts` — list of allowed upstream hosts
- `upstream_fetch_timeout` — timeout for origin requests - `blocked_networks` — list of CIDR ranges to refuse for SSRF protection,
- `upstream_max_response_size` — max origin response size added to the always-enforced built-in ranges (loopback, private,
- `downstream_timeout` — client response timeout link-local, CGNAT, benchmark, NAT64, and the like); an invalid CIDR
aborts startup
- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front
of pixa. `X-Forwarded-For` is believed only when the direct peer falls
inside one of these ranges; the logged and login-recorded client
address is then the rightmost forwarded entry that is not itself a
trusted proxy. Otherwise the direct peer address is used and the header
is ignored, so a client connecting directly from an address outside
these ranges cannot spoof its address.
An omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a
proxy on a private network; an explicitly empty list (`[]`) trusts no
one, and an explicit list replaces the default. An invalid CIDR aborts
startup. Set this to the address pixa sees for requests that come through
your proxy, such as `172.17.0.1/32`, when the defaults do not cover it, or
to trust nothing else (see the login limit under Routes). For a proxy on
the Docker host that connects to pixa over `127.0.0.1`, that address is the
gateway of the container's Docker network (`172.17.0.1` on the default
bridge), not the proxy's own address; a proxy that connects through another of
the host's addresses is seen with that address. To be sure which address it
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
through the proxy, and read `remoteIP` in pixa's request log line for it
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream
host, as a duration such as `30s` (the default) or `2m`
- `upstream_max_response_size` — largest upstream response accepted, in
bytes; default `52428800` (50 MiB). It also limits the image data pixa
decodes
- `downstream_timeout` — time allowed for answering one client request, as a
duration; default `60s`. The upstream fetch counts toward it, so keep it
longer than `upstream_fetch_timeout`
- `signing_key` — HMAC secret for URL signatures - `signing_key` — HMAC secret for URL signatures
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the - `cache_max_bytes` — disk cache size limit in bytes; `0` disables the
disk cache entirely; omitted defaults to 75% of the free space on disk cache entirely; omitted defaults to 75% of the free space on
@@ -144,11 +321,13 @@ them. We provide:
(bootstrap, then install-precommit) (bootstrap, then install-precommit)
- `script/projectname` — output the project name ("pixa") - `script/projectname` — output the project name ("pixa")
- `script/test` — run the test suite - `script/test` — run the test suite
- `script/lint` — run golangci-lint - `script/lint` — run golangci-lint, always in a container (builds
`Dockerfile.lint` when run outside one)
- `script/fmt` — format all code (writes) - `script/fmt` — format all code (writes)
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check - `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname` - `script/docker` — build the Docker image tagged via `script/projectname`
- `script/docker-smoke` — build the image, start it, wait for it to be healthy
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile - `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
runs the checks, so a green build implies a green repo) runs the checks, so a green build implies a green repo)
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then - `script/precommit` — pre-commit checks (`go mod tidy` guard, then
+217 -14
View File
@@ -1,28 +1,236 @@
# Workflow # Workflow
* branch (from `main`) * branch per issue from `next`
* do the work in Next Step * do the work in Next Step
* move Next Step to the top of Completed Steps * move Next Step to the top of Completed Steps
* move the top item of Future Steps into Next Step * move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work) * commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR * open a PR based on `next`
* an independent reviewer who did not write the change gates it
* the manager squash-merges the PR into `next` once review passes
* `next` stays green and mergeable to `main` at any time; only the owner
merges `next` into `main`, via the single milestone PR
* push * push
# Status # Status
pre-1.0. No git tags exist. Recent work extracted the internal/magic, 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 internal/allowlist, internal/httpfetcher, and internal/signature
packages. The gosec findings from the 2026-07-06 survey are resolved packages. The gosec findings from the 2026-07-06 survey are resolved.
and `make check` is green on main. The disk cache is now size-bounded The disk cache is now size-bounded with LRU eviction
with LRU eviction (`cache_max_bytes`), closing the unbounded disk (`cache_max_bytes`), closing the unbounded disk growth DoS vector.
growth DoS vector.
# Next Step # Next Step
P1: implement blocked networks configuration to extend SSRF protection P1: rate limit global concurrent upstream fetches to prevent resource
exhaustion
# Completed Steps # Completed Steps
- 2026-09-29 variant content types kept in memory (closes #70):
`Cache.metaCache` holds the content types of up to 10,000 variants in an LRU
(`github.com/hashicorp/golang-lru/v2`), filled by `StoreVariant` and by
`GetVariant` after it reads a `.meta` file; for a variant it holds, `Lookup`
skips the check of the disk and `GetVariant` skips the `.meta` read, still
opening the variant file and taking the size from it; eviction removes the
entry before deleting the files, and `GetVariant` removes it when the file
will not open; the cap is a constant, not a setting; the unused `variantMeta`
type is gone; `README.md` describes it.
- 2026-09-29 Dockerfiles install through `script/bootstrap` (closes #95): the
`Dockerfile` lint and build stages and `Dockerfile.lint` copy `script/`,
`go.mod` and `go.sum`, then run `script/bootstrap` in place of their own
`apk add` lines, so the build dependencies are listed in one place;
`script/bootstrap` now also installs a C compiler when `gcc` is missing; the
build uses `-trimpath` and `-s -w` and keeps `CGO_ENABLED=1` for govips;
`ARG VERSION` sits just above the build, so a new version reruns neither
`script/bootstrap` nor the tests.
- 2026-09-29 migrations at the path `REPO_POLICIES.md` sets (closes #96): the
migration files moved, contents unchanged, from `internal/database/schema/`
to `internal/db/migrations/` as `000_migration.sql` and `001_schema.sql`; the
`internal/db/migrations` package embeds them and `internal/database` reads
them through its `FS()`; the `internal/database` package itself stays; the
version still comes from the filename prefix, so a database that has recorded
versions 0 and 1 runs neither again.
- 2026-09-29 `trusted_proxies` advice and signature padding in `README.md`
(closes #150): the login-limit paragraph, the `trusted_proxies` entry and
`config.example.yml` say to set `trusted_proxies` to the address pixa sees for
requests that come through the proxy, which the request log shows as
`remoteIP` while it is not trusted; for a proxy on the Docker host that
connects over `127.0.0.1` that is the Docker network's gateway, not the
proxy's own address; the signature section says `sig` is base64url with the
`=` padding kept, and gives the example's `sig` for a stated signing key.
- 2026-09-29 fixed uid and gid for `pixad` (closes #151): the image creates the
`pixad` group with gid 65532 and the `pixad` user with uid 65532, instead of
the first free uid 1000, so a bind-mounted `/var/lib/pixa` given to `pixad`
is not owned on the host by a person's login account; the first-run step of
"Running under upaas" in `README.md` names the uid and gid.
- 2026-09-29 `max-age` never outlives an expiring URL (closes #63): both image
routes build `Cache-Control` from the request's `Expires`, which an encrypted
URL's expiry now fills too; `max-age` is one year, or the whole seconds left
until the `exp` of a `/v1/image/` URL or the expiry of an encrypted URL when
that is sooner, never negative; an allowlisted host's URL that has an `exp`
follows it too; `immutable` stays, as freshness now ends at the expiry;
documented in `README.md`.
- 2026-09-28 add the four settings `README.md` documented but pixa did not
have, which aborted startup as unknown keys (closes #61):
`access_control_allow_origin` (default `*`, the CORS origin),
`upstream_fetch_timeout` (default `30s`), `upstream_max_response_size`
(default 50 MiB) and `downstream_timeout` (default `60s`, both the
server's write timeout and the per-request timeout); each has a
`PIXA_` variable; durations are positive Go duration strings, the size a
whole number of bytes up to 1 GiB, the origin `*` or one `http` or
`https` origin as `README.md` describes it; an invalid value
aborts startup naming the key and the value; documented in
`config.example.yml` and `README.md`.
- 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats`
counts the cached source images and processed variants (`source_content`
plus `variant_content`) and takes their size from `Cache.UsageBytes`,
instead of reading `request_cache` and `output_content`, which nothing
writes; those two tables are left in the schema; a disabled disk cache
reports no items and no size. A hit is counted even when the request
context has ended. A miss is counted after it is served or fails, also
when the request context has ended by then, with the bytes it read from
upstream, so `upstream_fetch_count` and `upstream_fetch_bytes` move,
including for an upstream body that fails partway or a fetched source
that then fails the magic byte check; `transform_count` counts each image
the image processor transcodes.
- 2026-09-28 strip metadata from processed images (closes #82): every output is
exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC
profile; the image is first turned upright with `AutoRotate` (before sizes are
worked out) and, when it has an ICC profile, converted to sRGB; the `orig`
format is re-encoded and stripped like any other, as pixa never serves the
source bytes; there is no setting to keep metadata; documented in `README.md`.
- 2026-09-28 rate limit the login form (closes #66): `POST /` is limited to 5
attempts per minute per client address, and an attempt over the limit is
refused with 429 and a `Retry-After` header; the address is the one
`internal/clientip` resolves through `trusted_proxies`, an IPv6 client is
counted by its /64, and an IPv4-mapped address as the IPv4 address it
carries; the limit is a `RateLimit` middleware in `internal/middleware` on
`github.com/go-chi/httprate`, which the image routes can reuse; the library
keeps counts for the current and the previous minute only; documented in
`README.md`.
- 2026-09-28 refuse an unparseable `exp` on `/v1/image/` and log swallowed
cache errors (closes #72): an `exp` in the URL that is not a whole
number, an empty `exp=` included, is a 400 naming `exp` and the value,
instead of being ignored and answered with 401 as if the URL had no
`exp`; only an `exp` missing from the URL is unchanged; `README.md` says
so where it documents `exp`. A failed variant `.meta` write, source
metadata JSON write, `Stats` count query, stats counter update, negative
cache write or expired negative cache delete is now logged at `warn`
with the path or key and the error, and stays non-fatal.
- 2026-09-28 refuse an empty `fit` on `/v1/image/` (closes #139): a
`fit` in the URL with an empty value (`fit=`) is a 400 naming `fit`,
instead of being served as `cover` and verified against a signature
made for `cover`; only a `fit` missing from the URL is still `cover`;
any other value still goes through the existing fit-mode check;
`README.md` says so where it documents `fit`.
- 2026-09-28 refuse an invalid `q` on `/v1/image/` (closes #134): a `q`
that is not a whole number from 1 to 100, an empty `q` included, is a
400 naming `q` and the value, instead of being served at the default
85; the route reads `q` with the generator's quality check
(`parseFormInt` with `minQuality` and `maxQuality`); only a `q` missing
from the URL is still 85; a query string that cannot be decoded, such
as `q=80%`, is a 400 showing it; any query parameter given more than
once (`q`, `fit`, `sig`, `exp` alike) is a 400 naming it, so none is
read from its first value only; `README.md` states the range and both
query-string rules.
- 2026-09-28 unknown `PIXA_` environment variables abort startup (closes
#133): a variable whose name starts with `PIXA_` but is neither a
setting's variable nor `PIXA_CONFIG_PATH` aborts startup naming it, as
an unknown config key does, and `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; documented in
`README.md`.
- 2026-09-28 start on a fresh upaas volume (closes #129): the image
starts as root only to give `/var/lib/pixa` to `pixad` when `pixad`
does not own it (`deploy/docker-entrypoint.sh`), then runs the server
as `pixad` through `su-exec`, so a root-owned host directory
bind-mounted there no longer stops the container at startup;
`README.md` gains a "Running under upaas" section.
- 2026-09-28 run all linting in Docker via `Dockerfile.lint` +
`script/lint` (closes #104): `make lint` calls `script/lint`, the only
way the linter is run; inside a container (both Dockerfiles set
`container=docker`) it runs `golangci-lint`, anywhere else it builds the
hash-pinned `Dockerfile.lint`, whose last step runs `script/lint` again;
the `Dockerfile` lint stage runs `make lint`; no host or nix-shell
`golangci-lint` path remains (`script/bootstrap` installs no linter);
a per-run `CACHEBUST` build-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 verify` stays 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 by `PORT`; 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 in `config.docker.yml` or
passes `--config`, and its `HEALTHCHECK` probes `PORT` (default `8080`);
the config file is looked for under `/etc/pixa` and `~/.config/pixa`
instead of the daemon name `pixad`; documented in `README.md` and
`config.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`,
using `85` and `cover` when the URL has no `q` or `fit`, 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 in `internal/signature/golden_test.go` and the README signature
specification describe the new format.
- 2026-09-28 Docker image healthcheck (closes #111): a `HEALTHCHECK` in
the runtime stage probing `/.well-known/healthcheck.json` with busybox
`wget`; `script/docker-smoke` (`make docker-smoke`) builds the image,
starts it with a throwaway `PIXA_SIGNING_KEY`, and passes only once
Docker reports it healthy within 30 seconds, removing the container on
exit; the Gitea workflow runs it after `script/cibuild`.
- 2026-09-21 trusted-proxy client IP resolution (closes #94): a
`trusted_proxies` config key taking a list of CIDRs, parsed by the same
`net/netip` list parser as `blocked_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 new
`internal/clientip` package resolves the client address by honoring
`X-Forwarded-For` only 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 in `README.md` and `config.example.yml`.
- 2026-09-21 blocked networks configuration extending SSRF protection: a
`blocked_networks` config key taking a list of CIDRs (parsed with
`net/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 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`
(IPv4-mapped forms covered); enforcement stays in the dial-time
re-resolution so the DNS-rebinding window remains closed; documented in
`README.md` and `config.example.yml`.
- 2026-09-21 validate dimensions and fit mode on the encrypted-URL
route and the token generator (closes #62): `imgcache.ValidateDimension`
alone holds the `MaxDimension` bound and is used by the path parser, by
the new `ValidateImageRequest` (which also applies `ValidateFitMode`)
and by the generator; both the `/v1/image/` and `/v1/e/` routes call
`ValidateImageRequest`, 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 a `width` or `height` that is
not a number or fails the shared check, a `quality` that is not a number
from 1 to 100, a `ttl` that is not a number from 0 to the largest number
of seconds the expiry calculation can hold, or an unknown `fit`; an empty
`quality` is 85 and
an empty `ttl` never 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) and
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
existing timeouts and wired them onto the server; added a `LimitBody`
middleware capping the two form POST bodies (`POST /`, `POST /generate`)
at `MaxFormBytes` (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); left `WriteTimeout` at 60s unchanged
- 2026-08-07 update golangci-lint to v2.12.2 with the canonical - 2026-08-07 update golangci-lint to v2.12.2 with the canonical
`.golangci.yml` (v2 schema, `default: all` minus six disabled `.golangci.yml` (v2 schema, `default: all` minus six disabled
linters, `lll` 88, tests included): bumped the pinned linters, `lll` 88, tests included): bumped the pinned
@@ -116,12 +324,9 @@ P1: implement blocked networks configuration to extend SSRF protection
# Future Steps # Future Steps
- P1: rate limit global concurrent upstream fetches to prevent
resource exhaustion
- P1: strip EXIF and other metadata from processed images (privacy)
- P2: security - P2: security
- referer blacklist - referer blacklist
- per-IP rate limiting - per-IP rate limiting on the image routes
- per-origin rate limiting - per-origin rate limiting
- P2: HTTP response handling - P2: HTTP response handling
- Last-Modified headers - Last-Modified headers
@@ -129,8 +334,6 @@ P1: implement blocked networks configuration to extend SSRF protection
- X-Request-ID propagation - X-Request-ID propagation
- P2: auto format selection (format=auto based on Accept header) - P2: auto format selection (format=auto based on Accept header)
- P2: configuration - P2: configuration
- add all configuration options from README
- environment variable overrides
- YAML config file support - YAML config file support
- P2: operational - P2: operational
- optional Sentry error reporting - optional Sentry error reporting
+64
View File
@@ -1,4 +1,17 @@
# Pixa Example Configuration # Pixa Example Configuration
#
# Every key can also be set by an environment variable, which wins over
# this file: PIXA_ plus the key in upper case, with "." written as "_"
# (state_dir is PIXA_STATE_DIR, metrics.username is
# PIXA_METRICS_USERNAME). The one exception is port, which is set by
# PORT. In a variable, a list is comma-separated. A variable named in
# this file's env: section is set while the file loads, so it overrides
# both the environment the process was started with and this file's own
# key.
#
# Durations are Go duration strings such as 30s or 2m and must be
# positive; a bare number has no unit and aborts startup. Sizes are a
# whole number of bytes.
# Server settings # Server settings
port: 8080 port: 8080
@@ -22,12 +35,63 @@ allowlist_hosts:
- github.com - github.com
- user-images.githubusercontent.com - user-images.githubusercontent.com
# Additional CIDR ranges to refuse when fetching upstream, extending the
# SSRF protection. These are added to the always-enforced built-in ranges
# (loopback, RFC 1918 private, link-local, CGNAT, benchmark, NAT64, and
# similar), never replacing them. Each entry must be a valid CIDR in IPv4
# or IPv6 form; an invalid entry aborts startup.
# blocked_networks:
# - 100.64.0.0/10
# - 2001:db8::/32
# CIDR ranges of the reverse proxies in front of pixa. X-Forwarded-For
# is believed only when the direct peer is inside one of these ranges;
# the client address in the access log and login records is then the
# rightmost forwarded entry that is not itself a trusted proxy. A client
# connecting directly (peer outside these ranges) cannot spoof its
# address: the header is ignored and the peer address is used. When
# omitted, this defaults to the RFC 1918 private ranges (10.0.0.0/8,
# 172.16.0.0/12, 192.168.0.0/16), since pixa is deployed behind a proxy on
# a private network. An explicitly empty list ([]) trusts no one; an
# explicit list replaces the default. An invalid CIDR aborts startup.
# Uncomment to override the defaults with the address pixa sees for
# requests that come through your proxy. That is not always the proxy's own
# address: a proxy on the Docker host that connects over 127.0.0.1 is seen
# as the gateway of the container's Docker network (172.17.0.1 on the
# default bridge), and one that connects through another host address is
# seen with that address. To be sure, look it up in the request log as the
# trusted_proxies entry in README.md describes.
# trusted_proxies:
# - 10.0.0.0/8
# - 2001:db8::/32
# Allow HTTP upstream (only for testing, always use HTTPS in production) # Allow HTTP upstream (only for testing, always use HTTPS in production)
allow_http: false allow_http: false
# Maximum concurrent connections per upstream host (default: 20) # Maximum concurrent connections per upstream host (default: 20)
upstream_connections_per_host: 20 upstream_connections_per_host: 20
# Time allowed for one fetch from an upstream host (default: 30s)
upstream_fetch_timeout: 30s
# Largest upstream response accepted, in bytes, at most 1073741824
# (1 GiB) (default: 52428800, 50 MiB)
upstream_max_response_size: 52428800
# Time allowed for answering one client request, the upstream fetch
# included, so keep it longer than upstream_fetch_timeout (default: 60s)
downstream_timeout: 60s
# The origin a browser lets read pixa's responses, sent as the CORS
# Access-Control-Allow-Origin header: "*" (the default) is any site;
# otherwise one http or https origin such as https://example.com, whose
# host is a lowercase host name (letters, digits, hyphens and dots, with a
# letter in its last part) or an IP address (IPv6 in brackets, in its
# shortest form), with an optional port 1-65535 that has no leading zero
# and is not the scheme's default. Any other value, including another
# scheme such as a browser extension's, aborts startup.
access_control_allow_origin: "*"
# Maximum disk cache size in bytes. Explicit values are used exactly as # Maximum disk cache size in bytes. Explicit values are used exactly as
# given; 0 disables the disk cache entirely (every request fetches and # given; 0 disables the disk cache entirely (every request fetches and
# processes uncached). When omitted, the default is 75% of the free # processes uncached). When omitted, the default is 75% of the free
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# deploy/docker-entrypoint.sh: the Docker image's ENTRYPOINT. It runs as
# root only to give /var/lib/pixa to pixad: a host directory
# bind-mounted there keeps its host owner, often root, and pixad could
# not write to it. The server itself always runs as pixad.
set -eu
main() {
if [ "$(stat -c %U /var/lib/pixa)" != pixad ]; then
chown pixad:pixad /var/lib/pixa
fi
exec su-exec pixad /usr/local/bin/pixad "$@"
}
main "$@"
+5
View File
@@ -11,7 +11,10 @@ require (
github.com/getsentry/sentry-go v0.40.0 github.com/getsentry/sentry-go v0.40.0
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.16.0
github.com/gorilla/csrf v1.7.3
github.com/gorilla/securecookie v1.1.2 github.com/gorilla/securecookie v1.1.2
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/slok/go-http-metrics v0.13.0 github.com/slok/go-http-metrics v0.13.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
@@ -90,6 +93,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
@@ -112,6 +116,7 @@ require (
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/pretty v1.2.0 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.etcd.io/etcd/api/v3 v3.6.2 // indirect go.etcd.io/etcd/api/v3 v3.6.2 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.6.2 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.2 // indirect
go.etcd.io/etcd/client/v3 v3.6.2 // indirect go.etcd.io/etcd/client/v3 v3.6.2 // indirect
+12
View File
@@ -114,6 +114,8 @@ github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8=
github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
@@ -175,6 +177,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
@@ -224,6 +228,8 @@ github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
@@ -249,6 +255,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -394,6 +402,10 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.etcd.io/etcd/api/v3 v3.6.2 h1:25aCkIMjUmiiOtnBIp6PhNj4KdcURuBak0hU2P1fgRc= go.etcd.io/etcd/api/v3 v3.6.2 h1:25aCkIMjUmiiOtnBIp6PhNj4KdcURuBak0hU2P1fgRc=
go.etcd.io/etcd/api/v3 v3.6.2/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk= go.etcd.io/etcd/api/v3 v3.6.2/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk=
go.etcd.io/etcd/client/pkg/v3 v3.6.2 h1:zw+HRghi/G8fKpgKdOcEKpnBTE4OO39T6MegA0RopVU= go.etcd.io/etcd/client/pkg/v3 v3.6.2 h1:zw+HRghi/G8fKpgKdOcEKpnBTE4OO39T6MegA0RopVU=
+119
View File
@@ -0,0 +1,119 @@
// Package clientip resolves the real client IP address of an HTTP request
// when pixa runs behind a reverse proxy. Forwarding headers are believed
// only when the immediate peer is a configured trusted proxy, so an
// untrusted client cannot spoof its address by sending the header.
package clientip
import (
"context"
"net"
"net/netip"
"slices"
"strings"
)
// ForwardedForHeader is the request header carrying the proxy chain. It is
// honored only when the immediate peer is a trusted proxy.
const ForwardedForHeader = "X-Forwarded-For"
// Resolver determines the client IP of a request against a fixed set of
// trusted proxy networks.
type Resolver struct {
trusted []netip.Prefix
}
// NewResolver returns a Resolver that trusts forwarding headers only from
// peers inside the given CIDR ranges. A nil or empty list trusts no one,
// so the peer address is always used.
func NewResolver(trusted []netip.Prefix) *Resolver {
return &Resolver{trusted: trusted}
}
// Resolve returns the client IP for a request whose direct peer is
// remoteAddr (a "host:port" string as in http.Request.RemoteAddr) and
// whose X-Forwarded-For header lines are forwardedFor (as returned by
// http.Header.Values). When the peer is not a trusted proxy, the peer
// address is returned and the header is ignored entirely. When the peer is
// trusted, the header is walked right to left and the first address that is
// not itself a trusted proxy is returned; this is the client the outermost
// trusted proxy observed, and entries an untrusted client may have prepended
// sit to its left and are never reached.
func (r *Resolver) Resolve(remoteAddr string, forwardedFor []string) string {
peer := hostOnly(remoteAddr)
peerAddr, err := netip.ParseAddr(peer)
if err != nil || !r.isTrusted(peerAddr) {
return peer
}
for _, hop := range slices.Backward(forwardedForChain(forwardedFor)) {
hopAddr, err := netip.ParseAddr(hop)
if err != nil || r.isTrusted(hopAddr) {
continue
}
return hopAddr.String()
}
return peerAddr.String()
}
// isTrusted reports whether addr falls inside one of the trusted proxy
// ranges. Addresses are unmapped first so an IPv4-mapped IPv6 form matches
// an IPv4 range, matching the fetcher's blocklist comparison.
func (r *Resolver) isTrusted(addr netip.Addr) bool {
if !addr.IsValid() {
return false
}
unmapped := addr.Unmap()
return slices.ContainsFunc(r.trusted, func(prefix netip.Prefix) bool {
return prefix.Contains(unmapped)
})
}
// hostOnly strips the port from a "host:port" address. A value without a
// port (already a bare host) is returned unchanged.
func hostOnly(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return remoteAddr
}
return host
}
// forwardedForChain flattens the comma-separated entries of every
// X-Forwarded-For header line into a single ordered, trimmed list.
func forwardedForChain(values []string) []string {
var chain []string
for _, value := range values {
for part := range strings.SplitSeq(value, ",") {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
chain = append(chain, trimmed)
}
}
}
return chain
}
// contextKey is the private key type under which the resolved client IP is
// stored in a request context.
type contextKey struct{}
// WithClientIP returns a copy of ctx carrying the resolved client IP.
func WithClientIP(ctx context.Context, ip string) context.Context {
return context.WithValue(ctx, contextKey{}, ip)
}
// FromContext returns the resolved client IP stored in ctx, or an empty
// string if none was set.
func FromContext(ctx context.Context) string {
ip, _ := ctx.Value(contextKey{}).(string)
return ip
}
+189
View File
@@ -0,0 +1,189 @@
package clientip_test
import (
"net/netip"
"testing"
"sneak.berlin/go/pixa/internal/clientip"
)
// Addresses reused across the resolver cases.
const (
trustedRangeV4 = "10.0.0.0/8"
forwardedV4 = "203.0.113.7"
untrustedV4 = "198.51.100.9"
trustedPeer = "10.0.0.1:5000"
)
// mustPrefixes parses CIDR strings into prefixes for building a resolver.
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
t.Helper()
prefixes := make([]netip.Prefix, 0, len(cidrs))
for _, c := range cidrs {
p, err := netip.ParsePrefix(c)
if err != nil {
t.Fatalf("netip.ParsePrefix(%q) error = %v", c, err)
}
prefixes = append(prefixes, p)
}
return prefixes
}
type resolveCase struct {
name string
trusted []string
remoteAddr string
forwardedFor []string
want string
}
// runResolveCases runs each case against a resolver built from its trusted
// list and checks the resolved address.
func runResolveCases(t *testing.T, cases []resolveCase) {
t.Helper()
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := clientip.NewResolver(mustPrefixes(t, tt.trusted...))
got := r.Resolve(tt.remoteAddr, tt.forwardedFor)
if got != tt.want {
t.Errorf("Resolve(%q, %v) = %q, want %q",
tt.remoteAddr, tt.forwardedFor, got, tt.want)
}
})
}
}
// TestResolvePeerTrust covers the trust decision on the direct peer: a
// forwarded header is believed only from a trusted peer, and a client
// connecting directly cannot spoof its address.
func TestResolvePeerTrust(t *testing.T) {
t.Parallel()
runResolveCases(t, []resolveCase{
{
name: "trusted peer honors forwarded client",
trusted: []string{trustedRangeV4},
remoteAddr: trustedPeer,
forwardedFor: []string{forwardedV4},
want: forwardedV4,
},
{
name: "untrusted peer ignores forwarded header",
trusted: []string{trustedRangeV4},
remoteAddr: untrustedV4 + ":33333",
forwardedFor: []string{forwardedV4},
want: untrustedV4,
},
{
name: "spoofed chain from untrusted peer cannot influence result",
trusted: []string{trustedRangeV4},
remoteAddr: untrustedV4 + ":33333",
forwardedFor: []string{"1.2.3.4, 10.9.9.9, 127.0.0.1"},
want: untrustedV4,
},
{
name: "empty trusted list always uses peer",
trusted: nil,
remoteAddr: forwardedV4 + ":80",
forwardedFor: []string{"10.0.0.5"},
want: forwardedV4,
},
{
name: "trusted peer with no forwarded header uses peer",
trusted: []string{trustedRangeV4},
remoteAddr: trustedPeer,
forwardedFor: nil,
want: "10.0.0.1",
},
{
name: "unparseable peer is returned unchanged",
trusted: []string{trustedRangeV4},
remoteAddr: "garbage",
forwardedFor: []string{forwardedV4},
want: "garbage",
},
})
}
// TestResolveChainWalk covers walking the X-Forwarded-For chain from a
// trusted peer to the rightmost entry that is not itself a trusted proxy.
func TestResolveChainWalk(t *testing.T) {
t.Parallel()
runResolveCases(t, []resolveCase{
{
name: "rightmost untrusted entry across a mixed chain",
trusted: []string{trustedRangeV4, "192.168.0.0/16"},
remoteAddr: trustedPeer,
forwardedFor: []string{forwardedV4 + ", 192.168.1.1, 10.0.0.2"},
want: forwardedV4,
},
{
name: "spoofed client behind a trusted proxy is not believed",
trusted: []string{trustedRangeV4},
remoteAddr: trustedPeer,
forwardedFor: []string{"1.2.3.4, " + untrustedV4},
want: untrustedV4,
},
{
name: "chain split across multiple header lines",
trusted: []string{trustedRangeV4},
remoteAddr: trustedPeer,
forwardedFor: []string{forwardedV4, "10.0.0.2"},
want: forwardedV4,
},
{
name: "garbage entries are skipped",
trusted: []string{trustedRangeV4},
remoteAddr: trustedPeer,
forwardedFor: []string{forwardedV4 + ", not-an-ip"},
want: forwardedV4,
},
{
name: "all-trusted chain falls back to peer",
trusted: []string{trustedRangeV4},
remoteAddr: trustedPeer,
forwardedFor: []string{"10.0.0.9, 10.0.0.2"},
want: "10.0.0.1",
},
{
name: "trusted IPv6 peer honors forwarded client",
trusted: []string{"2001:db8::/32"},
remoteAddr: "[2001:db8::1]:9000",
forwardedFor: []string{forwardedV4},
want: forwardedV4,
},
{
name: "IPv4-mapped peer matches IPv4 trusted range",
trusted: []string{trustedRangeV4},
remoteAddr: "[::ffff:10.0.0.1]:5000",
forwardedFor: []string{forwardedV4},
want: forwardedV4,
},
})
}
func TestContextRoundTrip(t *testing.T) {
t.Parallel()
ctx := clientip.WithClientIP(t.Context(), forwardedV4)
if got := clientip.FromContext(ctx); got != forwardedV4 {
t.Errorf("FromContext = %q, want %q", got, forwardedV4)
}
}
func TestFromContextAbsent(t *testing.T) {
t.Parallel()
if got := clientip.FromContext(t.Context()); got != "" {
t.Errorf("FromContext with no value = %q, want empty", got)
}
}
@@ -0,0 +1,90 @@
package config
import (
"testing"
)
// TestBlockedNetworksParsed loads a valid blocked_networks list and checks
// each CIDR is parsed into the resolved prefixes in order.
func TestBlockedNetworksParsed(t *testing.T) {
t.Parallel()
yamlContent := signingKeyLine + `blocked_networks:
- 203.0.113.0/24
- 2001:db8::/32
`
c, err := configFromYAML(t, yamlContent)
if err != nil {
t.Fatalf("valid blocked_networks should load, got error: %v", err)
}
want := []string{"203.0.113.0/24", "2001:db8::/32"}
if len(c.BlockedNetworks) != len(want) {
t.Fatalf("BlockedNetworks = %v, want %d entries", c.BlockedNetworks, len(want))
}
for i, w := range want {
if got := c.BlockedNetworks[i].String(); got != w {
t.Errorf("BlockedNetworks[%d] = %q, want %q", i, got, w)
}
}
}
// TestBlockedNetworksOmittedIsEmpty confirms an omitted key leaves the
// operator list empty; the built-in defaults still apply in the fetcher.
func TestBlockedNetworksOmittedIsEmpty(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine)
if err != nil {
t.Fatalf("minimal config should be valid, got error: %v", err)
}
if len(c.BlockedNetworks) != 0 {
t.Errorf("BlockedNetworks = %v, want empty", c.BlockedNetworks)
}
}
// TestBlockedNetworksInvalidAbortsStartup checks that malformed values abort
// startup with an error naming the key and the offending value.
func TestBlockedNetworksInvalidAbortsStartup(t *testing.T) {
t.Parallel()
runAbortCases(t, []abortCase{
{
name: "not-a-cidr",
yaml: signingKeyLine + `blocked_networks:
- not-a-cidr
`,
wantErrSubstrings: []string{keyBlockedNetworks, "not-a-cidr"},
},
{
name: "bare-address-without-prefix",
yaml: signingKeyLine + `blocked_networks:
- 10.0.0.1
`,
wantErrSubstrings: []string{keyBlockedNetworks, "10.0.0.1"},
},
{
name: "empty-entry",
yaml: signingKeyLine + `blocked_networks:
- ""
`,
wantErrSubstrings: []string{keyBlockedNetworks},
},
{
name: "non-string-entry",
yaml: signingKeyLine + `blocked_networks:
- 42
`,
wantErrSubstrings: []string{keyBlockedNetworks},
},
{
name: "null-value",
yaml: signingKeyLine + `blocked_networks:
`,
wantErrSubstrings: []string{keyBlockedNetworks, nullValueText},
},
})
}
+505 -94
View File
@@ -6,16 +6,17 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"math" "math"
"net/netip"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"git.eeqj.de/sneak/smartconfig" "git.eeqj.de/sneak/smartconfig"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/pixa/internal/globals"
"sneak.berlin/go/pixa/internal/logger" "sneak.berlin/go/pixa/internal/logger"
) )
@@ -24,6 +25,10 @@ const (
DefaultPort = 8080 DefaultPort = 8080
DefaultStateDir = "/var/lib/pixa" DefaultStateDir = "/var/lib/pixa"
DefaultUpstreamConnectionsPerHost = 20 DefaultUpstreamConnectionsPerHost = 20
DefaultAccessControlAllowOrigin = "*"
DefaultUpstreamFetchTimeout = 30 * time.Second
DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB
DefaultDownstreamTimeout = 60 * time.Second
) )
// Configuration key names. // Configuration key names.
@@ -42,25 +47,43 @@ const (
keyAllowHTTP = "allow_http" keyAllowHTTP = "allow_http"
keyUpstreamConnectionsPerHost = "upstream_connections_per_host" keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
keyCacheMaxBytes = "cache_max_bytes" keyCacheMaxBytes = "cache_max_bytes"
keyBlockedNetworks = "blocked_networks"
keyTrustedProxies = "trusted_proxies"
keyAccessControlAllowOrigin = "access_control_allow_origin"
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
keyUpstreamMaxResponseSize = "upstream_max_response_size"
keyDownstreamTimeout = "downstream_timeout"
) )
// placeholderSigningKey is the dummy signing_key shipped in
// config.example.yml. It is 45 characters, so it passes the length
// check, but it is public in this repository and must be rejected at
// startup so no deployment ever signs URLs with it.
const placeholderSigningKey = "CHANGE_ME_generate_with_openssl_rand_base64_32"
// Static validation errors. Each use site attaches the offending key // Static validation errors. Each use site attaches the offending key
// and value by wrapping these with fmt.Errorf and %w. // and value by wrapping these with fmt.Errorf and %w.
var ( var (
errValueRequired = errors.New("a value is required") errValueRequired = errors.New("a value is required")
errValueEmpty = errors.New("value must not be empty") errValueEmpty = errors.New("value must not be empty")
errUnknownConfigKeys = errors.New("unknown config keys") errUnknownConfigKeys = errors.New("unknown config keys")
errUnknownEnvVars = errors.New("unknown environment variables")
errNotAString = errors.New("not a string") errNotAString = errors.New("not a string")
errNotAnInteger = errors.New("not an integer") errNotAnInteger = errors.New("not an integer")
errNotABoolean = errors.New("not a boolean") errNotABoolean = errors.New("not a boolean")
errNotAStringList = errors.New("not a list of strings") errNotAStringList = errors.New("not a list of strings")
errNotAValidCIDR = errors.New("not a valid CIDR network")
errNotAMetricsMap = errors.New("not a map of metrics settings") errNotAMetricsMap = errors.New("not a map of metrics settings")
errEmptyListEntry = errors.New("list contains an empty entry") errEmptyListEntry = errors.New("list contains an empty entry")
errEmptyEntry = errors.New("contains an empty entry") errEmptyEntry = errors.New("contains an empty entry")
errNotAValidURL = errors.New("not a valid URL") errNotAValidURL = errors.New("not a valid URL")
errPortOutOfRange = errors.New("outside the valid port range") errPortOutOfRange = errors.New("outside the valid port range")
errSizeOutOfRange = errors.New("outside the accepted range")
errTooFewConnections = errors.New("must be at least 1") errTooFewConnections = errors.New("must be at least 1")
errValueTooShort = errors.New("value too short") errValueTooShort = errors.New("value too short")
errPlaceholderKey = errors.New(
"is the placeholder from config.example.yml; " +
"generate a real key with: openssl rand -base64 32")
errMustBeSetTogether = errors.New("must be set together") errMustBeSetTogether = errors.New("must be set together")
errMustNotBeNegative = errors.New("must not be negative") errMustNotBeNegative = errors.New("must not be negative")
errOverflowsInt64 = errors.New("overflows a 64-bit integer") errOverflowsInt64 = errors.New("overflows a 64-bit integer")
@@ -73,13 +96,16 @@ var (
errNotBareHostname = errors.New( errNotBareHostname = errors.New(
"must be a bare hostname without scheme, path, or whitespace") "must be a bare hostname without scheme, path, or whitespace")
errNoHostnameLabels = errors.New("contains no hostname labels") errNoHostnameLabels = errors.New("contains no hostname labels")
errNotADuration = errors.New("not a duration such as 30s or 2m")
errMustBePositive = errors.New("must be positive")
errNotAnOrigin = errors.New(
`not "*" or an origin such as https://example.com`)
) )
// Params defines dependencies for Config. // Params defines dependencies for Config.
type Params struct { type Params struct {
fx.In fx.In
Globals *globals.Globals
Logger *logger.Logger Logger *logger.Logger
} }
@@ -100,6 +126,39 @@ type Config struct {
AllowHTTP bool // Allow non-TLS upstream (testing only) AllowHTTP bool // Allow non-TLS upstream (testing only)
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
// UpstreamFetchTimeout is the time allowed for one fetch from an
// upstream host. UpstreamMaxResponseSize is the largest upstream
// response accepted, in bytes, and also the image processor's input
// limit.
UpstreamFetchTimeout time.Duration
UpstreamMaxResponseSize int64
// AccessControlAllowOrigin is the origin the CORS middleware allows
// to read responses: "*" for any, or one origin such as
// https://example.com.
AccessControlAllowOrigin string
// DownstreamTimeout bounds how long answering one client request may
// take. It is both the HTTP server's write timeout and the deadline
// of the per-request timeout middleware.
DownstreamTimeout time.Duration
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
// addition to the built-in SSRF blocklist. Enforced by the upstream
// fetcher's dialer; the built-in ranges always apply.
BlockedNetworks []netip.Prefix
// TrustedProxies are the CIDR ranges of reverse proxies whose
// forwarding headers may be believed. Forwarded headers are honored
// only when the immediate peer falls inside one of these ranges;
// otherwise the peer address is used and the headers are ignored, so
// an untrusted client cannot spoof its address. An omitted key
// defaults to the RFC 1918 private ranges (see defaultTrustedProxies),
// since pixa is deployed behind a proxy on a private network; an
// explicitly empty list trusts nothing and always uses the peer
// address, and an explicit list replaces the default.
TrustedProxies []netip.Prefix
// CacheMaxBytes is the disk cache size limit in bytes. Zero // CacheMaxBytes is the disk cache size limit in bytes. Zero
// disables the disk cache entirely. When cache_max_bytes is // disables the disk cache entirely. When cache_max_bytes is
// omitted from the configuration, this holds the computed default // omitted from the configuration, this holds the computed default
@@ -108,24 +167,34 @@ type Config struct {
CacheMaxBytes int64 CacheMaxBytes int64
// cacheMaxBytesExplicit records whether cache_max_bytes was // cacheMaxBytesExplicit records whether cache_max_bytes was
// explicitly set in the configuration file. Explicit values are // explicitly set, in the environment or the configuration file.
// used exactly as given; only an omitted key gets the computed // Explicit values are used exactly as given; only an omitted key
// default (and its floor) in resolveCacheMaxBytes. // gets the computed default (and its floor) in resolveCacheMaxBytes.
cacheMaxBytesExplicit bool cacheMaxBytesExplicit bool
} }
// New creates a new Config instance by loading configuration from file. // New creates a new Config instance from the environment and the
// config file.
func New(_ fx.Lifecycle, params Params) (*Config, error) { func New(_ fx.Lifecycle, params Params) (*Config, error) {
log := params.Logger.Get() log := params.Logger.Get()
name := params.Globals.Appname
sc, err := loadConfigFile(log, name) // Look for the config file under the project name (/etc/pixa/,
// ~/.config/pixa/), matching the /var/lib/pixa state directory,
// not under the daemon name pixad.
sc, err := loadConfigFile(log, "pixa")
if err != nil {
return nil, err
}
// Loading the config file sets the variables in its env section,
// so this also checks their names.
err = validateKnownEnvVars()
if err != nil { if err != nil {
return nil, err return nil, err
} }
if sc == nil { if sc == nil {
log.Info("no config file found, using defaults") log.Info("no config file found, using environment variables and defaults")
} }
c, err := newFromSmartConfig(sc) c, err := newFromSmartConfig(sc)
@@ -150,22 +219,41 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
return c, nil return c, nil
} }
// newFromSmartConfig constructs a Config from a loaded smartconfig // newFromSmartConfig constructs a Config from the environment and a
// instance and validates it. A nil sc means no config file was found, // loaded smartconfig instance, and validates it. A nil sc means no
// in which case every option takes its default value. A key that is // config file was found, in which case every option the environment
// present but unparseable or invalid is an error: defaults apply only // does not set takes its default value. A key that is present but
// to omitted keys, never to invalid explicit values. // unparseable or invalid is an error: defaults apply only to omitted
// keys, never to invalid explicit values.
func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) { func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
if sc != nil { if sc != nil {
err := validateKnownKeys(sc) err := validateKnownKeys(sc)
if err != nil { if err != nil {
return nil, err return nil, err
} }
}
err = validateAllowlistHostsValue(sc) err := validateAllowlistHostsValue(sc)
if err != nil { if err != nil {
return nil, err return nil, err
} }
blockedNetworks, err := parseCIDRList(sc, keyBlockedNetworks)
if err != nil {
return nil, err
}
trustedProxies, err := parseCIDRList(sc, keyTrustedProxies)
if err != nil {
return nil, err
}
// parseCIDRList returns a nil slice only when the key is absent; an
// explicitly empty list ([]) comes back non-nil and empty. An omitted
// key takes the RFC 1918 default, while an explicit empty list is left
// as trust-nothing.
if trustedProxies == nil {
trustedProxies = defaultTrustedProxies()
} }
loader := &strictLoader{sc: sc} loader := &strictLoader{sc: sc}
@@ -183,29 +271,34 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
AllowHTTP: loader.boolVal(keyAllowHTTP, false), AllowHTTP: loader.boolVal(keyAllowHTTP, false),
UpstreamConnectionsPerHost: loader.intVal( UpstreamConnectionsPerHost: loader.intVal(
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost), keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
UpstreamFetchTimeout: loader.durationVal(
keyUpstreamFetchTimeout, DefaultUpstreamFetchTimeout),
UpstreamMaxResponseSize: loader.int64Val(
keyUpstreamMaxResponseSize, DefaultUpstreamMaxResponseSize),
AccessControlAllowOrigin: loader.stringVal(
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
DownstreamTimeout: loader.durationVal(
keyDownstreamTimeout, DefaultDownstreamTimeout),
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0), CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
BlockedNetworks: blockedNetworks,
TrustedProxies: trustedProxies,
} }
// The computed default for cache_max_bytes needs a validated // The computed default for cache_max_bytes needs a validated
// state_dir, so it is resolved later (resolveCacheMaxBytes); here // state_dir, so it is resolved later (resolveCacheMaxBytes); here
// we only record whether the operator set the key explicitly. // we only record whether the operator set the key explicitly.
if sc != nil { if _, present := lookupValue(sc, keyCacheMaxBytes); present {
if _, present := sc.Get(keyCacheMaxBytes); present {
c.cacheMaxBytesExplicit = true c.cacheMaxBytesExplicit = true
} }
}
// Build DBURL from StateDir if not explicitly set. The derived URL // Build DBURL from StateDir if not explicitly set. The derived URL
// is a default: it applies only when db_url is omitted, never to an // is a default: it applies only when db_url is omitted, never to an
// explicitly empty value. // explicitly empty value.
c.DBURL = loader.stringVal(keyDBURL, "") c.DBURL = loader.stringVal(keyDBURL, "")
if c.DBURL == "" && loader.err == nil { if c.DBURL == "" && loader.err == nil {
if sc != nil { if _, present := lookupValue(sc, keyDBURL); present {
if _, present := sc.Get(keyDBURL); present { return nil, fmt.Errorf("%s: %w; omit it to derive it from state_dir",
return nil, fmt.Errorf( settingName(keyDBURL), errValueEmpty)
"config key %q: %w; omit the key to derive it from state_dir",
keyDBURL, errValueEmpty)
}
} }
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir) c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
@@ -215,7 +308,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
return nil, loader.err return nil, loader.err
} }
err := c.validate() err = c.validate()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -299,13 +392,104 @@ func isKnownConfigKey(key string) bool {
switch key { switch key {
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN, case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP, keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, "env": keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks,
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
keyUpstreamMaxResponseSize, keyDownstreamTimeout, "env":
return true return true
} }
return false return false
} }
// envVarNames returns, for each configuration key, the environment
// variable that also sets it: PIXA_ plus the key in upper case, with "."
// written as "_", except the port, which REPO_POLICIES.md requires to be
// PORT. metrics is set through its two subkeys; env has no variable.
func envVarNames() map[string]string {
return map[string]string{ //nolint:gosec // G101: variable names, not secrets
keyDebug: "PIXA_DEBUG",
keyMaintenanceMode: "PIXA_MAINTENANCE_MODE",
keyPort: "PORT",
keyStateDir: "PIXA_STATE_DIR",
keySentryDSN: "PIXA_SENTRY_DSN",
keyDBURL: "PIXA_DB_URL",
keyMetricsUsername: "PIXA_METRICS_USERNAME",
keyMetricsPassword: "PIXA_METRICS_PASSWORD",
keySigningKey: "PIXA_SIGNING_KEY",
keyAllowlistHosts: "PIXA_ALLOWLIST_HOSTS",
keyAllowHTTP: "PIXA_ALLOW_HTTP",
keyUpstreamConnectionsPerHost: "PIXA_UPSTREAM_CONNECTIONS_PER_HOST",
keyCacheMaxBytes: "PIXA_CACHE_MAX_BYTES",
keyBlockedNetworks: "PIXA_BLOCKED_NETWORKS",
keyTrustedProxies: "PIXA_TRUSTED_PROXIES",
keyAccessControlAllowOrigin: "PIXA_ACCESS_CONTROL_ALLOW_ORIGIN",
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
}
}
// validateKnownEnvVars rejects environment variables whose names start
// with PIXA_ but that are neither a setting's variable nor
// PIXA_CONFIG_PATH, so a misspelled variable fails at startup instead of
// being silently ignored, as validateKnownKeys does for config file keys.
// New calls it after loading the config file, so the variables the
// file's env section sets are checked too.
func validateKnownEnvVars() error {
known := map[string]bool{"PIXA_CONFIG_PATH": true}
for _, name := range envVarNames() {
known[name] = true
}
var unknown []string
for _, entry := range os.Environ() {
name, _, _ := strings.Cut(entry, "=")
switch {
case !strings.HasPrefix(name, "PIXA_") || known[name]:
continue
case name == "PIXA_PORT":
unknown = append(unknown, name+" (use PORT for the port)")
default:
unknown = append(unknown, name)
}
}
if len(unknown) > 0 {
sort.Strings(unknown)
return fmt.Errorf("%w: %s", errUnknownEnvVars, strings.Join(unknown, ", "))
}
return nil
}
// lookupValue returns the value set for key and whether one is set. The
// key's environment variable wins when it is present, even when empty;
// its value is a string, read exactly as the same text quoted in the
// config file would be. Otherwise the config file's value is used.
func lookupValue(sc *smartconfig.Config, key string) (any, bool) {
value, present := os.LookupEnv(envVarNames()[key])
if present {
return value, true
}
if sc == nil {
return nil, false
}
return sc.Get(key)
}
// settingName names key in an error message together with its
// environment variable, since either one may have set the value.
func settingName(key string) string {
return fmt.Sprintf("config key %q (environment variable %s)",
key, envVarNames()[key])
}
// ensureStateDirWritable verifies at startup that StateDir can be // ensureStateDirWritable verifies at startup that StateDir can be
// created and written to, so a misconfigured path aborts startup // created and written to, so a misconfigured path aborts startup
// instead of failing later at first use. // instead of failing later at first use.
@@ -314,28 +498,51 @@ func (c *Config) ensureStateDirWritable() error {
err := os.MkdirAll(c.StateDir, stateDirPerms) err := os.MkdirAll(c.StateDir, stateDirPerms)
if err != nil { if err != nil {
return fmt.Errorf("config key %q: cannot create directory %q: %w", return fmt.Errorf("%s: cannot create directory %q: %w",
keyStateDir, c.StateDir, err) settingName(keyStateDir), c.StateDir, err)
} }
probe, err := os.CreateTemp(c.StateDir, ".startup-write-probe-*") probe, err := os.CreateTemp(c.StateDir, ".startup-write-probe-*")
if err != nil { if err != nil {
return fmt.Errorf("config key %q: directory %q is not writable: %w", return fmt.Errorf("%s: directory %q is not writable: %w",
keyStateDir, c.StateDir, err) settingName(keyStateDir), c.StateDir, err)
} }
probePath := probe.Name() probePath := probe.Name()
err = probe.Close() err = probe.Close()
if err != nil { if err != nil {
return fmt.Errorf("config key %q: cannot close probe file %q: %w", return fmt.Errorf("%s: cannot close probe file %q: %w",
keyStateDir, probePath, err) settingName(keyStateDir), probePath, err)
} }
err = os.Remove(probePath) err = os.Remove(probePath)
if err != nil { if err != nil {
return fmt.Errorf("config key %q: cannot remove probe file %q: %w", return fmt.Errorf("%s: cannot remove probe file %q: %w",
keyStateDir, probePath, err) settingName(keyStateDir), probePath, err)
}
return nil
}
// validateSigningKey checks that the signing key is present, long
// enough, and not the public placeholder from config.example.yml. The
// key value itself is never echoed in error messages.
func (c *Config) validateSigningKey() error {
if c.SigningKey == "" {
return fmt.Errorf("%s: %w", settingName(keySigningKey), errValueRequired)
}
// Minimum key length for security (32 bytes = 256 bits)
const minKeyLength = 32
if len(c.SigningKey) < minKeyLength {
return fmt.Errorf("%s: %w: must be at least %d characters, got %d",
settingName(keySigningKey), errValueTooShort, minKeyLength,
len(c.SigningKey))
}
if c.SigningKey == placeholderSigningKey {
return fmt.Errorf("%s: %w", settingName(keySigningKey), errPlaceholderKey)
} }
return nil return nil
@@ -344,39 +551,37 @@ func (c *Config) ensureStateDirWritable() error {
// validate checks that all required configuration values are set and // validate checks that all required configuration values are set and
// that every value is within its valid range. // that every value is within its valid range.
func (c *Config) validate() error { func (c *Config) validate() error {
// The signing key value is never echoed in error messages. err := c.validateSigningKey()
if c.SigningKey == "" { if err != nil {
return fmt.Errorf("config key %q: %w", keySigningKey, errValueRequired) return err
}
// Minimum key length for security (32 bytes = 256 bits)
const minKeyLength = 32
if len(c.SigningKey) < minKeyLength {
return fmt.Errorf("config key %q: %w: must be at least %d characters, got %d",
keySigningKey, errValueTooShort, minKeyLength, len(c.SigningKey))
} }
const maxPort = 65535 const maxPort = 65535
if c.Port < 1 || c.Port > maxPort { if c.Port < 1 || c.Port > maxPort {
return fmt.Errorf("config key %q: value %d is %w 1-%d", return fmt.Errorf("%s: value %d is %w 1-%d",
keyPort, c.Port, errPortOutOfRange, maxPort) settingName(keyPort), c.Port, errPortOutOfRange, maxPort)
} }
if c.UpstreamConnectionsPerHost < 1 { if c.UpstreamConnectionsPerHost < 1 {
return fmt.Errorf("config key %q: value %d %w", return fmt.Errorf("%s: value %d %w",
keyUpstreamConnectionsPerHost, c.UpstreamConnectionsPerHost, settingName(keyUpstreamConnectionsPerHost),
errTooFewConnections) c.UpstreamConnectionsPerHost, errTooFewConnections)
} }
if c.StateDir == "" { if c.StateDir == "" {
return fmt.Errorf("config key %q: %w", keyStateDir, errValueEmpty) return fmt.Errorf("%s: %w", settingName(keyStateDir), errValueEmpty)
} }
// Zero is valid (it disables the disk cache); only negative // Zero is valid (it disables the disk cache); only negative
// values are rejected. No floor applies to explicit values. // values are rejected. No floor applies to explicit values.
if c.CacheMaxBytes < 0 { if c.CacheMaxBytes < 0 {
return fmt.Errorf("config key %q: value %d %w", return fmt.Errorf("%s: value %d %w",
keyCacheMaxBytes, c.CacheMaxBytes, errMustNotBeNegative) settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
}
err = c.validateUpstreamMaxResponseSize()
if err != nil {
return err
} }
for _, host := range c.AllowlistHosts { for _, host := range c.AllowlistHosts {
@@ -389,14 +594,91 @@ func (c *Config) validate() error {
if c.SentryDSN != "" { if c.SentryDSN != "" {
parsed, err := url.Parse(c.SentryDSN) parsed, err := url.Parse(c.SentryDSN)
if err != nil || parsed.Scheme == "" || parsed.Host == "" { if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("config key %q: value %q is %w", return fmt.Errorf("%s: value %q is %w",
keySentryDSN, c.SentryDSN, errNotAValidURL) settingName(keySentryDSN), c.SentryDSN, errNotAValidURL)
} }
} }
if (c.MetricsUsername == "") != (c.MetricsPassword == "") { if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
return fmt.Errorf("config keys %q and %q %w", return fmt.Errorf("%s and %s %w",
keyMetricsUsername, keyMetricsPassword, errMustBeSetTogether) settingName(keyMetricsUsername), settingName(keyMetricsPassword),
errMustBeSetTogether)
}
return c.validateAccessControlAllowOrigin()
}
// validateUpstreamMaxResponseSize checks that upstream_max_response_size
// is from 1 byte to 1 GiB. An upstream response is read whole into
// memory, and the image processor reads one byte past this limit, which
// must not overflow.
func (c *Config) validateUpstreamMaxResponseSize() error {
const maxUpstreamMaxResponseSize = 1 << 30 // 1 GiB
if c.UpstreamMaxResponseSize < 1 ||
c.UpstreamMaxResponseSize > maxUpstreamMaxResponseSize {
return fmt.Errorf("%s: value %d is %w 1-%d",
settingName(keyUpstreamMaxResponseSize), c.UpstreamMaxResponseSize,
errSizeOutOfRange, maxUpstreamMaxResponseSize)
}
return nil
}
// validateAccessControlAllowOrigin accepts "*" or an origin exactly as a browser
// sends it: http or https, an IP address as netip writes it or a lowercase name
// with a letter in its last part, and an optional port 1-65535, not the default.
func (c *Config) validateAccessControlAllowOrigin() error {
origin := c.AccessControlAllowOrigin
if origin == "*" {
return nil
}
errOrigin := fmt.Errorf("%s: value %q is %w",
settingName(keyAccessControlAllowOrigin), origin, errNotAnOrigin)
parsed, err := url.Parse(origin)
if err != nil {
return errOrigin
}
defaultPort := map[string]string{"http": "80", "https": "443"}[parsed.Scheme]
if defaultPort == "" {
return errOrigin
}
const letters = "abcdefghijklmnopqrstuvwxyz"
host := parsed.Hostname()
lastPart := host[strings.LastIndex(host, ".")+1:]
addr, err := netip.ParseAddr(host)
switch {
case err == nil && addr.Is6():
host = "[" + addr.String() + "]"
case err == nil:
host = addr.String()
case strings.Trim(host, letters+"0123456789-.") != "": // a character other than these
return errOrigin
case !strings.ContainsAny(lastPart, letters):
return errOrigin
}
// The value must be exactly the origin rebuilt from its parts.
rebuilt := parsed.Scheme + "://" + host
port := parsed.Port()
if port != "" {
_, err := strconv.ParseUint(port, 10, 16)
if err != nil || port[0] == '0' || port == defaultPort {
return errOrigin
}
rebuilt += ":" + port
}
if rebuilt != origin {
return errOrigin
} }
return nil return nil
@@ -411,13 +693,13 @@ func (c *Config) validate() error {
// disable URL signing. // disable URL signing.
func validateAllowlistHost(host string) error { func validateAllowlistHost(host string) error {
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") { if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
return fmt.Errorf("config key %q: entry %q %w", return fmt.Errorf("%s: entry %q %w",
keyAllowlistHosts, host, errNotBareHostname) settingName(keyAllowlistHosts), host, errNotBareHostname)
} }
if strings.Trim(host, ".") == "" { if strings.Trim(host, ".") == "" {
return fmt.Errorf("config key %q: entry %q %w", return fmt.Errorf("%s: entry %q %w",
keyAllowlistHosts, host, errNoHostnameLabels) settingName(keyAllowlistHosts), host, errNoHostnameLabels)
} }
return nil return nil
@@ -516,6 +798,19 @@ func (l *strictLoader) int64Val(key string, defaultVal int64) int64 {
return val return val
} }
func (l *strictLoader) durationVal(key string, defaultVal time.Duration) time.Duration {
if l.err != nil {
return 0
}
val, err := getDuration(l.sc, key, defaultVal)
if err != nil {
l.err = err
}
return val
}
func (l *strictLoader) boolVal(key string, defaultVal bool) bool { func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
if l.err != nil { if l.err != nil {
return false return false
@@ -533,11 +828,7 @@ func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
// is omitted. A present value that is not a string, or is explicitly // is omitted. A present value that is not a string, or is explicitly
// null, is an error. // null, is an error.
func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) { func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
if sc == nil { raw, ok := lookupValue(sc, key)
return defaultVal, nil
}
raw, ok := sc.Get(key)
if !ok { if !ok {
return defaultVal, nil return defaultVal, nil
} }
@@ -559,11 +850,7 @@ func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
// omitted. A present value that is not a whole number, or is explicitly // omitted. A present value that is not a whole number, or is explicitly
// null, is an error; fractional values are never truncated. // null, is an error; fractional values are never truncated.
func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) { func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
if sc == nil { raw, ok := lookupValue(sc, key)
return defaultVal, nil
}
raw, ok := sc.Get(key)
if !ok { if !ok {
return defaultVal, nil return defaultVal, nil
} }
@@ -587,8 +874,8 @@ func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
case string: case string:
parsed, err := strconv.Atoi(strings.TrimSpace(val)) parsed, err := strconv.Atoi(strings.TrimSpace(val))
if err != nil { if err != nil {
return 0, fmt.Errorf("config key %q: value %q is %w", return 0, fmt.Errorf("%s: value %q is %w",
key, val, errNotAnInteger) settingName(key), val, errNotAnInteger)
} }
return parsed, nil return parsed, nil
@@ -603,11 +890,7 @@ func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
// is explicitly null, is an error; fractional values are never // is explicitly null, is an error; fractional values are never
// truncated and out-of-range values are never clamped. // truncated and out-of-range values are never clamped.
func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, error) { func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, error) {
if sc == nil { raw, ok := lookupValue(sc, key)
return defaultVal, nil
}
raw, ok := sc.Get(key)
if !ok { if !ok {
return defaultVal, nil return defaultVal, nil
} }
@@ -638,8 +921,8 @@ func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, erro
case string: case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64) parsed, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64)
if err != nil { if err != nil {
return 0, fmt.Errorf("config key %q: value %q is %w", return 0, fmt.Errorf("%s: value %q is %w",
key, val, errNotAnInteger) settingName(key), val, errNotAnInteger)
} }
return parsed, nil return parsed, nil
@@ -649,16 +932,48 @@ func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, erro
} }
} }
// getDuration returns the duration value for key, or defaultVal if the
// key is omitted. A present value must be a positive Go duration string
// such as "30s" or "2m", read with time.ParseDuration; a bare number has
// no unit and is an error, as is an explicit null.
func getDuration(
sc *smartconfig.Config, key string, defaultVal time.Duration,
) (time.Duration, error) {
raw, ok := lookupValue(sc, key)
if !ok {
return defaultVal, nil
}
if raw == nil {
return 0, errNullConfigValue(key)
}
str, ok := raw.(string)
if !ok {
return 0, fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw, errNotADuration)
}
parsed, err := time.ParseDuration(strings.TrimSpace(str))
if err != nil {
return 0, fmt.Errorf("%s: value %q is %w",
settingName(key), str, errNotADuration)
}
if parsed <= 0 {
return 0, fmt.Errorf("%s: value %q %w",
settingName(key), str, errMustBePositive)
}
return parsed, nil
}
// getBool returns the boolean value for key, or defaultVal if the key // getBool returns the boolean value for key, or defaultVal if the key
// is omitted. A present value that is not a boolean (or a ParseBool-able // is omitted. A present value that is not a boolean (or a ParseBool-able
// string), or is explicitly null, is an error; numbers are not accepted // string), or is explicitly null, is an error; numbers are not accepted
// as booleans. // as booleans.
func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) { func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) {
if sc == nil { raw, ok := lookupValue(sc, key)
return defaultVal, nil
}
raw, ok := sc.Get(key)
if !ok { if !ok {
return defaultVal, nil return defaultVal, nil
} }
@@ -673,8 +988,8 @@ func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error)
case string: case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(val)) parsed, err := strconv.ParseBool(strings.TrimSpace(val))
if err != nil { if err != nil {
return false, fmt.Errorf("config key %q: value %q is %w", return false, fmt.Errorf("%s: value %q is %w",
key, val, errNotABoolean) settingName(key), val, errNotABoolean)
} }
return parsed, nil return parsed, nil
@@ -690,7 +1005,7 @@ func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error)
// (or a comma-separated string), a non-string entry, or an empty entry // (or a comma-separated string), a non-string entry, or an empty entry
// is an error, never silently skipped. // is an error, never silently skipped.
func validateAllowlistHostsValue(sc *smartconfig.Config) error { func validateAllowlistHostsValue(sc *smartconfig.Config) error {
raw, ok := sc.Get(keyAllowlistHosts) raw, ok := lookupValue(sc, keyAllowlistHosts)
if !ok { if !ok {
return nil return nil
} }
@@ -720,8 +1035,8 @@ func validateAllowlistHostsValue(sc *smartconfig.Config) error {
for part := range strings.SplitSeq(val, ",") { for part := range strings.SplitSeq(val, ",") {
if strings.TrimSpace(part) == "" { if strings.TrimSpace(part) == "" {
return fmt.Errorf("config key %q: value %q %w", return fmt.Errorf("%s: value %q %w",
keyAllowlistHosts, val, errEmptyEntry) settingName(keyAllowlistHosts), val, errEmptyEntry)
} }
} }
default: default:
@@ -737,11 +1052,7 @@ func validateAllowlistHostsValue(sc *smartconfig.Config) error {
// comma-separated string (backwards compatibility). Malformed entries // comma-separated string (backwards compatibility). Malformed entries
// are rejected beforehand by validateAllowlistHostsValue. // are rejected beforehand by validateAllowlistHostsValue.
func getStringSlice(sc *smartconfig.Config) []string { func getStringSlice(sc *smartconfig.Config) []string {
if sc == nil { val, ok := lookupValue(sc, keyAllowlistHosts)
return nil
}
val, ok := sc.Get(keyAllowlistHosts)
if !ok || val == nil { if !ok || val == nil {
return nil return nil
} }
@@ -778,3 +1089,103 @@ func getStringSlice(sc *smartconfig.Config) []string {
return nil return nil
} }
// defaultTrustedProxies returns the trusted_proxies default: the three RFC
// 1918 private ranges. pixa is always deployed behind a TLS-terminating
// reverse proxy, which in practice sits on a private network, so its
// forwarding headers are believed unless the operator says otherwise.
// Loopback is deliberately excluded: it is not an RFC 1918 range, and no
// deployment reaches pixa over it. A fresh slice is returned on each call so
// callers may hold it without aliasing shared state.
func defaultTrustedProxies() []netip.Prefix {
return []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.168.0.0/16"),
}
}
// parseCIDRList parses the value of the named config key into CIDR
// prefixes, or returns nil if the key is omitted. It accepts a YAML list
// of strings or a comma-separated string. An explicitly null value, a
// wrong type, an empty entry, a non-string entry, or an unparseable CIDR
// aborts startup naming the key and the offending value; the default
// (an empty list) applies only to an omitted key.
func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
raw, ok := lookupValue(sc, key)
if !ok {
return nil, nil
}
if raw == nil {
return nil, errNullConfigValue(key)
}
entries, err := cidrListEntries(raw, key)
if err != nil {
return nil, err
}
prefixes := make([]netip.Prefix, 0, len(entries))
for _, entry := range entries {
prefix, err := netip.ParsePrefix(entry)
if err != nil {
return nil, fmt.Errorf("%s: value %q is %w",
settingName(key), entry, errNotAValidCIDR)
}
prefixes = append(prefixes, prefix)
}
return prefixes, nil
}
// cidrListEntries extracts the raw entries of the named CIDR-list key as
// trimmed, non-empty strings, from either a YAML list of strings or a
// comma-separated string; an empty string is an empty list, as for
// allowlist_hosts. Any other shape is a configuration error.
func cidrListEntries(raw any, key string) ([]string, error) {
switch val := raw.(type) {
case []any:
entries := make([]string, 0, len(val))
for _, item := range val {
str, ok := item.(string)
if !ok {
return nil, fmt.Errorf("config key %q: list entry %v (%T) is %w",
key, item, item, errNotAString)
}
if strings.TrimSpace(str) == "" {
return nil, fmt.Errorf("config key %q: %w",
key, errEmptyListEntry)
}
entries = append(entries, strings.TrimSpace(str))
}
return entries, nil
case string:
entries := make([]string, 0)
if strings.TrimSpace(val) == "" {
return entries, nil
}
for part := range strings.SplitSeq(val, ",") {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, fmt.Errorf("%s: value %q %w",
settingName(key), val, errEmptyEntry)
}
entries = append(entries, trimmed)
}
return entries, nil
default:
return nil, fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw, errNotAStringList)
}
}
@@ -6,6 +6,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"git.eeqj.de/sneak/smartconfig" "git.eeqj.de/sneak/smartconfig"
) )
@@ -303,6 +304,11 @@ func invalidHostAndCredentialCases() []abortCase {
yaml: "signing_key: short\n", yaml: "signing_key: short\n",
wantErrSubstrings: []string{keySigningKey}, wantErrSubstrings: []string{keySigningKey},
}, },
{
name: "signing_key is the documented placeholder",
yaml: "signing_key: " + placeholderSigningKey + "\n",
wantErrSubstrings: []string{keySigningKey},
},
{ {
name: "signing_key missing", name: "signing_key missing",
yaml: "port: 8080\n", yaml: "port: 8080\n",
@@ -594,3 +600,244 @@ func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
t.Errorf("error %q does not name the offending key state_dir", err.Error()) t.Errorf("error %q does not name the offending key state_dir", err.Error())
} }
} }
// TestOmittedOriginTimeoutsAndSizeUseDefaults checks that the CORS
// origin, the upstream fetch timeout, the upstream response size limit
// and the downstream timeout default to the values pixa used before they
// could be configured.
func TestOmittedOriginTimeoutsAndSizeUseDefaults(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine)
if err != nil {
t.Fatalf("minimal config should be valid, got error: %v", err)
}
if c.AccessControlAllowOrigin != "*" {
t.Errorf("AccessControlAllowOrigin = %q, want *", c.AccessControlAllowOrigin)
}
if c.UpstreamFetchTimeout != 30*time.Second {
t.Errorf("UpstreamFetchTimeout = %v, want 30s", c.UpstreamFetchTimeout)
}
if c.UpstreamMaxResponseSize != 50<<20 {
t.Errorf("UpstreamMaxResponseSize = %d, want %d (50 MiB)",
c.UpstreamMaxResponseSize, 50<<20)
}
if c.DownstreamTimeout != 60*time.Second {
t.Errorf("DownstreamTimeout = %v, want 60s", c.DownstreamTimeout)
}
}
// TestExplicitOriginTimeoutsAndSizeAreUsed checks that valid values for
// the CORS origin, the two timeouts and the response size limit are used
// as given. The size is the largest accepted, 1 GiB.
func TestExplicitOriginTimeoutsAndSizeAreUsed(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine+`
access_control_allow_origin: https://app.example.com
upstream_fetch_timeout: 10s
upstream_max_response_size: 1073741824
downstream_timeout: 2m
`)
if err != nil {
t.Fatalf("valid config should load, got error: %v", err)
}
if c.AccessControlAllowOrigin != "https://app.example.com" {
t.Errorf("AccessControlAllowOrigin = %q, want https://app.example.com",
c.AccessControlAllowOrigin)
}
if c.UpstreamFetchTimeout != 10*time.Second {
t.Errorf("UpstreamFetchTimeout = %v, want 10s", c.UpstreamFetchTimeout)
}
if c.UpstreamMaxResponseSize != 1073741824 {
t.Errorf("UpstreamMaxResponseSize = %d, want 1073741824",
c.UpstreamMaxResponseSize)
}
if c.DownstreamTimeout != 2*time.Minute {
t.Errorf("DownstreamTimeout = %v, want 2m", c.DownstreamTimeout)
}
}
// TestOriginWithPortOrAnyOriginIsAccepted checks the other accepted forms
// of access_control_allow_origin: "*", an origin with a port, and origins
// whose host is an IPv4 or IPv6 address.
func TestOriginWithPortOrAnyOriginIsAccepted(t *testing.T) {
t.Parallel()
for _, origin := range []string{
"*", "http://localhost:3000", "http://192.0.2.1", "http://[2001:db8::1]:8080",
} {
c, err := configFromYAML(t, signingKeyLine+
"access_control_allow_origin: \""+origin+"\"\n")
if err != nil {
t.Fatalf("origin %q should be accepted, got error: %v", origin, err)
}
if c.AccessControlAllowOrigin != origin {
t.Errorf("AccessControlAllowOrigin = %q, want %q",
c.AccessControlAllowOrigin, origin)
}
}
}
// invalidTimeoutCases are configs where upstream_fetch_timeout or
// downstream_timeout is not a positive Go duration string; each must
// abort startup naming the key and the value.
func invalidTimeoutCases() []abortCase {
return []abortCase{
{
name: "upstream_fetch_timeout not a duration",
yaml: signingKeyLine + "upstream_fetch_timeout: soon\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "soon"},
},
{
name: "upstream_fetch_timeout number without a unit",
yaml: signingKeyLine + "upstream_fetch_timeout: 45\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "45"},
},
{
name: "upstream_fetch_timeout zero",
yaml: signingKeyLine + "upstream_fetch_timeout: 0s\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "0s"},
},
{
name: "upstream_fetch_timeout negative",
yaml: signingKeyLine + "upstream_fetch_timeout: -5s\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "-5s"},
},
{
name: "upstream_fetch_timeout null",
yaml: signingKeyLine + "upstream_fetch_timeout: null\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, nullValueText},
},
{
name: "downstream_timeout not a duration",
yaml: signingKeyLine + "downstream_timeout: 1 minute\n",
wantErrSubstrings: []string{keyDownstreamTimeout, "1 minute"},
},
{
name: "downstream_timeout zero",
yaml: signingKeyLine + "downstream_timeout: 0s\n",
wantErrSubstrings: []string{keyDownstreamTimeout, "0s"},
},
{
name: "downstream_timeout negative",
yaml: signingKeyLine + "downstream_timeout: -1m\n",
wantErrSubstrings: []string{keyDownstreamTimeout, "-1m"},
},
{
name: "downstream_timeout null",
yaml: signingKeyLine + "downstream_timeout:\n",
wantErrSubstrings: []string{keyDownstreamTimeout, nullValueText},
},
}
}
// invalidSizeAndOriginCases are configs where upstream_max_response_size
// is not a whole number of bytes from 1 to 1 GiB, or
// access_control_allow_origin is neither "*" nor an origin; each must
// abort startup naming the key and the value.
func invalidSizeAndOriginCases() []abortCase {
badOrigins := []string{
"", // empty
"example.com", // no scheme
"https://example.com/images", // a path
"https://example.com/", // a trailing slash
// The CORS middleware reads a * inside an origin as a pattern
// that lets other sites read responses.
"https://*",
"https://*.example.com",
"https://*example.com",
"https://a.com,b.com", // two hosts
"https://example.com:", // an empty port
"https://:8443", // no host
"https://example.com:0", // a port below 1
"https://example.com:99999", // a port above 65535
"https://exämple.com", // a host name that is not ASCII
"https://example.com:443", // the default port for https
"http://example.com:80", // the default port for http
"https://example.com:08080", // a port with a leading zero
"https://01.2.3.4", // an IPv4 address with a leading zero
"https://10.0.0", // an IPv4 address with three parts
"https://192.168.1.256", // an IPv4 address part above 255
"https://example.123", // a host name whose last part is a number
"https://[0:0:0:0:0:0:0:1]", // an IPv6 address not in its shortest form
"file://example.com", // a scheme other than http or https
"HTTPS://example.com", // a scheme in upper case
"https://Example.com", // a host name in upper case
}
cases := make([]abortCase, 0, len(badOrigins))
for _, origin := range badOrigins {
cases = append(cases, abortCase{
name: "access_control_allow_origin " + origin,
yaml: signingKeyLine +
"access_control_allow_origin: \"" + origin + "\"\n",
wantErrSubstrings: []string{keyAccessControlAllowOrigin, origin},
})
}
return append(cases, []abortCase{
{
name: "access_control_allow_origin null",
yaml: signingKeyLine + "access_control_allow_origin: null\n",
wantErrSubstrings: []string{keyAccessControlAllowOrigin, nullValueText},
},
{
name: "upstream_max_response_size with a unit",
yaml: signingKeyLine + "upstream_max_response_size: 50MB\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "50MB"},
},
{
name: "upstream_max_response_size fractional",
yaml: signingKeyLine + "upstream_max_response_size: 1.5\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "1.5"},
},
{
name: "upstream_max_response_size zero",
yaml: signingKeyLine + "upstream_max_response_size: 0\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "0"},
},
{
name: "upstream_max_response_size negative",
yaml: signingKeyLine + "upstream_max_response_size: -1\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "-1"},
},
{
name: "upstream_max_response_size null",
yaml: signingKeyLine + "upstream_max_response_size: null\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, nullValueText},
},
{
name: "upstream_max_response_size above 1 GiB",
yaml: signingKeyLine + "upstream_max_response_size: 1073741825\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "1073741825"},
},
{
name: "upstream_max_response_size largest 64-bit integer",
yaml: signingKeyLine +
"upstream_max_response_size: 9223372036854775807\n",
wantErrSubstrings: []string{
keyUpstreamMaxResponseSize, "9223372036854775807",
},
},
}...)
}
// TestInvalidOriginTimeoutOrSizeAbortsStartup verifies the
// no-silent-fallback rule for the CORS origin, the two timeouts and the
// response size limit: a value that does not parse or is out of range
// aborts startup naming the key and the value.
func TestInvalidOriginTimeoutOrSizeAbortsStartup(t *testing.T) {
t.Parallel()
runAbortCases(t, append(invalidTimeoutCases(), invalidSizeAndOriginCases()...))
}
+400
View File
@@ -0,0 +1,400 @@
package config
import (
"net/netip"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"time"
"sneak.berlin/go/pixa/internal/globals"
"sneak.berlin/go/pixa/internal/logger"
)
// TestMain unsets PORT and every PIXA_ environment variable before the
// tests run, so each test sees only the variables it sets itself, not
// whatever the shell running the tests exports.
func TestMain(m *testing.M) {
for _, entry := range os.Environ() {
name, _, _ := strings.Cut(entry, "=")
if name != "PORT" && !strings.HasPrefix(name, "PIXA_") {
continue
}
err := os.Unsetenv(name)
if err != nil {
panic(err)
}
}
m.Run()
}
// wantStartupError fails the test unless err is a startup error that
// mentions every one of wants.
func wantStartupError(t *testing.T, err error, wants ...string) {
t.Helper()
if err == nil {
t.Fatalf("want a startup error mentioning %q, got none", wants)
}
t.Logf("got expected error: %v", err)
for _, want := range wants {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err.Error(), want)
}
}
}
// TestEnvironmentSetsEveryKey sets every key from its environment
// variable, with no config file at all: PORT for the port, and PIXA_
// plus the key in upper case, "." written as "_", for every other key.
func TestEnvironmentSetsEveryKey(t *testing.T) {
t.Setenv("PIXA_DEBUG", "true")
t.Setenv("PIXA_MAINTENANCE_MODE", "1")
t.Setenv("PORT", "9090")
t.Setenv("PIXA_STATE_DIR", "/srv/pixa-env")
t.Setenv("PIXA_SENTRY_DSN", "https://abc123@sentry.example.com/42")
t.Setenv("PIXA_DB_URL", "file:/srv/pixa-env/other.sqlite3")
t.Setenv("PIXA_METRICS_USERNAME", "metricsuser")
t.Setenv("PIXA_METRICS_PASSWORD", "metricspass")
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
t.Setenv("PIXA_ALLOWLIST_HOSTS", "s3.sneak.cloud,.example.com")
t.Setenv("PIXA_ALLOW_HTTP", "true")
t.Setenv("PIXA_UPSTREAM_CONNECTIONS_PER_HOST", "5")
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
t.Setenv("PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", "https://app.example.com")
t.Setenv("PIXA_UPSTREAM_FETCH_TIMEOUT", "10s")
t.Setenv("PIXA_UPSTREAM_MAX_RESPONSE_SIZE", "1048576")
t.Setenv("PIXA_DOWNSTREAM_TIMEOUT", "2m")
c, err := newFromSmartConfig(nil)
if err != nil {
t.Fatalf("configuration from the environment alone should load: %v", err)
}
want := Config{
Debug: true,
MaintenanceMode: true,
Port: 9090,
StateDir: "/srv/pixa-env",
SentryDSN: "https://abc123@sentry.example.com/42",
DBURL: "file:/srv/pixa-env/other.sqlite3",
MetricsUsername: "metricsuser",
MetricsPassword: "metricspass",
SigningKey: validTestSigningKey,
AllowlistHosts: []string{testHostS3, ".example.com"},
AllowHTTP: true,
UpstreamConnectionsPerHost: 5,
CacheMaxBytes: 1024,
cacheMaxBytesExplicit: true,
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")},
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
AccessControlAllowOrigin: "https://app.example.com",
UpstreamFetchTimeout: 10 * time.Second,
UpstreamMaxResponseSize: 1048576,
DownstreamTimeout: 2 * time.Minute,
}
if !reflect.DeepEqual(*c, want) {
t.Errorf("config from the environment =\n%+v\nwant\n%+v", *c, want)
}
}
// TestUnknownPixaVariableAbortsStartup checks that a PIXA_ variable that
// is not a setting's variable, such as a misspelled one, aborts startup
// naming it, as an unknown config key does, instead of being ignored.
func TestUnknownPixaVariableAbortsStartup(t *testing.T) {
t.Setenv("PIXA_TRUSTED_PROXY", "192.0.2.0/24")
t.Setenv("PIXA_SIGNINGKEY", validTestSigningKey)
err := validateKnownEnvVars()
wantStartupError(t, err, "PIXA_TRUSTED_PROXY", "PIXA_SIGNINGKEY")
}
// TestPixaPortAbortsStartupPointingToPort checks that PIXA_PORT aborts
// startup with a message saying to use PORT, which sets the port.
func TestPixaPortAbortsStartupPointingToPort(t *testing.T) {
t.Setenv("PIXA_PORT", "9090")
err := validateKnownEnvVars()
wantStartupError(t, err, "PIXA_PORT", "use PORT")
}
// TestSettingVariablesAndConfigPathAreAccepted checks that every
// setting's variable and PIXA_CONFIG_PATH pass the check for unknown
// PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the list.
func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
// A config file's env section loaded by another test can leave a
// PIXA_ variable set for the whole process, so every one is unset
// here first; t.Setenv restores each when the test ends.
for _, entry := range os.Environ() {
name, _, _ := strings.Cut(entry, "=")
if !strings.HasPrefix(name, "PIXA_") {
continue
}
t.Setenv(name, "")
err := os.Unsetenv(name)
if err != nil {
t.Fatalf("failed to unset %s: %v", name, err)
}
}
t.Setenv("PIXA_CONFIG_PATH", "/etc/pixa/config.yml")
for _, name := range envVarNames() {
t.Setenv(name, "")
}
err := validateKnownEnvVars()
if err != nil {
t.Fatalf("PIXA_CONFIG_PATH and every setting's variable "+
"must be accepted: %v", err)
}
}
// configFromNew writes yamlContent to a temporary config file, points
// PIXA_CONFIG_PATH at it, and runs New, as the server does at startup.
// The state directory is a temporary one and the disk cache is off, so
// New succeeds unless something in the test is wrong.
func configFromNew(t *testing.T, yamlContent string) (*Config, error) {
t.Helper()
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
if err != nil {
t.Fatalf("failed to write test config: %v", err)
}
t.Setenv("PIXA_CONFIG_PATH", configPath)
t.Setenv("PIXA_STATE_DIR", filepath.Join(tmpDir, "state"))
t.Setenv("PIXA_CACHE_MAX_BYTES", "0")
testLogger, err := logger.New(nil, logger.Params{Globals: &globals.Globals{}})
if err != nil {
t.Fatalf("failed to create logger: %v", err)
}
return New(nil, Params{Logger: testLogger})
}
// TestUnknownPixaVariableAbortsNew checks that New, which the server
// calls at startup, aborts on a misspelled PIXA_ variable.
func TestUnknownPixaVariableAbortsNew(t *testing.T) {
t.Setenv("PIXA_TRUSTED_PROXY", "192.0.2.0/24")
_, err := configFromNew(t, signingKeyLine)
wantStartupError(t, err, "PIXA_TRUSTED_PROXY")
}
// TestUnknownPixaVariableInEnvSectionAbortsNew checks that New aborts
// on a misspelled PIXA_ name in the config file's env section, which
// loading the file sets as an environment variable.
func TestUnknownPixaVariableInEnvSectionAbortsNew(t *testing.T) {
// The variable must be absent until the file loads. t.Setenv makes
// sure the one the file sets is removed when the test ends.
t.Setenv("PIXA_TRUSTED_PROXY", "")
err := os.Unsetenv("PIXA_TRUSTED_PROXY")
if err != nil {
t.Fatalf("failed to unset PIXA_TRUSTED_PROXY: %v", err)
}
_, err = configFromNew(t, signingKeyLine+
"env:\n PIXA_TRUSTED_PROXY: 192.0.2.0/24\n")
wantStartupError(t, err, "PIXA_TRUSTED_PROXY")
}
// TestPortFromEnvironmentOverridesConfigFile checks that PORT wins over
// the port in the config file.
func TestPortFromEnvironmentOverridesConfigFile(t *testing.T) {
t.Setenv("PORT", "9090")
c, err := configFromYAML(t, signingKeyLine+"port: 8080\n")
if err != nil {
t.Fatalf("PORT=9090 with port 8080 in the file should load: %v", err)
}
if c.Port != 9090 {
t.Errorf("Port = %d, want 9090 from PORT, not 8080 from the file", c.Port)
}
}
// TestInvalidPortFromEnvironmentAbortsStartup checks that a PORT that is
// not a number, or is outside the port range, aborts startup naming PORT
// and the value, even though the file's port is valid.
func TestInvalidPortFromEnvironmentAbortsStartup(t *testing.T) {
t.Setenv("PORT", "banana")
_, err := configFromYAML(t, signingKeyLine+"port: 8080\n")
wantStartupError(t, err, "PORT", "banana")
t.Setenv("PORT", "70000")
_, err = configFromYAML(t, signingKeyLine+"port: 8080\n")
wantStartupError(t, err, "PORT", "70000")
}
// TestListFromEnvironmentReplacesConfigFileList checks that a list
// variable replaces the file's list, split on commas with the spaces
// around each entry trimmed.
func TestListFromEnvironmentReplacesConfigFileList(t *testing.T) {
t.Setenv("PIXA_ALLOWLIST_HOSTS", " cdn.example.com , .example.org ")
c, err := configFromYAML(t, signingKeyLine+
"allowlist_hosts:\n - s3.sneak.cloud\n - sneak.berlin\n")
if err != nil {
t.Fatalf("PIXA_ALLOWLIST_HOSTS should load: %v", err)
}
want := []string{"cdn.example.com", ".example.org"}
if !slices.Equal(c.AllowlistHosts, want) {
t.Errorf("AllowlistHosts = %v, want %v from PIXA_ALLOWLIST_HOSTS",
c.AllowlistHosts, want)
}
}
// TestInvalidBlockedNetworksFromEnvironmentAbortsStartup checks that an
// invalid CIDR, or an empty entry, in PIXA_BLOCKED_NETWORKS aborts
// startup naming the variable, as the same list in the file does.
func TestInvalidBlockedNetworksFromEnvironmentAbortsStartup(t *testing.T) {
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24,not-a-cidr")
_, err := configFromYAML(t, signingKeyLine)
wantStartupError(t, err, "PIXA_BLOCKED_NETWORKS", "not-a-cidr")
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24,,198.51.100.0/24")
_, err = configFromYAML(t, signingKeyLine)
wantStartupError(t, err, "PIXA_BLOCKED_NETWORKS")
}
// TestInvalidDebugFromEnvironmentAbortsStartup checks that a PIXA_DEBUG
// that strconv.ParseBool rejects aborts startup instead of defaulting.
func TestInvalidDebugFromEnvironmentAbortsStartup(t *testing.T) {
t.Setenv("PIXA_DEBUG", "maybe")
_, err := configFromYAML(t, signingKeyLine)
wantStartupError(t, err, "PIXA_DEBUG", "maybe")
}
// TestInvalidOriginTimeoutOrSizeFromEnvironmentAbortsStartup checks that
// an invalid CORS origin, timeout or response size limit in its variable
// aborts startup naming the variable and the value.
func TestInvalidOriginTimeoutOrSizeFromEnvironmentAbortsStartup(t *testing.T) {
cases := []struct {
variable string
value string
}{
{"PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", "example.com"},
{"PIXA_UPSTREAM_FETCH_TIMEOUT", "soon"},
{"PIXA_UPSTREAM_MAX_RESPONSE_SIZE", "50MB"},
{"PIXA_DOWNSTREAM_TIMEOUT", "0s"},
}
for _, tc := range cases {
t.Run(tc.variable, func(t *testing.T) {
t.Setenv(tc.variable, tc.value)
_, err := configFromYAML(t, signingKeyLine)
wantStartupError(t, err, tc.variable, tc.value)
})
}
}
// TestConfigFileAloneBehavesAsBefore checks that with no variables set
// (TestMain unsets them) the config file's values are used and omitted
// keys take their defaults.
func TestConfigFileAloneBehavesAsBefore(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine+"port: 9191\n")
if err != nil {
t.Fatalf("config file should load: %v", err)
}
if c.Port != 9191 {
t.Errorf("Port = %d, want 9191 from the file", c.Port)
}
if c.StateDir != DefaultStateDir {
t.Errorf("StateDir = %q, want default %q", c.StateDir, DefaultStateDir)
}
if !slices.Equal(c.TrustedProxies, defaultTrustedProxies()) {
t.Errorf("TrustedProxies = %v, want default %v",
c.TrustedProxies, defaultTrustedProxies())
}
}
// TestEmptyTrustedProxiesFromEnvironmentTrustsNoOne checks that an empty
// PIXA_TRUSTED_PROXIES is an empty list, like [] in the file: it trusts
// no proxy instead of taking the default ranges.
func TestEmptyTrustedProxiesFromEnvironmentTrustsNoOne(t *testing.T) {
t.Setenv("PIXA_TRUSTED_PROXIES", "")
c, err := configFromYAML(t, signingKeyLine)
if err != nil {
t.Fatalf("empty PIXA_TRUSTED_PROXIES should load: %v", err)
}
if len(c.TrustedProxies) != 0 {
t.Errorf("TrustedProxies = %v, want none", c.TrustedProxies)
}
}
// TestEmptyVariableDoesNotFallBackToConfigFile checks that a variable
// that is present but empty is a set value: an empty PIXA_STATE_DIR
// aborts startup like state_dir: "" in the file, instead of falling
// through to the file's state_dir.
func TestEmptyVariableDoesNotFallBackToConfigFile(t *testing.T) {
t.Setenv("PIXA_STATE_DIR", "")
_, err := configFromYAML(t, signingKeyLine+"state_dir: /srv/pixa-file\n")
wantStartupError(t, err, "PIXA_STATE_DIR")
}
// TestMissingSigningKeyNamesItsVariable checks that with no config file
// and no PIXA_SIGNING_KEY, startup aborts naming the variable, which is
// how a container started without it reports the problem.
func TestMissingSigningKeyNamesItsVariable(t *testing.T) {
t.Parallel()
_, err := newFromSmartConfig(nil)
wantStartupError(t, err, "PIXA_SIGNING_KEY")
}
// TestSecretsFromEnvironmentAreNotPrinted checks that errors about the
// signing key and the metrics password name their variables but never
// print their values.
func TestSecretsFromEnvironmentAreNotPrinted(t *testing.T) {
t.Setenv("PIXA_SIGNING_KEY", "short-signing-secret")
_, err := newFromSmartConfig(nil)
wantStartupError(t, err, "PIXA_SIGNING_KEY")
if strings.Contains(err.Error(), "short-signing-secret") {
t.Errorf("error %q prints the signing key", err.Error())
}
t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey)
t.Setenv("PIXA_METRICS_PASSWORD", "metrics-password-secret")
_, err = newFromSmartConfig(nil)
wantStartupError(t, err, "PIXA_METRICS_PASSWORD")
if strings.Contains(err.Error(), "metrics-password-secret") {
t.Errorf("error %q prints the metrics password", err.Error())
}
}
@@ -0,0 +1,85 @@
package config
import (
"strings"
"testing"
)
// TestTrustedProxiesConfig checks the trusted_proxies key wiring: an
// explicit CIDR list lands in TrustedProxies in order and replaces the
// default, an omitted key falls back to the RFC 1918 private ranges, and an
// explicitly empty list trusts no one. The list parser itself is shared with
// blocked_networks and is exercised in depth by that key's tests.
func TestTrustedProxiesConfig(t *testing.T) {
t.Parallel()
t.Run("explicit list replaces the default in order", func(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t,
signingKeyLine+`trusted_proxies: ["10.0.0.0/8", "2001:db8::/32"]`+"\n")
if err != nil {
t.Fatalf("valid trusted_proxies should load: %v", err)
}
got := make([]string, len(c.TrustedProxies))
for i, p := range c.TrustedProxies {
got[i] = p.String()
}
if joined := strings.Join(got, ","); joined != "10.0.0.0/8,2001:db8::/32" {
t.Errorf("TrustedProxies = %v, want the two ranges in order", got)
}
})
t.Run("omitted key defaults to the RFC 1918 ranges", func(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine)
if err != nil {
t.Fatalf("minimal config should load: %v", err)
}
got := make([]string, len(c.TrustedProxies))
for i, p := range c.TrustedProxies {
got[i] = p.String()
}
want := "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
if joined := strings.Join(got, ","); joined != want {
t.Errorf("TrustedProxies = %v, want the RFC 1918 ranges %q", got, want)
}
})
t.Run("explicitly empty list trusts no one", func(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine+"trusted_proxies: []\n")
if err != nil {
t.Fatalf("empty trusted_proxies should load: %v", err)
}
if len(c.TrustedProxies) != 0 {
t.Errorf("TrustedProxies = %v, want empty", c.TrustedProxies)
}
})
}
// TestTrustedProxiesInvalidAbortsStartup checks that an invalid or null
// value aborts startup with an error naming the key and the offending value.
func TestTrustedProxiesInvalidAbortsStartup(t *testing.T) {
t.Parallel()
runAbortCases(t, []abortCase{
{
name: "invalid cidr",
yaml: signingKeyLine + `trusted_proxies: ["999.0.0.0/8"]` + "\n",
wantErrSubstrings: []string{keyTrustedProxies, "999.0.0.0/8"},
},
{
name: "null value",
yaml: signingKeyLine + "trusted_proxies:\n",
wantErrSubstrings: []string{keyTrustedProxies, nullValueText},
},
})
}
+15 -17
View File
@@ -4,9 +4,9 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"embed"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -15,14 +15,12 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/db/migrations"
"sneak.berlin/go/pixa/internal/logger" "sneak.berlin/go/pixa/internal/logger"
_ "modernc.org/sqlite" // SQLite driver registration _ "modernc.org/sqlite" // SQLite driver registration
) )
//go:embed schema/*.sql
var schemaFS embed.FS
// bootstrapVersion is the migration that creates the schema_migrations // bootstrapVersion is the migration that creates the schema_migrations
// table itself. It is applied before the normal migration loop. // table itself. It is applied before the normal migration loop.
const bootstrapVersion = 0 const bootstrapVersion = 0
@@ -113,29 +111,29 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
return s, nil return s, nil
} }
// collectMigrations reads the embedded schema directory and returns // collectMigrations reads the embedded migrations directory and returns
// migration filenames sorted lexicographically. // migration filenames sorted lexicographically.
func collectMigrations() ([]string, error) { func collectMigrations() ([]string, error) {
entries, err := schemaFS.ReadDir("schema") entries, err := fs.ReadDir(migrations.FS(), ".")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read schema directory: %w", err) return nil, fmt.Errorf("failed to read migrations directory: %w", err)
} }
var migrations []string var filenames []string
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
migrations = append(migrations, entry.Name()) filenames = append(filenames, entry.Name())
} }
} }
sort.Strings(migrations) sort.Strings(filenames)
return migrations, nil return filenames, nil
} }
// bootstrapMigrationsTable ensures the schema_migrations table exists // bootstrapMigrationsTable ensures the schema_migrations table exists
// by applying 000.sql if the table is missing. // by applying 000_migration.sql if the table is missing.
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error { func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
var tableExists int var tableExists int
@@ -150,9 +148,9 @@ func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger)
return nil return nil
} }
content, err := schemaFS.ReadFile("schema/000.sql") content, err := fs.ReadFile(migrations.FS(), "000_migration.sql")
if err != nil { if err != nil {
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err) return fmt.Errorf("failed to read bootstrap migration 000_migration.sql: %w", err)
} }
if log != nil { if log != nil {
@@ -177,12 +175,12 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
return err return err
} }
migrations, err := collectMigrations() filenames, err := collectMigrations()
if err != nil { if err != nil {
return err return err
} }
for _, migration := range migrations { for _, migration := range filenames {
version, parseErr := ParseMigrationVersion(migration) version, parseErr := ParseMigrationVersion(migration)
if parseErr != nil { if parseErr != nil {
return parseErr return parseErr
@@ -208,7 +206,7 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
} }
// Read and apply migration. // Read and apply migration.
content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration)) content, readErr := fs.ReadFile(migrations.FS(), migration)
if readErr != nil { if readErr != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, readErr) return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
} }
@@ -0,0 +1,54 @@
package database
import (
"bytes"
"database/sql"
"log/slog"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite" // SQLite driver registration
)
// TestApplyMigrations_SecondRunAppliesNothing applies the migrations twice
// to one database file, as happens when pixad starts again on the database
// it created, and checks that the second run applies none of them.
// ApplyMigrations logs a message starting with "applying" before it runs
// any migration, the bootstrap one included.
func TestApplyMigrations_SecondRunAppliesNothing(t *testing.T) {
t.Parallel()
ctx := t.Context()
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "state.sqlite3"))
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
var firstLog bytes.Buffer
err = ApplyMigrations(ctx, db, slog.New(slog.NewTextHandler(&firstLog, nil)))
if err != nil {
t.Fatalf("first ApplyMigrations failed: %v", err)
}
if !strings.Contains(firstLog.String(), "applying") {
t.Fatalf("first ApplyMigrations logged no applied migration:\n%s",
firstLog.String())
}
var secondLog bytes.Buffer
err = ApplyMigrations(ctx, db, slog.New(slog.NewTextHandler(&secondLog, nil)))
if err != nil {
t.Fatalf("second ApplyMigrations failed: %v", err)
}
if strings.Contains(secondLog.String(), "applying") {
t.Errorf("second ApplyMigrations ran a migration again:\n%s",
secondLog.String())
}
}
+15
View File
@@ -0,0 +1,15 @@
// Package migrations provides the embedded SQL migration files.
package migrations
import (
"embed"
"io/fs"
)
//go:embed *.sql
var files embed.FS
// FS returns the embedded filesystem containing the migration files.
func FS() fs.FS {
return files
}
+8 -1
View File
@@ -103,7 +103,8 @@ func (g *Generator) Parse(token string) (*Payload, error) {
} }
// ToImageRequest converts the payload to an ImageRequest. // ToImageRequest converts the payload to an ImageRequest.
// Applies default values for omitted optional fields. // Applies default values for omitted optional fields. An ExpiresAt of 0, a URL
// that never expires, gives the zero Expires.
func (p *Payload) ToImageRequest() *imgcache.ImageRequest { func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
format := p.Format format := p.Format
if format == "" { if format == "" {
@@ -120,6 +121,11 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
fitMode = DefaultFitMode fitMode = DefaultFitMode
} }
var expires time.Time
if p.ExpiresAt != 0 {
expires = time.Unix(p.ExpiresAt, 0)
}
return &imgcache.ImageRequest{ return &imgcache.ImageRequest{
SourceHost: p.SourceHost, SourceHost: p.SourceHost,
SourcePath: p.SourcePath, SourcePath: p.SourcePath,
@@ -131,6 +137,7 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
Format: format, Format: format,
Quality: quality, Quality: quality,
FitMode: fitMode, FitMode: fitMode,
Expires: expires,
} }
} }
+157 -28
View File
@@ -1,17 +1,37 @@
package handlers package handlers
import ( import (
"bytes"
"crypto/subtle" "crypto/subtle"
"errors"
"fmt"
"html/template"
"math"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"time" "time"
"sneak.berlin/go/pixa/internal/clientip"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/templates" "sneak.berlin/go/pixa/internal/templates"
) )
// errInvalidFormField reports a generator form field, or the q or exp
// parameter of /v1/image/, whose value is non-numeric or out of range. The
// offending field name is wrapped in so the response can name it.
var errInvalidFormField = errors.New("invalid")
// Bounds for the generator's quality and ttl fields; the quality bounds also
// apply to the q parameter of /v1/image/. maxTTL is in seconds: the expiry
// calculation time.Duration(ttl) * time.Second overflows above it.
const (
minQuality = 1
maxQuality = 100
maxTTL = int(math.MaxInt64 / time.Second)
)
// HandleRoot serves the login page or generator page based on authentication state. // HandleRoot serves the login page or generator page based on authentication state.
func (s *Handlers) HandleRoot() http.HandlerFunc { func (s *Handlers) HandleRoot() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
@@ -23,13 +43,13 @@ func (s *Handlers) HandleRoot() http.HandlerFunc {
// Check if authenticated // Check if authenticated
if s.sessMgr.IsAuthenticated(r) { if s.sessMgr.IsAuthenticated(r) {
s.renderGenerator(w, nil) s.renderGenerator(w, r, nil)
return return
} }
// Show login page // Show login page
s.renderLogin(w, "") s.renderLogin(w, r, "")
} }
} }
@@ -37,7 +57,7 @@ func (s *Handlers) HandleRoot() http.HandlerFunc {
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) { func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
s.renderLogin(w, "Invalid form data") s.renderLogin(w, r, "Invalid form data")
return return
} }
@@ -46,8 +66,9 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
// Constant-time comparison to prevent timing attacks // Constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 { if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
s.log.Warn("failed login attempt", "remote_addr", r.RemoteAddr) s.log.Warn("failed login attempt",
s.renderLogin(w, "Invalid signing key") "remote_addr", clientip.FromContext(r.Context()))
s.renderLogin(w, r, "Invalid signing key")
return return
} }
@@ -56,12 +77,13 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
err = s.sessMgr.CreateSession(w) err = s.sessMgr.CreateSession(w)
if err != nil { if err != nil {
s.log.Error("failed to create session", "error", err) s.log.Error("failed to create session", "error", err)
s.renderLogin(w, "Failed to create session") s.renderLogin(w, r, "Failed to create session")
return return
} }
s.log.Info("successful login", "remote_addr", r.RemoteAddr) s.log.Info("successful login",
"remote_addr", clientip.FromContext(r.Context()))
// Redirect to generator page // Redirect to generator page
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -87,7 +109,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
s.renderGenerator(w, &generatorData{Error: "Invalid form data"}) s.renderGenerator(w, r, &generatorData{Error: "Invalid form data"})
return return
} }
@@ -97,18 +119,26 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
// Validate source URL // Validate source URL
parsed, err := url.Parse(sourceURL) parsed, err := url.Parse(sourceURL)
if err != nil || parsed.Host == "" { if err != nil || parsed.Host == "" {
s.renderGeneratorWithForm(w, "Invalid source URL", r.Form) s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form,
http.StatusBadRequest)
return return
} }
payload, expiresAt, ttl := buildGeneratePayload(parsed, r.Form) payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form)
if err != nil {
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
http.StatusBadRequest)
return
}
// Generate encrypted token // Generate encrypted token
token, err := s.encGen.Generate(payload) token, err := s.encGen.Generate(payload)
if err != nil { if err != nil {
s.log.Error("failed to generate encrypted URL", "error", err) s.log.Error("failed to generate encrypted URL", "error", err)
s.renderGeneratorWithForm(w, "Failed to generate URL", r.Form) s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
http.StatusInternalServerError)
return return
} }
@@ -121,7 +151,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
expiresAtStr = expiresAt.Format(time.RFC3339) expiresAtStr = expiresAt.Format(time.RFC3339)
} }
s.renderGenerator(w, &generatorData{ s.renderGenerator(w, r, &generatorData{
GeneratedURL: generatedURL, GeneratedURL: generatedURL,
ExpiresAt: expiresAtStr, ExpiresAt: expiresAtStr,
FormURL: sourceURL, FormURL: sourceURL,
@@ -136,17 +166,40 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
} }
// buildGeneratePayload parses the numeric form fields and assembles the // buildGeneratePayload parses the numeric form fields and assembles the
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). // encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A
// non-numeric or out-of-range width, height, quality or ttl, or an
// unrecognized fit mode, is a client error naming the offending field. The
// format field is passed through unchecked.
func buildGeneratePayload( func buildGeneratePayload(
parsed *url.URL, form url.Values, parsed *url.URL, form url.Values,
) (*encurl.Payload, time.Time, int) { ) (*encurl.Payload, time.Time, int, error) {
width, _ := strconv.Atoi(form.Get("width")) width, err := parseFormDimension(form, "width")
height, _ := strconv.Atoi(form.Get("height")) if err != nil {
quality, _ := strconv.Atoi(form.Get("quality")) return nil, time.Time{}, 0, err
ttl, _ := strconv.Atoi(form.Get("ttl")) }
if quality <= 0 { height, err := parseFormDimension(form, "height")
quality = 85 if err != nil {
return nil, time.Time{}, 0, err
}
quality, err := parseFormInt(form, "quality",
encurl.DefaultQuality, minQuality, maxQuality)
if err != nil {
return nil, time.Time{}, 0, err
}
ttl, err := parseFormInt(form, "ttl", 0, 0, maxTTL)
if err != nil {
return nil, time.Time{}, 0, err
}
fitMode := imgcache.FitMode(form.Get("fit"))
err = imgcache.ValidateFitMode(fitMode)
if err != nil {
return nil, time.Time{}, 0,
fmt.Errorf("%w: %s", imgcache.ErrInvalidFitMode, form.Get("fit"))
} }
var ( var (
@@ -167,11 +220,57 @@ func buildGeneratePayload(
Height: height, Height: height,
Format: imgcache.ImageFormat(form.Get("format")), Format: imgcache.ImageFormat(form.Get("format")),
Quality: quality, Quality: quality,
FitMode: imgcache.FitMode(form.Get("fit")), FitMode: fitMode,
ExpiresAt: expiresAtUnix, ExpiresAt: expiresAtUnix,
} }
return payload, expiresAt, ttl return payload, expiresAt, ttl, nil
}
// parseFormDimension reads an optional width or height form field. An empty
// value means "original size" (0). A non-numeric value, or one
// imgcache.ValidateDimension rejects, is an error naming the field.
func parseFormDimension(form url.Values, field string) (int, error) {
raw := form.Get(field)
if raw == "" {
return 0, nil
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
}
err = imgcache.ValidateDimension(field, value)
if err != nil {
return 0, err
}
return value, nil
}
// parseFormInt reads an optional integer form field or URL query parameter,
// returning def when the field is empty and an error naming the field when the
// value is non-numeric or outside minValue to maxValue.
func parseFormInt(
form url.Values, field string, def, minValue, maxValue int,
) (int, error) {
raw := form.Get(field)
if raw == "" {
return def, nil
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
}
if value < minValue || value > maxValue {
return 0, fmt.Errorf("%w %s: must be from %d to %d",
errInvalidFormField, field, minValue, maxValue)
}
return value, nil
} }
// generatorData holds template data for the generator page. // generatorData holds template data for the generator page.
@@ -186,15 +285,20 @@ type generatorData struct {
FormQuality string FormQuality string
FormFit string FormFit string
FormTTL string FormTTL string
CSRFField template.HTML
} }
func (s *Handlers) renderLogin(w http.ResponseWriter, errorMsg string) { func (s *Handlers) renderLogin(
w http.ResponseWriter, r *http.Request, errorMsg string,
) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := struct { data := struct {
Error string Error string
CSRFField template.HTML
}{ }{
Error: errorMsg, Error: errorMsg,
CSRFField: csrfField(r),
} }
err := templates.Render(w, "login.html", data) err := templates.Render(w, "login.html", data)
@@ -204,24 +308,49 @@ func (s *Handlers) renderLogin(w http.ResponseWriter, errorMsg string) {
} }
} }
func (s *Handlers) renderGenerator(w http.ResponseWriter, data *generatorData) { func (s *Handlers) renderGenerator(
w http.ResponseWriter, r *http.Request, data *generatorData,
) {
s.renderGeneratorStatus(w, r, data, http.StatusOK)
}
// renderGeneratorStatus renders the generator page with an explicit HTTP
// status; a rejected form uses 400. The page is rendered into a buffer before
// the status is written, so a template failure can still answer 500.
func (s *Handlers) renderGeneratorStatus(
w http.ResponseWriter, r *http.Request, data *generatorData, status int,
) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if data == nil { if data == nil {
data = &generatorData{} data = &generatorData{}
} }
err := templates.Render(w, "generator.html", data) data.CSRFField = csrfField(r)
var page bytes.Buffer
err := templates.Render(&page, "generator.html", data)
if err != nil { if err != nil {
s.log.Error("failed to render generator template", "error", err) s.log.Error("failed to render generator template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(status)
_, err = page.WriteTo(w)
if err != nil {
s.log.Error("failed to write generator page", "error", err)
} }
} }
func (s *Handlers) renderGeneratorWithForm( func (s *Handlers) renderGeneratorWithForm(
w http.ResponseWriter, errorMsg string, form url.Values, w http.ResponseWriter, r *http.Request, errorMsg string,
form url.Values, status int,
) { ) {
s.renderGenerator(w, &generatorData{ s.renderGeneratorStatus(w, r, &generatorData{
Error: errorMsg, Error: errorMsg,
FormURL: form.Get("url"), FormURL: form.Get("url"),
FormWidth: form.Get("width"), FormWidth: form.Get("width"),
@@ -230,7 +359,7 @@ func (s *Handlers) renderGeneratorWithForm(
FormQuality: form.Get("quality"), FormQuality: form.Get("quality"),
FormFit: form.Get("fit"), FormFit: form.Get("fit"),
FormTTL: form.Get("ttl"), FormTTL: form.Get("ttl"),
}) }, status)
} }
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string { func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
@@ -0,0 +1,41 @@
package handlers
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"sneak.berlin/go/pixa/internal/clientip"
"sneak.berlin/go/pixa/internal/config"
)
// TestFailedLoginLogsResolvedClientIP verifies the failed-login record
// carries the resolved client IP from the request context, not the raw
// proxy peer address.
func TestFailedLoginLogsResolvedClientIP(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
h := &Handlers{
log: slog.New(slog.NewJSONHandler(&buf, nil)),
config: &config.Config{SigningKey: testSigningKey},
}
form := url.Values{loginKeyField: {"wrong-key"}}
req := httptest.NewRequestWithContext(
t.Context(), http.MethodPost, "/",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(clientip.WithClientIP(req.Context(), "203.0.113.7"))
h.handleLoginPost(httptest.NewRecorder(), req)
if !strings.Contains(buf.String(), `"remote_addr":"203.0.113.7"`) {
t.Errorf("failed-login log missing resolved client IP; got %q", buf.String())
}
}
@@ -0,0 +1,273 @@
package handlers
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/session"
)
// testSigningKey is a throwaway signing key for the CSRF flow tests. It
// seeds the session manager, the encrypted-URL generator, and the CSRF
// token key, exactly as the real signing key does in production.
const testSigningKey = "test-signing-key-0123456789abcdef"
// Form field names used in the CSRF flow tests.
const (
loginKeyField = "key"
// gorilla/csrf's default form field name, not a credential.
csrfTokenField = "gorilla.csrf.Token" //nolint:gosec // G101 false positive
)
// csrfFieldPattern extracts the token rendered by csrf.TemplateField into
// the form. The field name is gorilla/csrf's default.
var csrfFieldPattern = regexp.MustCompile(
`name="gorilla\.csrf\.Token" value="([^"]+)"`)
// newCSRFTestRouter builds a router that mirrors the production wiring for
// the CSRF-protected UI routes (see server.SetupRoutes): the login and
// generator forms and their POST targets sit behind the real CSRF
// middleware. Requests are marked plaintext (Debug: true) so the flow runs
// over httptest's http transport without an https Referer.
func newCSRFTestRouter(t *testing.T) (*Handlers, http.Handler) {
t.Helper()
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
sessMgr, err := session.NewManager(testSigningKey)
if err != nil {
t.Fatalf("session.NewManager() error = %v", err)
}
encGen, err := encurl.NewGenerator(testSigningKey)
if err != nil {
t.Fatalf("encurl.NewGenerator() error = %v", err)
}
protect, err := newCSRFProtect(testSigningKey, cfg.Debug)
if err != nil {
t.Fatalf("newCSRFProtect() error = %v", err)
}
h := &Handlers{
log: slog.New(slog.DiscardHandler),
config: cfg,
sessMgr: sessMgr,
encGen: encGen,
csrfProtect: protect,
}
r := chi.NewRouter()
r.Group(func(r chi.Router) {
r.Use(h.CSRF())
r.Get("/", h.HandleRoot())
r.Post("/", h.HandleRoot())
r.Post("/generate", h.HandleGenerateURL())
})
return h, r
}
// csrfCredentials performs a GET that renders a form and returns the CSRF
// cookies the middleware set and the token embedded in the form. Passing
// the authenticated session cookie renders the generator form instead of
// the login form.
func csrfCredentials(
t *testing.T, srv http.Handler, reqCookies []*http.Cookie,
) ([]*http.Cookie, string) {
t.Helper()
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
for _, c := range reqCookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET / status = %d, want %d", rec.Code, http.StatusOK)
}
match := csrfFieldPattern.FindStringSubmatch(rec.Body.String())
if match == nil {
t.Fatalf("no CSRF token field found in rendered form")
}
return rec.Result().Cookies(), match[1]
}
// postForm submits form values with the given cookies and returns the
// recorder.
func postForm(
srv http.Handler, path string,
cookies []*http.Cookie, form url.Values,
) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, path,
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
return rec
}
// TestLoginPostRejectedWithoutToken verifies that POST / with no CSRF token
// is rejected. This is login CSRF: no session cookie exists yet, so the
// protection must rest on a token bound to a pre-session cookie.
func TestLoginPostRejectedWithoutToken(t *testing.T) {
t.Parallel()
_, srv := newCSRFTestRouter(t)
rec := postForm(srv, "/", nil, url.Values{loginKeyField: {testSigningKey}})
if rec.Code != http.StatusForbidden {
t.Errorf("POST / without token status = %d, want %d",
rec.Code, http.StatusForbidden)
}
}
// TestLoginPostRejectedWithForeignToken verifies that a token that does not
// match the request's CSRF cookie is rejected: a token minted for one
// cookie cannot authorize a request carrying a different cookie.
func TestLoginPostRejectedWithForeignToken(t *testing.T) {
t.Parallel()
_, srv := newCSRFTestRouter(t)
cookiesA, _ := csrfCredentials(t, srv, nil)
_, tokenB := csrfCredentials(t, srv, nil)
rec := postForm(srv, "/", cookiesA, url.Values{
loginKeyField: {testSigningKey},
csrfTokenField: {tokenB},
})
if rec.Code != http.StatusForbidden {
t.Errorf("POST / with foreign token status = %d, want %d",
rec.Code, http.StatusForbidden)
}
}
// TestLoginPostAcceptedWithValidToken verifies that POST / with a matching
// cookie and token succeeds: the login is processed and a session is
// established (303 redirect).
func TestLoginPostAcceptedWithValidToken(t *testing.T) {
t.Parallel()
_, srv := newCSRFTestRouter(t)
cookies, token := csrfCredentials(t, srv, nil)
rec := postForm(srv, "/", cookies, url.Values{
loginKeyField: {testSigningKey},
csrfTokenField: {token},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST / with valid token status = %d, want %d",
rec.Code, http.StatusSeeOther)
}
var authed bool
for _, c := range rec.Result().Cookies() {
if c.Name == session.CookieName && c.Value != "" {
authed = true
}
}
if !authed {
t.Error("valid login did not set a session cookie")
}
}
// TestGeneratePostRejectedWithoutToken verifies that POST /generate is
// rejected without a CSRF token even when the request carries a valid
// authenticated session. The session cookie is not sufficient; the policy
// requires a CSRF token on this cookie-authenticated form.
func TestGeneratePostRejectedWithoutToken(t *testing.T) {
t.Parallel()
h, srv := newCSRFTestRouter(t)
sessionCookie := newSessionCookie(t, h)
rec := postForm(srv, "/generate",
[]*http.Cookie{sessionCookie},
url.Values{"url": {"https://example.com/a.jpg"}})
if rec.Code != http.StatusForbidden {
t.Errorf("POST /generate without token status = %d, want %d",
rec.Code, http.StatusForbidden)
}
}
// TestGeneratePostAcceptedWithValidToken verifies that POST /generate
// succeeds with a valid session and a matching CSRF cookie and token.
func TestGeneratePostAcceptedWithValidToken(t *testing.T) {
t.Parallel()
h, srv := newCSRFTestRouter(t)
sessionCookie := newSessionCookie(t, h)
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
cookies = append(cookies, sessionCookie)
rec := postForm(srv, "/generate", cookies, url.Values{
"url": {"https://example.com/a.jpg"},
"format": {"jpeg"},
csrfTokenField: {token},
})
if rec.Code != http.StatusOK {
t.Fatalf("POST /generate with valid token status = %d, want %d",
rec.Code, http.StatusOK)
}
if !strings.Contains(rec.Body.String(), "/v1/e/") {
t.Error("generator response did not contain a generated URL")
}
}
// newSessionCookie creates an authenticated session cookie via the
// handler's session manager.
func newSessionCookie(t *testing.T, h *Handlers) *http.Cookie {
t.Helper()
rec := httptest.NewRecorder()
err := h.sessMgr.CreateSession(rec)
if err != nil {
t.Fatalf("CreateSession() error = %v", err)
}
for _, c := range rec.Result().Cookies() {
if c.Name == session.CookieName {
return c
}
}
t.Fatalf("session manager did not set a %q cookie", session.CookieName)
return nil
}
@@ -0,0 +1,163 @@
package handlers
import (
"maps"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
)
// Generator form field names, and a value that is not a number.
const (
widthField = "width"
heightField = "height"
qualityField = "quality"
ttlField = "ttl"
fitField = "fit"
notANumber = "abc"
)
// generatePost submits the /generate form with a valid session and CSRF token
// plus the caller's extra fields, returning the recorder.
func generatePost(
t *testing.T, extra url.Values,
) *httptest.ResponseRecorder {
t.Helper()
h, srv := newCSRFTestRouter(t)
sessionCookie := newSessionCookie(t, h)
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
cookies = append(cookies, sessionCookie)
form := url.Values{
sourceURLField: {testSourceURL},
csrfTokenField: {token},
}
maps.Copy(form, extra)
return postForm(srv, "/generate", cookies, form)
}
// TestGeneratePostRejectsNonNumericWidth verifies that a non-numeric width is
// rejected with 400 naming the field rather than being coerced to 0 and
// minting a 0-width token.
func TestGeneratePostRejectsNonNumericWidth(t *testing.T) {
t.Parallel()
rec := generatePost(t, url.Values{"width": {"abc"}})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if strings.Contains(rec.Body.String(), "/v1/e/") {
t.Error("a token was generated for non-numeric width")
}
}
// TestGeneratePostRejectsOverLimitWidth verifies that a width beyond
// MaxDimension is rejected at generation time so an unusable token cannot be
// minted.
func TestGeneratePostRejectsOverLimitWidth(t *testing.T) {
t.Parallel()
rec := generatePost(t, url.Values{"width": {"100000"}})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if strings.Contains(rec.Body.String(), "/v1/e/") {
t.Error("a token was generated for an over-limit width")
}
}
// TestGeneratePostRejectsBadField verifies that each generator field whose
// value is not a number, out of range, or unrecognized is rejected with 400,
// mints no token, and is named in the error shown on the page.
func TestGeneratePostRejectsBadField(t *testing.T) {
t.Parallel()
tests := []struct {
field, value, wantError string
}{
{widthField, notANumber, "invalid width: not a number"},
{widthField, "-1", "width is negative"},
{widthField, "8193", "width is above 8192"},
{heightField, notANumber, "invalid height: not a number"},
{heightField, "8193", "height is above 8192"},
{qualityField, notANumber, "invalid quality: not a number"},
{qualityField, "0", "invalid quality: must be from 1 to 100"},
{qualityField, "101", "invalid quality: must be from 1 to 100"},
{ttlField, notANumber, "invalid ttl: not a number"},
{ttlField, "-1", "invalid ttl: must be from 0 to"},
{ttlField, "10000000000", "invalid ttl: must be from 0 to"},
{fitField, "bogus", "invalid fit mode: bogus"},
}
for _, tt := range tests {
t.Run(tt.field+"="+tt.value, func(t *testing.T) {
t.Parallel()
rec := generatePost(t, url.Values{tt.field: {tt.value}})
body := rec.Body.String()
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if strings.Contains(body, "/v1/e/") {
t.Error("a token was generated")
}
if !strings.Contains(body, tt.wantError) {
t.Errorf("page does not show %q", tt.wantError)
}
})
}
}
// TestBuildGeneratePayloadDefaultAndLimits verifies that an empty quality
// takes the default, and that the largest accepted width, height, quality and
// ttl are accepted with an expiry still in the future.
func TestBuildGeneratePayloadDefaultAndLimits(t *testing.T) {
t.Parallel()
parsed, err := url.Parse(testSourceURL)
if err != nil {
t.Fatalf("url.Parse() error = %v", err)
}
payload, _, _, err := buildGeneratePayload(parsed, url.Values{})
if err != nil {
t.Fatalf("empty form: error = %v", err)
}
if payload.Quality != encurl.DefaultQuality {
t.Errorf("empty quality gave %d, want %d",
payload.Quality, encurl.DefaultQuality)
}
_, expiresAt, _, err := buildGeneratePayload(parsed, url.Values{
widthField: {strconv.Itoa(imgcache.MaxDimension)},
heightField: {strconv.Itoa(imgcache.MaxDimension)},
qualityField: {strconv.Itoa(maxQuality)},
ttlField: {strconv.Itoa(maxTTL)},
})
if err != nil {
t.Fatalf("largest accepted values: error = %v", err)
}
if !expiresAt.After(time.Now()) {
t.Errorf("ttl %d gave expiry %v, want a time in the future",
maxTTL, expiresAt)
}
}
+45
View File
@@ -0,0 +1,45 @@
package handlers
import (
"errors"
"net/http"
)
// MaxFormBytes bounds the request body accepted on the HTML form POST
// routes (POST / and POST /generate). The forms carry a handful of short
// fields, so 1 MiB is generous while making the bound explicit rather than
// resting on ParseForm's incidental 10 MB cap.
const MaxFormBytes = 1 << 20 // 1 MiB
// LimitBody returns middleware that caps the request body on POST requests
// at maxBytes and rejects an oversized body with 413 Request Entity Too
// Large.
//
// It parses the form here, before the CSRF middleware reads the token from
// it. The CSRF middleware reads the token with PostFormValue, which
// swallows a parse error, so if the body were only capped there an
// oversized body would read as a missing token and be refused as 403. By
// parsing under the cap first, an oversized body is refused as 413. A
// successful parse is cached on the request, so the CSRF check and the
// handler reuse it rather than reading the body again.
func (s *Handlers) LimitBody(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
err := r.ParseForm()
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
http.Error(w, "Request body too large",
http.StatusRequestEntityTooLarge)
return
}
}
next.ServeHTTP(w, r)
})
}
}
@@ -0,0 +1,177 @@
package handlers
import (
"log/slog"
"net/http"
"net/url"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/session"
)
// Form field names and a throwaway source image URL for the body-limit
// tests.
const (
sourceURLField = "url"
testSourceURL = "https://example.com/a.jpg"
)
// newBodyLimitTestRouter mirrors the production wiring for the form POST
// routes (see server.SetupRoutes): LimitBody sits in front of the CSRF
// middleware, which sits in front of the handlers. maxBytes is the body
// cap under test, so a test can trip the limit with a small body.
func newBodyLimitTestRouter(
t *testing.T, maxBytes int64,
) (*Handlers, http.Handler) {
t.Helper()
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
sessMgr, err := session.NewManager(testSigningKey)
if err != nil {
t.Fatalf("session.NewManager() error = %v", err)
}
encGen, err := encurl.NewGenerator(testSigningKey)
if err != nil {
t.Fatalf("encurl.NewGenerator() error = %v", err)
}
protect, err := newCSRFProtect(testSigningKey, cfg.Debug)
if err != nil {
t.Fatalf("newCSRFProtect() error = %v", err)
}
h := &Handlers{
log: slog.New(slog.DiscardHandler),
config: cfg,
sessMgr: sessMgr,
encGen: encGen,
csrfProtect: protect,
}
r := chi.NewRouter()
r.Group(func(r chi.Router) {
r.Use(h.LimitBody(maxBytes))
r.Use(h.CSRF())
r.Get("/", h.HandleRoot())
r.Post("/", h.HandleRoot())
r.Post("/generate", h.HandleGenerateURL())
})
return h, r
}
// TestOversizedLoginPostRejectedBeforeCSRF is the core regression: an
// oversized POST / carrying an otherwise valid CSRF cookie and token must
// be rejected with 413. If the body limit ran after CSRF, the truncated
// body would read as a missing token and return 403; if it ran after the
// handler, a valid token would return 303. Getting 413 proves the limit
// fires before CSRF parses the form.
func TestOversizedLoginPostRejectedBeforeCSRF(t *testing.T) {
t.Parallel()
_, srv := newBodyLimitTestRouter(t, 16)
cookies, token := csrfCredentials(t, srv, nil)
rec := postForm(srv, "/", cookies, url.Values{
loginKeyField: {testSigningKey},
csrfTokenField: {token},
})
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("oversized POST / status = %d, want %d",
rec.Code, http.StatusRequestEntityTooLarge)
}
}
// TestOversizedGeneratePostRejectedBeforeCSRF is the same regression for
// POST /generate, which also parses a form behind CSRF.
func TestOversizedGeneratePostRejectedBeforeCSRF(t *testing.T) {
t.Parallel()
h, srv := newBodyLimitTestRouter(t, 16)
sessionCookie := newSessionCookie(t, h)
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
cookies = append(cookies, sessionCookie)
rec := postForm(srv, "/generate", cookies, url.Values{
sourceURLField: {testSourceURL},
csrfTokenField: {token},
})
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("oversized POST /generate status = %d, want %d",
rec.Code, http.StatusRequestEntityTooLarge)
}
}
// TestWithinLimitLoginPostSucceeds verifies the limit does not disturb a
// normal request: under the production cap, a valid login still parses and
// establishes a session (303). This guards against the body limit
// consuming or corrupting the form the CSRF check and handler depend on.
func TestWithinLimitLoginPostSucceeds(t *testing.T) {
t.Parallel()
_, srv := newBodyLimitTestRouter(t, MaxFormBytes)
cookies, token := csrfCredentials(t, srv, nil)
rec := postForm(srv, "/", cookies, url.Values{
loginKeyField: {testSigningKey},
csrfTokenField: {token},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("within-limit POST / status = %d, want %d",
rec.Code, http.StatusSeeOther)
}
var authed bool
for _, c := range rec.Result().Cookies() {
if c.Name == session.CookieName && c.Value != "" {
authed = true
}
}
if !authed {
t.Error("within-limit valid login did not set a session cookie")
}
}
// TestWithinLimitGeneratePostSucceeds is the same non-regression check for
// POST /generate.
func TestWithinLimitGeneratePostSucceeds(t *testing.T) {
t.Parallel()
h, srv := newBodyLimitTestRouter(t, MaxFormBytes)
sessionCookie := newSessionCookie(t, h)
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
cookies = append(cookies, sessionCookie)
rec := postForm(srv, "/generate", cookies, url.Values{
sourceURLField: {testSourceURL},
"format": {"jpeg"},
csrfTokenField: {token},
})
if rec.Code != http.StatusOK {
t.Fatalf("within-limit POST /generate status = %d, want %d",
rec.Code, http.StatusOK)
}
if !strings.Contains(rec.Body.String(), "/v1/e/") {
t.Error("within-limit generate response did not contain a generated URL")
}
}
+65
View File
@@ -0,0 +1,65 @@
package handlers
import (
"html/template"
"net/http"
"github.com/gorilla/csrf"
"sneak.berlin/go/pixa/internal/seal"
)
// csrfKeySalt provides domain separation for the CSRF authentication key,
// derived from the signing key so tokens survive restarts without extra
// configuration and never reuse the session or encrypted-URL key material.
const csrfKeySalt = "pixa-csrf-v1"
// newCSRFProtect builds the CSRF-protection middleware for the
// state-mutating HTML form routes. The token lives in its own cookie,
// independent of the session cookie, so it also protects the login POST
// where no session exists yet (login CSRF).
//
// When plaintext is true (local HTTP development), requests are marked
// plaintext so the library neither demands an https Referer nor sets a
// Secure cookie the browser would withhold over http. In production the
// service runs behind a TLS-terminating proxy, so plaintext is false and
// the library enforces its https Referer origin check.
func newCSRFProtect(
signingKey string, plaintext bool,
) (func(http.Handler) http.Handler, error) {
key, err := seal.DeriveKey([]byte(signingKey), csrfKeySalt)
if err != nil {
return nil, err
}
protect := csrf.Protect(
key[:],
csrf.Path("/"),
csrf.Secure(!plaintext),
csrf.SameSite(csrf.SameSiteStrictMode),
)
if !plaintext {
return protect, nil
}
return func(next http.Handler) http.Handler {
protected := protect(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
protected.ServeHTTP(w, csrf.PlaintextHTTPRequest(r))
})
}, nil
}
// CSRF returns the CSRF-protection middleware for the login and generator
// form routes.
func (s *Handlers) CSRF() func(http.Handler) http.Handler {
return s.csrfProtect
}
// csrfField returns the hidden form input carrying the CSRF token for the
// given request, to be embedded verbatim in a rendered form.
func csrfField(r *http.Request) template.HTML {
return csrf.TemplateField(r)
}
+11
View File
@@ -39,15 +39,22 @@ type Handlers struct {
imgCache *imgcache.Cache imgCache *imgcache.Cache
sessMgr *session.Manager sessMgr *session.Manager
encGen *encurl.Generator encGen *encurl.Generator
csrfProtect func(http.Handler) http.Handler
} }
// New creates a new Handlers instance. // New creates a new Handlers instance.
func New(lc fx.Lifecycle, params Params) (*Handlers, error) { func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
csrfProtect, err := newCSRFProtect(params.Config.SigningKey, params.Config.Debug)
if err != nil {
return nil, err
}
s := &Handlers{ s := &Handlers{
log: params.Logger.Get(), log: params.Logger.Get(),
hc: params.Healthcheck, hc: params.Healthcheck,
db: params.Database, db: params.Database,
config: params.Config, config: params.Config,
csrfProtect: csrfProtect,
} }
lc.Append(fx.Hook{ lc.Append(fx.Hook{
@@ -99,11 +106,15 @@ func (s *Handlers) initImageService() error {
// Create the fetcher config // Create the fetcher config
fetcherCfg := httpfetcher.DefaultConfig() fetcherCfg := httpfetcher.DefaultConfig()
fetcherCfg.AllowHTTP = s.config.AllowHTTP fetcherCfg.AllowHTTP = s.config.AllowHTTP
fetcherCfg.Timeout = s.config.UpstreamFetchTimeout
fetcherCfg.MaxResponseSize = s.config.UpstreamMaxResponseSize
if s.config.UpstreamConnectionsPerHost > 0 { if s.config.UpstreamConnectionsPerHost > 0 {
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
} }
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
// Create the service // Create the service
svc, err := imgcache.NewService(&imgcache.ServiceConfig{ svc, err := imgcache.NewService(&imgcache.ServiceConfig{
Cache: cache, Cache: cache,
+118
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"image" "image"
"image/color" "image/color"
"image/jpeg" "image/jpeg"
@@ -276,3 +277,120 @@ func TestHandleImage_ETagHeader(t *testing.T) {
t.Errorf("ETag should be quoted, got %q", etag) t.Errorf("ETag should be quoted, got %q", etag)
} }
} }
// TestHandleImage_InvalidFitMode_Returns400 verifies that the plain image
// route rejects an unrecognized fit mode with 400.
func TestHandleImage_InvalidFitMode_Returns400(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t)
status := getImage(t, fix,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?fit=bogus")
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", status, http.StatusBadRequest)
}
}
// TestHandleImage_InvalidQuery_Returns400 verifies that the plain image route
// answers a q that is not a whole number from 1 to 100, an empty one
// included, with 400 naming q and the value, a parameter given more than once
// with 400 naming it, and a query string that cannot be decoded with 400
// showing it, instead of serving the image at the default quality 85 or at
// the first value given.
func TestHandleImage_InvalidQuery_Returns400(t *testing.T) {
t.Parallel()
tests := []struct {
query, wantError string
}{
{"q=banana", `invalid q: not a number, got "banana"`},
{"q=0", `invalid q: must be from 1 to 100, got "0"`},
{"q=101", `invalid q: must be from 1 to 100, got "101"`},
{"q=", `invalid q: not a number, got ""`},
{"q=80&q=500", `invalid q: given more than once`},
{"q=80&q=", `invalid q: given more than once`},
{"fit=cover&fit=contain", `invalid fit: given more than once`},
{"q=80%", `invalid query string "q=80%": invalid URL escape "%"`},
{
"q=50;fit=contain",
`invalid query string "q=50;fit=contain": ` +
`invalid semicolon separator in query`,
},
}
for _, tt := range tests {
t.Run(tt.query, func(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t)
r := chi.NewRouter()
r.Get("/v1/image/*", fix.handler.HandleImage())
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?"+tt.query, nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
var body struct {
Error string `json:"error"`
}
err := json.NewDecoder(rec.Body).Decode(&body)
if err != nil {
t.Fatalf("decoding response body: %v", err)
}
if body.Error != tt.wantError {
t.Errorf("error = %q, want %q", body.Error, tt.wantError)
}
})
}
}
// TestHandleImage_EmptyFit_Returns400 verifies that the plain image route
// answers a fit that is in the URL but empty with 400 naming fit, instead of
// serving the image as cover. Only a fit missing from the URL means cover.
func TestHandleImage_EmptyFit_Returns400(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t)
r := chi.NewRouter()
r.Get("/v1/image/*", fix.handler.HandleImage())
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?fit=", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
var body struct {
Error string `json:"error"`
}
err := json.NewDecoder(rec.Body).Decode(&body)
if err != nil {
t.Fatalf("decoding response body: %v", err)
}
wantError := `invalid fit: not a fit mode, got ""`
if body.Error != wantError {
t.Errorf("error = %q, want %q", body.Error, wantError)
}
}
+106 -26
View File
@@ -2,12 +2,15 @@ package handlers
import ( import (
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imgcache"
) )
@@ -89,39 +92,66 @@ func (s *Handlers) parseImageRequest(
// Convert to ImageRequest // Convert to ImageRequest
req := parsed.ToImageRequest() req := parsed.ToImageRequest()
// Parse signature params from query string // Parse signature params from query string. r.URL.Query() would silently
query := r.URL.Query() // drop a pair it cannot decode, such as q=80%, so that q would be served
req.Signature = query.Get("sig") // at 85; a query string that cannot be decoded is refused instead. A
// parameter given more than once is refused too, as only its first value
// would be read.
query, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
s.respondError(w, fmt.Sprintf("invalid query string %q: %v",
r.URL.RawQuery, err), http.StatusBadRequest)
if expStr := query.Get("exp"); expStr != "" { return nil, false
exp, parseErr := strconv.ParseInt(expStr, 10, 64)
if parseErr == nil {
req.Expires = time.Unix(exp, 0)
}
} }
// Parse optional quality and fit params for name, values := range query {
if qStr := query.Get("q"); qStr != "" { if len(values) > 1 {
q, parseErr := strconv.Atoi(qStr) s.respondError(w, fmt.Sprintf("invalid %s: given more than once",
if parseErr == nil && q > 0 && q <= 100 { name), http.StatusBadRequest)
req.Quality = q
}
}
if fit := query.Get("fit"); fit != "" {
req.FitMode = imgcache.FitMode(fit)
fitErr := imgcache.ValidateFitMode(req.FitMode)
if fitErr != nil {
s.respondError(w, "invalid fit mode: "+fit, http.StatusBadRequest)
return nil, false return nil, false
} }
} }
// Default quality if not set req.Signature = query.Get("sig")
if req.Quality == 0 {
req.Quality = 85 req.Expires, err = parseExpires(query)
if err != nil {
s.respondError(w, err.Error(), http.StatusBadRequest)
return nil, false
}
// Parse optional quality and fit params. Only a q missing from the URL is
// 85. A q in the URL that is not a whole number from 1 to 100, an empty
// one included, is refused, checked as the generator checks its quality
// field; that check alone would take an empty q as missing.
qStr := query.Get("q")
if query.Has("q") && qStr == "" {
s.respondError(w, `invalid q: not a number, got ""`,
http.StatusBadRequest)
return nil, false
}
req.Quality, err = parseFormInt(query, "q",
encurl.DefaultQuality, minQuality, maxQuality)
if err != nil {
s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr),
http.StatusBadRequest)
return nil, false
}
// Only a fit missing from the URL is cover. A fit in the URL that is not a
// fit mode is refused by the fit-mode check below; that check would take an
// empty fit as missing, so an empty one is refused here.
req.FitMode = imgcache.FitMode(query.Get("fit"))
if query.Has("fit") && req.FitMode == "" {
s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest)
return nil, false
} }
// Default fit mode if not set // Default fit mode if not set
@@ -129,9 +159,41 @@ func (s *Handlers) parseImageRequest(
req.FitMode = imgcache.FitCover req.FitMode = imgcache.FitCover
} }
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
// route. Dimensions are already bounded by the path parser above; this
// also rejects an unrecognized fit mode with 400 instead of letting it
// reach the processor as a 500.
err = imgcache.ValidateImageRequest(req)
if err != nil {
s.respondError(w, "invalid image request: "+err.Error(),
http.StatusBadRequest)
return nil, false
}
return req, true return req, true
} }
// parseExpires reads the exp query parameter, a Unix time in seconds. An exp
// missing from the URL gives the zero time, which the signature check takes
// as no expiration. An exp in the URL that is not a whole number, an empty
// one included, is an error naming exp and the value.
func parseExpires(query url.Values) (time.Time, error) {
if !query.Has("exp") {
return time.Time{}, nil
}
expStr := query.Get("exp")
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
return time.Time{}, fmt.Errorf("%w exp: not a number, got %q",
errInvalidFormField, expStr)
}
return time.Unix(exp, 0), nil
}
// respondImageError maps image retrieval errors to HTTP responses. // respondImageError maps image retrieval errors to HTTP responses.
func (s *Handlers) respondImageError( func (s *Handlers) respondImageError(
w http.ResponseWriter, req *imgcache.ImageRequest, err error, w http.ResponseWriter, req *imgcache.ImageRequest, err error,
@@ -158,6 +220,24 @@ func (s *Handlers) respondImageError(
s.respondError(w, "internal error", http.StatusInternalServerError) s.respondError(w, "internal error", http.StatusInternalServerError)
} }
// cacheControl returns the Cache-Control header for an image served through a
// URL that expires at expires, or never when expires is the zero time. A cache
// may keep the image for a year, but not past the URL's expiry, after which
// pixa refuses the URL. The seconds left are rounded down and never negative.
// immutable only stops revalidation while the image is fresh, so it also ends
// at the expiry.
func cacheControl(expires time.Time) string {
const oneYear = 365 * 24 * time.Hour
maxAge := oneYear
if !expires.IsZero() {
maxAge = min(maxAge, max(time.Until(expires), 0))
}
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
}
// writeImageResponse writes headers and streams the image content, // writeImageResponse writes headers and streams the image content,
// handling conditional and HEAD requests. // handling conditional and HEAD requests.
func (s *Handlers) writeImageResponse( func (s *Handlers) writeImageResponse(
@@ -173,7 +253,7 @@ func (s *Handlers) writeImageResponse(
} }
// Cache control headers // Cache control headers
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("Cache-Control", cacheControl(req.Expires))
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus)) w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
if resp.ETag != "" { if resp.ETag != "" {
@@ -0,0 +1,203 @@
package handlers
import (
"image/color"
"log/slog"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
)
// photoPath is the path of the JPEG that newSignedHostServer serves.
const photoPath = "/images/photo.jpg"
// newSignedHostServer returns a router for both image routes, and the Handlers
// behind it, whose fetcher serves a JPEG at photoPath on signedHost. signedHost
// is not on the allowlist, so a /v1/image/ URL for it is served only with a
// valid signature.
func newSignedHostServer(t *testing.T) (*Handlers, http.Handler) {
t.Helper()
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
StateDir: t.TempDir(),
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
})
if err != nil {
t.Fatalf("imgcache.NewCache() error = %v", err)
}
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
Cache: cache,
Fetcher: newMockFetcher(fstest.MapFS{
signedHost + photoPath: &fstest.MapFile{Data: jpegData},
}),
SigningKey: testSigningKey,
})
if err != nil {
t.Fatalf("imgcache.NewService() error = %v", err)
}
encGen, err := encurl.NewGenerator(testSigningKey)
if err != nil {
t.Fatalf("encurl.NewGenerator() error = %v", err)
}
h := &Handlers{
log: slog.New(slog.DiscardHandler),
imgSvc: svc,
encGen: encGen,
}
r := chi.NewRouter()
r.Get("/v1/image/*", h.HandleImage())
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
return h, r
}
// getMaxAge sends a GET for target to srv, requires a 200, and returns the
// max-age of the response's Cache-Control header, which must read
// "public, max-age=<seconds>, immutable".
func getMaxAge(t *testing.T, srv http.Handler, target string) int {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
header := rec.Header().Get("Cache-Control")
t.Logf("GET %s: %d, Cache-Control: %s", target, rec.Code, header)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
value, hasPrefix := strings.CutPrefix(header, "public, max-age=")
value, hasSuffix := strings.CutSuffix(value, ", immutable")
maxAge, err := strconv.Atoi(value)
if !hasPrefix || !hasSuffix || err != nil {
t.Fatalf("Cache-Control = %q, want public, max-age=<seconds>, immutable",
header)
}
return maxAge
}
// TestHandleImage_SignedURL_MaxAgeEndsAtExp verifies that an image served
// through a signed URL expiring in 60 seconds may be cached for at most those
// 60 seconds. The lower bound of 50 shows the max-age is the time left, not 0.
func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t)
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: photoPath,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatJPEG,
}, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
maxAge := getMaxAge(t, srv, signedURL)
if maxAge < 50 || maxAge > 60 {
t.Errorf("max-age = %d, want 50 to 60", maxAge)
}
}
// TestHandleImage_AllowlistedHost_MaxAge verifies the max-age of an image from
// an allowlisted host, which is served without checking sig or exp. A URL with
// no exp may be cached for a year. A URL whose exp has passed is the one request
// that reaches the header after its expiry, and must get 0, never less.
func TestHandleImage_AllowlistedHost_MaxAge(t *testing.T) {
t.Parallel()
pastExp := strconv.FormatInt(time.Now().Add(-time.Hour).Unix(), 10)
tests := []struct {
name string
query string
wantMaxAge int
}{
{"no exp", "", 31536000},
{"exp already past", "?exp=" + pastExp, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t)
r := chi.NewRouter()
r.Get("/v1/image/*", fix.handler.HandleImage())
maxAge := getMaxAge(t, r,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg"+tt.query)
if maxAge != tt.wantMaxAge {
t.Errorf("max-age = %d, want %d", maxAge, tt.wantMaxAge)
}
})
}
}
// TestHandleImageEnc_MaxAge verifies that an image served through an encrypted
// URL with a 60 second TTL may be cached for at most those 60 seconds, that one
// with a two-year TTL may be cached for a year, and that one made without a
// TTL, which never expires, may be cached for a year.
func TestHandleImageEnc_MaxAge(t *testing.T) {
t.Parallel()
tests := []struct {
name string
expiresAt int64
wantAtLeast int
wantAtMost int
}{
{"60 second TTL", time.Now().Add(time.Minute).Unix(), 50, 60},
{"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour).Unix(), 31536000, 31536000},
{"no TTL", 0, 31536000, 31536000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t)
token, err := h.encGen.Generate(&encurl.Payload{
SourceHost: signedHost,
SourcePath: photoPath,
Width: 50,
Height: 50,
Format: imgcache.FormatJPEG,
ExpiresAt: tt.expiresAt,
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
maxAge := getMaxAge(t, srv, "/v1/e/"+token+"/img.jpg")
if maxAge < tt.wantAtLeast || maxAge > tt.wantAtMost {
t.Errorf("max-age = %d, want %d to %d",
maxAge, tt.wantAtLeast, tt.wantAtMost)
}
})
}
}
@@ -0,0 +1,171 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/signature"
)
// signedHost is not on the allowlist setupTestHandler builds, so a request
// for it needs a valid signature. setupTestHandler serves no image for it: a
// request that passes the signature check gets 502 from the failed fetch, and
// one that fails the check gets 401.
const signedHost = "signed.example.com"
// getImage sends a GET for target to the image route of fix and returns the
// response status.
func getImage(t *testing.T, fix *testFixtures, target string) int {
t.Helper()
r := chi.NewRouter()
r.Get("/v1/image/*", fix.handler.HandleImage())
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
t.Logf("GET %s: %d", target, rec.Code)
return rec.Code
}
// TestHandleImage_SignatureCoversQualityAndFit signs a URL for quality 85
// and fit cover, the values the route uses when a URL has no q or fit, and
// sends that signature with each q and fit below.
func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
t.Parallel()
expires := time.Now().Add(time.Hour)
signer := signature.New("test-signing-key-must-be-32-chars")
sig := signer.Sign(&signature.Request{
SourceHost: signedHost,
SourcePath: "/images/photo.jpg",
Width: 50,
Height: 50,
Format: string(imgcache.FormatJPEG),
Quality: 85,
FitMode: string(imgcache.FitCover),
Expires: expires,
})
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
signedHost, sig, expires.Unix())
tests := []struct {
name string
query string
wantStatus int
}{
{"no q or fit", "", http.StatusBadGateway},
{"q=85 and fit=cover", "&q=85&fit=cover", http.StatusBadGateway},
{"replayed with q=40", "&q=40", http.StatusUnauthorized},
{"replayed with fit=contain", "&fit=contain", http.StatusUnauthorized},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
status := getImage(t, setupTestHandler(t), signedURL+tt.query)
if status != tt.wantStatus {
t.Errorf("status = %d, want %d", status, tt.wantStatus)
}
})
}
}
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by the
// service's signed-URL generator to the route.
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
t.Parallel()
tests := []struct {
name string
quality int
fitMode imgcache.FitMode
}{
{"quality 40 and fit contain", 40, imgcache.FitContain},
{"quality and fit unset", 0, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t)
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: "/images/photo.jpg",
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatJPEG,
Quality: tt.quality,
FitMode: tt.fitMode,
}, time.Hour)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
status := getImage(t, fix, signedURL)
if status != http.StatusBadGateway {
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
}
})
}
}
// TestHandleImage_InvalidExp_Returns400 sends a signed-host URL whose exp is
// not a whole number, and one whose exp is empty. Each is refused with 400
// naming exp and the value, not with the 401 a URL without exp still gets.
func TestHandleImage_InvalidExp_Returns400(t *testing.T) {
t.Parallel()
tests := []struct {
query string
wantStatus int
wantError string
}{
{"sig=x&exp=banana", http.StatusBadRequest,
`invalid exp: not a number, got "banana"`},
{"sig=x&exp=", http.StatusBadRequest, `invalid exp: not a number, got ""`},
{"sig=x", http.StatusUnauthorized, "unauthorized"},
}
for _, tt := range tests {
t.Run(tt.query, func(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t)
r := chi.NewRouter()
r.Get("/v1/image/*", fix.handler.HandleImage())
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/v1/image/"+signedHost+"/images/photo.jpg/50x50.jpeg?"+tt.query, nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
var body struct {
Error string `json:"error"`
}
err := json.NewDecoder(rec.Body).Decode(&body)
if err != nil {
t.Fatalf("decoding response body: %v", err)
}
if rec.Code != tt.wantStatus || body.Error != tt.wantError {
t.Errorf("got %d %q, want %d %q",
rec.Code, body.Error, tt.wantStatus, tt.wantError)
}
})
}
}
+15 -2
View File
@@ -50,6 +50,19 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
// Convert payload to ImageRequest // Convert payload to ImageRequest
req := payload.ToImageRequest() req := payload.ToImageRequest()
// Apply the same dimension and fit-mode bounds as the plain image
// route: a sealed payload is trusted for its origin, not for staying
// within limits, so an over-limit size or unknown fit mode is a 400
// here rather than an out-of-memory or a 500 from the processor.
err = imgcache.ValidateImageRequest(req)
if err != nil {
s.log.Debug("encrypted URL failed validation", "error", err)
s.respondError(w, "invalid encrypted URL: "+err.Error(),
http.StatusBadRequest)
return
}
// Log the request // Log the request
s.log.Debug("encrypted image request", s.log.Debug("encrypted image request",
"host", req.SourceHost, "host", req.SourceHost,
@@ -76,8 +89,8 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10)) w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
} }
// Cache headers - encrypted URLs can be cached since they're immutable // Cache headers: max-age ends at the URL's expiry
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("Cache-Control", cacheControl(req.Expires))
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus)) w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
// Stream the response // Stream the response
@@ -0,0 +1,98 @@
package handlers
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
)
// newEncTestServer builds a router serving the encrypted-URL route with a
// generator seeded by the shared test signing key. The image service is left
// nil: these tests exercise validation that rejects a token before any image
// is fetched, so the handler must never reach the service.
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
t.Helper()
encGen, err := encurl.NewGenerator(testSigningKey)
if err != nil {
t.Fatalf("encurl.NewGenerator() error = %v", err)
}
h := &Handlers{
log: slog.New(slog.DiscardHandler),
encGen: encGen,
}
r := chi.NewRouter()
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
return encGen, r
}
// getEncToken issues a GET for the given token and returns the recorder.
func getEncToken(srv http.Handler, token string) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/v1/e/"+token+"/img.jpg", nil)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
return rec
}
// TestHandleImageEnc_OverLimitDimension_Returns400 verifies that a decrypted
// token requesting a dimension beyond MaxDimension is rejected with 400
// instead of reaching the image processor and libvips.
func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
t.Parallel()
encGen, srv := newEncTestServer(t)
token, err := encGen.Generate(&encurl.Payload{
SourceHost: "cdn.example.com",
SourcePath: "/photo.jpg",
Width: 100000,
Height: 100000,
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
rec := getEncToken(srv, token)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
// TestHandleImageEnc_InvalidFitMode_Returns400 verifies that a decrypted token
// carrying an unrecognized fit mode is rejected with 400 rather than surfacing
// as a 500 from the image processor's default branch.
func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
t.Parallel()
encGen, srv := newEncTestServer(t)
token, err := encGen.Generate(&encurl.Payload{
SourceHost: "cdn.example.com",
SourcePath: "/photo.jpg",
Width: 800,
Height: 600,
FitMode: imgcache.FitMode("bogus"),
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
rec := getEncToken(srv, token)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
@@ -0,0 +1,111 @@
package httpfetcher
import (
"context"
"errors"
"net"
"net/http"
"net/netip"
"testing"
)
// TestIsPrivateIPBlocksSpecialRanges covers the internal and special-use
// ranges added to the built-in blocklist, in IPv4, IPv6, and IPv4-mapped
// forms, alongside public controls that must stay reachable.
func TestIsPrivateIPBlocksSpecialRanges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ip string
want bool
}{
{"cgnat-low", "100.64.0.1", true},
{"cgnat-high", "100.127.255.254", true},
{"ietf-protocol", "192.0.0.1", true},
{"benchmark-low", "198.18.0.1", true},
{"benchmark-high", "198.19.255.254", true},
{"nat64", "64:ff9b::1", true},
{"nat64-embeds-private", "64:ff9b::a00:1", true}, // maps 10.0.0.1
{"ipv4-mapped-private", "::ffff:10.0.0.1", true},
{"cloud-metadata", "169.254.169.254", true},
{"public-v4", "8.8.8.8", false},
{"test-net-1-public", testPublicHost, false}, // TEST-NET-1, stays public
{"public-v6", "2001:4860:4860::8888", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ip := net.ParseIP(tc.ip)
if ip == nil {
t.Fatalf("failed to parse IP %q", tc.ip)
}
got := isPrivateIP(ip)
if got != tc.want {
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
}
})
}
}
// transportOf returns the *http.Transport backing a fetcher, so a test can
// exercise the SSRF-safe dialer New installed with the operator blocklist.
func transportOf(t *testing.T, f *HTTPFetcher) *http.Transport {
t.Helper()
transport, ok := f.client.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport is %T, want *http.Transport", f.client.Transport)
}
return transport
}
// TestDialerEnforcesBlockedNetworks proves an operator-supplied
// blocked_networks entry is enforced by the dialer, in addition to the
// built-in ranges, while an address outside both stays dialable.
func TestDialerEnforcesBlockedNetworks(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
// TEST-NET-2 (198.51.100.0/24) is public to the built-in check, so
// blocking it can only come from the operator-supplied list.
cfg.BlockedNetworks = []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}
transport := transportOf(t, New(cfg))
blocked := []string{
"198.51.100.5:80", // operator-supplied range
"10.0.0.5:80", // built-in RFC 1918, still enforced
"100.64.0.1:80", // built-in CGNAT range
}
for _, addr := range blocked {
t.Run("blocked/"+addr, func(t *testing.T) {
t.Parallel()
_, err := transport.DialContext(context.Background(), "tcp", addr)
if !errors.Is(err, ErrSSRFBlocked) {
t.Errorf("DialContext(%q) = %v, want ErrSSRFBlocked", addr, err)
}
})
}
t.Run("public-not-blocked", func(t *testing.T) {
t.Parallel()
// A cancelled context makes the dial fail without touching the
// network; the point is only that a public literal outside every
// blocked range is not SSRF-blocked.
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := transport.DialContext(ctx, "tcp", testPublicHost+":80")
if errors.Is(err, ErrSSRFBlocked) {
t.Errorf("public target SSRF-blocked with operator list set: %v", err)
}
})
}
+421
View File
@@ -0,0 +1,421 @@
package httpfetcher
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
)
// testPublicHost is a TEST-NET-1 (RFC 5737) literal. isPrivateIP treats it as
// public, so validateURL and the redirect check accept it with no DNS lookup,
// while the recording dialer routes it to the local httptest server. The
// address is reserved for documentation and is never routed on the network.
const testPublicHost = "192.0.2.10"
// imagePayload is the body served by the fake upstream's image route.
const imagePayload = "fake-jpeg-bytes"
// errUnexpectedDial reports a dial to any host other than testPublicHost, which
// would mean SSRF protection let a forbidden target reach the transport.
var errUnexpectedDial = errors.New("unexpected dial target")
// upstreamURL builds a fetch URL on the fake public host for the given path.
func upstreamURL(path string) string {
return "http://" + testPublicHost + path
}
// recordingDialer records every address the transport asks it to dial and
// routes connections for testPublicHost to a real local server, so the SSRF
// checks run against a public-looking host while bytes go to httptest.
type recordingDialer struct {
target string
mu sync.Mutex
dialed []string
}
func (d *recordingDialer) dialContext(
ctx context.Context,
network, addr string,
) (net.Conn, error) {
d.mu.Lock()
d.dialed = append(d.dialed, addr)
d.mu.Unlock()
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
if host != testPublicHost {
return nil, fmt.Errorf("%w: %s", errUnexpectedDial, addr)
}
var dialer net.Dialer
return dialer.DialContext(ctx, network, d.target)
}
// dialedAddrs returns a copy of the addresses the dialer was asked to reach.
func (d *recordingDialer) dialedAddrs() []string {
d.mu.Lock()
defer d.mu.Unlock()
return slices.Clone(d.dialed)
}
// startUpstream launches a fake upstream with the routes the fetch tests
// exercise and stops it when the test finishes.
func startUpstream(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/image", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", contentTypeJPEG)
_, _ = io.WriteString(w, imagePayload)
})
mux.HandleFunc("/status/500", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
mux.HandleFunc("/html", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, "<html></html>")
})
mux.HandleFunc("/redirect/private", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
})
mux.HandleFunc("/redirect/public", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/image", http.StatusFound)
})
mux.HandleFunc("/redirect/chain", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/redirect/hop", http.StatusFound)
})
mux.HandleFunc("/redirect/hop", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/image", http.StatusFound)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// newServerFetcher builds a fetcher whose transport routes testPublicHost to
// srv, leaving the real SSRF validation and redirect checks in place.
func newServerFetcher(
t *testing.T,
srv *httptest.Server,
cfg *Config,
) (*HTTPFetcher, *recordingDialer) {
t.Helper()
if cfg == nil {
cfg = DefaultConfig()
}
cfg.AllowHTTP = true
f := New(cfg)
transport, ok := f.client.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport is %T, want *http.Transport", f.client.Transport)
}
dialer := &recordingDialer{target: srv.Listener.Addr().String()}
transport.DialContext = dialer.dialContext
return f, dialer
}
// testContext returns a context cancelled when the test ends, bounding any
// fetch that would otherwise block on a leaked semaphore slot.
func testContext(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Cleanup(cancel)
return ctx
}
// fetchImage fetches path from the fake upstream and fails on error.
func fetchImage(t *testing.T, f *HTTPFetcher, path string) *FetchResult {
t.Helper()
res, err := f.Fetch(testContext(t), upstreamURL(path))
if err != nil {
t.Fatalf("Fetch(%s) error = %v", path, err)
}
return res
}
// fetchExpectError fetches path and fails unless Fetch returns an error.
func fetchExpectError(t *testing.T, f *HTTPFetcher, path string) error {
t.Helper()
res, err := f.Fetch(testContext(t), upstreamURL(path))
if err == nil {
_ = res.Content.Close()
t.Fatalf("Fetch(%s) = nil error, want an error", path)
}
return err
}
// fetchBody fetches path and returns the fully read, closed response body.
func fetchBody(t *testing.T, f *HTTPFetcher, path string) string {
t.Helper()
res := fetchImage(t, f, path)
defer func() { _ = res.Content.Close() }()
data, err := io.ReadAll(res.Content)
if err != nil {
t.Fatalf("read body: %v", err)
}
return string(data)
}
// semLen reports how many per-host semaphore slots are currently held.
func semLen(f *HTTPFetcher, host string) int {
return len(f.getHostSemaphore(host))
}
func TestFetchRedirectToPrivateIPBlocked(t *testing.T) {
t.Parallel()
srv := startUpstream(t)
f, dialer := newServerFetcher(t, srv, nil)
_, err := f.Fetch(testContext(t), upstreamURL("/redirect/private"))
if !errors.Is(err, ErrSSRFBlocked) {
t.Fatalf("Fetch() error = %v, want ErrSSRFBlocked", err)
}
for _, addr := range dialer.dialedAddrs() {
if strings.Contains(addr, "169.254.169.254") {
t.Errorf("dialer connected to the private redirect target: %s", addr)
}
}
}
func TestFetchRedirectToPublicSucceeds(t *testing.T) {
t.Parallel()
srv := startUpstream(t)
f, _ := newServerFetcher(t, srv, nil)
if body := fetchBody(t, f, "/redirect/public"); body != imagePayload {
t.Errorf("body = %q, want %q", body, imagePayload)
}
}
func TestFetchRedirectChainSucceeds(t *testing.T) {
t.Parallel()
srv := startUpstream(t)
f, _ := newServerFetcher(t, srv, nil)
if body := fetchBody(t, f, "/redirect/chain"); body != imagePayload {
t.Errorf("body = %q, want %q", body, imagePayload)
}
}
func TestFetchRejectsNon2xx(t *testing.T) {
t.Parallel()
srv := startUpstream(t)
f, _ := newServerFetcher(t, srv, nil)
err := fetchExpectError(t, f, "/status/500")
if !errors.Is(err, ErrUpstreamError) {
t.Fatalf("Fetch() error = %v, want ErrUpstreamError", err)
}
}
func TestFetchRejectsDisallowedContentType(t *testing.T) {
t.Parallel()
srv := startUpstream(t)
f, _ := newServerFetcher(t, srv, nil)
err := fetchExpectError(t, f, "/html")
if !errors.Is(err, ErrInvalidContentType) {
t.Fatalf("Fetch() error = %v, want ErrInvalidContentType", err)
}
}
func TestFetchMaxResponseSizeEnforced(t *testing.T) {
t.Parallel()
srv := startUpstream(t)
cfg := DefaultConfig()
cfg.MaxResponseSize = 8
f, _ := newServerFetcher(t, srv, cfg)
res := fetchImage(t, f, "/image")
defer func() { _ = res.Content.Close() }()
data, err := io.ReadAll(res.Content)
if !errors.Is(err, ErrResponseTooLarge) {
t.Fatalf("read error = %v, want ErrResponseTooLarge", err)
}
if int64(len(data)) > cfg.MaxResponseSize {
t.Errorf("read %d bytes, exceeds limit %d", len(data), cfg.MaxResponseSize)
}
}
func TestFetchSemaphoreReleasedOnError(t *testing.T) {
t.Parallel()
srv := startUpstream(t)
cfg := DefaultConfig()
cfg.MaxConnectionsPerHost = 1
f, _ := newServerFetcher(t, srv, cfg)
err := fetchExpectError(t, f, "/status/500")
if !errors.Is(err, ErrUpstreamError) {
t.Fatalf("Fetch() error = %v, want ErrUpstreamError", err)
}
if held := semLen(f, testPublicHost); held != 0 {
t.Fatalf("semaphore slot leaked after error: %d held", held)
}
// One slot per host: this fetch proceeds only if the slot was released.
res := fetchImage(t, f, "/image")
_ = res.Content.Close()
}
// assertSlotReleasedByClose fetches an image over a one-slot host, hands the
// open result to consume, and asserts the slot is held before and freed after,
// then that a follow-up fetch can still acquire it.
func assertSlotReleasedByClose(
t *testing.T,
consume func(*testing.T, *FetchResult),
) {
t.Helper()
srv := startUpstream(t)
cfg := DefaultConfig()
cfg.MaxConnectionsPerHost = 1
f, _ := newServerFetcher(t, srv, cfg)
res := fetchImage(t, f, "/image")
if held := semLen(f, testPublicHost); held != 1 {
t.Fatalf("slot not held while body is open: %d held", held)
}
consume(t, res)
if held := semLen(f, testPublicHost); held != 0 {
t.Fatalf("slot not released after close: %d held", held)
}
next := fetchImage(t, f, "/image")
_ = next.Content.Close()
}
func TestFetchSemaphoreReleasedOnBodyClose(t *testing.T) {
t.Parallel()
assertSlotReleasedByClose(t, func(t *testing.T, res *FetchResult) {
t.Helper()
_, err := io.ReadAll(res.Content)
if err != nil {
t.Fatalf("read body: %v", err)
}
err = res.Content.Close()
if err != nil {
t.Fatalf("close body: %v", err)
}
})
}
func TestFetchSemaphoreReleasedOnPartialReadClose(t *testing.T) {
t.Parallel()
assertSlotReleasedByClose(t, func(t *testing.T, res *FetchResult) {
t.Helper()
buf := make([]byte, 1)
_, err := res.Content.Read(buf)
if err != nil {
t.Fatalf("partial read: %v", err)
}
err = res.Content.Close()
if err != nil {
t.Fatalf("close body: %v", err)
}
})
}
// The dial-time re-resolution in ssrfSafeDialer is what closes the DNS
// rebinding window: even if validateURL saw a public answer earlier, the
// dialer independently re-checks the address it is about to connect to. A full
// rebinding simulation (a resolver returning public, then private) would mean
// replacing the global net.DefaultResolver with a fake DNS server, which is
// heavyweight and unsafe to mutate under parallel -race tests. The property is
// proven directly here instead: the dialer rejects a private target outright,
// which is exactly the check that fires when a validated host later resolves
// to a private address.
func TestSSRFSafeDialerBlocksPrivateTarget(t *testing.T) {
t.Parallel()
for _, addr := range []string{
"169.254.169.254:80", // link-local (cloud metadata)
"127.0.0.1:80", // loopback
"10.0.0.5:80", // RFC 1918 private
} {
t.Run(addr, func(t *testing.T) {
t.Parallel()
_, err := ssrfSafeDialer(context.Background(), "tcp", addr)
if !errors.Is(err, ErrSSRFBlocked) {
t.Errorf("ssrfSafeDialer(%q) = %v, want ErrSSRFBlocked", addr, err)
}
})
}
}
func TestSSRFSafeDialerAllowsPublicTarget(t *testing.T) {
t.Parallel()
// A cancelled context makes the dial fail immediately without touching the
// network; the point is only that a public literal is not SSRF-blocked.
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := ssrfSafeDialer(ctx, "tcp", testPublicHost+":80")
if err == nil {
t.Fatal("expected a dial error for an unreachable public target")
}
if errors.Is(err, ErrSSRFBlocked) {
t.Errorf("public target was SSRF-blocked: %v", err)
}
}
+71 -5
View File
@@ -11,6 +11,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/http/httptrace" "net/http/httptrace"
"net/netip"
neturl "net/url" neturl "net/url"
"slices" "slices"
"strings" "strings"
@@ -46,6 +47,20 @@ const (
localhostIPv6 = "::1" localhostIPv6 = "::1"
) )
// builtinBlockedPrefixes are internal or special-use ranges that Go's
// net.IP predicates (IsPrivate, IsLinkLocalUnicast, and the like) do not
// already cover. They are always blocked, in addition to any
// operator-supplied networks. IPv4-mapped IPv6 addresses are unmapped
// before matching, so these IPv4 ranges are caught in both forms.
//
//nolint:gochecknoglobals // immutable built-in blocklist
var builtinBlockedPrefixes = []netip.Prefix{
netip.MustParsePrefix("100.64.0.0/10"), // RFC 6598 CGNAT / carrier-grade NAT
netip.MustParsePrefix("192.0.0.0/24"), // RFC 6890 IETF protocol assignments
netip.MustParsePrefix("198.18.0.0/15"), // RFC 2544 benchmarking range
netip.MustParsePrefix("64:ff9b::/96"), // RFC 6052 NAT64 (maps onto IPv4)
}
// Fetcher errors. // Fetcher errors.
var ( var (
ErrSSRFBlocked = errors.New("request blocked: private or internal IP") ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
@@ -107,6 +122,9 @@ type Config struct {
AllowHTTP bool AllowHTTP bool
// MaxConnectionsPerHost limits concurrent connections to each upstream host. // MaxConnectionsPerHost limits concurrent connections to each upstream host.
MaxConnectionsPerHost int MaxConnectionsPerHost int
// BlockedNetworks are operator-supplied CIDR ranges refused by the
// dialer, in addition to the always-enforced built-in ranges.
BlockedNetworks []netip.Prefix
} }
// DefaultConfig returns a Config with sensible defaults. // DefaultConfig returns a Config with sensible defaults.
@@ -142,9 +160,13 @@ func New(config *Config) *HTTPFetcher {
config = DefaultConfig() config = DefaultConfig()
} }
// Create transport with SSRF-safe dialer // Create transport with SSRF-safe dialer. The dialer re-resolves and
// re-checks at connect time (closing the DNS-rebinding window) against
// both the built-in ranges and the operator-supplied blocklist.
transport := &http.Transport{ transport := &http.Transport{
DialContext: ssrfSafeDialer, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialSSRFSafe(ctx, network, addr, config.BlockedNetworks)
},
TLSHandshakeTimeout: DefaultTLSTimeout, TLSHandshakeTimeout: DefaultTLSTimeout,
MaxIdleConns: DefaultMaxIdleConns, MaxIdleConns: DefaultMaxIdleConns,
IdleConnTimeout: DefaultIdleConnTimeout, IdleConnTimeout: DefaultIdleConnTimeout,
@@ -451,11 +473,53 @@ func isPrivateIP(ip net.IP) bool {
} }
} }
return false // Special-use ranges the net.IP predicates above do not cover.
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return true
}
addr = addr.Unmap()
return slices.ContainsFunc(builtinBlockedPrefixes, func(prefix netip.Prefix) bool {
return prefix.Contains(addr)
})
} }
// ssrfSafeDialer is a custom dialer that validates IP addresses before connecting. // isBlockedIP reports whether ip is refused, either by the built-in
// internal-range check or by one of the operator-supplied prefixes.
func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
if isPrivateIP(ip) {
return true
}
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return true
}
addr = addr.Unmap()
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool {
return prefix.Contains(addr)
})
}
// ssrfSafeDialer validates IP addresses against the built-in blocked ranges
// before connecting. New wraps dialSSRFSafe with the operator-supplied
// blocklist; this entry point enforces the built-in ranges alone.
func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error) { func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error) {
return dialSSRFSafe(ctx, network, addr, nil)
}
// dialSSRFSafe re-resolves addr and refuses to connect to any built-in
// internal range or operator-supplied blocked prefix, closing the
// DNS-rebinding window at connect time.
func dialSSRFSafe(
ctx context.Context,
network, addr string,
blocked []netip.Prefix,
) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr) host, port, err := net.SplitHostPort(addr)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -468,9 +532,11 @@ func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error)
} }
// Check all resolved IPs // Check all resolved IPs
if slices.ContainsFunc(ips, isPrivateIP) { for _, ip := range ips {
if isBlockedIP(ip, blocked) {
return nil, ErrSSRFBlocked return nil, ErrSSRFBlocked
} }
}
// Connect using the first valid IP // Connect using the first valid IP
var dialer net.Dialer var dialer net.Dialer
+22
View File
@@ -161,6 +161,13 @@ func (p *ImageProcessor) Process(
} }
defer img.Close() defer img.Close()
// Turn the image upright now: encode strips the EXIF orientation tag,
// and sizes below must be worked out on the upright image.
err = img.AutoRotate()
if err != nil {
return nil, fmt.Errorf("failed to auto-rotate: %w", err)
}
// Get original dimensions // Get original dimensions
origWidth := img.Width() origWidth := img.Width()
origHeight := img.Height() origHeight := img.Height()
@@ -404,6 +411,21 @@ func (p *ImageProcessor) encode(
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format) return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
} }
// Stripping drops the ICC profile as well, and clients show an image
// with no profile as sRGB, so convert to sRGB first. "srgb" names
// libvips' built-in profile; govips' own sRGB path variable is set on
// first use but read without a lock, so concurrent requests race on it.
if img.HasICCProfile() {
err := img.TransformICCProfileWithFallback("srgb", "srgb")
if err != nil {
return nil, fmt.Errorf("failed to convert to sRGB: %w", err)
}
}
// Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for
// GIF, which carries none of them.
params.StripMetadata = true
output, _, err := img.Export(&params) output, _, err := img.Export(&params)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -9,7 +9,9 @@ import (
"image/jpeg" "image/jpeg"
"image/png" "image/png"
"io" "io"
"math"
"os" "os"
"slices"
"testing" "testing"
"github.com/davidbyttow/govips/v2/vips" "github.com/davidbyttow/govips/v2/vips"
@@ -561,3 +563,152 @@ func TestImageProcessor_EncodeAVIF(t *testing.T) {
encodeAndCheck(t, FormatAVIF, 85, mimeAVIF) encodeAndCheck(t, FormatAVIF, 85, mimeAVIF)
} }
// processAndDecode runs input through Process and decodes the output with
// vips, so a test can inspect the image a client would receive.
func processAndDecode(t *testing.T, input []byte, req *Request) *vips.ImageRef {
t.Helper()
result, err := New(Params{}).Process(
context.Background(), bytes.NewReader(input), req,
)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer func() { _ = result.Content.Close() }()
data, err := io.ReadAll(result.Content)
if err != nil {
t.Fatalf("failed to read result: %v", err)
}
output, err := vips.NewImageFromBuffer(data)
if err != nil {
t.Fatalf("failed to decode output: %v", err)
}
t.Cleanup(output.Close)
return output
}
func TestImageProcessor_StripsEXIF(t *testing.T) {
t.Parallel()
// gps-exif.jpg carries GPS coordinates, a camera make, model and serial
// number, and a capture time.
input, err := os.ReadFile("testdata/gps-exif.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
fixture, err := vips.NewImageFromBuffer(input)
if err != nil {
t.Fatalf("failed to decode test JPEG: %v", err)
}
t.Cleanup(fixture.Close)
if !slices.Contains(fixture.GetFields(), "exif-ifd3-GPSLatitude") {
t.Fatal("testdata/gps-exif.jpg has no GPS latitude")
}
formats := []Format{
FormatJPEG, FormatPNG, FormatWebP, FormatAVIF, FormatGIF, FormatOriginal,
}
for _, format := range formats {
t.Run(string(format), func(t *testing.T) {
t.Parallel()
output := processAndDecode(t, input, &Request{Format: format})
if output.HasExif() {
t.Errorf("output has EXIF: %v", output.GetExif())
}
})
}
}
func TestImageProcessor_AppliesEXIFOrientation(t *testing.T) {
t.Parallel()
// orientation-6.jpg is stored 16x8, red on the left and blue on the
// right, with EXIF orientation 6 (turn 90 degrees clockwise to view).
// Upright it is 8x16, red on top and blue below.
input, err := os.ReadFile("testdata/orientation-6.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
tests := []struct {
name string
size Size
wantW int
wantH int
}{
{name: "original size", size: Size{}, wantW: 8, wantH: 16},
{name: "width only", size: Size{Width: 4}, wantW: 4, wantH: 8},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
output := processAndDecode(t, input, &Request{
Size: tt.size,
Format: FormatPNG,
})
if output.Width() != tt.wantW || output.Height() != tt.wantH {
t.Fatalf("output is %dx%d, want %dx%d",
output.Width(), output.Height(), tt.wantW, tt.wantH)
}
top, err := output.GetPoint(tt.wantW/2, 0)
if err != nil {
t.Fatalf("GetPoint() error = %v", err)
}
bottom, err := output.GetPoint(tt.wantW/2, tt.wantH-1)
if err != nil {
t.Fatalf("GetPoint() error = %v", err)
}
if top[0] <= top[2] || bottom[2] <= bottom[0] {
t.Errorf("top pixel = %v, bottom pixel = %v, want red above blue",
top, bottom)
}
})
}
}
func TestImageProcessor_ConvertsWideGamutToSRGB(t *testing.T) {
t.Parallel()
// display-p3.jpg is a flat 8x8 image with the Display P3 profile
// embedded, filled with Display P3 (234, 51, 35), which is sRGB red.
input, err := os.ReadFile("testdata/display-p3.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input, &Request{Format: FormatPNG})
if output.HasICCProfile() {
t.Error("output has an ICC profile")
}
pixel, err := output.GetPoint(4, 4)
if err != nil {
t.Fatalf("GetPoint() error = %v", err)
}
want := []float64{255, 0, 0}
for i := range want {
if math.Abs(pixel[i]-want[i]) > 5 {
t.Fatalf("pixel = %v, want within 5 of %v", pixel, want)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

+135 -45
View File
@@ -14,6 +14,7 @@ import (
"sync" "sync"
"time" "time"
lru "github.com/hashicorp/golang-lru/v2"
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
) )
@@ -26,6 +27,10 @@ var (
// HTTP status code for successful fetch. // HTTP status code for successful fetch.
const httpStatusOK = 200 const httpStatusOK = 200
// metaCacheSize is how many variants' content types metaCache holds. A
// variant not among them is served as before, reading its .meta file.
const metaCacheSize = 10000
// CacheConfig holds cache configuration. // CacheConfig holds cache configuration.
type CacheConfig struct { type CacheConfig struct {
StateDir string StateDir string
@@ -49,12 +54,6 @@ type CacheConfig struct {
Logger *slog.Logger Logger *slog.Logger
} }
// variantMeta stores content type for fast cache hits without reading .meta file.
type variantMeta struct {
ContentType string
Size int64
}
// Cache implements the caching layer for the image proxy. // Cache implements the caching layer for the image proxy.
type Cache struct { type Cache struct {
db *sql.DB db *sql.DB
@@ -76,9 +75,10 @@ type Cache struct {
evictionStarted bool evictionStarted bool
evictionStopOnce sync.Once evictionStopOnce sync.Once
// In-memory cache of variant metadata (content type, size) to avoid // metaCache holds the content types of the variants most recently
// reading .meta files // stored or served, so a hit does not read the variant's .meta file.
metaCache map[VariantKey]variantMeta // It never stands in for the variant file, which is always opened.
metaCache *lru.Cache[VariantKey, string]
// contentLocks serializes StoreSource and evictSourceBlob per // contentLocks serializes StoreSource and evictSourceBlob per
// content hash, closing the race window between an eviction's row // content hash, closing the race window between an eviction's row
@@ -101,6 +101,11 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
log = slog.Default() log = slog.Default()
} }
metaCache, err := lru.New[VariantKey, string](metaCacheSize)
if err != nil {
return nil, fmt.Errorf("failed to create variant content type cache: %w", err)
}
c := &Cache{ c := &Cache{
db: db, db: db,
config: config, config: config,
@@ -109,7 +114,7 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
evictionPressure: make(chan struct{}, 1), evictionPressure: make(chan struct{}, 1),
evictionStop: make(chan struct{}), evictionStop: make(chan struct{}),
evictionDone: make(chan struct{}), evictionDone: make(chan struct{}),
metaCache: make(map[VariantKey]variantMeta), metaCache: metaCache,
contentLocks: newContentLock(), contentLocks: newContentLock(),
} }
@@ -125,7 +130,7 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
} }
variants, err := NewVariantStorage( variants, err := NewVariantStorage(
filepath.Join(config.StateDir, "cache", "variants"), filepath.Join(config.StateDir, "cache", "variants"), log,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create variant storage: %w", err) return nil, fmt.Errorf("failed to create variant storage: %w", err)
@@ -154,13 +159,15 @@ type LookupResult struct {
CacheStatus CacheStatus CacheStatus CacheStatus
} }
// Lookup checks if a processed variant exists on disk. Hits touch the // Lookup checks if a processed variant exists on disk: a variant held
// variant's LRU timestamp; a disabled cache always misses. // in metaCache counts as present without a check of the disk. Hits
// touch the variant's LRU timestamp; a disabled cache always misses.
func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, error) { func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, error) {
cacheKey := CacheKey(req) cacheKey := CacheKey(req)
// Check variant storage directly - no DB needed for cache hits // Check memory, then variant storage - no DB needed for cache hits
if !c.disabled && c.variants.Exists(cacheKey) { if !c.disabled &&
(c.metaCache.Contains(cacheKey) || c.variants.Exists(cacheKey)) {
c.touchVariant(ctx, cacheKey) c.touchVariant(ctx, cacheKey)
return &LookupResult{ return &LookupResult{
@@ -177,13 +184,28 @@ func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, e
}, nil }, nil
} }
// GetVariant returns a reader, size, and content type for a cached variant. // GetVariant returns a reader, size, and content type for a cached
// variant. The content type comes from metaCache, or else from the
// variant's .meta file and is then kept in metaCache.
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) { func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
if c.disabled { if c.disabled {
return nil, 0, "", ErrNotFound return nil, 0, "", ErrNotFound
} }
return c.variants.LoadWithMeta(cacheKey) contentType, known := c.metaCache.Get(cacheKey)
if !known {
return c.loadVariantWithMeta(cacheKey)
}
reader, size, err := c.variants.LoadWithSize(cacheKey)
if err != nil {
// The file is gone, e.g. deleted outside pixa
c.metaCache.Remove(cacheKey)
return nil, 0, "", err
}
return reader, size, contentType, nil
} }
// StoreSource stores fetched source content and metadata. On a // StoreSource stores fetched source content and metadata. On a
@@ -263,23 +285,7 @@ func (c *Cache) StoreSource(
return "", fmt.Errorf("failed to insert source metadata: %w", err) return "", fmt.Errorf("failed to insert source metadata: %w", err)
} }
// Store metadata JSON file c.writeMetadataSidecar(req, pathHash, contentHash, result)
meta := &SourceMetadata{
Host: req.SourceHost,
Path: req.SourcePath,
Query: req.SourceQuery,
ContentHash: string(contentHash),
StatusCode: result.StatusCode,
ContentType: result.ContentType,
ContentLength: result.ContentLength,
ResponseHeaders: result.Headers,
FetchedAt: time.Now().UTC().Unix(),
FetchDurationMs: result.FetchDurationMs,
RemoteAddr: result.RemoteAddr,
}
// A failure here is non-fatal; the metadata is in the database.
_ = c.srcMetadata.Store(req.SourceHost, pathHash, meta)
c.notifyWritePressure() c.notifyWritePressure()
@@ -302,6 +308,8 @@ func (c *Cache) StoreVariant(
return err return err
} }
c.metaCache.Add(cacheKey, contentType)
_, err = c.db.ExecContext(ctx, ` _, err = c.db.ExecContext(ctx, `
INSERT INTO variant_content (cache_key, size_bytes, content_type) INSERT INTO variant_content (cache_key, size_bytes, content_type)
VALUES (?, ?, ?) VALUES (?, ?, ?)
@@ -435,13 +443,22 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
return nil, fmt.Errorf("failed to get cache stats: %w", err) return nil, fmt.Errorf("failed to get cache stats: %w", err)
} }
// Get actual item count and total size from content tables // Count and size the cached source images and processed variants. A
_ = c.db.QueryRowContext(ctx, // disabled cache holds none, whatever rows an earlier run left.
`SELECT COUNT(*) FROM request_cache`, if !c.disabled {
).Scan(&stats.TotalItems) err = c.db.QueryRowContext(ctx, `
_ = c.db.QueryRowContext(ctx, SELECT (SELECT COUNT(*) FROM source_content)
`SELECT COALESCE(SUM(size_bytes), 0) FROM output_content`, + (SELECT COUNT(*) FROM variant_content)
).Scan(&stats.TotalSizeBytes) `).Scan(&stats.TotalItems)
if err != nil {
c.log.Warn("failed to count cache items for stats", "error", err)
}
stats.TotalSizeBytes, err = c.UsageBytes(ctx)
if err != nil {
c.log.Warn("failed to sum cache size for stats", "error", err)
}
}
// Compute hit rate as a ratio // Compute hit rate as a ratio
if stats.HitCount+stats.MissCount > 0 { if stats.HitCount+stats.MissCount > 0 {
@@ -453,15 +470,17 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
// IncrementStats increments cache statistics. // IncrementStats increments cache statistics.
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) { func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
var err error
if hit { if hit {
_, _ = c.db.ExecContext(ctx, ` _, err = c.db.ExecContext(ctx, `
UPDATE cache_stats UPDATE cache_stats
SET hit_count = hit_count + 1, SET hit_count = hit_count + 1,
last_updated_at = CURRENT_TIMESTAMP last_updated_at = CURRENT_TIMESTAMP
WHERE id = 1 WHERE id = 1
`) `)
} else { } else {
_, _ = c.db.ExecContext(ctx, ` _, err = c.db.ExecContext(ctx, `
UPDATE cache_stats UPDATE cache_stats
SET miss_count = miss_count + 1, SET miss_count = miss_count + 1,
last_updated_at = CURRENT_TIMESTAMP last_updated_at = CURRENT_TIMESTAMP
@@ -469,14 +488,81 @@ func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64)
`) `)
} }
if err != nil {
c.log.Warn("failed to count cache hit or miss", "hit", hit, "error", err)
}
if fetchBytes > 0 { if fetchBytes > 0 {
_, _ = c.db.ExecContext(ctx, ` _, err = c.db.ExecContext(ctx, `
UPDATE cache_stats UPDATE cache_stats
SET upstream_fetch_count = upstream_fetch_count + 1, SET upstream_fetch_count = upstream_fetch_count + 1,
upstream_fetch_bytes = upstream_fetch_bytes + ?, upstream_fetch_bytes = upstream_fetch_bytes + ?,
last_updated_at = CURRENT_TIMESTAMP last_updated_at = CURRENT_TIMESTAMP
WHERE id = 1 WHERE id = 1
`, fetchBytes) `, fetchBytes)
if err != nil {
c.log.Warn("failed to count upstream fetch",
"fetch_bytes", fetchBytes, "error", err)
}
}
}
// IncrementTransformCount counts one image transcoded by the image processor.
func (c *Cache) IncrementTransformCount(ctx context.Context) {
_, err := c.db.ExecContext(ctx, `
UPDATE cache_stats
SET transform_count = transform_count + 1,
last_updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`)
if err != nil {
c.log.Warn("failed to count transform", "error", err)
}
}
// loadVariantWithMeta is GetVariant for a variant metaCache does not
// hold: it reads the content type from the variant's .meta file and
// keeps it in metaCache.
func (c *Cache) loadVariantWithMeta(
cacheKey VariantKey,
) (io.ReadCloser, int64, string, error) {
reader, size, contentType, err := c.variants.LoadWithMeta(cacheKey)
if err != nil {
return nil, 0, "", err
}
c.metaCache.Add(cacheKey, contentType)
return reader, size, contentType, nil
}
// writeMetadataSidecar writes the JSON metadata sidecar of a stored source.
// A failure is logged and is otherwise non-fatal; the metadata is in the
// database.
func (c *Cache) writeMetadataSidecar(
req *ImageRequest,
pathHash PathHash,
contentHash ContentHash,
result *httpfetcher.FetchResult,
) {
meta := &SourceMetadata{
Host: req.SourceHost,
Path: req.SourcePath,
Query: req.SourceQuery,
ContentHash: string(contentHash),
StatusCode: result.StatusCode,
ContentType: result.ContentType,
ContentLength: result.ContentLength,
ResponseHeaders: result.Headers,
FetchedAt: time.Now().UTC().Unix(),
FetchDurationMs: result.FetchDurationMs,
RemoteAddr: result.RemoteAddr,
}
err := c.srcMetadata.Store(req.SourceHost, pathHash, meta)
if err != nil {
c.log.Warn("failed to write metadata sidecar",
"host", req.SourceHost, "path_hash", pathHash, "error", err)
} }
} }
@@ -528,10 +614,14 @@ func (c *Cache) checkNegativeCache(
// Check if expired // Check if expired
if time.Now().After(expiresAt) { if time.Now().After(expiresAt) {
// Clean up expired entry // Clean up expired entry
_, _ = c.db.ExecContext(ctx, ` _, err = c.db.ExecContext(ctx, `
DELETE FROM negative_cache DELETE FROM negative_cache
WHERE source_host = ? AND source_path = ? AND source_query = ? WHERE source_host = ? AND source_path = ? AND source_query = ?
`, req.SourceHost, req.SourcePath, req.SourceQuery) `, req.SourceHost, req.SourcePath, req.SourceQuery)
if err != nil {
c.log.Warn("failed to delete expired negative cache entry",
"host", req.SourceHost, "path", req.SourcePath, "error", err)
}
return false, nil return false, nil
} }
+5 -1
View File
@@ -271,7 +271,9 @@ func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, erro
// evictVariant removes one variant: accounting row first, then the // evictVariant removes one variant: accounting row first, then the
// content and .meta files, so the database never references a deleted // content and .meta files, so the database never references a deleted
// file. // file. The metaCache entry goes before the files; a GetVariant that
// read them just before may put it back, and the next GetVariant then
// fails to open the file and removes it again.
func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error { func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
_, err := c.db.ExecContext(ctx, _, err := c.db.ExecContext(ctx,
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey)) `DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
@@ -279,6 +281,8 @@ func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
return fmt.Errorf("failed to delete variant accounting row: %w", err) return fmt.Errorf("failed to delete variant accounting row: %w", err)
} }
c.metaCache.Remove(cacheKey)
err = c.variants.DeleteWithMeta(cacheKey) err = c.variants.DeleteWithMeta(cacheKey)
if err != nil { if err != nil {
return err return err
+24 -3
View File
@@ -59,6 +59,24 @@ func ValidateFitMode(fit FitMode) error {
} }
} }
// ValidateImageRequest checks a request's width and height with
// ValidateDimension and its fit mode with ValidateFitMode. Both the plain
// /v1/image/ route and the encrypted /v1/e/ route validate through this
// function so a request from either source enforces identical bounds.
func ValidateImageRequest(req *ImageRequest) error {
err := ValidateDimension("width", req.Size.Width)
if err != nil {
return err
}
err = ValidateDimension("height", req.Size.Height)
if err != nil {
return err
}
return ValidateFitMode(req.FitMode)
}
// ImageRequest represents a request for a processed image // ImageRequest represents a request for a processed image
type ImageRequest struct { type ImageRequest struct {
// SourceHost is the origin host (e.g., "cdn.example.com") // SourceHost is the origin host (e.g., "cdn.example.com")
@@ -77,7 +95,8 @@ type ImageRequest struct {
FitMode FitMode FitMode FitMode
// Signature is the HMAC signature for non-allowlisted hosts // Signature is the HMAC signature for non-allowlisted hosts
Signature string Signature string
// Expires is the signature expiration timestamp // Expires is when the URL expires: the exp of a signed URL, or the expiry
// of an encrypted URL; the zero time if it has none
Expires time.Time Expires time.Time
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request // AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
AllowHTTP bool AllowHTTP bool
@@ -144,9 +163,11 @@ type ImageCache interface {
// CacheStats contains cache statistics // CacheStats contains cache statistics
type CacheStats struct { type CacheStats struct {
// TotalItems is the number of cached items // TotalItems is the number of cached source images plus processed
// variants
TotalItems int64 TotalItems int64
// TotalSizeBytes is the total size of cached content // TotalSizeBytes is the total size of cached source images and
// processed variants
TotalSizeBytes int64 TotalSizeBytes int64
// HitCount is the number of cache hits // HitCount is the number of cache hits
HitCount int64 HitCount int64
@@ -0,0 +1,253 @@
package imgcache
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"sync"
"testing"
"time"
)
// webpRequest returns a request for a 100x100 WebP variant of path.
func webpRequest(path string) *ImageRequest {
return &ImageRequest{
SourceHost: testHostCDN,
SourcePath: path,
Size: Size{Width: 100, Height: 100},
Format: FormatWebP,
Quality: 85,
FitMode: FitCover,
}
}
// assertVariantServed checks that GetVariant serves key with the given
// content and the image/webp content type storeEvictionTestVariant stores.
func assertVariantServed(t *testing.T, cache *Cache, key VariantKey, content []byte) {
t.Helper()
reader, size, contentType, err := cache.GetVariant(key)
if err != nil {
t.Fatalf("GetVariant(%s) error = %v", key, err)
}
defer func() { _ = reader.Close() }()
got, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("reading variant %s: %v", key, err)
}
if !bytes.Equal(got, content) {
t.Errorf("GetVariant(%s) content = %q, want %q", key, got, content)
}
if size != int64(len(content)) {
t.Errorf("GetVariant(%s) size = %d, want %d", key, size, len(content))
}
if contentType != testContentTypeWebP {
t.Errorf("GetVariant(%s) content type = %q, want %q",
key, contentType, testContentTypeWebP)
}
}
// assertVariantNotFound checks that GetVariant refuses key with
// ErrNotFound.
func assertVariantNotFound(t *testing.T, cache *Cache, key VariantKey) {
t.Helper()
reader, _, _, err := cache.GetVariant(key)
if err == nil {
_ = reader.Close()
}
if !errors.Is(err, ErrNotFound) {
t.Errorf("GetVariant(%s) error = %v, want ErrNotFound", key, err)
}
}
// assertLookupMisses checks that Lookup reports request as a miss.
func assertLookupMisses(t *testing.T, cache *Cache, request *ImageRequest) {
t.Helper()
lookup, err := cache.Lookup(t.Context(), request)
if err != nil {
t.Fatalf("Lookup(%s) error = %v", request.SourcePath, err)
}
if lookup.Hit {
t.Errorf("Lookup(%s) is a hit, want a miss", request.SourcePath)
}
}
// TestSecondHitDoesNotReadMetaFile checks that once a variant has been
// stored or read, a hit takes its content type from memory: with the
// .meta file deleted, GetVariant must still return the stored content
// type rather than the application/octet-stream it uses without one.
func TestSecondHitDoesNotReadMetaFile(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<20)
content := []byte("webp variant bytes")
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
// A second Cache on the same state directory starts with nothing in
// memory, as pixad does after a restart, so its first read uses the
// .meta file.
restarted, err := NewCache(cache.db, cache.config)
if err != nil {
t.Fatalf("NewCache() error = %v", err)
}
assertVariantServed(t, restarted, testVariantKeyOne, content)
err = os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
if err != nil {
t.Fatalf("removing .meta file: %v", err)
}
assertVariantServed(t, cache, testVariantKeyOne, content)
assertVariantServed(t, restarted, testVariantKeyOne, content)
}
// TestReadDuringStoreKeepsStoredContentType checks that a GetVariant
// which began before StoreVariant finished cannot replace the content
// type the store kept in memory. Such a read can find the variant file
// but not yet its .meta file, and so gets application/octet-stream. The
// test deletes the .meta file after the store, then runs the part of
// GetVariant that comes after its check of memory.
func TestReadDuringStoreKeepsStoredContentType(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<20)
content := []byte("webp variant bytes")
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
err := os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
if err != nil {
t.Fatalf("removing .meta file: %v", err)
}
reader, _, contentType, err := cache.loadVariantWithMeta(testVariantKeyOne)
if err != nil {
t.Fatalf("loadVariantWithMeta(%s) error = %v", testVariantKeyOne, err)
}
_ = reader.Close()
t.Logf("the read without a .meta file got content type %q", contentType)
assertVariantServed(t, cache, testVariantKeyOne, content)
}
// TestEvictedVariantIsNotServed checks that a variant the evictor
// removed is a miss and cannot be read, although it had been stored
// and served before.
func TestEvictedVariantIsNotServed(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1500)
oldRequest := webpRequest("/old.jpg")
newRequest := webpRequest("/new.jpg")
oldKey := CacheKey(oldRequest)
newKey := CacheKey(newRequest)
oldContent := bytes.Repeat([]byte{0x01}, 1000)
newContent := bytes.Repeat([]byte{0x02}, 1000)
storeEvictionTestVariant(t, cache, oldKey, oldContent)
storeEvictionTestVariant(t, cache, newKey, newContent)
assertVariantServed(t, cache, oldKey, oldContent)
setVariantLastAccessed(t, cache, oldKey, time.Now().Add(-time.Hour))
err := cache.EvictToLimit(t.Context())
if err != nil {
t.Fatalf("EvictToLimit() error = %v", err)
}
assertLookupMisses(t, cache, oldRequest)
assertVariantNotFound(t, cache, oldKey)
assertVariantServed(t, cache, newKey, newContent)
}
// TestVariantDeletedFromDiskIsNotServed checks that a variant whose
// file was deleted by something other than the evictor cannot be read,
// and is a miss afterwards.
func TestVariantDeletedFromDiskIsNotServed(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<20)
request := webpRequest("/deleted.jpg")
key := CacheKey(request)
content := []byte("webp variant bytes")
storeEvictionTestVariant(t, cache, key, content)
assertVariantServed(t, cache, key, content)
err := os.Remove(cache.variants.keyToPath(key))
if err != nil {
t.Fatalf("removing variant file: %v", err)
}
assertVariantNotFound(t, cache, key)
assertLookupMisses(t, cache, request)
}
// TestConcurrentVariantStoreReadAndEvict stores, reads and evicts
// variants from several goroutines at once, for the race detector.
func TestConcurrentVariantStoreReadAndEvict(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<20)
ctx := t.Context()
var wg sync.WaitGroup
for goroutine := range 8 {
wg.Go(func() {
key := VariantKey(fmt.Sprintf("aabbccdd01%02d", goroutine))
content := []byte(key)
for range 20 {
err := cache.StoreVariant(
ctx, key, bytes.NewReader(content), testContentTypeWebP)
if err != nil {
t.Errorf("StoreVariant(%s) error = %v", key, err)
return
}
reader, _, contentType, err := cache.GetVariant(key)
if err != nil {
t.Errorf("GetVariant(%s) error = %v", key, err)
return
}
_ = reader.Close()
if contentType != testContentTypeWebP {
t.Errorf("GetVariant(%s) content type = %q, want %q",
key, contentType, testContentTypeWebP)
}
err = cache.evictVariant(ctx, key)
if err != nil {
t.Errorf("evictVariant(%s) error = %v", key, err)
return
}
assertVariantNotFound(t, cache, key)
}
})
}
wg.Wait()
}
+54 -29
View File
@@ -143,7 +143,8 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
s.log.Error("failed to get cached variant", "key", result.CacheKey, "error", err) s.log.Error("failed to get cached variant", "key", result.CacheKey, "error", err)
// Fall through to re-process // Fall through to re-process
} else { } else {
s.cache.IncrementStats(ctx, true, 0) // Counted also when the request context has ended meanwhile
s.cache.IncrementStats(context.WithoutCancel(ctx), true, 0)
return &ImageResponse{ return &ImageResponse{
Content: reader, Content: reader,
@@ -155,12 +156,15 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
} }
} }
// Cache miss - check if we have source content cached // Cache miss - process the cached source or fetch it, then count the
// miss with the bytes it fetched from upstream, also when it failed or
// the request context has ended meanwhile
cacheKey := CacheKey(req) cacheKey := CacheKey(req)
s.cache.IncrementStats(ctx, false, 0) response, fetchedBytes, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
s.cache.IncrementStats(context.WithoutCancel(ctx), false, fetchedBytes)
response, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -205,12 +209,23 @@ func (s *Service) ValidateRequest(req *ImageRequest) error {
return s.signer.Verify(signatureRequest(req)) return s.signer.Verify(signatureRequest(req))
} }
// GenerateSignedURL generates a signed URL for the given request. // GenerateSignedURL generates a signed URL for the given request. The URL
// carries q and fit next to sig and exp, so the image route verifies it for
// the quality and fit it was signed with. An unset quality or fit is first
// set to 85 or cover, the values the route uses when a URL has no q or fit.
func (s *Service) GenerateSignedURL( func (s *Service) GenerateSignedURL(
baseURL string, baseURL string,
req *ImageRequest, req *ImageRequest,
ttl time.Duration, ttl time.Duration,
) (string, error) { ) (string, error) {
if req.Quality == 0 {
req.Quality = 85
}
if req.FitMode == "" {
req.FitMode = FitCover
}
sigReq := signatureRequest(req) sigReq := signatureRequest(req)
path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl) path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl)
@@ -218,7 +233,8 @@ func (s *Service) GenerateSignedURL(
req.Expires = sigReq.Expires req.Expires = sigReq.Expires
req.Signature = sigReq.Signature req.Signature = sigReq.Signature
return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil return fmt.Sprintf("%s%s?sig=%s&exp=%d&q=%d&fit=%s",
baseURL, path, sig, exp, req.Quality, req.FitMode), nil
} }
// loadCachedSource attempts to load source content from cache, returning nil // loadCachedSource attempts to load source content from cache, returning nil
@@ -256,22 +272,20 @@ func (s *Service) loadCachedSource(contentHash ContentHash) []byte {
} }
// processFromSourceOrFetch processes an image, using cached source content // processFromSourceOrFetch processes an image, using cached source content
// if available. // if available. It also returns the number of bytes fetched from upstream,
// as fetchAndProcess does, or 0 when the cached source was used.
func (s *Service) processFromSourceOrFetch( func (s *Service) processFromSourceOrFetch(
ctx context.Context, ctx context.Context,
req *ImageRequest, req *ImageRequest,
cacheKey VariantKey, cacheKey VariantKey,
) (*ImageResponse, error) { ) (*ImageResponse, int64, error) {
// Check if we have cached source content // Check if we have cached source content
contentHash, _, err := s.cache.LookupSource(ctx, req) contentHash, _, err := s.cache.LookupSource(ctx, req)
if err != nil { if err != nil {
s.log.Warn("source lookup failed", "error", err) s.log.Warn("source lookup failed", "error", err)
} }
var ( var sourceData []byte
sourceData []byte
fetchBytes int64
)
if contentHash != "" { if contentHash != "" {
s.log.Debug("using cached source", "hash", contentHash) s.log.Debug("using cached source", "hash", contentHash)
@@ -280,26 +294,25 @@ func (s *Service) processFromSourceOrFetch(
// Fetch from upstream if we don't have source data or it's empty // Fetch from upstream if we don't have source data or it's empty
if len(sourceData) == 0 { if len(sourceData) == 0 {
resp, err := s.fetchAndProcess(ctx, req, cacheKey) return s.fetchAndProcess(ctx, req, cacheKey)
if err != nil {
return nil, err
} }
return resp, nil // Process using cached source; nothing was fetched from upstream
} resp, err := s.processAndStore(
ctx, req, cacheKey, sourceData, int64(len(sourceData)),
)
// Process using cached source return resp, 0, err
fetchBytes = int64(len(sourceData))
return s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes)
} }
// fetchAndProcess fetches from upstream, processes, and caches the result. // fetchAndProcess fetches from upstream, processes, and caches the result.
// It also returns the number of bytes read from upstream, including when
// reading the response or a later step fails.
func (s *Service) fetchAndProcess( func (s *Service) fetchAndProcess(
ctx context.Context, ctx context.Context,
req *ImageRequest, req *ImageRequest,
cacheKey VariantKey, cacheKey VariantKey,
) (*ImageResponse, error) { ) (*ImageResponse, int64, error) {
// Fetch from upstream // Fetch from upstream
sourceURL := req.SourceURL() sourceURL := req.SourceURL()
@@ -310,23 +323,28 @@ func (s *Service) fetchAndProcess(
// Store negative cache for certain errors // Store negative cache for certain errors
if isNegativeCacheable(err) { if isNegativeCacheable(err) {
statusCode := extractStatusCode(err) statusCode := extractStatusCode(err)
_ = s.cache.StoreNegative(ctx, req, statusCode, err.Error())
storeErr := s.cache.StoreNegative(ctx, req, statusCode, err.Error())
if storeErr != nil {
s.log.Warn("failed to store negative cache entry",
"host", req.SourceHost, "path", req.SourcePath, "error", storeErr)
}
} }
return nil, fmt.Errorf("upstream fetch failed: %w", err) return nil, 0, fmt.Errorf("upstream fetch failed: %w", err)
} }
defer func() { _ = fetchResult.Content.Close() }() defer func() { _ = fetchResult.Content.Close() }()
// Read and validate the source content // Read and validate the source content
sourceData, err := io.ReadAll(fetchResult.Content) sourceData, err := io.ReadAll(fetchResult.Content)
fetchBytes := int64(len(sourceData))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read upstream response: %w", err) return nil, fetchBytes, fmt.Errorf("failed to read upstream response: %w", err)
} }
// Calculate download bitrate // Calculate download bitrate
fetchBytes := int64(len(sourceData))
var downloadRate string var downloadRate string
if fetchResult.FetchDurationMs > 0 { if fetchResult.FetchDurationMs > 0 {
@@ -351,7 +369,7 @@ func (s *Service) fetchAndProcess(
// Validate magic bytes match content type // Validate magic bytes match content type
err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType) err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType)
if err != nil { if err != nil {
return nil, fmt.Errorf("content validation failed: %w", err) return nil, fetchBytes, fmt.Errorf("content validation failed: %w", err)
} }
// Store source content // Store source content
@@ -361,7 +379,9 @@ func (s *Service) fetchAndProcess(
// Continue even if caching fails // Continue even if caching fails
} }
return s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes) resp, err := s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes)
return resp, fetchBytes, err
} }
// processAndStore processes an image and stores the result. // processAndStore processes an image and stores the result.
@@ -389,6 +409,9 @@ func (s *Service) processAndStore(
processDuration := time.Since(processStart) processDuration := time.Since(processStart)
// Counted also when the request context has ended meanwhile
s.cache.IncrementTransformCount(context.WithoutCancel(ctx))
// Read processed content // Read processed content
processedData, err := io.ReadAll(processResult.Content) processedData, err := io.ReadAll(processResult.Content)
_ = processResult.Content.Close() _ = processResult.Content.Close()
@@ -452,6 +475,8 @@ func signatureRequest(req *ImageRequest) *signature.Request {
Width: req.Size.Width, Width: req.Size.Width,
Height: req.Size.Height, Height: req.Size.Height,
Format: string(req.Format), Format: string(req.Format),
Quality: req.Quality,
FitMode: string(req.FitMode),
Signature: req.Signature, Signature: req.Signature,
Expires: req.Expires, Expires: req.Expires,
} }
+396
View File
@@ -1,13 +1,20 @@
package imgcache package imgcache
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"image/color"
"io"
"io/fs"
"log/slog"
"math" "math"
"strings"
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/database" "sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/httpfetcher"
) )
func setupStatsTestDB(t *testing.T) *sql.DB { func setupStatsTestDB(t *testing.T) *sql.DB {
@@ -101,3 +108,392 @@ func TestStats_ZeroCounts(t *testing.T) {
t.Errorf("HitRate = %f, want 0.0 for zero counts", stats.HitRate) t.Errorf("HitRate = %f, want 0.0 for zero counts", stats.HitRate)
} }
} }
// TestStats_LogsFailedCountQueries verifies that a failed item count query
// and a failed size query are each logged at warn and Stats still succeeds.
func TestStats_LogsFailedCountQueries(t *testing.T) {
t.Parallel()
db := setupStatsTestDB(t)
var logBuf bytes.Buffer
cache, err := NewCache(db, CacheConfig{
StateDir: t.TempDir(),
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
})
if err != nil {
t.Fatal(err)
}
_, err = db.ExecContext(t.Context(),
`DROP TABLE source_content; DROP TABLE variant_content`)
if err != nil {
t.Fatal(err)
}
_, err = cache.Stats(t.Context())
if err != nil {
t.Fatalf("Stats() error = %v, want nil", err)
}
for _, msg := range []string{
"failed to count cache items for stats",
"failed to sum cache size for stats",
} {
want := `"level":"WARN","msg":"` + msg + `"`
if !strings.Contains(logBuf.String(), want) {
t.Errorf("log missing %s; got %q", want, logBuf.String())
}
}
}
// TestIncrementStats_LogsFailedUpdates verifies that a failed hit or miss
// count update and a failed upstream fetch count update are each logged at
// warn.
func TestIncrementStats_LogsFailedUpdates(t *testing.T) {
t.Parallel()
db := setupStatsTestDB(t)
var logBuf bytes.Buffer
cache, err := NewCache(db, CacheConfig{
StateDir: t.TempDir(),
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
})
if err != nil {
t.Fatal(err)
}
_, err = db.ExecContext(t.Context(), `DROP TABLE cache_stats`)
if err != nil {
t.Fatal(err)
}
cache.IncrementStats(t.Context(), false, 1024)
for _, msg := range []string{
"failed to count cache hit or miss",
"failed to count upstream fetch",
} {
want := `"level":"WARN","msg":"` + msg + `"`
if !strings.Contains(logBuf.String(), want) {
t.Errorf("log missing %s; got %q", want, logBuf.String())
}
}
}
// TestStats_TotalsCountSourcesAndVariants verifies that TotalItems and
// TotalSizeBytes cover the stored source images and processed variants.
func TestStats_TotalsCountSourcesAndVariants(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
storeEvictionTestSource(t, cache, testHostCDN, testPathCat,
bytes.Repeat([]byte{0xAA}, 1000))
storeEvictionTestVariant(t, cache, testVariantKeyOne,
bytes.Repeat([]byte{0xAB}, 500))
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
bytes.Repeat([]byte{0xAC}, 250))
stats, err := cache.Stats(t.Context())
if err != nil {
t.Fatalf("Stats() error = %v", err)
}
if stats.TotalItems != 3 {
t.Errorf("TotalItems = %d, want 3 (1 source, 2 variants)", stats.TotalItems)
}
if stats.TotalSizeBytes != 1750 {
t.Errorf("TotalSizeBytes = %d, want 1750 (1000+500+250)",
stats.TotalSizeBytes)
}
}
// TestStats_DisabledCacheReportsNoItems verifies that a disabled disk cache
// reports no items and no size, even when its database still holds the
// rows of an earlier run with the disk cache enabled.
func TestStats_DisabledCacheReportsNoItems(t *testing.T) {
t.Parallel()
enabled, _ := newEvictionTestCache(t, 1<<30)
storeEvictionTestSource(t, enabled, testHostCDN, testPathCat,
bytes.Repeat([]byte{0xAA}, 1000))
storeEvictionTestVariant(t, enabled, testVariantKeyOne,
bytes.Repeat([]byte{0xAB}, 500))
disabled, err := NewCache(enabled.db, CacheConfig{
StateDir: t.TempDir(),
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
DisableDiskCache: true,
})
if err != nil {
t.Fatal(err)
}
stats, err := disabled.Stats(t.Context())
if err != nil {
t.Fatalf("Stats() error = %v", err)
}
if stats.TotalItems != 0 || stats.TotalSizeBytes != 0 {
t.Errorf("TotalItems = %d, TotalSizeBytes = %d, want 0 and 0",
stats.TotalItems, stats.TotalSizeBytes)
}
}
// cacheStatsCounters holds the counters of the cache_stats row, in column
// order.
type cacheStatsCounters struct {
hitCount int64
missCount int64
upstreamFetchCount int64
upstreamFetchBytes int64
transformCount int64
}
// readCacheStatsCounters reads the counters of the cache_stats row.
func readCacheStatsCounters(t *testing.T, cache *Cache) cacheStatsCounters {
t.Helper()
var got cacheStatsCounters
err := cache.db.QueryRowContext(t.Context(), `
SELECT hit_count, miss_count, upstream_fetch_count,
upstream_fetch_bytes, transform_count
FROM cache_stats WHERE id = 1
`).Scan(&got.hitCount, &got.missCount, &got.upstreamFetchCount,
&got.upstreamFetchBytes, &got.transformCount)
if err != nil {
t.Fatalf("failed to read cache_stats: %v", err)
}
return got
}
// TestService_Get_CountsStats walks Get through a miss that fetches the
// source, a hit, a miss that reuses the cached source, and two misses whose
// source cannot be used, checking every cache_stats counter after each.
func TestService_Get_CountsStats(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t)
// NewTestFS builds the same files the test service's fetcher serves.
testFS, _ := NewTestFS(t)
photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG)
if err != nil {
t.Fatal(err)
}
fake, err := fs.ReadFile(testFS, fixtures.InvalidFile)
if err != nil {
t.Fatal(err)
}
photoBytes, fakeBytes := int64(len(photo)), int64(len(fake))
// want is hits, misses, upstream fetches, upstream bytes, transforms.
steps := []struct {
name string
path string
size int
wantErr bool
want cacheStatsCounters
}{
{"miss that fetches the source", testPathPhoto, 50, false,
cacheStatsCounters{0, 1, 1, photoBytes, 1}},
{"hit", testPathPhoto, 50, false,
cacheStatsCounters{1, 1, 1, photoBytes, 1}},
{"miss that reuses the cached source", testPathPhoto, 25, false,
cacheStatsCounters{1, 2, 1, photoBytes, 2}},
{"miss whose source fails the magic byte check", "/images/fake.jpg", 50, true,
cacheStatsCounters{1, 3, 2, photoBytes + fakeBytes, 2}},
{"miss whose source is not found", "/images/nonexistent.jpg", 50, true,
cacheStatsCounters{1, 4, 2, photoBytes + fakeBytes, 2}},
}
for _, step := range steps {
resp, err := svc.Get(t.Context(), &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: step.path,
Size: Size{Width: step.size, Height: step.size},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
})
if (err != nil) != step.wantErr {
t.Fatalf("%s: Get() error = %v, want error %t", step.name, err, step.wantErr)
}
if err == nil {
_ = resp.Content.Close()
}
got := readCacheStatsCounters(t, svc.cache)
if got != step.want {
t.Fatalf("after the %s: counters = %+v, want %+v", step.name, got, step.want)
}
}
}
// fakeUpstream answers every fetch with itself as a JPEG body. The body
// serves data, then calls cancel, when set, and returns err; io.EOF ends
// the body normally.
type fakeUpstream struct {
data *bytes.Reader
cancel context.CancelFunc
err error
}
func (u *fakeUpstream) Fetch(
context.Context, string,
) (*httpfetcher.FetchResult, error) {
return &httpfetcher.FetchResult{
Content: io.NopCloser(u),
ContentLength: -1,
ContentType: testContentTypeJPEG,
}, nil
}
func (u *fakeUpstream) Read(p []byte) (int, error) {
if u.data.Len() > 0 {
return u.data.Read(p)
}
if u.cancel != nil {
u.cancel()
}
return 0, u.err
}
// TestService_Get_CountsInterruptedMisses checks every cache_stats counter
// after a miss whose request context ends during or after the upstream
// fetch, and after a miss whose upstream body is over the size limit.
func TestService_Get_CountsInterruptedMisses(t *testing.T) {
t.Parallel()
photo := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
half := len(photo) / 2
// want is hits, misses, upstream fetches, upstream bytes, transforms.
tests := []struct {
name string
served int // bytes of the photo the upstream body serves
cancel bool // whether the body then ends the request context
readErr error // what the body then returns
wantErr bool
want cacheStatsCounters
}{
{"request context ends during the fetch", half, true, context.Canceled, true,
cacheStatsCounters{0, 1, 1, int64(half), 0}},
{"request context ends after the fetch", len(photo), true, io.EOF, false,
cacheStatsCounters{0, 1, 1, int64(len(photo)), 1}},
{"upstream body over the size limit", half, false,
httpfetcher.ErrResponseTooLarge, true,
cacheStatsCounters{0, 1, 1, int64(half), 0}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
upstream := &fakeUpstream{
data: bytes.NewReader(photo[:tc.served]),
err: tc.readErr,
}
if tc.cancel {
upstream.cancel = cancel
}
svc.fetcher = upstream
resp, err := svc.Get(ctx, &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: testPathPhoto,
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
})
t.Logf("Get() error = %v", err)
if (err != nil) != tc.wantErr {
t.Fatalf("Get() error = %v, want error %t", err, tc.wantErr)
}
if err == nil {
_ = resp.Content.Close()
}
got := readCacheStatsCounters(t, svc.cache)
if got != tc.want {
t.Errorf("counters = %+v, want %+v", got, tc.want)
}
})
}
}
// TestService_Get_CountsHitAfterRequestEnds checks every cache_stats counter
// after a hit served with a request context that has already ended: only
// the hit count moves.
func TestService_Get_CountsHitAfterRequestEnds(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t)
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: testPathPhoto,
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// A first request caches the variant.
resp, err := svc.Get(t.Context(), req)
if err != nil {
t.Fatalf("first Get() error = %v", err)
}
_ = resp.Content.Close()
want := readCacheStatsCounters(t, svc.cache)
want.hitCount++
ctx, cancel := context.WithCancel(t.Context())
cancel()
resp, err = svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() with an ended request context: error = %v", err)
}
_ = resp.Content.Close()
if resp.CacheStatus != CacheHit {
t.Fatalf("CacheStatus = %v, want %v", resp.CacheStatus, CacheHit)
}
got := readCacheStatsCounters(t, svc.cache)
if got != want {
t.Errorf("counters = %+v, want %+v", got, want)
}
}
+31 -13
View File
@@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@@ -392,6 +393,7 @@ func CacheKey(req *ImageRequest) VariantKey {
// Unlike ContentStorage, the key is provided by the caller (not computed from content). // Unlike ContentStorage, the key is provided by the caller (not computed from content).
type VariantStorage struct { type VariantStorage struct {
baseDir string baseDir string
log *slog.Logger
} }
// VariantMeta contains metadata about a cached variant. // VariantMeta contains metadata about a cached variant.
@@ -404,13 +406,14 @@ type VariantMeta struct {
} }
// NewVariantStorage creates a new variant storage at the given base directory. // NewVariantStorage creates a new variant storage at the given base directory.
func NewVariantStorage(baseDir string) (*VariantStorage, error) { // A failed .meta write is logged to log.
func NewVariantStorage(baseDir string, log *slog.Logger) (*VariantStorage, error) {
err := os.MkdirAll(baseDir, StorageDirPerm) err := os.MkdirAll(baseDir, StorageDirPerm)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create variant storage directory: %w", err) return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
} }
return &VariantStorage{baseDir: baseDir}, nil return &VariantStorage{baseDir: baseDir, log: log}, nil
} }
// Store writes content and metadata to storage at the given key. // Store writes content and metadata to storage at the given key.
@@ -478,7 +481,11 @@ func (s *VariantStorage) Store(
} }
// Metadata write failure is non-fatal; content is already stored. // Metadata write failure is non-fatal; content is already stored.
_ = os.WriteFile(metaPath, metaData, StorageFilePerm) err = os.WriteFile(metaPath, metaData, StorageFilePerm)
if err != nil {
s.log.Warn("failed to write variant metadata sidecar",
"path", metaPath, "error", err)
}
return size, nil return size, nil
} }
@@ -499,32 +506,43 @@ func (s *VariantStorage) Load(key VariantKey) (io.ReadCloser, error) {
return f, nil return f, nil
} }
// LoadWithMeta returns a reader, size, and content type for the content at // LoadWithSize returns a reader and file size for the content at the
// the given key. // given key.
func (s *VariantStorage) LoadWithMeta( func (s *VariantStorage) LoadWithSize(key VariantKey) (io.ReadCloser, int64, error) {
key VariantKey,
) (io.ReadCloser, int64, string, error) {
path := s.keyToPath(key) path := s.keyToPath(key)
metaPath := path + ".meta"
f, err := os.Open(path) //nolint:gosec // path derived from cache key f, err := os.Open(path) //nolint:gosec // path derived from cache key
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil, 0, "", ErrNotFound return nil, 0, ErrNotFound
} }
return nil, 0, "", fmt.Errorf("failed to open content: %w", err) return nil, 0, fmt.Errorf("failed to open content: %w", err)
} }
stat, err := f.Stat() stat, err := f.Stat()
if err != nil { if err != nil {
_ = f.Close() _ = f.Close()
return nil, 0, "", fmt.Errorf("failed to stat content: %w", err) return nil, 0, fmt.Errorf("failed to stat content: %w", err)
}
return f, stat.Size(), nil
}
// LoadWithMeta returns a reader, size, and content type for the content at
// the given key.
func (s *VariantStorage) LoadWithMeta(
key VariantKey,
) (io.ReadCloser, int64, string, error) {
f, size, err := s.LoadWithSize(key)
if err != nil {
return nil, 0, "", err
} }
// Load metadata for content type // Load metadata for content type
contentType := "application/octet-stream" // fallback contentType := "application/octet-stream" // fallback
metaPath := s.keyToPath(key) + ".meta"
metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key
if err == nil { if err == nil {
@@ -534,7 +552,7 @@ func (s *VariantStorage) LoadWithMeta(
} }
} }
return f, stat.Size(), contentType, nil return f, size, contentType, nil
} }
// Exists checks if content exists at the given key. // Exists checks if content exists at the given key.
@@ -4,8 +4,10 @@ import (
"bytes" "bytes"
"errors" "errors"
"io" "io"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -404,3 +406,35 @@ func TestCacheKey(t *testing.T) {
t.Error("CacheKey() produced same key for different quality") t.Error("CacheKey() produced same key for different quality")
} }
} }
// TestVariantStorage_StoreLogsFailedMetaWrite verifies that a .meta write
// that fails is logged at warn and the store still succeeds.
func TestVariantStorage_StoreLogsFailedMetaWrite(t *testing.T) {
t.Parallel()
var logBuf bytes.Buffer
storage, err := NewVariantStorage(
t.TempDir(), slog.New(slog.NewJSONHandler(&logBuf, nil)))
if err != nil {
t.Fatalf("NewVariantStorage() error = %v", err)
}
key := CacheKey(&ImageRequest{SourceHost: testHostCDN, SourcePath: testPathCat})
// A directory where the .meta file goes makes the .meta write fail.
err = os.MkdirAll(storage.keyToPath(key)+".meta", StorageDirPerm)
if err != nil {
t.Fatalf("failed to create directory: %v", err)
}
_, err = storage.Store(key, bytes.NewReader([]byte("variant data")), "image/webp")
if err != nil {
t.Fatalf("Store() error = %v, want nil", err)
}
want := `"level":"WARN","msg":"failed to write variant metadata sidecar"`
if !strings.Contains(logBuf.String(), want) {
t.Errorf("log missing %s; got %q", want, logBuf.String())
}
}
@@ -24,6 +24,7 @@ const (
testHostExample = "example.com" testHostExample = "example.com"
testPathCat = "/photos/cat.jpg" testPathCat = "/photos/cat.jpg"
testContentTypeJPEG = "image/jpeg" testContentTypeJPEG = "image/jpeg"
testContentTypeWebP = "image/webp"
testHeaderContentType = "Content-Type" testHeaderContentType = "Content-Type"
) )
+25 -4
View File
@@ -23,6 +23,21 @@ var (
// MaxDimension is the maximum allowed width or height. // MaxDimension is the maximum allowed width or height.
const MaxDimension = 8192 const MaxDimension = 8192
// ValidateDimension checks one requested width or height; name ("width" or
// "height") appears in the error. 0 means "original size" and is valid.
func ValidateDimension(name string, value int) error {
if value < 0 {
return fmt.Errorf("%w: %s is negative", ErrInvalidSize, name)
}
if value > MaxDimension {
return fmt.Errorf("%w: %s is above %d",
ErrDimensionTooLarge, name, MaxDimension)
}
return nil
}
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png" // sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png"
var sizeFormatRegex = regexp.MustCompile(`^(\d+)x(\d+)\.(\w+)$|^(orig)\.(\w+)$`) var sizeFormatRegex = regexp.MustCompile(`^(\d+)x(\d+)\.(\w+)$|^(orig)\.(\w+)$`)
@@ -225,14 +240,20 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
return Size{}, "", ErrInvalidSize return Size{}, "", ErrInvalidSize
} }
if width > MaxDimension || height > MaxDimension {
return Size{}, "", ErrDimensionTooLarge
}
size = Size{Width: width, Height: height} size = Size{Width: width, Height: height}
formatStr = matches[3] formatStr = matches[3]
} }
err := ValidateDimension("width", size.Width)
if err != nil {
return Size{}, "", err
}
err = ValidateDimension("height", size.Height)
if err != nil {
return Size{}, "", err
}
format, err := parseFormat(formatStr) format, err := parseFormat(formatStr)
if err != nil { if err != nil {
return Size{}, "", err return Size{}, "", err
@@ -0,0 +1,64 @@
package imgcache
import (
"errors"
"testing"
)
func TestValidateImageRequest(t *testing.T) {
t.Parallel()
tests := []struct {
name string
req ImageRequest
wantErr error
}{
{
name: "within bounds",
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: FitCover},
},
{
name: "original size and empty fit",
req: ImageRequest{Size: Size{Width: 0, Height: 0}},
},
{
name: "width over limit",
req: ImageRequest{Size: Size{Width: MaxDimension + 1, Height: 600}},
wantErr: ErrDimensionTooLarge,
},
{
name: "height over limit",
req: ImageRequest{Size: Size{Width: 800, Height: MaxDimension + 1}},
wantErr: ErrDimensionTooLarge,
},
{
name: "negative width",
req: ImageRequest{Size: Size{Width: -1, Height: 600}},
wantErr: ErrInvalidSize,
},
{
name: "invalid fit mode",
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: "bogus"},
wantErr: ErrInvalidFitMode,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ValidateImageRequest(&tt.req)
if tt.wantErr == nil {
if err != nil {
t.Fatalf("ValidateImageRequest() error = %v, want nil", err)
}
return
}
if !errors.Is(err, tt.wantErr) {
t.Fatalf("ValidateImageRequest() error = %v, want %v", err, tt.wantErr)
}
})
}
}
@@ -0,0 +1,118 @@
package middleware
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"sneak.berlin/go/pixa/internal/clientip"
"sneak.berlin/go/pixa/internal/config"
)
// testForwardedClient is the client address the proxy forwards.
const testForwardedClient = "203.0.113.7"
// newTestMiddleware builds a Middleware whose resolver trusts the given
// CIDRs and whose logger writes JSON to buf.
func newTestMiddleware(t *testing.T, buf *bytes.Buffer, trusted ...string) *Middleware {
t.Helper()
prefixes := make([]netip.Prefix, 0, len(trusted))
for _, c := range trusted {
p, err := netip.ParsePrefix(c)
if err != nil {
t.Fatalf("netip.ParsePrefix(%q) error = %v", c, err)
}
prefixes = append(prefixes, p)
}
return &Middleware{
log: slog.New(slog.NewJSONHandler(buf, nil)),
config: &config.Config{TrustedProxies: prefixes},
clientIP: clientip.NewResolver(prefixes),
}
}
// TestClientIPMiddlewareStoresResolvedIP verifies the ClientIP middleware
// puts the resolved address into the request context for a trusted and an
// untrusted peer.
func TestClientIPMiddlewareStoresResolvedIP(t *testing.T) {
t.Parallel()
tests := []struct {
name string
remoteAddr string
forwarded string
want string
}{
{
name: "trusted peer honors forwarded client",
remoteAddr: "10.0.0.1:5000",
forwarded: testForwardedClient,
want: testForwardedClient,
},
{
name: "untrusted peer ignores forwarded header",
remoteAddr: "198.51.100.9:5000",
forwarded: testForwardedClient,
want: "198.51.100.9",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mw := newTestMiddleware(t, &bytes.Buffer{}, "10.0.0.0/8")
var got string
handler := mw.ClientIP()(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
got = clientip.FromContext(r.Context())
}))
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil)
req.RemoteAddr = tt.remoteAddr
req.Header.Set("X-Forwarded-For", tt.forwarded)
handler.ServeHTTP(httptest.NewRecorder(), req)
if got != tt.want {
t.Errorf("client IP in context = %q, want %q", got, tt.want)
}
})
}
}
// TestLoggingUsesResolvedClientIP verifies the logging middleware records
// the resolved forwarded client IP rather than the proxy peer address.
func TestLoggingUsesResolvedClientIP(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
mw := newTestMiddleware(t, &buf, "10.0.0.0/8")
handler := mw.ClientIP()(mw.Logging()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})))
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:5000"
req.Header.Set("X-Forwarded-For", testForwardedClient)
handler.ServeHTTP(httptest.NewRecorder(), req)
if !strings.Contains(buf.String(), `"remoteIP":"`+testForwardedClient+`"`) {
t.Errorf("log output missing resolved client IP; got %q", buf.String())
}
}
+79 -12
View File
@@ -3,17 +3,19 @@ package middleware
import ( import (
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"net/netip"
"time" "time"
basicauth "github.com/99designs/basicauth-go" basicauth "github.com/99designs/basicauth-go"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
"github.com/go-chi/httprate"
metrics "github.com/slok/go-http-metrics/metrics/prometheus" metrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware" ghmm "github.com/slok/go-http-metrics/middleware"
"github.com/slok/go-http-metrics/middleware/std" "github.com/slok/go-http-metrics/middleware/std"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/pixa/internal/clientip"
"sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/logger" "sneak.berlin/go/pixa/internal/logger"
) )
@@ -21,6 +23,33 @@ import (
// CORSMaxAgeSeconds is the max age for CORS preflight cache (24 hours). // CORSMaxAgeSeconds is the max age for CORS preflight cache (24 hours).
const CORSMaxAgeSeconds = 86400 const CORSMaxAgeSeconds = 86400
// HSTSValue is the Strict-Transport-Security header value: one year with
// includeSubDomains. Emitted unconditionally even though pixa listens plain
// HTTP behind a TLS-terminating proxy; browsers ignore an HSTS header received
// over plaintext (RFC 6797 section 8.1), so it never lies about the connection,
// and emitting it here avoids trusting a forwarded-proto header.
const HSTSValue = "max-age=31536000; includeSubDomains"
// ContentSecurityPolicyValue is the Content-Security-Policy header value.
// default-src 'self' is the baseline and frame-ancestors 'none' is the primary
// clickjacking control. 'unsafe-inline' is required in script-src and style-src
// because the served templates carry inline onclick handlers (generator page)
// and the bundled Tailwind asset injects a runtime <style> element; dropping it
// needs template changes outside this issue's scope.
const ContentSecurityPolicyValue = "default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
"frame-ancestors 'none'"
// PermissionsPolicyValue is the Permissions-Policy header value. Every listed
// feature is denied because pixa uses none of them.
const PermissionsPolicyValue = "accelerometer=(), autoplay=(), camera=(), " +
"display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), " +
"microphone=(), payment=(), usb=()"
// Params defines dependencies for Middleware. // Params defines dependencies for Middleware.
type Params struct { type Params struct {
fx.In fx.In
@@ -33,6 +62,7 @@ type Params struct {
type Middleware struct { type Middleware struct {
log *slog.Logger log *slog.Logger
config *config.Config config *config.Config
clientIP *clientip.Resolver
} }
// New creates a new Middleware instance. // New creates a new Middleware instance.
@@ -40,22 +70,49 @@ func New(_ fx.Lifecycle, params Params) (*Middleware, error) {
s := &Middleware{ s := &Middleware{
log: params.Logger.Get(), log: params.Logger.Get(),
config: params.Config, config: params.Config,
clientIP: clientip.NewResolver(params.Config.TrustedProxies),
} }
return s, nil return s, nil
} }
func ipFromHostPort(hp string) string { // ClientIP returns a middleware that resolves the real client IP,
h, _, err := net.SplitHostPort(hp) // honoring X-Forwarded-For only from trusted proxies, and stores it in
if err != nil { // the request context for the logging middleware and handlers to read.
return "" func (s *Middleware) ClientIP() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP.Resolve(
r.RemoteAddr, r.Header.Values(clientip.ForwardedForHeader))
ctx := clientip.WithClientIP(r.Context(), ip)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RateLimit returns a middleware that limits each client to requestLimit
// requests per window and refuses a request over the limit with 429 Too Many
// Requests and a Retry-After header. Clients are told apart by the address
// the ClientIP middleware stored in the request context, so ClientIP must
// run first. An IPv6 client is counted by its /64, which one client usually
// holds whole; an IPv4-mapped address (::ffff:a.b.c.d) is counted as the
// IPv4 address it carries, since every such address falls in the same /64.
// Counts are kept only for the current and the previous window, so memory
// stays bounded.
func (s *Middleware) RateLimit(
requestLimit int, window time.Duration,
) func(http.Handler) http.Handler {
return httprate.LimitBy(requestLimit, window,
func(r *http.Request) (string, error) {
ip := clientip.FromContext(r.Context())
addr, err := netip.ParseAddr(ip)
if err == nil {
ip = addr.Unmap().String()
} }
if len(h) > 0 && h[0] == '[' { return httprate.CanonicalizeIP(ip), nil
return h[1 : len(h)-1] })
}
return h
} }
type loggingResponseWriter struct { type loggingResponseWriter struct {
@@ -100,7 +157,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
"request_id", reqID, "request_id", reqID,
"referer", r.Referer(), "referer", r.Referer(),
"proto", r.Proto, "proto", r.Proto,
"remoteIP", ipFromHostPort(r.RemoteAddr), "remoteIP", clientip.FromContext(ctx),
"status", lrw.statusCode, "status", lrw.statusCode,
"response_bytes", lrw.bytesWritten, "response_bytes", lrw.bytesWritten,
"latency_ms", latency.Milliseconds(), "latency_ms", latency.Milliseconds(),
@@ -115,7 +172,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
// CORS returns a CORS middleware. // CORS returns a CORS middleware.
func (s *Middleware) CORS() func(http.Handler) http.Handler { func (s *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ return cors.Handler(cors.Options{
AllowedOrigins: []string{"*"}, AllowedOrigins: []string{s.config.AccessControlAllowOrigin},
AllowedMethods: []string{"GET", "HEAD", "OPTIONS"}, AllowedMethods: []string{"GET", "HEAD", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
ExposedHeaders: []string{"Link"}, ExposedHeaders: []string{"Link"},
@@ -164,6 +221,16 @@ func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
// Disable XSS filtering (modern browsers don't need it, can cause issues) // Disable XSS filtering (modern browsers don't need it, can cause issues)
w.Header().Set("X-XSS-Protection", "0") w.Header().Set("X-XSS-Protection", "0")
// Force HTTPS on future visits (ignored by browsers over plaintext)
w.Header().Set("Strict-Transport-Security", HSTSValue)
// Restrict content sources; frame-ancestors is the primary
// clickjacking control, X-Frame-Options the legacy fallback
w.Header().Set("Content-Security-Policy", ContentSecurityPolicyValue)
// Deny browser features pixa does not use
w.Header().Set("Permissions-Policy", PermissionsPolicyValue)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
@@ -9,6 +9,53 @@ import (
"sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/config"
) )
// TestCORSAnswersWithConfiguredOrigin checks that the CORS middleware
// answers with access_control_allow_origin, where "*" lets any origin read
// responses and a single origin lets only that origin read them.
func TestCORSAnswersWithConfiguredOrigin(t *testing.T) {
t.Parallel()
const appOrigin = "https://app.example.com"
cases := []struct {
configured string
requestOrigin string
want string
}{
{"*", "https://any.example.com", "*"},
{appOrigin, appOrigin, appOrigin},
{appOrigin, "https://other.example.com", ""},
}
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
for _, tc := range cases {
mw := &Middleware{
log: slog.Default(),
config: &config.Config{AccessControlAllowOrigin: tc.configured},
}
handler := mw.CORS()(testHandler)
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/v1/image/example.com/a.jpg/1x1.png", nil)
req.Header.Set("Origin", tc.requestOrigin)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
got := rec.Header().Get("Access-Control-Allow-Origin")
if got != tc.want {
t.Errorf("configured %q, request from %q: "+
"Access-Control-Allow-Origin = %q, want %q",
tc.configured, tc.requestOrigin, got, tc.want)
}
}
}
func TestSecurityHeaders(t *testing.T) { func TestSecurityHeaders(t *testing.T) {
t.Parallel() t.Parallel()
@@ -56,6 +103,61 @@ func TestSecurityHeaders(t *testing.T) {
} }
} }
func TestSecurityHeaders_PolicyHeaders(t *testing.T) {
t.Parallel()
cfg := &config.Config{}
mw := &Middleware{
log: slog.Default(),
config: cfg,
}
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := mw.SecurityHeaders()(testHandler)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
tests := []struct {
header string
want string
}{
{"Strict-Transport-Security", "max-age=31536000; includeSubDomains"},
{
"Content-Security-Policy",
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
"frame-ancestors 'none'",
},
{
"Permissions-Policy",
"accelerometer=(), autoplay=(), camera=(), " +
"display-capture=(), geolocation=(), gyroscope=(), " +
"magnetometer=(), microphone=(), payment=(), usb=()",
},
}
for _, tt := range tests {
t.Run(tt.header, func(t *testing.T) {
t.Parallel()
got := rec.Header().Get(tt.header)
if got != tt.want {
t.Errorf("%s = %q, want %q", tt.header, got, tt.want)
}
})
}
}
func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) { func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {
t.Parallel() t.Parallel()
+22 -7
View File
@@ -10,23 +10,38 @@ import (
// HTTP server configuration constants. // HTTP server configuration constants.
const ( const (
HTTPReadTimeout = 30 * time.Second HTTPReadTimeout = 30 * time.Second
HTTPWriteTimeout = 60 * time.Second // HTTPReadHeaderTimeout bounds the request-header read on its own,
// short, so a slowloris client dribbling headers is dropped well
// before it ties up a connection for the whole ReadTimeout window.
HTTPReadHeaderTimeout = 10 * time.Second
// HTTPIdleTimeout bounds how long an idle keep-alive connection is
// held open, so idle connections cannot accumulate without limit on a
// service targeting high concurrency.
HTTPIdleTimeout = 120 * time.Second
HTTPMaxHeaderBytes = 8 << 10 // 8KB HTTPMaxHeaderBytes = 8 << 10 // 8KB
) )
func (s *Server) serveUntilShutdown() { // newHTTPServer builds the http.Server with the hardening timeouts and
listenAddr := fmt.Sprintf(":%d", s.config.Port) // limits applied. It is separate from serveUntilShutdown so the
s.httpServer = &http.Server{ // configuration can be asserted in a test without binding a listener.
Addr: listenAddr, func (s *Server) newHTTPServer() *http.Server {
return &http.Server{
Addr: fmt.Sprintf(":%d", s.config.Port),
ReadTimeout: HTTPReadTimeout, ReadTimeout: HTTPReadTimeout,
WriteTimeout: HTTPWriteTimeout, ReadHeaderTimeout: HTTPReadHeaderTimeout,
WriteTimeout: s.config.DownstreamTimeout,
IdleTimeout: HTTPIdleTimeout,
MaxHeaderBytes: HTTPMaxHeaderBytes, MaxHeaderBytes: HTTPMaxHeaderBytes,
Handler: s, Handler: s,
} }
}
func (s *Server) serveUntilShutdown() {
s.httpServer = s.newHTTPServer()
s.SetupRoutes() s.SetupRoutes()
s.log.Info("http begin listen", "listenaddr", listenAddr) s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
err := s.httpServer.ListenAndServe() err := s.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) { if err != nil && !errors.Is(err, http.ErrServerClosed) {
+69
View File
@@ -0,0 +1,69 @@
package server
import (
"testing"
"time"
"sneak.berlin/go/pixa/internal/config"
)
// TestNewHTTPServerTimeouts verifies that the constructed http.Server
// carries every hardening timeout wired onto it, including the slowloris
// defense (ReadHeaderTimeout) and the keep-alive bound (IdleTimeout). This
// guards against a field being defined but never set on the server, so
// each assertion compares the server field to its constant, or, for
// WriteTimeout, to downstream_timeout from the config.
func TestNewHTTPServerTimeouts(t *testing.T) {
t.Parallel()
s := &Server{config: &config.Config{
Port: 8080,
DownstreamTimeout: 45 * time.Second,
}}
srv := s.newHTTPServer()
fields := []struct {
name string
got time.Duration
want time.Duration
}{
{"ReadTimeout", srv.ReadTimeout, HTTPReadTimeout},
{"ReadHeaderTimeout", srv.ReadHeaderTimeout, HTTPReadHeaderTimeout},
{"WriteTimeout", srv.WriteTimeout, 45 * time.Second},
{"IdleTimeout", srv.IdleTimeout, HTTPIdleTimeout},
}
for _, f := range fields {
if f.got != f.want {
t.Errorf("%s = %v, want %v", f.name, f.got, f.want)
}
}
if srv.MaxHeaderBytes != HTTPMaxHeaderBytes {
t.Errorf("MaxHeaderBytes = %d, want %d",
srv.MaxHeaderBytes, HTTPMaxHeaderBytes)
}
if srv.Handler != s {
t.Error("Handler is not the server")
}
}
// TestHardeningTimeoutValues pins the intent behind the two new timeouts
// without hard-coding brittle exact durations: the header-read phase is
// bounded strictly shorter than the whole-request read (the slowloris
// dribble), and idle keep-alive connections are bounded rather than held
// open forever.
func TestHardeningTimeoutValues(t *testing.T) {
t.Parallel()
if HTTPReadHeaderTimeout <= 0 || HTTPReadHeaderTimeout > HTTPReadTimeout {
t.Errorf("ReadHeaderTimeout = %v, want positive and <= ReadTimeout %v",
HTTPReadHeaderTimeout, HTTPReadTimeout)
}
if HTTPIdleTimeout <= 0 {
t.Errorf("IdleTimeout = %v, want positive bound", HTTPIdleTimeout)
}
}
@@ -0,0 +1,281 @@
package server
import (
"io"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"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/logger"
"sneak.berlin/go/pixa/internal/middleware"
)
// testSigningKey is a throwaway signing key; submitting it logs in.
const testSigningKey = "test-signing-key-0123456789abcdef"
// wrongKey is submitted for a failed login.
const wrongKey = "not-the-signing-key"
// Addresses for the login rate limit tests. The test server trusts
// 10.0.0.0/8 as its proxies, so the X-Forwarded-For sent by proxyPeer is
// believed and the one sent by firstClient or secondClient is ignored.
const (
firstClient = "198.51.100.1:40000"
secondClient = "198.51.100.2:40000"
proxyPeer = "10.0.0.1:40000"
firstForwarded = "203.0.113.1"
secondForwarded = "203.0.113.2"
)
// csrfFieldPattern extracts the CSRF token rendered into the login form.
var csrfFieldPattern = regexp.MustCompile(
`name="gorilla\.csrf\.Token" value="([^"]+)"`)
// newTestServer builds the server's real routes from the constructors
// cmd/pixad uses, with a throwaway state directory. Debug marks requests
// as plain HTTP, so the CSRF check runs without an https Referer.
func newTestServer(t *testing.T) *Server {
t.Helper()
stateDir := t.TempDir()
cfg := &config.Config{
Debug: true,
SigningKey: testSigningKey,
StateDir: stateDir,
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
DownstreamTimeout: config.DefaultDownstreamTimeout,
}
lc := fxtest.NewLifecycle(t)
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
if err != nil {
t.Fatalf("logger.New() error = %v", err)
}
db, err := database.New(lc, database.Params{Logger: log, Config: cfg})
if err != nil {
t.Fatalf("database.New() error = %v", err)
}
h, err := handlers.New(lc, handlers.Params{
Logger: log, Database: db, Config: cfg,
})
if err != nil {
t.Fatalf("handlers.New() error = %v", err)
}
mw, err := middleware.New(lc, middleware.Params{Logger: log, Config: cfg})
if err != nil {
t.Fatalf("middleware.New() error = %v", err)
}
lc.RequireStart()
t.Cleanup(lc.RequireStop)
s := &Server{config: cfg, mw: mw, h: h}
s.SetupRoutes()
return s
}
// clientRequest builds a request for / arriving from remoteAddr, carrying
// forwardedFor as its X-Forwarded-For header when that is not empty.
func clientRequest(
t *testing.T, method string, body io.Reader, remoteAddr, forwardedFor string,
) *http.Request {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
req.RemoteAddr = remoteAddr
if forwardedFor != "" {
req.Header.Set("X-Forwarded-For", forwardedFor)
}
return req
}
// postLogin loads the login form with GET / and submits key in it with
// POST /, as a browser does, both from the same client. GET / is not rate
// limited, so the form must load even for a client over the limit.
func postLogin(
t *testing.T, s *Server, remoteAddr, forwardedFor, key string,
) *httptest.ResponseRecorder {
t.Helper()
page := httptest.NewRecorder()
s.ServeHTTP(page,
clientRequest(t, http.MethodGet, nil, remoteAddr, forwardedFor))
if page.Code != http.StatusOK {
t.Fatalf("GET / status = %d, want %d", page.Code, http.StatusOK)
}
match := csrfFieldPattern.FindStringSubmatch(page.Body.String())
if match == nil {
t.Fatalf("no CSRF token field found in the login form")
}
form := url.Values{"key": {key}, "gorilla.csrf.Token": {match[1]}}
req := clientRequest(t, http.MethodPost,
strings.NewReader(form.Encode()), remoteAddr, forwardedFor)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range page.Result().Cookies() {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
return rec
}
// tripLoginRateLimit makes LoginAttemptsPerMinute failed logins from one
// client, each answered with the login form again, then one more, which
// must be refused with 429. It returns the response to that last attempt.
func tripLoginRateLimit(
t *testing.T, s *Server, remoteAddr, forwardedFor string,
) *httptest.ResponseRecorder {
t.Helper()
for attempt := range LoginAttemptsPerMinute {
rec := postLogin(t, s, remoteAddr, forwardedFor, wrongKey)
if rec.Code != http.StatusOK {
t.Fatalf("failed login %d status = %d, want %d",
attempt+1, rec.Code, http.StatusOK)
}
}
rec := postLogin(t, s, remoteAddr, forwardedFor, wrongKey)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("login over the limit status = %d, want %d",
rec.Code, http.StatusTooManyRequests)
}
return rec
}
// TestLoginRateLimitRefusesAttemptOverLimit verifies the login attempt
// after LoginAttemptsPerMinute failed ones from one client is refused with
// 429 and a Retry-After header, and that the client cannot get around the
// limit by sending X-Forwarded-For: from a peer that is not a trusted
// proxy, the header is ignored.
func TestLoginRateLimitRefusesAttemptOverLimit(t *testing.T) {
t.Parallel()
s := newTestServer(t)
rec := tripLoginRateLimit(t, s, firstClient, "")
retryAfter := rec.Header().Get("Retry-After")
seconds, err := strconv.Atoi(retryAfter)
if err != nil || seconds <= 0 {
t.Errorf("Retry-After = %q, want a positive number of seconds",
retryAfter)
}
rec = postLogin(t, s, firstClient, secondForwarded, wrongKey)
if rec.Code != http.StatusTooManyRequests {
t.Errorf("login with X-Forwarded-For from an untrusted peer "+
"status = %d, want %d", rec.Code, http.StatusTooManyRequests)
}
}
// TestLoginRateLimitLeavesOtherClientsAlone verifies one client going over
// the limit does not limit another: a failed login from a different
// address is answered with the login form, and the signing key still logs
// it in.
func TestLoginRateLimitLeavesOtherClientsAlone(t *testing.T) {
t.Parallel()
s := newTestServer(t)
tripLoginRateLimit(t, s, firstClient, "")
rec := postLogin(t, s, secondClient, "", wrongKey)
if rec.Code != http.StatusOK {
t.Errorf("failed login from another client status = %d, want %d",
rec.Code, http.StatusOK)
}
rec = postLogin(t, s, secondClient, "", testSigningKey)
if rec.Code != http.StatusSeeOther {
t.Errorf("login with the signing key from another client "+
"status = %d, want %d", rec.Code, http.StatusSeeOther)
}
}
// TestLoginRateLimitCountsClientsBehindProxySeparately verifies the limit
// counts the client address resolved from X-Forwarded-For, not the address
// of the trusted proxy the requests arrive from, so two clients behind the
// same proxy are counted separately.
func TestLoginRateLimitCountsClientsBehindProxySeparately(t *testing.T) {
t.Parallel()
s := newTestServer(t)
tripLoginRateLimit(t, s, proxyPeer, firstForwarded)
rec := postLogin(t, s, proxyPeer, secondForwarded, wrongKey)
if rec.Code != http.StatusOK {
t.Errorf("failed login from a second client behind the proxy "+
"status = %d, want %d", rec.Code, http.StatusOK)
}
}
// TestLoginRateLimitCountsIPv6ClientsByPrefix verifies an IPv6 client is
// counted by its /64: another address in the same /64 is refused too,
// while an address in a different /64 is not.
func TestLoginRateLimitCountsIPv6ClientsByPrefix(t *testing.T) {
t.Parallel()
s := newTestServer(t)
tripLoginRateLimit(t, s, proxyPeer, "2001:db8::1")
rec := postLogin(t, s, proxyPeer, "2001:db8::2", wrongKey)
if rec.Code != http.StatusTooManyRequests {
t.Errorf("login from the same /64 status = %d, want %d",
rec.Code, http.StatusTooManyRequests)
}
rec = postLogin(t, s, proxyPeer, "2001:db8:0:1::1", wrongKey)
if rec.Code != http.StatusOK {
t.Errorf("login from another /64 status = %d, want %d",
rec.Code, http.StatusOK)
}
}
// TestLoginRateLimitCountsIPv4MappedClientsSeparately verifies an IPv4
// client that the proxy forwards in IPv4-mapped IPv6 form (::ffff:a.b.c.d)
// is counted by its IPv4 address, not by the /64 that every such address
// shares, so two of them behind the proxy are counted separately.
func TestLoginRateLimitCountsIPv4MappedClientsSeparately(t *testing.T) {
t.Parallel()
s := newTestServer(t)
tripLoginRateLimit(t, s, proxyPeer, "::ffff:"+firstForwarded)
rec := postLogin(t, s, proxyPeer, "::ffff:"+secondForwarded, wrongKey)
if rec.Code != http.StatusOK {
t.Errorf("failed login from a second IPv4-mapped client "+
"status = %d, want %d", rec.Code, http.StatusOK)
}
}
+23 -5
View File
@@ -2,21 +2,28 @@ package server
import ( import (
"net/http" "net/http"
"time"
sentryhttp "github.com/getsentry/sentry-go/http" sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"sneak.berlin/go/pixa/internal/handlers"
"sneak.berlin/go/pixa/internal/static" "sneak.berlin/go/pixa/internal/static"
) )
// LoginAttemptsPerMinute is how many login attempts (POST /) one client may
// make per minute; the next is refused with 429 Too Many Requests.
const LoginAttemptsPerMinute = 5
// SetupRoutes configures all HTTP routes. // SetupRoutes configures all HTTP routes.
func (s *Server) SetupRoutes() { func (s *Server) SetupRoutes() {
s.router = chi.NewRouter() s.router = chi.NewRouter()
s.router.Use(middleware.Recoverer) s.router.Use(middleware.Recoverer)
s.router.Use(middleware.RequestID) s.router.Use(middleware.RequestID)
s.router.Use(s.mw.ClientIP())
s.router.Use(s.mw.SecurityHeaders()) s.router.Use(s.mw.SecurityHeaders())
s.router.Use(s.mw.Logging()) s.router.Use(s.mw.Logging())
@@ -26,7 +33,7 @@ func (s *Server) SetupRoutes() {
} }
s.router.Use(s.mw.CORS()) s.router.Use(s.mw.CORS())
s.router.Use(middleware.Timeout(HTTPWriteTimeout)) s.router.Use(middleware.Timeout(s.config.DownstreamTimeout))
if s.sentryEnabled { if s.sentryEnabled {
sentryHandler := sentryhttp.New(sentryhttp.Options{ sentryHandler := sentryhttp.New(sentryhttp.Options{
@@ -44,11 +51,22 @@ func (s *Server) SetupRoutes() {
// Static files (Tailwind CSS, etc.) // Static files (Tailwind CSS, etc.)
s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler())) s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler()))
// Login/generator UI // Login/generator UI. The form routes carry CSRF protection; the
s.router.Get("/", s.h.HandleRoot()) // token cookie is independent of the session cookie, so it also
s.router.Post("/", s.h.HandleRoot()) // covers the login POST, where no session exists yet. LimitBody caps
// the POST body ahead of CSRF, which reads its token from that body.
// The login POST is rate limited per client after both, so every
// attempt that reaches the signing key comparison is counted.
s.router.Group(func(r chi.Router) {
r.Use(s.h.LimitBody(handlers.MaxFormBytes))
r.Use(s.h.CSRF())
r.Get("/", s.h.HandleRoot())
r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute)).
Post("/", s.h.HandleRoot())
r.Post("/generate", s.h.HandleGenerateURL())
})
s.router.Get("/logout", s.h.HandleLogout()) s.router.Get("/logout", s.h.HandleLogout())
s.router.Post("/generate", s.h.HandleGenerateURL())
// Main image proxy route // Main image proxy route
// /v1/image/<host>/<path>/<width>x<height>.<format> // /v1/image/<host>/<path>/<width>x<height>.<format>
+37 -11
View File
@@ -41,9 +41,12 @@ func goldenVectors() []goldenVector {
Width: 800, Width: 800,
Height: 600, Height: 600,
Format: testFormatWebP, Format: testFormatWebP,
Quality: 85,
FitMode: testFitCover,
}, },
// Signed data: "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200" // Signed data:
wantSignature: "x5PfPp8QSDo0cJT96od-AEgrQyOVLfqifH5sst61_-w=", // "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover"
wantSignature: "kdqeGoW2SX7qnaYtoB970wEnLydn0UnIgQYQLfAnjXQ=",
wantSignedPath: testSignedPath, wantSignedPath: testSignedPath,
}, },
{ {
@@ -55,10 +58,12 @@ func goldenVectors() []goldenVector {
Width: 800, Width: 800,
Height: 600, Height: 600,
Format: testFormatWebP, Format: testFormatWebP,
Quality: 85,
FitMode: testFitCover,
}, },
// Signed data: // Signed data:
// "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200" // "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200:85:cover"
wantSignature: "394_Vf9TdQFkpQ3XKFDQSyxgqKq8N7mApf2S4QaHqyo=", wantSignature: "pKgVBOTd_Q_EikI7MNQLC9Q8Hurdxzyv3EIYvVhqc2I=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg" + wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg" +
"%3Ftoken=abc&v=2/800x600.webp", "%3Ftoken=abc&v=2/800x600.webp",
}, },
@@ -71,11 +76,34 @@ func goldenVectors() []goldenVector {
Width: 0, Width: 0,
Height: 0, Height: 0,
Format: testFormatPNG, Format: testFormatPNG,
Quality: 85,
FitMode: testFitCover,
}, },
// Signed data: "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200" // Signed data:
wantSignature: "7Be7oteeQwvnSPU4bchyQ4ZGYGsAGBKpeEtuQ02ox60=", // "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200:85:cover"
wantSignature: "6_rZ0yyVbGZRs8kG7n7HLgLi5Jt8vjiWQljIEL1jbIs=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png", wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
}, },
{
name: "non-default quality and fit",
req: signature.Request{
SourceHost: testHost,
SourcePath: testPath,
SourceQuery: "",
Width: 800,
Height: 600,
Format: testFormatWebP,
Quality: 40,
FitMode: testFitContain,
},
// Signed data:
// "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:40:contain"
wantSignature: "pGaXpPUbI3A7nMx-4T9bfq9bYWBNL0kY4bxlcv3g1F8=",
// The path is the same as for the default quality and fit:
// q=40&fit=contain go in the query string next to sig and
// exp (imgcache.Service.GenerateSignedURL adds all four).
wantSignedPath: testSignedPath,
},
} }
} }
@@ -84,11 +112,9 @@ func goldenVectors() []goldenVector {
// hardcoded signing key. // hardcoded signing key.
// //
// If any of these assertions fail, the signed byte format // If any of these assertions fail, the signed byte format
// ("host:path:query:width:height:format:expiration"), the base64url // ("host:path:query:width:height:format:expiration:quality:fit"), the
// encoding, or the signed URL layout has changed. Such a change breaks // base64url encoding, or the signed URL layout has changed. Update these
// every signature already issued to clients, so it must be made // constants only when that change is intended.
// deliberately: update these constants only as part of an intentional,
// documented signature format migration.
func TestSigner_GoldenVectors(t *testing.T) { func TestSigner_GoldenVectors(t *testing.T) {
t.Parallel() t.Parallel()
+68
View File
@@ -0,0 +1,68 @@
package signature_test
import (
"errors"
"testing"
"time"
"sneak.berlin/go/pixa/internal/signature"
)
// signedQualityFitRequest returns a request signed for quality 85 and fit
// mode "cover", the effective defaults the handler applies before
// verification.
func signedQualityFitRequest(signer *signature.Signer) *signature.Request {
req := &signature.Request{
SourceHost: testHost,
SourcePath: testPath,
Width: 800,
Height: 600,
Format: testFormatWebP,
Quality: 85,
FitMode: testFitCover,
Expires: time.Now().Add(1 * time.Hour),
}
req.Signature = signer.Sign(req)
return req
}
// TestSigner_Verify_QualityAndFitAreSigned proves that quality and fit are
// covered by the signature: a URL signed for one quality or fit mode must
// not verify when replayed with a different quality or fit mode. This is the
// amplification vector from the issue — one signed URL replayed across many
// quality and fit values yields many unauthorized cache entries and
// transcodes — so it must be rejected.
func TestSigner_Verify_QualityAndFitAreSigned(t *testing.T) {
t.Parallel()
signer := signature.New("test-secret-key")
cases := []struct {
name string
tamper func(r *signature.Request)
}{
{
name: "replayed with different quality",
tamper: func(r *signature.Request) { r.Quality = 40 },
},
{
name: "replayed with different fit mode",
tamper: func(r *signature.Request) { r.FitMode = testFitContain },
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := signedQualityFitRequest(signer)
tt.tamper(req)
err := signer.Verify(req)
if !errors.Is(err, signature.ErrInvalid) {
t.Errorf("Verify() = %v, want %v", err, signature.ErrInvalid)
}
})
}
}
+20 -5
View File
@@ -37,6 +37,15 @@ type Request struct {
Height int Height int
// Format is the requested output format (e.g. "webp"). // Format is the requested output format (e.g. "webp").
Format string Format string
// Quality is the requested output quality (1-100) for lossy formats.
// It is the effective value the request resolves to: callers pass the
// default quality when the request omits the parameter, so an omitted
// quality signs identically to that same value stated explicitly.
Quality int
// FitMode is how the image is fit into the requested dimensions
// (e.g. "cover"). Like Quality it is the effective value: callers pass
// the default fit mode when the request omits the parameter.
FitMode string
// Signature is the HMAC signature to verify. // Signature is the HMAC signature to verify.
Signature string Signature string
// Expires is the signature expiration timestamp. // Expires is the signature expiration timestamp.
@@ -56,7 +65,8 @@ func New(secretKey string) *Signer {
} }
// Sign generates an HMAC-SHA256 signature for the given request. // Sign generates an HMAC-SHA256 signature for the given request.
// The signature covers: host + path + query + width + height + format + expiration. // The signature covers: host + path + query + width + height + format +
// expiration + quality + fit.
func (s *Signer) Sign(req *Request) string { func (s *Signer) Sign(req *Request) string {
data := s.buildSignatureData(req) data := s.buildSignatureData(req)
mac := hmac.New(sha256.New, s.secretKey) mac := hmac.New(sha256.New, s.secretKey)
@@ -68,7 +78,8 @@ func (s *Signer) Sign(req *Request) string {
// Verify checks if the signature on the request is valid and not expired. // Verify checks if the signature on the request is valid and not expired.
// Signatures are exact-match only: every component of the signed data // Signatures are exact-match only: every component of the signed data
// (host, path, query, dimensions, format, expiration) must match exactly. // (host, path, query, dimensions, format, expiration, quality, fit) must
// match exactly.
// No suffix matching, wildcard matching, or partial matching is supported. // No suffix matching, wildcard matching, or partial matching is supported.
// A signature for "cdn.example.com" will NOT verify for "example.com" or // A signature for "cdn.example.com" will NOT verify for "example.com" or
// "other.cdn.example.com", and vice versa. // "other.cdn.example.com", and vice versa.
@@ -142,11 +153,13 @@ func (s *Signer) GenerateSignedURL(
} }
// buildSignatureData creates the string to be signed. // buildSignatureData creates the string to be signed.
// Format: "host:path:query:width:height:format:expiration" // Format: "host:path:query:width:height:format:expiration:quality:fit"
// All components are used verbatim (exact match). No normalization, // All components are used verbatim (exact match). No normalization,
// suffix matching, or wildcard expansion is performed. // suffix matching, or wildcard expansion is performed. Quality and fit
// are the effective transform values, so replaying a signed URL with a
// different quality or fit mode fails verification.
func (s *Signer) buildSignatureData(req *Request) string { func (s *Signer) buildSignatureData(req *Request) string {
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d", return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%d:%s",
req.SourceHost, req.SourceHost,
req.SourcePath, req.SourcePath,
req.SourceQuery, req.SourceQuery,
@@ -154,6 +167,8 @@ func (s *Signer) buildSignatureData(req *Request) string {
req.Height, req.Height,
req.Format, req.Format,
req.Expires.Unix(), req.Expires.Unix(),
req.Quality,
req.FitMode,
) )
} }
+2
View File
@@ -15,6 +15,8 @@ const (
testPath = "/photos/cat.jpg" testPath = "/photos/cat.jpg"
testFormatWebP = "webp" testFormatWebP = "webp"
testFormatPNG = "png" testFormatPNG = "png"
testFitCover = "cover"
testFitContain = "contain"
testSignedPath = "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp" testSignedPath = "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
testSig = "abc123" testSig = "abc123"
) )
+3 -2
View File
@@ -47,6 +47,7 @@
{{end}} {{end}}
<form method="POST" action="/generate" class="bg-white rounded-lg shadow-md p-6 space-y-4"> <form method="POST" action="/generate" class="bg-white rounded-lg shadow-md p-6 space-y-4">
{{ .CSRFField }}
<div> <div>
<label for="url" class="block text-sm font-medium text-gray-700 mb-1"> <label for="url" class="block text-sm font-medium text-gray-700 mb-1">
Source URL Source URL
@@ -72,7 +73,7 @@
id="width" id="width"
name="width" name="width"
min="0" min="0"
max="10000" max="8192"
value="{{if .FormWidth}}{{.FormWidth}}{{else}}0{{end}}" value="{{if .FormWidth}}{{.FormWidth}}{{else}}0{{end}}"
placeholder="0 = original" placeholder="0 = original"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
@@ -87,7 +88,7 @@
id="height" id="height"
name="height" name="height"
min="0" min="0"
max="10000" max="8192"
value="{{if .FormHeight}}{{.FormHeight}}{{else}}0{{end}}" value="{{if .FormHeight}}{{.FormHeight}}{{else}}0{{end}}"
placeholder="0 = original" placeholder="0 = original"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+1
View File
@@ -17,6 +17,7 @@
{{end}} {{end}}
<form method="POST" action="/" class="space-y-4"> <form method="POST" action="/" class="space-y-4">
{{ .CSRFField }}
<div> <div>
<label for="key" class="block text-sm font-medium text-gray-700 mb-1"> <label for="key" class="block text-sm font-medium text-gray-700 mb-1">
Signing Key Signing Key
+12 -58
View File
@@ -3,20 +3,16 @@
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git, # or apk (detected in that order); assumes NOTHING is present (not git,
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt # make, or go). The linter is never installed on the host: golangci-lint
# it is installed from a hash-verified GitHub release archive (never # runs only inside a container, Dockerfile.lint or the Dockerfile lint
# curl | sh). CGO image libraries (pkg-config, vips, libheif) are # stage (see script/lint). A C compiler and the CGO image libraries
# installed for the govips bindings. # (pkg-config, vips, libheif) are installed for the govips bindings.
# Both Dockerfiles run this script too, so their build dependencies are
# the ones listed here.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-08-07. Never "latest"; exact versions only.
GOLANGCI_LINT_VERSION="2.12.2"
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
PKGMGR="" PKGMGR=""
SUDO="" SUDO=""
@@ -57,54 +53,13 @@ missing() {
! command -v "$1" >/dev/null 2>&1 ! command -v "$1" >/dev/null 2>&1
} }
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# apt has no golangci-lint package: install a pinned release archive
# from GitHub, verified by hardcoded sha256 (never curl | sh).
install_golangci_lint_release() {
case "$(uname -m)" in
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
*)
echo "bootstrap: unsupported architecture $(uname -m)" >&2
exit 1
;;
esac
if missing curl; then pkg_install curl curl curl curl; fi
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/$name.tar.gz" \
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
verify_sha256 "$tmp/$name.tar.gz" "$sha"
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
rm -rf "$tmp"
}
ensure_golangci_lint() {
if ! missing golangci-lint; then return 0; fi
detect_pkgmgr
case "$PKGMGR" in
apt) install_golangci_lint_release ;;
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
esac
}
# CGO dependencies for govips (image processing) # CGO dependencies for govips (image processing)
ensure_cgo_deps() { ensure_cgo_deps() {
# cgo compiles with gcc on Linux; build-base and build-essential
# also bring the C library headers.
if missing gcc; then
pkg_install gcc build-essential gcc build-base
fi
if missing pkg-config; then if missing pkg-config; then
pkg_install pkg-config pkg-config pkg-config pkgconfig pkg_install pkg-config pkg-config pkg-config pkgconfig
fi fi
@@ -123,9 +78,8 @@ main() {
if missing git; then pkg_install git git git git; fi if missing git; then pkg_install git git git git; fi
if missing make; then pkg_install gnumake make make make; fi if missing make; then pkg_install gnumake make make make; fi
# Go toolchain and linter # Go toolchain
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
ensure_golangci_lint
# CGO image libraries # CGO image libraries
ensure_cgo_deps ensure_cgo_deps
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# script/docker-smoke: build the Docker image, start it, and wait up to
# 30 seconds for its HEALTHCHECK to report healthy. Needs a Docker
# daemon, so it is not part of script/check; the Gitea workflow runs it
# after script/cibuild.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
"$SCRIPT_DIR/docker"
# A fresh random key: the container publishes no port and is
# removed on exit.
key="$(head -c 32 /dev/urandom | base64)"
# Docker 25 and later probe every 5 seconds during the image's
# 10-second start period. --health-interval=1s makes it probe every
# second after that instead of every 30, so a slow start still shows
# as healthy within the 30-second wait.
cid="$(docker create --health-interval=1s \
-e PIXA_SIGNING_KEY="$key" "$("$SCRIPT_DIR/projectname")")"
# Remove the container on any exit; turning signals into exit makes
# an interrupted run clean up too.
trap 'docker rm -f "$cid" >/dev/null' EXIT
trap 'exit 1' HUP INT TERM
docker start "$cid" >/dev/null
deadline=$(($(date +%s) + 30))
while [ "$(date +%s)" -lt "$deadline" ]; do
health="$(docker inspect --format '{{.State.Health.Status}}' "$cid")"
if [ "$health" = healthy ]; then
echo "docker-smoke: container is healthy"
return 0
fi
sleep 1
done
echo "docker-smoke: container not healthy after 30 seconds; its log:" >&2
docker logs "$cid" >&2
return 1
}
main "$@"
+27 -13
View File
@@ -1,23 +1,37 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. CGO dependencies (pkg-config, vips, # script/lint: run golangci-lint over the whole tree. This is the only
# libheif) come from nix-shell when not already available (e.g. inside # way the linter is run, everywhere; it is never installed on the host.
# a Docker build or an existing nix-shell). #
# Inside a container it runs the linter. Anywhere else it builds
# Dockerfile.lint, whose last step runs this script again inside that
# container.
#
# Dockerfile.lint and the Dockerfile lint stage set container=docker
# (the systemd convention for marking a container) to say where we are.
# /.dockerenv cannot: it is missing inside build steps, and present on
# hosts that are themselves containers.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
run_with_cgo_deps() { main() {
if command -v pkg-config >/dev/null 2>&1; then cd "$ROOT"
sh -c "$1" if [ "${container:-}" = docker ]; then
# `golangci-lint config verify` is not run: it fetches its JSON
# schema over an unpinned live HTTPS call, which REPO_POLICIES.md
# forbids.
echo "Running linter..."
golangci-lint run --config .golangci.yml ./...
else else
nix-shell -p pkg-config vips libheif golangci-lint git --run "$1" # A new CACHEBUST on every run means the lint step is never
# served from cache (see Dockerfile.lint). The cacheonly output
# leaves no image behind.
docker build \
--progress=plain \
--build-arg CACHEBUST="$(date +%s)-$$" \
--output=type=cacheonly \
-f Dockerfile.lint .
fi fi
} }
main() {
cd "$ROOT"
echo "Running linter..."
run_with_cgo_deps "golangci-lint run"
}
main "$@" main "$@"
+6 -2
View File
@@ -10,14 +10,18 @@ run_with_cgo_deps() {
if command -v pkg-config >/dev/null 2>&1; then if command -v pkg-config >/dev/null 2>&1; then
sh -c "$1" sh -c "$1"
else else
nix-shell -p pkg-config vips libheif golangci-lint git --run "$1" nix-shell -p pkg-config vips libheif git --run "$1"
fi fi
} }
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "Running tests..." echo "Running tests..."
run_with_cgo_deps "CGO_ENABLED=1 go test -timeout 30s -race -v ./..." # Run without -v first for clean output on success; on failure rerun
# with -v for full diagnostics, then exit non-zero (REPO_POLICIES.md
# conditional-verbose-rerun pattern). The first run already proved the
# tests broken, so the build fails even if the rerun happens to pass.
run_with_cgo_deps "CGO_ENABLED=1 go test -timeout 30s -race -cover ./... || { echo '--- Rerunning with -v for details ---'; CGO_ENABLED=1 go test -timeout 30s -race -v ./...; exit 1; }"
} }
main "$@" main "$@"