3 Commits
Author SHA1 Message Date
sneak b1dc65761b Say that a config file's env: section overrides the environment (closes #128)
check / check (push) Successful in 2m56s
A variable named in the config file's env: section is set while the
file loads, so it overrides both the environment the process was started
with and the file's own key. README.md and the config.example.yml header
said only that a variable wins over the file; they now state this
exception where the precedence is given.

Model: opus-5-5
2026-09-28 10:32:05 +00:00
sneak 2005143e3d Take every setting from PIXA_ variables and PORT (closes #128)
check / check (push) Successful in 2m57s
Each config key can now be set by PIXA_ plus the key in upper case
("." written as "_"), and the port by PORT. One list pairs keys with
variables; the typed getters read a present variable, even an empty
one, before the config file, so every existing check covers it, and
errors a variable can reach name both the key and the variable. An
empty string for blocked_networks or trusted_proxies is now an empty
list, as for allowlist_hosts. The image no longer bakes in
config.docker.yml or passes --config, and its HEALTHCHECK probes
${PORT:-8080}; the config file is looked for under /etc/pixa rather
than /etc/pixad, so a file mounted at /etc/pixa/config.yml is still
read.

Model: opus-5-5
2026-09-28 10:08:37 +00:00
sneak f16b6bd6a6 test: every setting from its environment variable (closes #128)
Tests, written before the change, for the PIXA_ variables and PORT:
every key set from the environment with no config file, PORT over the
file's port, a list variable replacing the file's list, an empty
variable as a set value, invalid values aborting startup naming the
variable, and the signing key and metrics password never printed. All
fail until the change lands, except the check that a config file alone
behaves as before. TestMain unsets PORT and every PIXA_ variable so the
shell running the tests cannot change their result.

Model: opus-5-5
2026-09-28 10:07:50 +00:00
8 changed files with 26 additions and 285 deletions
+8 -15
View File
@@ -79,14 +79,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, quality, component (host, path, query, dimensions, format, expiration) must
fit) must match exactly what was signed. No suffix matching, wildcard match exactly what was signed. No suffix matching, wildcard matching,
matching, or partial matching is supported. 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:quality:fit") HMAC-SHA256(secret, "host:path:query:width:height:format:expiration")
``` ```
Where: Where:
@@ -98,25 +98,18 @@ Where:
- `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` — Unix timestamp when signature expires
- `quality` — the URL's `q` query parameter (1-100), or `85` when the URL
has no `q`
- `fit` — the URL's `fit` query parameter (cover, contain, fill, inside,
outside), or `cover` when the URL has no `fit`
**Example:** resize `https://cdn.example.com/photos/cat.jpg` to 800x600 **Example:** resize
WebP with expiration 1704067200, default quality and fit: `https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
expiration 1704067200:
1. Build input: 1. Build input:
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover` `cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200`
2. Compute HMAC-SHA256 with your secret key 2. Compute HMAC-SHA256 with your secret key
3. Base64URL-encode the result 3. Base64URL-encode the result
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=<base64url>&exp=1704067200`
For the same image at quality 40 with fit `contain`, the input ends in
`:40:contain` and the URL is
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200&q=40&fit=contain`.
**Allowlist patterns:** **Allowlist patterns:**
- **Exact match**: `cdn.example.com` — matches only that host - **Exact match**: `cdn.example.com` — matches only that host
-7
View File
@@ -42,13 +42,6 @@ exhaustion
the config file is looked for under `/etc/pixa` and `~/.config/pixa` the config file is looked for under `/etc/pixa` and `~/.config/pixa`
instead of the daemon name `pixad`; documented in `README.md` and instead of the daemon name `pixad`; documented in `README.md` and
`config.example.yml`. `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 - 2026-09-28 Docker image healthcheck (closes #111): a `HEALTHCHECK` in
the runtime stage probing `/.well-known/healthcheck.json` with busybox the runtime stage probing `/.well-known/healthcheck.json` with busybox
`wget`; `script/docker-smoke` (`make docker-smoke`) builds the image, `wget`; `script/docker-smoke` (`make docker-smoke`) builds the image,
@@ -1,120 +0,0 @@
package handlers
import (
"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. No image is served 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)
}
})
}
}
+2 -16
View File
@@ -205,23 +205,12 @@ 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. The URL // GenerateSignedURL generates a signed URL for the given request.
// 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)
@@ -229,8 +218,7 @@ 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&q=%d&fit=%s", return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil
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
@@ -464,8 +452,6 @@ 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,
} }
+11 -37
View File
@@ -41,12 +41,9 @@ 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::800:600:webp:1704067200"
// "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover" wantSignature: "x5PfPp8QSDo0cJT96od-AEgrQyOVLfqifH5sst61_-w=",
wantSignature: "kdqeGoW2SX7qnaYtoB970wEnLydn0UnIgQYQLfAnjXQ=",
wantSignedPath: testSignedPath, wantSignedPath: testSignedPath,
}, },
{ {
@@ -58,12 +55,10 @@ 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:85:cover" // "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200"
wantSignature: "pKgVBOTd_Q_EikI7MNQLC9Q8Hurdxzyv3EIYvVhqc2I=", wantSignature: "394_Vf9TdQFkpQ3XKFDQSyxgqKq8N7mApf2S4QaHqyo=",
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",
}, },
@@ -76,34 +71,11 @@ func goldenVectors() []goldenVector {
Width: 0, Width: 0,
Height: 0, Height: 0,
Format: testFormatPNG, Format: testFormatPNG,
Quality: 85,
FitMode: testFitCover,
}, },
// Signed data: // Signed data: "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200"
// "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200:85:cover" wantSignature: "7Be7oteeQwvnSPU4bchyQ4ZGYGsAGBKpeEtuQ02ox60=",
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,
},
} }
} }
@@ -112,9 +84,11 @@ 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:quality:fit"), the // ("host:path:query:width:height:format:expiration"), the base64url
// base64url encoding, or the signed URL layout has changed. Update these // encoding, or the signed URL layout has changed. Such a change breaks
// constants only when that change is intended. // every signature already issued to clients, so it must be made
// 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
@@ -1,68 +0,0 @@
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)
}
})
}
}
+5 -20
View File
@@ -37,15 +37,6 @@ 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.
@@ -65,8 +56,7 @@ 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 + // The signature covers: host + path + query + width + height + format + expiration.
// 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)
@@ -78,8 +68,7 @@ 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, quality, fit) must // (host, path, query, dimensions, format, expiration) must match exactly.
// 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.
@@ -153,13 +142,11 @@ 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:quality:fit" // Format: "host:path:query:width:height:format:expiration"
// 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. Quality and fit // suffix matching, or wildcard expansion is performed.
// 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:%d:%s", return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
req.SourceHost, req.SourceHost,
req.SourcePath, req.SourcePath,
req.SourceQuery, req.SourceQuery,
@@ -167,8 +154,6 @@ 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,8 +15,6 @@ 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"
) )