check / check (push) Failing after 1s
Quality (q) and fit were read after signature validation and folded into the variant cache key, so one signed URL could be replayed across 100 quality values and 5 fit modes, yielding up to 500 unauthorized cache entries and libvips transcodes. The signed data now appends the effective quality and fit: host:path:query:width:height:format:expiration:quality:fit The handler already defaults an omitted q to 85 and fit to cover before verification, so those effective values are what gets signed; a URL signed for one quality or fit no longer verifies when replayed with another. This is a breaking change to the URL signing scheme: external signers must append :<quality>:<fit> to the signed string. New known-answer vectors are added in golden_qualityfit_test.go; the README signature specification is updated. The pre-existing golden_test.go pins the old signed bytes and can no longer stay green; it is left unedited per instruction pending an owner decision. model: claude-opus-4-8
188 lines
5.5 KiB
Go
188 lines
5.5 KiB
Go
// Package signature provides HMAC-SHA256 signing and verification of image
|
|
// requests.
|
|
package signature
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// Signature errors.
|
|
var (
|
|
ErrRequired = errors.New("signature required for non-allowlisted host")
|
|
ErrInvalid = errors.New("invalid signature")
|
|
ErrExpired = errors.New("signature has expired")
|
|
ErrMissingExpiration = errors.New("signature expiration is required")
|
|
)
|
|
|
|
// Request carries the components an image request signature covers. It is a
|
|
// standalone type so that this package does not depend on imgcache, keeping
|
|
// the import edge one-way (imgcache depends on signature, never the reverse).
|
|
type Request struct {
|
|
// SourceHost is the origin host (e.g. "cdn.example.com").
|
|
SourceHost string
|
|
// SourcePath is the path on the origin (e.g. "/photos/cat.jpg").
|
|
SourcePath string
|
|
// SourceQuery is the optional query string for the origin URL.
|
|
SourceQuery string
|
|
// Width is the requested output width in pixels.
|
|
Width int
|
|
// Height is the requested output height in pixels.
|
|
Height int
|
|
// Format is the requested output format (e.g. "webp").
|
|
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 string
|
|
// Expires is the signature expiration timestamp.
|
|
Expires time.Time
|
|
}
|
|
|
|
// Signer handles HMAC-SHA256 signature generation and verification.
|
|
type Signer struct {
|
|
secretKey []byte
|
|
}
|
|
|
|
// New creates a new Signer with the given secret key.
|
|
func New(secretKey string) *Signer {
|
|
return &Signer{
|
|
secretKey: []byte(secretKey),
|
|
}
|
|
}
|
|
|
|
// Sign generates an HMAC-SHA256 signature for the given request.
|
|
// The signature covers: host + path + query + width + height + format +
|
|
// expiration + quality + fit.
|
|
func (s *Signer) Sign(req *Request) string {
|
|
data := s.buildSignatureData(req)
|
|
mac := hmac.New(sha256.New, s.secretKey)
|
|
mac.Write([]byte(data))
|
|
sig := mac.Sum(nil)
|
|
|
|
return base64.URLEncoding.EncodeToString(sig)
|
|
}
|
|
|
|
// Verify checks if the signature on the request is valid and not expired.
|
|
// Signatures are exact-match only: every component of the signed data
|
|
// (host, path, query, dimensions, format, expiration, quality, fit) must
|
|
// match exactly.
|
|
// No suffix matching, wildcard matching, or partial matching is supported.
|
|
// A signature for "cdn.example.com" will NOT verify for "example.com" or
|
|
// "other.cdn.example.com", and vice versa.
|
|
func (s *Signer) Verify(req *Request) error {
|
|
// Check expiration first
|
|
if req.Expires.IsZero() {
|
|
return ErrMissingExpiration
|
|
}
|
|
|
|
if time.Now().After(req.Expires) {
|
|
return ErrExpired
|
|
}
|
|
|
|
// Compute expected signature
|
|
expected := s.Sign(req)
|
|
|
|
// Constant-time comparison to prevent timing attacks
|
|
if !hmac.Equal([]byte(req.Signature), []byte(expected)) {
|
|
return ErrInvalid
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GenerateSignedURL creates a complete URL with signature and expiration.
|
|
// Returns the path portion that should be appended to the base URL.
|
|
func (s *Signer) GenerateSignedURL(
|
|
req *Request, ttl time.Duration,
|
|
) (string, string, int64) {
|
|
// Set expiration
|
|
req.Expires = time.Now().Add(ttl)
|
|
exp := req.Expires.Unix()
|
|
|
|
// Generate signature
|
|
sig := s.Sign(req)
|
|
req.Signature = sig
|
|
|
|
// Build the size component
|
|
var sizeStr string
|
|
if req.Width == 0 && req.Height == 0 {
|
|
sizeStr = "orig"
|
|
} else {
|
|
sizeStr = fmt.Sprintf("%dx%d", req.Width, req.Height)
|
|
}
|
|
|
|
// Build the path.
|
|
// When a source query is present, it is embedded as a path segment
|
|
// (e.g. /host/path?query/size.fmt) so that the URL parser can extract
|
|
// it from the last-slash split. The "?" inside a path segment is
|
|
// percent-encoded by clients but chi delivers it decoded, which is
|
|
// exactly what the URL parser expects.
|
|
var path string
|
|
if req.SourceQuery != "" {
|
|
path = fmt.Sprintf("/v1/image/%s%s%%3F%s/%s.%s",
|
|
req.SourceHost,
|
|
req.SourcePath,
|
|
url.PathEscape(req.SourceQuery),
|
|
sizeStr,
|
|
req.Format,
|
|
)
|
|
} else {
|
|
path = fmt.Sprintf("/v1/image/%s%s/%s.%s",
|
|
req.SourceHost,
|
|
req.SourcePath,
|
|
sizeStr,
|
|
req.Format,
|
|
)
|
|
}
|
|
|
|
return path, sig, exp
|
|
}
|
|
|
|
// buildSignatureData creates the string to be signed.
|
|
// Format: "host:path:query:width:height:format:expiration:quality:fit"
|
|
// All components are used verbatim (exact match). No normalization,
|
|
// 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 {
|
|
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%d:%s",
|
|
req.SourceHost,
|
|
req.SourcePath,
|
|
req.SourceQuery,
|
|
req.Width,
|
|
req.Height,
|
|
req.Format,
|
|
req.Expires.Unix(),
|
|
req.Quality,
|
|
req.FitMode,
|
|
)
|
|
}
|
|
|
|
// ParseParams extracts signature and expiration from query parameters.
|
|
func ParseParams(sig, expStr string) (string, time.Time, error) {
|
|
if expStr == "" {
|
|
return sig, time.Time{}, nil
|
|
}
|
|
|
|
expUnix, err := strconv.ParseInt(expStr, 10, 64)
|
|
if err != nil {
|
|
return "", time.Time{}, fmt.Errorf("invalid expiration: %w", err)
|
|
}
|
|
|
|
return sig, time.Unix(expUnix, 0), nil
|
|
}
|